---
name: app
description: >-
  Build a small personal app for a human — an HTML page backed by live,
  queryable data — deploy it with one command, and keep collaborating with
  the human through it via a data API. One primitive: collections (a
  mutable, queryable row store with a live change feed). Use when a question
  or workflow deserves a persistent app the human can reopen anytime, not
  just a one-shot reply. Drives the `homespun` CLI: deploy an app, read/write its
  data, watch it for changes.
---

<!-- homespun skill v1.1.1 -->

# app

`homespun` deploys small apps for you. You author an HTML page plus a manifest
(what data it stores, who may read/write it), `homespun deploy` puts it live at
its own URL, and you stay a first-class collaborator on that app afterwards —
reading and writing its data, watching it for changes — through the exact
same collection API the page itself uses.

## When to use this

Use `homespun` when the interaction is richer than a text reply, OR when it
should **persist** — a dashboard the human reopens, a shared list you and the
human both edit over time, a tool that outlives this conversation. For a
one-shot question, just ask in text. For a rich but disposable one-shot
interaction, deploy a small app anyway — there's no separate "form" primitive
in v2; an app with one collection and no persistence expectation is just a
small, short-lived app.

<!-- homespun:core:start -->

## The mental model: one primitive

Everything an app stores is a **collection** — a named, mutable, queryable
set of rows, each with a `key`, a `data` payload, an optimistic-lock
`version`, and an `author`. Every write (create, update, delete) also lands
on the app's **change feed**, an ordered log you and the page can both
subscribe to. That's it: one data primitive, one feed. There is no separate
"event" type and no template/app split — an app IS its HTML plus its
manifest plus its collections.

**Append-only collections are how you get "events."** If you want an
audit trail or a one-shot journaled fact ("this happened") rather than a
mutable row, declare the collection `appendOnly: true` in the manifest (see
below) and only ever `create` into it — never `update`/`delete`. You get
exactly what a v1 "event" gave you (an ordered, immutable, replayable log),
expressed through the same collection API instead of a second primitive.

The three things you build:

1. **A manifest** — declares the app's collections (with row schema and
   who may write/delete each), which external hosts its page may fetch from,
   and whether it may load CDN scripts/styles.
2. **An HTML page** — talks to its own data exclusively through
   `window.homespun.collections.*` and `window.homespun.feed`, injected by the
   relay at runtime.
3. **Deploys** — `homespun deploy` puts the app live; `homespun deploy --app <id>`
   redeploys it in place, same URL.

After that, you (the agent) read and write the same collections the page
reads and writes, via `homespun data`, and watch the app's live feed via
`homespun apps watch` — the same round trip the human's browser gets, just from
the CLI.

<!-- homespun:core:end -->

## Setup

If the `homespun` command isn't on your PATH yet, install it first:
`npm i -g @homespun/cli`.

The hosted relay (`https://homespun.dev`) is the default — `homespun agent register`
works out of the box. The CLI needs:

- **An agent API key.** Either pre-provided by the operator (as
  `HOMESPUN_API_KEY`), or obtained yourself via `homespun agent register` (see
  "Registering" below). Once registered, the key is saved to the config file
  and you don't need `HOMESPUN_API_KEY` at all.
- **A relay URL.** Only relevant for self-hosters — set `HOMESPUN_URL` (or pass
  `--url`) to point at a non-hosted relay. Note this is the **control-plane**
  URL (where `deploy`/`apps`/`data` talk) — the *deployed app itself* is
  served on its own domain (see "Serving and security" below), not under
  this URL.

Output is JSON on stdout. Errors are `{"error":{"code","message"}}` on stderr
with a non-zero exit.

## Keeping this skill up to date

This skill carries its version in an HTML comment near the top of the file:

```
<!-- homespun skill vX.Y.Z -->
```

