HTML is canonical. data-kedge binds an element to the app database. Markdown directives lower to the same HTML before compilation.

page access

Gate one HTML page with a shorthand:

<meta name="kedge-auth" content="github">

Omitted paths mean the current resolved route. Expanded properties mirror the flat Compose policy:

<meta name="kedge-auth-owner" content="true">
<meta name="kedge-auth-github" content="my-org, another-org">
<meta name="kedge-auth-google" content="example.com">
<meta name="kedge-auth-paths" content="/admin/**">
<meta name="kedge-auth-except" content="/admin/health">

Use kedge-identity or kedge-identity-* instead when the page stays public but its $verified bindings need a signed-in viewer. Identity does not accept path fields. A gated page automatically restricts its generated data capabilities to the admitted viewer.

See app authentication for provider and Compose forms.

bound regions

A collection binding repeats one direct child <template>:

<ol data-kedge="stories?$order=-created_at&$limit=20">
  <template>
    <li><a href="{{url}}">{{title}}</a></li>
  </template>
  <li data-when=":empty">No stories yet.</li>
</ol>

A single-record binding uses the bound element's body directly:

<!-- item/[id].html serves /item/:id -->
<article data-kedge="stories/{{$url.id}}">
  <h1>{{title}}</h1>
  <p data-when=":empty">Story not found.</p>
</article>

Lists require exactly one direct <template>; records must not contain one. data-when=":empty" is a direct child and cannot contain another binding.

binding grammar

[me/]<collection>[/<id>][?filters&$modifiers]

Names use letters, numbers, and underscores and cannot start with a number.

target meaning
stories public collection
stories/abc public record with a literal ID
stories/{{$url.id}} public record selected by a route or query parameter
stories/{{id}} public record selected by the enclosing row
me/votes records authored by the current viewer
me/preferences/self one viewer-owned record for a fixed field tuple

{{field}} is valid only inside a record template. {{$url.name}} comes from a parameterized route or request query.

filters

stories?kind=link
comments?story={{$url.id}}
events?starts_at.gte=$now

Comparison suffixes are .gt, .gte, .lt, .lte, .ne, and .in; .in accepts up to 32 comma-separated values. Values may be literals, {{$url.name}}, $viewer, $now, or row-local {{field}} for equality.

URL parameters with an empty value are ignored. A missing $viewer suppresses a viewer-dependent query rather than broadening it.

list and pagination modifiers

modifier meaning
$order=field ascending order
$order=-field descending order
$limit=n return 1 through 1000 rows
$after=cursor continue after an opaque signed cursor
$count return one record with count
$count&$group=field return field and count for each group

Ordering defaults to id, which is also the stable tiebreaker. A direct data-when=":more" child receives {{$next}}:

<ol data-kedge="stories?$order=-created_at&$limit=20&$after={{$url.after}}">
  <template><li>{{title}}</li></template>
  <li data-when=":more"><a href="?after={{$next}}">more</a></li>
</ol>

Dynamic $order={{$url.sort}} is accepted only when a select, radio group, or button group named sort enumerates 1 through 32 allowed order values.

viewer responses and aggregates

For shared ratings, votes, polls, reactions, and rankings, store items separately from viewer responses. Use me/responses/self?item={{id}} so a later submission replaces that viewer's response; a public response form or hidden item input creates another row on every submit. $tally exposes a count, and $tally=field groups by an enumerated managed-form field:

<ul data-kedge="items"><template><li>{{name}}
  <form data-kedge="me/ratings/self?item={{id}}"><select name="score" required><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option></select><button>rate</button></form>
  <ul data-kedge="me/ratings?item={{id}}&$tally=score"><template><li>{{score}}: {{count}}</li></template><li data-when=":empty">No ratings</li></ul>
</li></template></ul>
me/votes?author=$viewer&$via=story:stories,comment:comments&$tally

$via joins up to four field:collection pairs. It requires a filter and an ungrouped tally.

Nested reads use one field={{field}} filter with public $count or private $tally. All parent rows are grouped into one query.

audiences

$verified and $owner restrict a read or managed write. Read and write audiences are independent:

<form data-kedge="posts?$verified">
  <textarea name="body" required></textarea>
  <button>post</button>
</form>

