An HTML app is a web page with data-kedge bindings. Kedge compiles its reads, writes, and schema at deploy time and renders requests against the app's local database replica. HTML apps also integrate with app authentication, account controls, and ownership-aware writes.

a guestbook

~/guestbook-html$ show index.html
── index.html
<!doctype html>
<title>Guestbook</title>

<form data-kedge="messages">
  <label>Message <input name="text" required maxlength="280"></label>
  <button>post</button>
</form>

<ul data-kedge="messages?$order=-created_at">
  <template><li>{{text}}</li></template>
  <li data-when=":empty">No messages yet.</li>
</ul>

~/guestbook-html$ kedge up

The form creates messages records. The list renders its <template> once per record. Native input constraints are enforced on the server. Bound regions update live; the form remains a normal POST and redirect without JavaScript.

Submitting applies in the page immediately: the new row renders locally, marked pending until the server confirms it. Offline, writes queue in the browser and replay on reconnect, and visited pages reload from a local cache. The reference lists the limits.

HTML and Markdown

Markdown directives are shorthand for the same HTML:

~/guestbook-markdown$ show index.md
── index.md
# Guestbook

:::form{bind="messages"}
:input[Message]{name=text required maxlength=280}
:button[post]
:::

:::each{bind="messages?$order=-created_at" as=ul}
{{text}}
::empty[No messages yet.]
:::

~/guestbook-markdown$ kedge up

Markdown lowers to HTML before compilation. Raw HTML remains available in a Markdown file. The HTML app reference lists both forms.

Kedger News

~/kedge-news$ show index.md item/[id].md
── index.md
---
title: Kedger News
style: /style.css
---

# [⚓︎](/) [Kedger News](/) :account[login]{provider=github next="/" score="me/votes?author=$viewer&$via=story:stories,comment:comments&$tally"}

:::details[submit]{.submit}
:::form{bind="stories"}
:input[title]{name=title required maxlength=120}
:input[url]{name=url type=url required placeholder="https://…"}
:button[submit]
:::
:::

:::each{bind="stories?$order=-created_at" as=ol .stories}
:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine" .vote aria-label="upvote"}
[{{title}}]({{url}}) _({{url | host}})_

:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}
by :author[{{author.name}}] {{created_at | ago}} |
:value[{{count}} comments]{bind="comments?story={{id}}&$count" href="/item/{{id}}"}

::empty[No stories yet.]
:::

_served from {{$dc.metro}}_

── item/[id].md
---
title: Kedger News
style: /style.css
---

# [⚓︎](/) [Kedger News](/) [stories](/) :account[login]{provider=github next="/item/{{$url.id}}" score="me/votes?author=$viewer&$via=story:stories,comment:comments&$tally"}

:::record{bind="stories/{{$url.id}}" .story}
:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine" .vote aria-label="upvote"}
[{{title}}]({{url}}) _({{url | host}})_

:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}
by :author[{{author.name}}] {{created_at | ago}} |
:value[{{count}} comments]{bind="comments?story={{id}}&$count" href="#comments"}
:::

:::form{bind="comments?story={{$url.id}}" .submit .comment-form}
:textarea[]{name=text required maxlength=2000 aria-label=comment}
:button[add comment]
:::

:::each{bind="comments?story={{$url.id}}&$order=created_at" as=ol #comments .comments}
:button[▲]{bind="me/votes/self?comment={{id}}&$toggle" when="!:mine" .vote aria-label="upvote comment"}
:value[{{count}} points]{bind="me/votes?comment={{id}}&$tally" when=":mine"}
by :author[{{author.name}}] · {{created_at | ago}}
:button[delete]{bind="comments/{{id}}?$delete" when=":mine" .text-action}

{{text}}

::empty[No comments yet.]
:::

~/kedge-news$ kedge up

Kedger News includes story submission, discussions, private votes, public scores, ownership controls, GitHub login, and shared styling.

how it works

The sections below break index.md and item/[id].md into the bindings for stories, votes, discussions, and identity.

create stories

:::form{bind="stories"}
:input[title]{name=title required maxlength=120}
:input[url]{name=url type=url required placeholder="https://…"}
:button[submit]
:::

The form creates a record and infers title and url from its controls.

render stories

:::each{bind="stories?$order=-created_at" as=ol}
[{{title}}]({{url}}) _({{url | host}})_
by :author[{{author.name}}] {{created_at | ago}}
::empty[No stories yet.]
:::

The binding orders records; interpolations and formatters render each row.

tally private votes

:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine"}
:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}

me/votes is private to the current author. $tally exposes only the count.

bind a discussion route

item/[id].md serves /item/:id:

:::record{bind="stories/{{$url.id}}"}
# {{title}}
:::

:::form{bind="comments?story={{$url.id}}"}
:textarea[]{name=text required maxlength=2000 aria-label=comment}
:button[add comment]
:::

{{$url.id}} selects the story and is sealed into each new comment.

attach identity

:account[login]{provider=github next="/"}
:button[delete]{bind="comments/{{id}}?$delete" when=":mine"}

The account control supplies login and logout. :mine gates the control; the server enforces ownership. Use app authentication to protect a route before it reaches the page.

agents

An agent is a member of the app declared in the page. It runs when records arrive, on a schedule, or when a viewer finds a record stale; it reads only what the page grants and writes only the fields it is given, through the same validators as a form. Its answer appears in the page as it is written.

<template data-kedge="comments" data-agent="Helper"
  data-reads="dishes" data-creates="comments">
  Answer questions about this potluck from the dish list. If a comment is not a
  question for you, write nothing.
</template>

The answer is an ordinary record, so {{author.name}} says Helper, live regions update when it lands, and the owner can delete it like any other row. The reference lists the attributes, the privacy bound, and the per-app budget.

escape hatches

Use a SQLite view for a read beyond the binding grammar. Use a handler for transactions or domain logic. Both use the shared database.

Routes and assets follow the site model.