**The skill version is the app package version** (`@homespun/relay`,
`@homespun/cli`, `@homespun/core` — all kept in lockstep by the release
script). `scripts/cut-release.sh` updates this comment alongside the
`package.json`s and the CLI's `VERSION` constant, so every release
bumps the skill version even if the SKILL.md content didn't change.
That keeps "what relay am I talking to" and "what skill do I have"
answered by one number.

The relay you talk to publishes its own version of this skill at
`GET /skills/homespun/SKILL.md/version`. **At conversation start, check whether your
local copy is behind, and if so refresh it.**

The check is a short shell snippet:

```sh
LOCAL_VER=$(grep -m1 -oE '<!-- homespun skill v[0-9]+\.[0-9]+\.[0-9]+' <path-to-your-local-skill> | grep -oE '[0-9]+\.[0-9]+\.[0-9]+$')
REMOTE_VER=$(homespun skill version --plain)
# If the relay is on an old image that lacks a version comment, the probe
# returns "0.0.0". Don't clobber a newer local copy with the older relay
# skill — skip the update and proceed with what's local.
if [ "$REMOTE_VER" = "0.0.0" ]; then
  : # nothing to do
elif [ "$LOCAL_VER" != "$REMOTE_VER" ]; then
  homespun skill show > <path-to-your-local-skill>   # overwrites with the relay's current skill
fi
```

Where `<path-to-your-local-skill>` is wherever YOUR runtime stores the app
skill (e.g. `~/.claude/skills/homespun/SKILL.md` for Claude Code,
`~/.cursor/rules/app.mdc` for Cursor, the relevant section of
`~/.codex/AGENTS.md` for Codex / Gemini-style concatenated files).

The two `grep`s above are intentionally strict:

- `-m1` stops at the first match in the file, so prose mentions of the
  pattern (this very section!) don't double-count.
- `[0-9]+\.[0-9]+\.[0-9]+` only matches a real semver triple, so a
  literal `<!-- homespun skill vX.Y.Z -->` in documentation is skipped.

Rules:

- If the local file has no `<!-- homespun skill v... -->` comment, treat it as
  stale and run `homespun skill show > <path>`.
- If `REMOTE_VER` is `0.0.0`, skip the update — the relay is on an old
  image without a version comment, and clobbering your (presumably newer)
  local copy with that would be a downgrade.
- If the network is unreachable or `homespun skill version` fails, **do not
  update** — proceed with the local skill you have.
- Don't loop. Check once at conversation start; if you've already refreshed in
  this run and it's still mismatched, stop and report the error to
  the human.
- If you've hand-edited the local skill (added your own notes), save your
  changes first — `homespun skill show > <path>` is a clobbering write.

## Discover the CLI with `--help`

**Before using a command, run its help.** This skill summarizes the workflow,
but `--help` is the authoritative, always-current reference for every flag,
argument, and default:

- `homespun --help` — the command list and global options.
- `homespun <command> --help` — every flag and option for that command, e.g.
  `homespun deploy --help`, `homespun apps --help`, `homespun apps watch --help`,
  `homespun data --help`.

If a command errors or you are unsure of an option name, **run `--help`
instead of guessing** — the CLI is self-documenting and the help text reflects
the installed version, which this skill may not.

### If `homespun` exits 75 ("CLI upgrade required")

The relay you're talking to needs a newer `@homespun/cli` than you have
installed. The CLI signals this with **exit code 75** (`EX_TEMPFAIL`) and a
stderr message that starts with `app: this relay requires @homespun/cli >=
<version>`. If that message includes a `To upgrade: <command>` line, the
command is correct for how `homespun` was installed on this machine — there's
nothing to guess.

What to do, in this order:

1. **Run the printed upgrade command once.** If no command is printed (the
   message says "vendored" or "unknown" install), stop and ask the human to
   bump `@homespun/cli` — don't try to install one yourself.
2. **Re-run your original `homespun` command once.** If it succeeds, continue.
3. **If it still fails with exit 75 after one upgrade + retry**, stop and
   report the error to the human. Do not loop — repeated upgrade attempts
   in the same run are a bug, not a recovery strategy.

## Registering