<ol data-kedge="posts?$order=-created_at">
  <template><li>{{body}}</li></template>
</ol>

Everyone can read posts; only signed-in viewers can submit the form. Every read of one collection must use the same audience, and every write must use the same audience. Public and me/ spelling must also remain consistent. App authentication is a separate ingress check.

interpolation

value meaning
{{field}} record field
{{author.name}} managed author display name
{{$url.name}} route or query parameter
{{$viewer.id}}, {{$viewer.name}} current app identity
{{$viewer.verified}}, {{$viewer.owner}} identity facts
{{$dc.code}}, {{$dc.metro}} serving datacenter
{{$page.path}}, {{$page.dir}}, {{$page.slug}} current page
{{$now}} server time in UTC

All values are escaped. Interpolation is rejected in <script>, <style>, and event-handler attributes. A context value in href or src needs a fixed relative, http, https, or mailto prefix.

expression result
{{created_at | ago}} relative time
{{created_at | date}} calendar date
{{url | host}} lowercase hostname without www.
{{count | number}} grouped integer
{{count | plural:reply}} count and singular/plural noun
{{text | truncate:80}} at most 80 characters plus an ellipsis
{{body | markdown}} sanitized stored Markdown

markdown is element-content only. It drops raw HTML and unsafe URLs and does not run app directives.

conditional content

Outside rows, data-when accepts $viewer.verified, !$viewer.verified, $viewer.owner, and !$viewer.owner. Record templates also accept:

condition meaning
:mine current viewer authored the record
!:mine current viewer did not author the record
field field is non-empty
!field field is empty
field=value field equals the literal value

:empty, :more, and :error are region and form states.

managed forms

<form data-kedge="comments?story={{$url.id}}">
  <textarea name="text" required maxlength="2000"></textarea>
  <button>comment</button>
  <p data-when=":error">{{$error}}</p>
</form>

Kedge supplies method, action, CSRF protection, and the write capability. Do not set action; an explicit method must be POST.

target operation
comments create a record
comments/<id> update a record
comments/{{id}} update the enclosing row
me/preferences/self create or replace the viewer's fixed-tuple record

Equality filters are sealed setters. Writes return to the current page; $return=/path overrides the redirect. Native controls infer fields:

control inferred field
text, hidden input, or textarea text
type=url absolute URL
type=number integer, or number when step allows fractions
select, radio, named button enumerated value
type=file replicated image URL

Server-enforced constraints are required, minlength, maxlength, pattern, min, max, step, and enumerated values. Forms set at most 32 fields; id, created_at, updated_at, and author are reserved.

File fields accept GIF, JPEG, PNG, or WebP values from 1 byte through 8 MiB. The stored value works directly in src="{{photo}}". All submitted field values together may use at most 64 KiB, excluding file bytes.

Rejected submissions preserve values and render {{$error}} in the data-when=":error" branch. Kedge adds the branch when omitted.

Authored records are mutable by their author. The app owner may delete any managed record and update records with no author. Redirects wait briefly for the committed database frontier.

action buttons

<strong data-kedge="counters/home">{{visits}}</strong>
<button data-kedge="counters/home?$increment=visits">add one</button>

$increment requires a literal public record and creates an INTEGER COUNTER NOT NULL DEFAULT 0. Each repeatable action adds one; concurrent increments merge.

<button data-kedge="comments/{{id}}?$delete" data-when=":mine">delete</button>
<button
  data-kedge="me/votes/self?story={{id}}&$toggle"
  data-when="!:mine">vote</button>

$toggle requires one field={{field}} filter. Kedge manages the viewer-owned row and aria-pressed; $return, $verified, and $owner also apply. Inside a repeated record, use this action for binary participation or the self form pattern above for a score or choice.

agents

A top-level <template data-kedge="…" data-agent="Name"> makes a member of the app that runs on a trigger, reads what the page grants, and writes through the same validators as a managed form. The template text is its instruction; {{field}} interpolates the attached record. Nothing in the render path calls a model, and a form submission never waits on an agent.

<template data-kedge="dishes" data-agent="Curator" data-writes="kind note">
  Classify each dish as main, side, dessert, or drink in kind. If it duplicates
  another dish, say which in note; otherwise leave note empty.
