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.