If you weren't handed an API key, provision one yourself — **once** — with:

```sh
homespun agent register --name "<short-descriptive-agent-name>"
```

Pick a stable, descriptive name — it's how a human tells your agent apart from
other agents on the relay (e.g. `claude-code-lalit-macbook`, `ci-pr-review-bot`,
`telegram-helper`). The relay defaults the name if omitted, but the default is
unhelpful; always set one.

Self-hosters add `--url "$HOMESPUN_URL"` (or set `HOMESPUN_URL`) to target a
non-hosted relay.

Whether `homespun agent register` works depends on the relay's `REGISTRATION_MODE`:

- `closed` (the default) — the endpoint returns 404. The operator must hand
  you a key directly; self-registration is disabled.
- `secret` — pass the operator-shared registration secret with `--secret <s>`
  or the `HOMESPUN_REGISTER_SECRET` env var. A missing/wrong secret is a 401.
- `open` (the hosted relay's mode) — public; `homespun agent register --name <name>`
  works with no secret.

On success this calls the relay's `POST /v1/register`, mints an agent + API
key, and saves the key and relay URL to the CLI config file
(`${XDG_CONFIG_HOME:-~/.config}/homespun/config.json`, mode 0600). After that,
every other command picks the key up from that file automatically — no env
vars needed. The key is not printed by default (pass `--print-key` if you
need it echoed), and the relay rate-limits `/v1/register` per IP.

## Claiming: your app needs a human owner

**Registering an agent does not give it an owner.** `POST /v1/register` mints
an agent with no human attached at all — this is true even if a human ran
`homespun agent register` themselves and handed you the resulting key; registration
and ownership are two separate steps no matter who typed the command. Every
app row (`App.ownerHumanId`) is owned by a human, so creating a new app via
`homespun deploy` rejects with `agent_not_claimed` until your agent has been
**claimed** by a human. Do this once, before your first deploy:

1. **The human mints a one-shot claim code.** In the relay's UI: Account menu →
   "My agents" → "Claim a new agent" → "Generate claim code" — this calls
   `POST /v1/self/claim-codes` and shows the human a code like `cc_...`
   (15-minute TTL, single use). Ask the human to do this and hand you the
   code out-of-band (paste it into the chat, an env var, however you two are
   talking).
2. **You claim yourself with the code:**

   ```sh
   homespun agent claim <code>
   ```

   This calls `POST /v1/agents/claim`, which sets `Agent.ownerHumanId` to that
   human and migrates ownership of anything the agent already created. Output:
   `{ ok: true, owner_human_id, claimed_at }`.
3. **This is one-way.** An already-claimed agent re-running `homespun agent claim`
   gets `agent_already_claimed` (409) — there's no unclaim/re-claim in v1. To
   change owners, register a fresh agent and have the new human claim that one.

If `homespun deploy` fails with `agent_not_claimed`, stop and ask the human to
mint you a claim code — don't guess at a workaround.

<!-- homespun:core:start -->

## Authoring an app: the manifest

The manifest is a plain JSON Schema 2020-12 document with one namespaced
extension key, `x-homespun-manifest`. It is the **whole consent surface** — what
it declares is exactly what the relay enforces at runtime, so be as precise
as you can: unknown keys are hard rejected (a typo is a deploy-time error,
never silently ignored), and there are no implicit grants — `owner`/`agent`
are never auto-added to a permission list.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "GroceryItem": {
      "type": "object",
      "properties": {
        "name": { "type": "string", "maxLength": 200 },
        "checked": { "type": "boolean" }
      },
      "required": ["name"]
    },
    "AuditEntry": {
      "type": "object",
      "properties": {
        "action": { "type": "string" },
        "detail": { "type": "string" }
      },
      "required": ["action"]
    }
  },
  "x-homespun-manifest": {
    "app": {
      "name": "Grocery list",
      "description": "Shared household grocery list",
      "icon": "🛒"
    },
    "collections": {
      "items": {
        "schema": { "$ref": "#/$defs/GroceryItem" },
        "write": ["agent", "owner", "member"],
        "delete": ["agent", "owner", "member"]
      },
      "audit": {
        "schema": { "$ref": "#/$defs/AuditEntry" },
        "write": ["agent"],
        "delete": ["owner"],
        "appendOnly": true
      }
    },
    "externalHosts": ["https://api.example.com"],
    "cdn": false
  }
}
```

Fields, exactly:

- **`x-homespun-manifest.app`** — `name` (required, ≤80 chars), `description`
  (≤280 chars), `icon` (an emoji). Shown to the human as the app's display
  identity.
- **`x-homespun-manifest.collections`** — a map of collection name →
  `{ schema?, write, delete, read?, appendOnly? }`. An app may declare zero
  collections (a purely presentational app).
  - **`schema`** — `{ "$ref": "#/$defs/<Name>" }` into the document's own
    `$defs`. Optional — omit it for a schemaless collection (rows validated
    only at your own discretion). Cross-document refs are not supported.
  - **`write`** — required, non-empty array of roles that may `create`/
    `upsert`/`update` rows in this collection.
  - **`delete`** — required, non-empty array of roles that may delete rows.
  - **`read`** — optional array of roles; stored for future enforcement but
    **not enforced yet** — reads are gated at the app-visibility level only.
  - **`appendOnly`** — optional boolean (default `false`). Set `true` for a
    journal/event-shaped collection: rows are created but never
    updated/deleted by policy (this is advisory at the schema level — pair
    it with a `write`/`delete` list that doesn't grant update/delete if you
    need it enforced too).
  - **Roles** (the full vocabulary): `agent` (you, the deploying/owning
    agent), `owner` (the human who owns the app), `member` (a human invited
    as a collaborator), `anyone` (any authenticated-or-not visitor, subject
    to the app's visibility), `author` (row-scoped — the human/agent who
    authored *that specific row*; valid in `delete`, not in `write`, since a
    create has no pre-existing row to be the author of).
- **`x-homespun-manifest.externalHosts`** — an array of `https://` origins
  (DNS name, optional single leftmost `*.` wildcard, no path/query/IP
  literal) the page's `fetch`/`XMLHttpRequest` is allowed to reach. This is
  the **only** way a deployed app can talk to anything besides its own data
  API — see "Serving and security" below.