</template>
<template data-kedge="digest/today" data-agent="Planner" data-every="1d"
  data-reads="dishes" data-writes="body">
  One paragraph: what is covered, what is missing, who should bring what.
</template>
attribute meaning
data-kedge="collection" run once for each new record; field=value filters and $verified/$owner narrow the trigger
data-kedge="collection/id" a public record the agent keeps current
data-kedge="me/collection/self" one record per viewer the agent keeps current
data-agent="Name" the member name shown as author of records it creates; unique per app, at most 8 agents
data-reads="binding https://…" space-separated bindings and https:// sources it may read; a collection attachment defaults to its own binding
data-writes="field field" fields it may fill on the attached record; a field only an agent writes is text
data-creates="collection" collections with a managed form it may author records in
data-every="1d" regenerate a record target on an interval, traffic or not
data-max-age="1d" regenerate a record target on view when missing or older than this

A record target needs exactly one of data-every and data-max-age; a collection attachment takes neither. Intervals use m, h, or d, from 10m to 30d. An agent attached to a public binding reads only public collections; one attached to a me/ binding also reads that viewer's me/ collections. A https:// source is fetched by the platform before the run, GET only, from a fixed public host; {{field}} in a source URL must come from an enumerated, numeric, or pattern field. Output that violates a field constraint is rejected and retried once; a failed run leaves the record as submitted. Agents never delete, never run on records authored by an agent, and never see app settings or environment.

Runs are metered to the owner's model allowance, capped per app by the agent.budget property ($1 a month unless set; kedge up --agent.budget 5 sets it on deploy) and at 60 runs an hour per agent; a spent budget pauses agents while the app keeps recording. Each run appears on the app's activity page with its cost. A just-submitted row is data-kedge-pending until the server render replaces it, and a record target renders its :empty branch until the first run lands; the live channel delivers the result. While a run is answering, each granted field shows the text so far to viewers whose live connection is on the node that took the trigger, rendered through the same template; the committed value replaces it everywhere. Nothing partial is stored.

data-agent, data-reads, data-writes, data-creates, data-every, and data-max-age are reserved alongside data-kedge and data-when; any other data-* attribute is yours.

schema and storage

Managed tables receive id, created_at, updated_at, and author columns. Schema inference is additive; deploy does not drop, retype, or tighten fields.

SQLite views are read-only and provide their own indexes. Handlers and services can use the same /shared.db.

On a managed create, Kedge removes authored rows older than one year and rejects the write when the collection already has 100,000 authored records. Rows created directly through SQL are not part of those browser-write limits.

viewer identity

A first authored write creates an anonymous app identity; reads and public counters do not. me/, :mine, authored writes, and sign-in migration use it. See app authentication for verified identity and ingress.

live updates

Read bindings use one signed event stream for server-rendered fragments. Managed forms use fetch when available and POST/303 otherwise. Static assets remain cacheable; rendered data pages are private, no-store.

local-first writes

Submissions queue in the browser and replay in order when the app is reachable. A create renders its row in the page at once, marked data-kedge-pending until the server render replaces it; style pending rows through the attribute. Each create carries a client-generated id, so a replayed submission never duplicates a record. Visited pages are cached for offline reload with their last-rendered data plus pending writes. Aggregates, single-record pages, and templates that use fields the form does not set wait for the server render. Erasing your data also clears the local cache and queue.

Markdown shorthand

Markdown source accepts title, style, auth, and identity front matter. Auth and identity use the same shorthand or flat object as Compose. Without title, the first level-one heading supplies the browser title.

---
identity: github
---
---
auth:
  github: [my-org, another-org]
---
Markdown canonical HTML
:::record{bind="…"} <article data-kedge="…">
:::each{bind="…" as=ol} bound <ol> with <template><li>
:::form{bind="…"} <form data-kedge="…">
:::details[summary] <details><summary>…
::empty[text] direct data-when=":empty" list item
:value[text]{bind="…"} <span data-kedge="…">
:button[text]{bind="…"} managed <button data-kedge="…">
:input[label]{name=…} native labeled <input>
:textarea[label]{name=…} native labeled <textarea>
:author[text] author span using {{author.name}}
:account[login]{provider=… next=…} login, identity, and logout controls

Attributes pass through. .class and #id set class and ID; bind and when become data-kedge and data-when. Raw HTML remains available.