- **`x-homespun-manifest.cdn`** — boolean, default `false`. Set `true` to allow
  `<script src>`/`<link rel=stylesheet>` from any `https:` origin (a CDN).
  It does **not** widen what the page can `fetch()` — that's `externalHosts`
  only, kept separate on purpose so a page can load, say, a charting library
  from a CDN without also being able to exfiltrate data to arbitrary hosts.
- **`x-homespun-manifest.capabilities`**: optional array from the STRICT
  allowlist `"camera"`, `"microphone"`, `"fullscreen"`, `"autoplay"`. Each
  granted name flips its `Permissions-Policy` directive from denied to `self`
  on the served app document; everything you don't list stays denied. Unknown
  values are a hard validation error. Example: `"capabilities": ["camera"]`
  lets the page call `getUserMedia({ video: true })`, while microphone stays
  blocked.
- **`x-homespun-manifest.embeds`**: optional array of `https://` origins
  (same rules as `externalHosts`: DNS name, optional single leftmost `*.`
  wildcard, no path/query/IP literal) the page may embed in an `<iframe>`,
  emitted as a `frame-src` grant. Display-only: it does **not** widen
  `connect-src` or `form-action`, so framing a site never lets the page send
  data to it. For a YouTube player use the privacy-preserving nocookie host:
  `"embeds": ["https://www.youtube-nocookie.com"]`.

<!-- homespun:core:end -->

## Writing the HTML: `window.homespun`

The relay injects `window.homespun` into every served app document (via a
deferred `<script>` — it doesn't touch your `<head>` beyond that one tag).
The page talks to its own data **only** through this bridge:

**Script ordering — read this before writing any page script.** The SDK is
injected as `<script src="/_hs/sdk.<hash>.js" defer>`. A `defer` script
always runs *after* the document has parsed but *before*
`DOMContentLoaded` fires — and, critically, **after** any plain, non-deferred
inline `<script>` on the page. If your own `<script>` block references
`app.*` at its top level (not inside a callback), it runs before the SDK
has defined `window.homespun` and throws `ReferenceError: app is not defined` —
silently killing the whole script (no render, no click handlers, no errors
visible unless you check the console). Fix it one of two ways:

- Wrap anything touching `window.homespun` in a `DOMContentLoaded` listener
  (fires after the deferred SDK script has run) — this is what every example
  below does, and it's the safer default since it works regardless of where
  your `<script>` tag sits in the document.
- Or mark your own page script `defer` too and place it *after* the SDK's
  `<script>` tag — deferred scripts run in document order, so a later
  `defer` script always sees a defined `window.homespun`.

Either way: **never reference `window.homespun` at the top level of a plain
inline `<script>`** — it is not defined yet when that line runs.

| Surface | What it does |
|---|---|
| `homespun.ready` | `Promise<void>` — resolves once the session is resolved and every declared collection has been snapshotted into the local mirror. `await` it before your first synchronous read. |
| `homespun.collections.snapshot(name)` | Synchronous read of every row currently in the local mirror. `[]` before `ready`. |
| `homespun.collections.get(name, key)` | Synchronous point read; `undefined` if absent/deleted. |
| `homespun.collections.on(name, handler)` | Live deltas for one collection, already folded into row shape — `{kind:"upsert", collection, row: HomespunRow}` or `{kind:"delete", collection, row:{key, deletedAt}}`. Returns an unsubscribe function. |
| `homespun.collections.create(name, data)` | `POST` — server generates the row key. Returns the created `HomespunRow`. |
| `homespun.collections.upsert(name, key, data)` | Create-or-return-existing for a caller-supplied key (idempotent). |
| `homespun.collections.update(name, key, data, {ifMatch?})` | Optimistic-locked update. A stale `ifMatch` rejects with `code:"conflict"` and `details.current` set to the winning row. |
| `homespun.collections.delete(name, key, {ifMatch?})` | Soft-delete (tombstone). |
| `homespun.feed.on(handler, {collection?})` | Unfiltered (or single-collection-filtered) live change feed — every create/update/delete across the app, in order. Each entry is a raw `FeedEntry`: `{seq, op:"create"\|"update"\|"delete", collection, key, data, author, ts}`. **Note the field is `op`, not `kind`** — `feed.on` and `collections.on` carry different shapes (see below). |
| `homespun.feed.cursor` | Highest feed `seq` applied locally so far (memory-only). |
| `app.app.{slug,name,description,icon,visibility,collections}` | Manifest-derived, safe-to-expose facts about this app. |
| `homespun.session.{kind,humanId}` | Who's looking at the page right now: `"owner"` \| `"member"` \| `"anonymous"`, and their human id (`null` if anonymous). |
| `homespun.session.displayName` | The viewer's own name (`null` when anonymous). Self-facing only: falls back to a name derived from their email when they haven't set one, same rule the dashboard uses for its own greeting. |
| `homespun.session.login()` | Full-navigation redirect to the identity provider's `/authorize` flow. |
| `homespun.session.logout()` | Clears the stored session token and reloads as anonymous. |
| `homespun.members.list()` | Every human Member of this app (always including its owner) plus every Agent its owner currently owns, as `{kind:"human"\|"agent", id, displayName, role?}`. Names only: never an email, and never anything derived from one for anyone other than themselves. |
| `homespun.members.nameFor(author)` | Resolve a row's or feed entry's own `author` (`{kind, id}`) straight to a display name, never throws. Falls back to `"a member"` / `"an agent"` for an id no longer in the directory (a removed member, an unclaimed/reassigned agent), and `"a visitor"` for an anonymous author. |
| `homespun.uploadBlob(file, opts?)` / `homespun.downloadBlob(id)` / `homespun.saveBlob(id, filename?)` | Binary attachment upload/download. Names kept from v1 for continuity. |

A minimal grocery-list page against the manifest above:

```html
<!doctype html>
<meta charset="utf-8" />
<ul id="list"></ul>
<input id="new-item" placeholder="Add an item" />
<button id="add">Add</button>

<script>
  // Everything that touches `window.homespun` waits for DOMContentLoaded, which
  // fires after the relay's deferred SDK script has already run and defined
  // `window.homespun` — see "Script ordering" above. Never call `app.*` at the
  // top level of a plain inline <script>.
  window.addEventListener("DOMContentLoaded", () => {
    const list = document.getElementById("list");

    function render() {
      list.innerHTML = "";
      for (const row of homespun.collections.snapshot("items")) {
        const li = document.createElement("li");
        // The row's real author, server-stamped and tamper-proof (never a
        // client-written `by` field (see "Rules of thumb" below).
        const by = homespun.members.nameFor(row.author);
        li.textContent =
          row.data.name + (row.data.checked ? " ✓" : "") + " (added by " + by + ")";
        li.onclick = () =>
          homespun.collections.update("items", row.key, {
            ...row.data,
            checked: !row.data.checked,
          });
        list.appendChild(li);
      }
    }

    homespun.ready.then(render);
    // Live updates — from the human's own edits AND from `homespun data upsert`
    // calls the agent makes later.
    homespun.collections.on("items", render);

    document.getElementById("add").addEventListener("click", async () => {
      const input = document.getElementById("new-item");
      if (!input.value.trim()) return;
      await homespun.collections.create("items", {
        name: input.value.trim(),
        checked: false,
      });
      input.value = "";
    });
  });
</script>
```

Rules of thumb:

- **Never touch `window.homespun` before `DOMContentLoaded`** (see "Script
  ordering" above) — this is the #1 way a page silently fails to render.
- **`collections.on` and `feed.on` are not interchangeable.** `collections.on`
  gives you a row-shaped delta already folded for one collection
  (`{kind:"upsert"|"delete", row}`) — reach for it when you just want to
  re-render on change, as in the example above. `feed.on` gives you the raw,
  unfolded `FeedEntry` (`{seq, op, collection, key, data, author, ts}`,
  field is **`op`** not `kind`) across the whole app (or one collection via
  `{collection}`) — reach for it when you need ordering/`seq`, cross-collection
  events, or the entry's own metadata (`author`, `ts`) rather than just the
  resulting row.
- **`.textContent`, never `.innerHTML`**, for anything containing human- or
  agent-authored text — the same injection discipline as any other web page.
- **Never invent a client-side `by`/`author` field for what the row's real,
  server-stamped `author` already is.** A page-written field like
  `{ ...data, by: "Alice" }` is just ordinary row data: any visitor can set
  it to anything, so it proves nothing about who actually wrote the row.
  Render the row's own `author` instead: `homespun.members.nameFor(row.author)`
  (or `entry.author` off the feed) turns the tamper-proof `{kind, id}` the
  relay stamped into a real name. Greet the current viewer the same way, with
  `homespun.session.displayName`, falling back to something generic (e.g.
  "Sign in" or "Welcome") when it's `null`.
- **No relay-injected stylesheet or CSS variables in v2.** Unlike the old
  app viewer, a deployed app gets no default styling — you own 100% of the
  CSS from the first paint. Write real, theme-aware CSS (respect
  `prefers-color-scheme` yourself) rather than assuming a house style exists.
- **Network access is manifest-gated, not blanket-blocked.** A v2 app is a
  real top-level page (not a sandboxed iframe): `fetch`/`XMLHttpRequest` work
  against `'self'` (its own data API) plus whatever origins you declared in
  `externalHosts`; nothing else. `<script src>`/`<link rel=stylesheet>` from
  an external `https:` origin additionally requires `cdn: true`. Images,
  fonts, and media may load from any `https:` origin (or `data:`) regardless
  of `cdn`/`externalHosts` — those are display-only and can't exfiltrate
  data. Anything not covered by one of these is blocked by the app's CSP;
  there is no escape hatch besides declaring it in the manifest and
  redeploying.

## Serving and security — what an app's origin can and can't do

Each deployed app is served **top-level**, at its own subdomain
(`<slug>.homespunapps.com`) — not embedded in an iframe. A few things follow
from that:

- **No cookies on the app's origin.** The usercontent domain strips every
  inbound `Cookie` header and drops every outbound `Set-Cookie` — nothing on
  that origin ever reads or sets one. Session state lives in the browser's
  `localStorage`, scoped per-app-origin, and is established via
  `homespun.session.login()` (a redirect to the identity provider) rather than a
  cookie.
- **`connect-src` is `'self'` plus your declared `externalHosts` — never
  wider**, regardless of the `cdn` flag. `cdn: true` only widens
  `script-src`/`style-src` (code you load), not what the page can fetch —
  keeping "can load a charting library" and "can exfiltrate data" as two
  separate grants.
- **Visibility gates who can open the app at all**: `private` (only the
  owner plus invited members, sign-in gated; this is the default), `link`
  (anyone with the URL), `public` (listed and discoverable). This is
  orthogonal to the per-collection `write`/`delete` role lists in the
  manifest; visibility controls who can load the page, the manifest roles
  control who can write which collection once they are on it.

## Deploying and iterating

`homespun deploy` is the one command for both creating and redeploying — decided
by whether you pass `--app`, not by two separate verbs. Tell the human their
new app is private until they invite members or change its visibility.

**Canonical shape — a directory with two fixed filenames:**

```sh
homespun deploy ./my-app
#   reads ./my-app/index.html and ./my-app/manifest.json — no discovery
#   heuristics, both files required
```

**Escape hatch — a single HTML file plus an explicit manifest:**

```sh
homespun deploy ./index.html --manifest ./manifest.json
# --manifest also accepts inline JSON
```

**Create** (no `--app`) — `POST /v1/apps`:

```sh
homespun deploy ./my-app
# private by default; add --visibility link|public to share wider

homespun deploy ./my-app --visibility public --slug grocery-list
# -> { app_id, slug, visibility, url, version, created: true }
```

- `--slug` is accepted with `--visibility public`, `--visibility private`,
  or no `--visibility` at all (the default is private). An explicit
  `--visibility link` app always gets a server-generated slug; passing
  `--slug` with it is rejected before the request even goes out.

**Redeploy** (`--app <id-or-slug>`) — `POST /v1/apps/:id/versions`:

```sh
homespun deploy ./my-app --app grocery-list
# -> { app_id, version, visibility, created: false, compat, breaks? }
```

- `--slug`/`--visibility` cannot be changed here — slug is immutable for the
  app's lifetime; change visibility with `homespun apps update --visibility`.
- **The compat gate.** By default the relay refuses a redeploy that
  *narrows* the manifest against the currently-live one — removing a
  collection, tightening a schema, dropping a write/delete role that used to
  be granted — because rows already written under the old contract could
  stop making sense. It fails `422` with `details.breaks[]` naming every
  offending path. Adding collections/roles or loosening constraints is
  always compatible. Pass `--force` to redeploy anyway (a narrowed
  collection is detached, not deleted — its rows aren't destroyed).

An app can go **dormant** after a period of inactivity; a dormant app's live
watchers get a terminal `{"type":"_dormant"}` frame. `homespun apps wake <app>`
brings it back before you deploy/read/write against it again.

## Reading and writing data as the agent

You use the **same collection API** the deployed page uses, just from the
CLI/your own process rather than the browser — `homespun data` for point-in-time
reads/writes, `homespun apps watch` for the live feed.

```sh
# List / point-read rows
homespun data grocery-list items list
homespun data grocery-list items get row_abc123

# Write — upsert is the ONLY create-shaped verb: omit --key to add a new
# row (server-generated key); pass --key to ensure a row exists at that key
# (returns the existing row with deduped:true on a collision, never errors)
homespun data grocery-list items upsert --data '{"name":"Milk","checked":false}'
homespun data grocery-list items upsert --key milk --data '{"name":"Milk"}'

# Update / delete — optionally optimistic-locked with --if-match <version>
homespun data grocery-list items update milk --data '{"name":"Milk","checked":true}'
homespun data grocery-list items delete milk --yes
```

`<app>` accepts either the app id or its slug throughout — `homespun data`,
`homespun deploy --app`, and every `homespun apps` subcommand resolve a slug via a
lookup automatically.

**Watching the live feed** — the direct replacement for polling: streams the
app's change feed as JSON-lines, one compact object per line, over a
WebSocket with an automatic long-poll fallback (byte-identical output either
way, so a pipe consumer can't tell which transport served a given line):

```sh
homespun apps watch grocery-list
homespun apps watch grocery-list --collection items          # filter to one collection
homespun apps watch grocery-list --since <cursor> --once      # replay + exit after one entry
homespun apps watch grocery-list --timeout 300                # give up after 5 minutes
```

A dormancy transition mid-watch prints a single `{"type":"_dormant"}` line
and exits `0` — that's "the app went to sleep," not an error.

**Managing the app itself:**

```sh
homespun apps list                      # your apps, newest activity first
homespun apps list --status dormant     # filter by lifecycle status
homespun apps show grocery-list         # full detail: manifest, current_version, row_count, storage_bytes
homespun apps update grocery-list --visibility private
homespun apps wake grocery-list         # wake a dormant app
homespun apps delete grocery-list --yes # destructive — permanently removes the app and its data
```

Other identity/config commands still work exactly as you'd expect and are
unrelated to any of the above: `homespun config show` (inspect the resolved
url/api-key — `homespun config` bare with no verb is rejected with
`invalid_args`; `show` is the read-only inspection verb, alongside
`list`/`use`/`add`/`rm` for multi-profile management), `homespun agent logout`
(clear local credentials), `homespun key list|revoke` (inspect/revoke your own
API key). Run `--help` on any of them.

**Not yet available.** `homespun` does not yet support snapshotting an app as a
reusable template, publishing it to a marketplace, or installing someone
else's app into your own account — don't tell a human any of that works.
Every app today is deployed and iterated individually with `homespun deploy`.

## If you know the old (event) skill — migration note

If you've used app before and learned the event/app/template model, here's
the direct mapping — everything below is a rename or a fold onto the one
collection primitive, not a new concept:

| Old | New |
|---|---|
| `homespun.emit(type, data)` | `homespun.collections.create("events", { type, ...data })` on a manifest collection with `appendOnly: true` |
| `homespun.on(type, handler)` | `homespun.feed.on(handler, { collection: "events" })`, then check `entry.data.type === type` yourself |
| `homespun.state.events` | `homespun.collections.snapshot("events")` |
| `homespun.state.last(type)` | `homespun.collections.snapshot("events").findLast(r => r.data.type === type)` |
| `homespun.inputData` | a seed row you write yourself at deploy time (e.g. an app-config collection, key `"main"`), read via `homespun.collections.get(...)` |
| `homespun.records.*` | `homespun.collections.*` — same seven methods (`snapshot/get/on/create/upsert/update/delete`), just renamed to match the manifest's `collections` keyword |
| `homespun create` / `homespun template create` / `homespun upgrade` | `homespun deploy` (create with no `--app`; redeploy with `--app <id>`) |
| `homespun watch <homespun-id>` | `homespun apps watch <app>` |
| `homespun send` | `homespun data <app> <collection> upsert` (or `update`) — there's no separate "send an event" verb; you write into a collection like anything else |

There is no shim: a deployed app talks the new API only. If you're
migrating an existing template/app's HTML, expect to rewrite its data
calls, not just its imports.
