@desplega.ai/agent-swarm 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/{acp-adapter-zvzr538d.js → acp-adapter-1885dpr7.js} +4 -4
  2. package/dist/{actions-jhtqdw69.js → actions-pn76n32q.js} +18 -18
  3. package/dist/{amp-adapter-bd84rhs2.js → amp-adapter-af84rx1d.js} +3 -3
  4. package/dist/{app-kreq1gwg.js → app-6sv8mhzm.js} +3 -3
  5. package/dist/{assistant-3k22sbsq.js → assistant-sc2tf825.js} +20 -20
  6. package/dist/{boot-reembed-f6z923ny.js → boot-reembed-gvxjs2py.js} +1 -1
  7. package/dist/{boot-reembed-e53x6b9v.js → boot-reembed-y3m0ydzj.js} +1 -1
  8. package/dist/{boot-scrub-sweep-0538vsb7.js → boot-scrub-sweep-1j9mk05f.js} +3 -3
  9. package/dist/{boot-seeds-6wxqkfk2.js → boot-seeds-37sp626b.js} +4 -4
  10. package/dist/{claude-adapter-gfts5044.js → claude-adapter-rp0jtnwr.js} +4 -4
  11. package/dist/{claude-managed-setup-h00hsgtz.js → claude-managed-setup-rdt248k7.js} +1 -1
  12. package/dist/{claude-sdk-session-56besmfz.js → claude-sdk-session-53rcw5we.js} +9822 -9749
  13. package/dist/{cli-3tnpj2bh.js → cli-038fga5k.js} +2 -2
  14. package/dist/{cli-kmqgmcnb.js → cli-0dxxwfkt.js} +8 -8
  15. package/dist/{cli-471qk6s2.js → cli-1012xb7j.js} +28 -28
  16. package/dist/{cli-sv5gb0yb.js → cli-1eszr343.js} +1 -1
  17. package/dist/{cli-7225gwj7.js → cli-1m17dc9d.js} +1 -1
  18. package/dist/{cli-xftrvxag.js → cli-1ma335fz.js} +3 -3
  19. package/dist/{cli-s48yvzb8.js → cli-1n9gdg56.js} +4 -4
  20. package/dist/{cli-ash6xxk7.js → cli-1xsraagq.js} +3 -3
  21. package/dist/{cli-xbzvet96.js → cli-36ckw0wx.js} +5 -4
  22. package/dist/{cli-mhywgrrz.js → cli-4sydckyj.js} +1 -1
  23. package/dist/{cli-3r448dqn.js → cli-4yef8ghv.js} +3 -3
  24. package/dist/{cli-g0hgtz4p.js → cli-58vwn100.js} +3 -3
  25. package/dist/{cli-pw7v69vp.js → cli-625tb7aq.js} +1 -1
  26. package/dist/{cli-m2qgh5sv.js → cli-6aen3ezb.js} +1 -1
  27. package/dist/{cli-z071fb95.js → cli-6eyncg0y.js} +6 -6
  28. package/dist/{cli-0bh477zn.js → cli-73ezfhha.js} +120 -12
  29. package/dist/{cli-x5mq1fyq.js → cli-74c1wpm0.js} +3 -3
  30. package/dist/{cli-b1dfr1m4.js → cli-7a6p4e65.js} +1 -1
  31. package/dist/{cli-fpmsqq59.js → cli-836ehxmt.js} +1 -1
  32. package/dist/{cli-1pa3rz69.js → cli-8vverzf7.js} +1 -1
  33. package/dist/{cli-rdq7tzwm.js → cli-8y99kfey.js} +11 -11
  34. package/dist/{cli-z1ew8vbq.js → cli-aa3tb29r.js} +10 -5
  35. package/dist/{cli-6ncjvx55.js → cli-aypbg9r2.js} +1 -1
  36. package/dist/{cli-3kh4kpqy.js → cli-bxrqvpg5.js} +6 -6
  37. package/dist/{cli-0hyhdkdk.js → cli-cebvbc9t.js} +4 -1
  38. package/dist/{cli-p6argxh9.js → cli-dm8p2dpf.js} +7 -7
  39. package/dist/{cli-jh0qcrga.js → cli-e4d78xsv.js} +1 -1
  40. package/dist/{cli-7a91w22y.js → cli-e8xyjvfc.js} +1 -1
  41. package/dist/{cli-axh29tyr.js → cli-f0p0f6ja.js} +10 -10
  42. package/dist/{cli-w9g4sz81.js → cli-gf2850q7.js} +1 -1
  43. package/dist/{cli-bn5kevnf.js → cli-gx0j2khs.js} +4 -4
  44. package/dist/{cli-gf0kfg81.js → cli-h0mgxann.js} +5 -5
  45. package/dist/{cli-kr2pmfqx.js → cli-kgnkyabk.js} +85 -71
  46. package/dist/{cli-hqhzg1qq.js → cli-mzkwkbdy.js} +1 -1
  47. package/dist/{cli-k9z4zd4p.js → cli-npfe9t1s.js} +2 -2
  48. package/dist/{cli-syhq378v.js → cli-p0qerd2r.js} +1 -1
  49. package/dist/{cli-sjyetj21.js → cli-pygdsgh8.js} +1 -1
  50. package/dist/{cli-2wag8973.js → cli-qeaq3j6t.js} +1 -1
  51. package/dist/{cli-nx462e4v.js → cli-qp2m4btv.js} +3 -3
  52. package/dist/{cli-tbhwrxpt.js → cli-s9rr4gr2.js} +5 -5
  53. package/dist/{cli-fz9kewbh.js → cli-sds5zzs8.js} +1 -1
  54. package/dist/{cli-vy0fay6k.js → cli-tkf4x6ha.js} +15 -15
  55. package/dist/{cli-mfe7f7b5.js → cli-v87dr6e3.js} +2 -2
  56. package/dist/{cli-rqqsfz30.js → cli-vcgscaan.js} +91 -21
  57. package/dist/{cli-zfg9enh9.js → cli-vfha21c9.js} +6 -1
  58. package/dist/{cli-3p8fqcnh.js → cli-vq9gqarq.js} +1 -1
  59. package/dist/{cli-a4gvwttf.js → cli-wk28hzqn.js} +2 -2
  60. package/dist/{cli-p0rakbrk.js → cli-zvbb4n0e.js} +6 -5
  61. package/dist/cli.js +17 -17
  62. package/dist/{codex-adapter-p6rmm7vv.js → codex-adapter-2zrmab9b.js} +2 -2
  63. package/dist/{codex-hook-x7nejyrr.js → codex-hook-e2he7aca.js} +2 -2
  64. package/dist/{codex-session-runner-8vd5t9t0.js → codex-session-runner-kbkz62vc.js} +2 -2
  65. package/dist/{commands-7ems2p13.js → commands-hd574th6.js} +3 -3
  66. package/dist/{controller-m263fg43.js → controller-pz5mcfg4.js} +3 -3
  67. package/dist/{controllers-connect-bg33ae53.js → controllers-connect-5rgq2hfk.js} +19 -19
  68. package/dist/{cursor-adapter-cz6nm6mj.js → cursor-adapter-mgaprq0j.js} +2 -2
  69. package/dist/{db-w505dwa8.js → db-6nf1207q.js} +6 -2
  70. package/dist/{db-ys2rbdft.js → db-h06dxp05.js} +3 -3
  71. package/dist/{dispatcher-0s0mckqr.js → dispatcher-zthpy4m1.js} +17 -17
  72. package/dist/{dsh-adapter-3n3net33.js → dsh-adapter-64dhw0j7.js} +2 -2
  73. package/dist/{e2b-thdcxtjd.js → e2b-16d6ch39.js} +1 -1
  74. package/dist/{e2b-7am6tmq6.js → e2b-zn0b4w5w.js} +1 -1
  75. package/dist/{external-channel-5b23fv8z.js → external-channel-x01t7ke6.js} +2 -2
  76. package/dist/{grok-adapter-q9fghe5q.js → grok-adapter-h21jv594.js} +5 -5
  77. package/dist/handlers-2qjw434w.js +39 -0
  78. package/dist/{heartbeat-tww6yx1w.js → heartbeat-zzfksckz.js} +20 -20
  79. package/dist/{hook-pvmh9b6m.js → hook-3xfqhh2s.js} +2 -2
  80. package/dist/{hook-dx0pkvar.js → hook-z4zhzj1f.js} +10 -5
  81. package/dist/{http-k2ejbt05.js → http-q813tmya.js} +121 -75
  82. package/dist/{index-2z710v4x.js → index-220tk9bw.js} +9 -9
  83. package/dist/index-d0g18bax.js +41 -0
  84. package/dist/{index-dgz61nff.js → index-m0a4ydqj.js} +17 -17
  85. package/dist/{index-h2ek08d6.js → index-wbbs14c5.js} +17 -17
  86. package/dist/{index-x5aktmp8.js → index-x8xbyn1t.js} +1 -1
  87. package/dist/{keepalive-nhvpw10z.js → keepalive-y5t1my7m.js} +3 -3
  88. package/dist/{lead-jb6sf69z.js → lead-2xwtq66b.js} +30 -30
  89. package/dist/{maintenance-agakc519.js → maintenance-ybfsaedv.js} +1 -1
  90. package/dist/{oauth-refresh-sweep-4dcbhtn0.js → oauth-refresh-sweep-909jmn12.js} +2 -2
  91. package/dist/{onboard-4n2kwa4v.js → onboard-xn6fkkys.js} +3 -3
  92. package/dist/{opencode-adapter-3s7ax02e.js → opencode-adapter-x4tqzhkh.js} +3 -3
  93. package/dist/{otel-impl-tfa12080.js → otel-impl-e6mn0z13.js} +1 -1
  94. package/dist/{pi-mono-adapter-ytp7gz8b.js → pi-mono-adapter-mcfkn9vf.js} +3 -3
  95. package/dist/{pricing-refresh-3cqx3f3x.js → pricing-refresh-ygd362ep.js} +3 -3
  96. package/dist/{rbac-roles-9fae5rnv.js → rbac-roles-c9axvfnz.js} +3 -3
  97. package/dist/{rbac-roles-y6zbfzve.js → rbac-roles-mb470p4e.js} +2 -2
  98. package/dist/{render-v2-emawjvc4.js → render-v2-g0306nh1.js} +3 -3
  99. package/dist/{resume-zhgts7h7.js → resume-v315hnq6.js} +5 -5
  100. package/dist/{session-access-cm823e06.js → session-access-2zg285a1.js} +4 -4
  101. package/dist/{setup-tkp99w1e.js → setup-a4mtf02k.js} +1 -1
  102. package/dist/{work-objects-f722h263.js → work-objects-9fr0fgtn.js} +21 -21
  103. package/dist/{worker-p7f336rq.js → worker-qae22jeb.js} +30 -30
  104. package/openapi.json +393 -1
  105. package/package.json +3 -3
  106. package/src/apps/catalog.generated.json +3 -2
  107. package/src/apps/definition.ts +152 -4
  108. package/src/apps/row-store.ts +19 -7
  109. package/src/apps/schema-migrate.ts +14 -6
  110. package/src/apps/script-types.ts +1 -0
  111. package/src/apps/sync.ts +20 -19
  112. package/src/be/db/tasks/read.ts +112 -0
  113. package/src/be/db.ts +9 -4
  114. package/src/be/migrations/231_slack_tasks_last_updated_index.sql +5 -0
  115. package/src/be/swarm-config-guard.ts +15 -0
  116. package/src/github/handlers.ts +6 -5
  117. package/src/hooks/hook.ts +2 -1
  118. package/src/http/apps.ts +15 -2
  119. package/src/http/status.ts +49 -43
  120. package/src/http/tasks.ts +47 -0
  121. package/src/providers/acp-targets.ts +1 -1
  122. package/src/providers/amp-adapter.ts +1 -1
  123. package/src/providers/codex-adapter.ts +4 -4
  124. package/src/providers/opencode-adapter.ts +2 -2
  125. package/src/providers/pi-mono-extension.ts +2 -1
  126. package/src/telemetry.ts +15 -3
  127. package/src/tests/app-row-tools.test.ts +25 -0
  128. package/src/tests/apps-json-column.test.ts +611 -0
  129. package/src/tests/apps-script-types.test.ts +2 -0
  130. package/src/tests/apps-sync-engine.test.ts +42 -0
  131. package/src/tests/claude-stop-hook.test.ts +47 -0
  132. package/src/tests/codex-adapter.test.ts +42 -0
  133. package/src/tests/db-task-read-contract.test.ts +4 -0
  134. package/src/tests/db-task-write-contract.test.ts +2 -0
  135. package/src/tests/docs-index-footprint.test.js +2 -2
  136. package/src/tests/env-flag.test.ts +15 -1
  137. package/src/tests/github-handlers-cancel-config.test.ts +29 -8
  138. package/src/tests/github-handlers-inline-comments.test.ts +9 -2
  139. package/src/tests/runner-harness-telemetry-env.test.ts +6 -0
  140. package/src/tests/script-connections.test.ts +2 -0
  141. package/src/tests/skill-script-refs.test.ts +63 -0
  142. package/src/tests/slack-render-v2-query-plans.test.ts +147 -0
  143. package/src/tests/status.test.ts +158 -9
  144. package/src/tests/task-lineage.test.ts +180 -0
  145. package/src/tests/telemetry-init.test.ts +62 -5
  146. package/src/tests/user-token-rest-auth.test.ts +38 -0
  147. package/src/tools/app-rows.ts +5 -0
  148. package/src/utils/env-flag.ts +9 -0
  149. package/templates/skills/apps/SKILL.md +4 -2
  150. package/templates/skills/apps/content.md +4 -2
  151. package/templates/skills/code-quality/content.md +9 -14
  152. package/dist/handlers-mqrym347.js +0 -39
  153. package/dist/index-pqfwh9nn.js +0 -41
@@ -5,7 +5,7 @@ import {
5
5
  extractArgsJsonSchema,
6
6
  typecheckScript,
7
7
  parseSkillContent
8
- } from "./cli-0bh477zn.js";
8
+ } from "./cli-73ezfhha.js";
9
9
  import {
10
10
  getDbClient2,
11
11
  computeContentHash2,
@@ -26,17 +26,17 @@ import {
26
26
  deleteSkill2,
27
27
  getSkillByName2,
28
28
  init_db3
29
- } from "./cli-rqqsfz30.js";
29
+ } from "./cli-vcgscaan.js";
30
30
  import"./cli-3ggwdtb0.js";
31
- import"./cli-sjyetj21.js";
31
+ import"./cli-pygdsgh8.js";
32
32
  import {
33
33
  agentFsProvisionSeeder
34
- } from "./cli-syhq378v.js";
35
- import"./cli-vy0fay6k.js";
36
- import"./cli-mfe7f7b5.js";
34
+ } from "./cli-p0qerd2r.js";
35
+ import"./cli-tkf4x6ha.js";
36
+ import"./cli-v87dr6e3.js";
37
37
  import {
38
38
  validateDefinition
39
- } from "./cli-tbhwrxpt.js";
39
+ } from "./cli-s9rr4gr2.js";
40
40
  import"./cli-8nyhf7za.js";
41
41
  import"./cli-kvzgak7c.js";
42
42
  import"./cli-445c1vp7.js";
@@ -6120,7 +6120,7 @@ var config_default2 = `{
6120
6120
  `;
6121
6121
 
6122
6122
  // templates/skills/apps/content.md
6123
- var content_default2 = "# Swarm Apps\n\nSwarm Apps are persistent, agent-authored internal applications: a schema-backed row store, named queries, custom script/task actions, and a validated json-render interface served at `/apps/<id>`. Use an app when people need to view and change live records; use `create_page` for a static snapshot.\n\n> **Capability gate**: app tools are available when the server's `CAPABILITIES` includes `pages` (for example `CAPABILITIES=core,task-pool,pages`). There is no separate `apps` capability. If these tools are absent from your MCP list, this is why.\n\n## Tools and iteration loop\n\n| Tool | Use it for |\n|---|---|\n| `app-list` | Discover apps by id, name, description, and timestamps. Definitions are omitted. |\n| `app-get` | Read one complete stored definition before changing it. |\n| `app-upsert` | Create an app, or replace an existing app's entire definition when you intentionally have the full desired state. |\n| `app-patch` | Make a focused change while preserving everything not mentioned in the patch. Prefer this for iteration. |\n| `app-query` | Run one declared named query and return its rows. Use this when an agent or saved script needs to consume app data rather than render the app UI. |\n| `app-rows-list` | List rows with typed equality filters, sorting, and a limit. Returns `{ rows, total }`. |\n| `app-row-get` | Read a row by `rowId`. Returns `{ row }`. |\n| `app-row-create` | Create a row from `values`. Returns `{ row }`. |\n| `app-rows-create` | Create 1 to 500 rows from a `rows: values[]` array, within 10 MiB. Returns `{ rows }`. |\n| `app-row-patch` | Patch a row by `rowId` with `values`. Returns `{ row }`. |\n| `app-row-delete` | Delete a row by `rowId`. Returns `{ ok: true }`. |\n| `app-rows-delete` | Delete matching rows with a non-empty `equals` filter. Returns `{ deleted }`. |\n| `app-action-run` | Run a declared script, task, or sync action with `{ appId, action, input? }`. Agents can invoke it through MCP only. |\n| `app-history` | List definition snapshots and their compact model/column digests before choosing a restore point. |\n| `app-diff` | Show a unified definition diff; defaults to newest snapshot → CURRENT. |\n| `app-rollback` | Restore a snapshot through the normal schema-migration engine. |\n\nFor every edit: `app-get` -> modify the smallest coherent subtree -> `app-patch` -> if rejected, fix every returned `issues[]` entry (`path` + `message`) and retry. Validation happens before storage, so a rejected update leaves the saved app unchanged. Do not guess at the current definition or use `app-upsert` with a partial definition.\n\n`app-patch` can also change `name`; omit `description` to preserve it or pass `description: null` to clear it.\n\n### Reading an app's callable surface\n\nOne `app-get` returns everything needed to drive an app you did not write: `definition.queries` are runnable with `app-query` (supply every `$param` the query references), and `definition.actions` are invocable with `app-action-run` or `POST /api/apps/<id>/actions/<name>` (a `script` action runs its script, a `task` action spawns a task, a `sync` action refreshes sources). Read the query parameters and action arguments from the definition before invocation. Do not guess parameter names.\n\n### Verified action caller\n\nScript actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.\n\n```typescript\ntype AppActionCaller = {\n kind: \"user\" | \"agent\" | \"operator\" | \"guest\";\n id: string | null;\n name?: string;\n pageId?: string;\n};\n```\n\nThe caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.\nWith the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.\nTask actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.\n\n### Verified action caller\n\nScript actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.\n\n```typescript\ntype AppActionCaller = {\n kind: \"user\" | \"agent\" | \"operator\" | \"guest\";\n id: string | null;\n name?: string;\n pageId?: string;\n};\n```\n\nThe caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.\nWith the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.\nTask actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.\n\n## Safe schema evolution and rollback\n\nPrefer **hiding** a column over deleting it. A hidden column's values stay on every existing row, so an unhide or rollback can make it visible again. Hiding is metadata-only: hidden columns cannot be used in queries, page bindings, `app.mutate` values, or new row writes, and `required` is ignored while hidden. Row GETs still expose the historical value for backward compatibility.\n\nDo not reuse a hidden column name. The name remains held until you either unhide it or explicitly purge its stored values. To permanently remove a column, delete its declaration with `null` and include `{ \"columnName\": { \"purge\": true } }` in the same `migration` object. That is deliberately one-shot and irreversible for row data.\n\nSchema-changing writes can carry this sibling `migration` object:\n\n```json\n{\n \"migration\": {\n \"priority\": { \"from\": \"flag\", \"map\": { \"urgent\": \"high\", \"watch\": \"low\" }, \"else\": \"none\" },\n \"status\": { \"from\": \"flag\", \"map\": { \"watch\": \"watching\", \"done\": \"done\" }, \"else\": \"open\" },\n \"oldColumn\": { \"purge\": true },\n \"count\": { \"coerce\": true, \"else\": 0 },\n \"owner\": { \"set\": \"unassigned\" }\n }\n}\n```\n\nUse `set` to backfill a changed column, `from` with optional `map`/`else` to derive it from one existing (including hidden) column, `coerce` for a kind change, and `purge` only for explicit destruction. Non-purge directives target a column changed in that same write. The server fails loudly before writing when a lossy change needs a directive: inspect every returned `issues[]` item, including its per-value row counts, then choose an explicit policy and retry. Never guess a migration just to get a 200.\n\n**Source lifecycle** rides the same engine with the same all-or-nothing guarantees. Free: adding a source, adding a NEW column with a `source` binding, binding an existing column that holds zero values, changing a binding's `field`/`transform`, and changing a source's `args`/`config`/`connection`/`scriptId` (window changes churn staleness on the next pass — expect `stale` flips when narrowing). Rejected with a path-bearing issue: changing `joinKey` (immutable — remove the source and add it again), changing `connector` while the source owns rows (row count in the message), and binding an existing column that already holds values (purge it — or add a fresh column — and bind that instead; a sync pass would otherwise overwrite the data). Removing a source **detaches** its rows: values survive as ordinary columns, the `source`/`syncedAt`/`stale` envelope is stripped, and the report counts them in `detachedRows`. A patch removing a source must drop its column bindings in the same call — a dangling `source.of` is rejected before anything is written.\n\nSync never adopts rows it does not own: a pulled record whose join key matches an unowned row creates a second, source-owned row. Adding a source over a join-key column that already holds values therefore duplicates those records on the first pass — and remove-source → re-add duplicates for the same reason, because detached rows keep their values. To convert a hand-maintained model into a synced one, purge the old rows first or use a fresh join-key column.\n\nFor example, split a legacy `flag` into `priority` and `status` without losing data in one patch: add the two columns, map both from `flag`, update every affected query/page binding, then hide `flag`.\n\n```json\n{\n \"appId\": \"<app-id>\",\n \"definition\": {\n \"models\": {\n \"ticket\": {\n \"columns\": {\n \"priority\": { \"kind\": \"enum\", \"enum\": [\"none\", \"low\", \"high\"] },\n \"status\": { \"kind\": \"enum\", \"enum\": [\"open\", \"watching\", \"done\"] },\n \"flag\": { \"kind\": \"string\", \"hidden\": true }\n }\n }\n }\n },\n \"migration\": {\n \"priority\": { \"from\": \"flag\", \"map\": { \"urgent\": \"high\", \"watch\": \"low\" }, \"else\": \"none\" },\n \"status\": { \"from\": \"flag\", \"map\": { \"watch\": \"watching\", \"done\": \"done\" }, \"else\": \"open\" }\n }\n}\n```\n\nEvery schema migration returns `{ scanned, backfilled, coerced, mapped, elsed, purgedValues, idxRebuilt, detachedRows, orphanFields }`. Read `orphanFields`: it reports extra row fields that predate or no longer appear in the definition; they are preserved until an explicit purge. `schemaVersion` is server-managed: never set it in an input patch or try to use it as application data.\n\nEvery successful definition write snapshots the previous definition. Use `app-history` to select a version, `app-diff({ appId, from: <version> })` to inspect it against CURRENT, then `app-rollback({ appId, version: <version>, migration? })` to restore it. Rollback is a forward migration over live rows, not row-level time travel: it creates a new pre-rollback snapshot and may reject a lossy restore with the same migration directives and counts. A rejected rollback changes nothing.\n\n### Per-user configuration\n\n`userConfig` declares up to 20 typed settings in the versioned definition; its values are deliberately stored outside the definition, separately for each user (and the operator dashboard). Each field is `{ \"kind\": \"string\" | \"number\" | \"boolean\" | \"date\" | \"enum\", \"default\"?: ..., \"enum\"?: [...], \"label\"?: \"...\" }`. Defaults follow column rules, including enum membership; `required` is not allowed. A missing or obsolete stored value reads as its default, or `null` when no default is declared.\n\nUse `{ \"$state\": \"/user/<field>\" }` on a page for the read-only current-user binding; the field must be declared and the path cannot continue below it. Pure and bound reusable elements cannot bind `/user` directly — pass the value through an element prop from the consuming page. An agent acting on an owned user-requested task reads and edits that requester's preferences. `userConfig` schema changes are always migration-compatible and appear in migration reports; no migration directives are needed. Rollback restores the historical schema but never alters saved per-user values, so values can resurface when a field returns.\n\n### Theming\n\nAn optional top-level `\"theme\"` sets the preset the dashboard applies to the app's rendered canvas — a lowercase slug validated by shape only. The preset catalog ships with the dashboard (`apps/ui/src/lib/themes.ts` + `theme-classics.ts` — update this list and the `app-upsert` tool description when it changes); current ids: `hive` (the stock look), the accent/field presets `meadow`, `iris`, `rose`, `cobalt`, `ember`, `carbon`, and the classic editor/platform presets `github`, `vscode`, `material`, `solarized`, `tokyo`, `monokai`, `gruvbox`, `catppuccin`. Action and destructive tones are never themed; status tones are themed ONLY by the classic presets (their identity includes a status palette — hue semantics stay fixed, success reads green and error reads red everywhere). An id the viewer's dashboard build does not know degrades to the dashboard's own theme, so unknown slugs are safe but pointless. Omit `theme` to inherit whatever theme the viewer runs; `\"hive\"` is an explicit reset to the stock look even inside a themed dashboard.\n\nViewers can override the preset per app from the app's settings drawer; the override rides the reserved `$theme` key of the user-config values (accepted on every app, even without a declared `userConfig` schema, and never visible at `/user/...`). A present override always beats the definition's `theme` — even an unrecognized one, which degrades to the dashboard theme — so treat `theme` as the app's default styling, not a guarantee.\n\nDistinguish the two rollback 400s. A lossy-migration 400 is fixable: copy the required `migration` entries from the message and retry. A target-snapshot validation 400 explicitly says that directives cannot repair that historical definition; use `app-history` and choose a different version.\n\nWhen a schema patch reports stale page/query bindings, repair them in that same patch. Page elements are atomic: replace the complete `pages.<page>.elements.<id>` declaration, removing any reference to a hidden or deleted column, instead of sending only the changed nested prop. This is also the repair move for older apps whose stored page already references an undeclared column; full-definition validation otherwise blocks every patch.\n\n## Definition reference\n\n```json\n{\n \"models\": {\n \"modelName\": {\n \"columns\": {\n \"externalId\": { \"kind\": \"string\" },\n \"title\": { \"kind\": \"string\" }\n }\n }\n },\n \"queries\": { \"queryName\": { \"model\": \"modelName\" } },\n \"actions\": { \"actionName\": { \"kind\": \"task\", \"prompt\": \"Review the current rows and report\" } },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": { \"root\": { \"type\": \"Container\", \"props\": {} } }\n }\n },\n \"defaultPage\": \"main\",\n \"theme\": \"ember\"\n}\n```\n\nModel, reusable-element, element-prop, query, action, column, page, and page-param names start with a lowercase letter, contain only letters, numbers, or underscores, and are at most 40 characters. A definition has 0-10 models and at most 20 reusable elements; each declared model has 1-40 columns. Zero-model apps are valid for pure UI utilities as long as their pages and reusable elements validate.\n\n### Reusable elements\n\nTop-level `elements` are versioned inside the app definition and can be reused from the app's own pages or, when explicitly exported, from another app. They are private by default. Each entry declares a `mode`, optional typed `props`, one `root`, and a flat `elements` node map using the same validated tree vocabulary as a page:\n\n```json\n{\n \"elements\": {\n \"statCard\": {\n \"mode\": \"pure\",\n \"export\": true,\n \"props\": {\n \"label\": { \"kind\": \"string\", \"required\": true },\n \"value\": { \"kind\": \"number\", \"default\": 0 }\n },\n \"root\": \"card\",\n \"elements\": {\n \"card\": { \"type\": \"Card\", \"props\": {}, \"children\": [\"label\", \"slot\"] },\n \"label\": { \"type\": \"Metric\", \"props\": { \"label\": { \"$state\": \"/props/label\" }, \"value\": { \"$state\": \"/props/value\" } } },\n \"slot\": { \"type\": \"ElementSlot\", \"props\": {} }\n }\n }\n }\n}\n```\n\n- `pure` elements may read only their declared `/props/<name>` state; `$item` and `$index` are also legal inside repeated nodes. They cannot invoke any action step. A pure tree may contain at most one leaf `ElementSlot` where consumer children are inserted.\n- `bound` elements may additionally read the defining app's declared queries and actions and use its models. Those references are validated against the defining app, including hidden-column rules. Exported bound elements cannot use `app.navigate`; private bound elements may navigate within their defining app.\n- Prop kinds are `string`, `number`, `boolean`, `date`, or `enum`. An enum prop requires a non-empty `enum: [\"value\", ...]` values array. A literal default and each literal consumer value must match the kind and, for enums, be one of those values. A required prop without a default must be supplied.\n\nReference an element with an `ElementRef` node. Omit `app` for same-app reuse; cross-app references name the defining app id and require `export: true`. `instanceKey` is optional. Consumer `children` are accepted only when the target has an `ElementSlot`:\n\n```json\n{\n \"type\": \"ElementRef\",\n \"props\": {\n \"app\": \"<defining-app-id>\",\n \"element\": \"statCard\",\n \"props\": { \"label\": \"Open issues\", \"value\": 12 },\n \"instanceKey\": \"openIssues\"\n },\n \"children\": [\"details\"]\n}\n```\n\nElement references float to the defining app's current definition. `app` and `element` must be literal strings; dynamic references are not supported. The server rejects reference cycles and expansion deeper than five, missing/private targets, invalid props, and a breaking write to an exported element that other apps reference. Removing or unexporting an exported element, changing its mode, removing a declared prop, changing a prop kind, or adding a required prop without a default is breaking; publish the new contract under a new element name. If consumers are abandoned intentionally, retry the PUT, PATCH, or rollback with `\"forceElementBreak\": [\"elementName\"]`. The named consumers will then render an error once element rendering is available; deleting an entire app is deliberately not compatibility-gated.\n\n### Models\n\nEach column is `{ \"kind\": ..., \"required\"?: boolean, \"default\"?: ..., \"index\"?: boolean, \"enum\"?: string[], \"hidden\"?: boolean }`.\n\n| `kind` | Values and indexing |\n|---|---|\n| `string` | String value; set `index: true` only when equality lookups need it. |\n| `number` | Finite number; never indexed. |\n| `boolean` | Boolean; set `index: true` only when equality lookups need it. |\n| `date` | ISO-8601 string; never indexed. |\n| `enum` | String from the non-empty unique `enum` list; always indexed, without `index: true`. |\n\n`default` must match the column kind (and be one of the enum values). `required: true` rejects missing/null values unless a default supplies the value. `id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` — and the sync envelope `source`, `syncedAt`, `stale` — are system columns and reserved names. An app created before the sync envelope that declared ordinary columns with these names surfaces `definitionError` after upgrade until they are renamed: patch in a fresh column with a `{from}` migration directive, then purge the old one. Rows always expose `id`/`createdAt`/`updatedAt`; `createdBy`/`updatedBy` record the acting principal (`user:<id>`, `agent:<id>`, `operator`, or `sync:<source>` for engine writes) and are system-managed on every row write.\n\n### Sources and sync\n\nA model may declare up to 4 named `sources` — one-way inbound projections of an external system into its rows. The `script` connector is THE way to bring external data in (GitHub, Linear, any API): a saved script owns the fetch, its auth placeholders, and its paging. `swarm-tasks` is the one native connector, projecting this swarm's own task pool (`config`: `status`/`tags` comma-lists, `agentId`, `assetKey` namespace prefix, `limit` up to 200 — default 100, `includeHeartbeat`). Synced rows are readable by EVERY principal with `app.use` on the app; an unscoped `swarm-tasks` source publishes task prompts (truncated, secret-scrubbed) to all of them, so scope it with `config.assetKey`/`config.agentId` unless that exposure is intended. A user-invoked sync only ever pulls the invoking user's own requested tasks. Script sources run with their run-as identity's credentials — an agent-owned script runs as its owner and only the owner may wire it; an owner-less catalog (global) script runs as the LEAD, so only the lead or the operator can wire or alter such a source (stored references stay valid with their args and connection pinned).\n\n```json\n{\n \"models\": {\n \"issue\": {\n \"columns\": {\n \"issueKey\": { \"kind\": \"string\" },\n \"title\": { \"kind\": \"string\", \"source\": { \"of\": \"gh\", \"field\": \"title\" } },\n \"openedAt\": { \"kind\": \"date\", \"source\": { \"of\": \"gh\", \"field\": \"created_at\", \"transform\": \"date-parse\" } },\n \"note\": { \"kind\": \"string\" }\n },\n \"sources\": {\n \"gh\": { \"connector\": \"script\", \"scriptId\": \"<script-uuid>\", \"joinKey\": \"issueKey\", \"args\": { \"repo\": \"owner/name\" }, \"connection\": \"github\" }\n }\n }\n }\n}\n```\n\n- **Join key**: each source names a `joinKey` — an owned, non-hidden `string` column carrying the external identity. It must not be `required`, must not declare a `default`, and cannot itself be source-bound. A row belongs to at most ONE source (`row.source`).\n- **Column bindings**: `{ \"of\": \"<sourceName>\", \"field\": \"user.login\", \"transform\"? }` projects a (dotted-path) field of the pulled record into the column on every pass. Transforms and their required column kinds: `slug`/`lower`/`upper` → `string`, `cents` → `number` (×100, rounded), `date-parse` → `date` (ISO). A failed transform nulls that one column and warns; it never fails the pass.\n- **Read-only contract**: source-bound columns and the join key reject every direct write (row create/patch/bulk) with a path-bearing issue — mutate them via the source or a sync refresh. Owned columns on the same model stay writable. Every `required` owned column on a sourced model must declare a `default`, because sync-created rows cannot supply it.\n- **Provenance**: synced rows carry `source` (owner), `syncedAt` (last confirmed presence — sortable), and `stale` (true when a complete pull no longer returned the record; clears on reappearance). `syncedAt` advances on every confirmed row WITHOUT touching `updatedAt`; `updatedAt` moves only when projected data actually changed.\n- **`connection`** (optional, script sources): names a connections-system slug the source depends on. It buys write-time validation plus a preflight before every pull; it does not by itself gate egress. Credentials resolve for the sync run-as identity — the script's owner when it has one, else the lead agent — so global connections always work and agent-scoped ones need matching script ownership. The slug is injected into the script's args as `connection`, letting one generic script serve N connections.\n\n**The canonical sync-script contract** (every sync script, always):\n\n```ts\nexport default async function run(args, ctx) {\n // args = { ...source.args, app: { id }, model: \"<model>\", source: \"<sourceName>\", connection?: \"<slug>\" }\n const records = [{ key: \"42\", fields: { title: \"…\", created_at: \"…\" } }];\n return { records, complete: true }; // or return the bare array — a bare array IS a complete snapshot\n}\n```\n\nReturn `Array<{ key, fields }>` or `{ records, complete? }`. `key` is the external identity the engine joins on `joinKey` (numbers are coerced to strings); `fields` is the bag bindings project from. Return `complete: false` whenever the pull window may have missed records — a paged fetch, a hit `limit`, or client-side filtering of a full page. An incomplete pull skips the stale sweep, so rows outside a narrow window are not falsely marked stale; compute `complete` BEFORE any client-side filtering. Hard cap 500 records per pull (over-cap pulls truncate and count as incomplete). An invalid return shape, thrown error, or non-zero exit fails the pass with zero row churn.\n\nThe seeded catalog script `github-issues-pull` is the worked example (placeholder auth via `[REDACTED:GITHUB_TOKEN]`, PR filtering, `complete` computed before filtering). For scheduled refreshes, point a `targetType: \"script\"` schedule at the seeded `app-sync-run` with `scriptArgs: { \"appId\": \"<id>\", \"model\"?, \"source\"? }`.\n\n**Three refresh doors, one engine**: `POST /api/apps/<id>/sync` (body `{ \"model\"?, \"source\"? }`), a `sync` action kind, and the `app-sync` MCP tool (`ctx.swarm.app_sync` from scripts). `{model?, source?}` fans out to every matching (model × source) pair; each pass reports `{ pulled, created, updated, refreshed, unchanged, markedStale, warnings }`. A pair already syncing in this API process short-circuits with `alreadyRunning: true` instead of pulling twice. Each pair's last-pass status (`lastFinishedAt`, counts, `error?`) comes back as `syncStatus` — keyed `\"<model>:<source>\"` — on `GET /api/apps/<id>` and in `app-get` data, so UI and agents can render \"last synced / last error\" without extra calls.\n\n### Freshness and the refresh button\n\nFreshness renders with the existing column kinds — no special components. Give the table `{ \"key\": \"syncedAt\", \"kind\": \"date\" }` and `{ \"key\": \"stale\", \"kind\": \"badge\", \"tones\": { \"true\": \"warning\" } }` columns, declare a `sync` action, and wire a Button to it:\n\n```json\n{\n \"actions\": { \"refresh\": { \"kind\": \"sync\" } },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": {\n \"root\": { \"type\": \"Container\", \"props\": {}, \"children\": [\"refreshBtn\", \"issuesTable\"] },\n \"refreshBtn\": {\n \"type\": \"Button\",\n \"props\": { \"label\": \"Refresh\" },\n \"on\": { \"press\": [{ \"action\": \"app.action\", \"params\": { \"name\": \"refresh\" } }] }\n },\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Issue\" },\n { \"key\": \"syncedAt\", \"label\": \"Synced\", \"kind\": \"date\" },\n { \"key\": \"stale\", \"label\": \"Stale\", \"kind\": \"badge\", \"tones\": { \"true\": \"warning\" } }\n ]\n }\n }\n }\n }\n }\n}\n```\n\nA `sync` action answers the script-action state shape (`/actions/refresh` → `running` → `ok`/`error`, result under `result.passes`), so the page's queries refetch when it completes. Use `app-query` to READ rows (named queries may filter on `stale`/`source` and sort by `syncedAt`); use `app-sync` to REFRESH sources; never poll a sync door in a loop — schedule it or give people the button.\n\n### Queries\n\nA named query is:\n\n```json\n{\n \"model\": \"idea\",\n \"filter\": { \"status\": \"open\" },\n \"sort\": { \"column\": \"createdAt\", \"dir\": \"desc\" },\n \"limit\": 100\n}\n```\n\n`filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and on synced models `source`, `syncedAt`, `stale`) — filtering on `id` is the universal way to select one row for a detail view, and `{ \"stale\": true }` selects rows a complete pull stopped confirming. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a model column, `createdAt`, `updatedAt`, or `syncedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.\n\nA filter value may instead be exactly `{ \"$param\": \"<name>\" }`. At execution, the caller supplies that name and the server coerces its value to the filtered column's kind before equality matching:\n\n```json\n{\n \"queries\": {\n \"issueDetail\": { \"model\": \"issue\", \"filter\": { \"issueId\": { \"$param\": \"issueId\" } }, \"limit\": 1 }\n }\n}\n```\n\nThe placeholder object must contain only `$param`. Missing caller values fail loudly rather than returning unfiltered rows: HTTP reports 400 and `app-query` returns a tool error listing every missing name. Supplying a param name the query does not reference is also an error. The app UI supplies current declared route params automatically. Direct calls use `app-query({ appId, query: \"issueDetail\", params: { issueId: 42 } })` or `ctx.swarm.app_query({ appId, query: \"issueDetail\", params: { issueId: 42 } })`.\n\n### Custom actions\n\nActions are optional (maximum 20) and are invoked from the page with the `app.action` runtime action.\n\n```json\n{\n \"actions\": {\n \"recalculate\": {\n \"kind\": \"script\",\n \"scriptId\": \"00000000-0000-4000-8000-000000000000\",\n \"args\": { \"mode\": \"fast\" }\n },\n \"triage\": {\n \"kind\": \"task\",\n \"prompt\": \"Triage this idea and report a recommendation\"\n }\n }\n}\n```\n\n| Kind | Contract |\n|---|---|\n| `script` | `scriptId` must identify an existing script; optional `args` are defaults. Invocation input overrides same-named defaults, and the runtime also supplies `app: { id }`. |\n| `task` | `prompt` must be non-empty; omit `agentId` to use default (lead) assignment. Only set `agentId` to a real agent UUID. Invocation `input` is included as context for the task prompt. Spawned tasks carry asset key `shared/app:<appId>/action:<name>/`, so a `swarm-tasks` source with `config.assetKey: \"shared/app:<appId>/\"` pulls back the tasks this app spawned. |\n| `sync` | Optional `model` and `source` narrow which (model × source) pairs refresh; omitting both syncs every pair the app declares. At least one pair must exist. Answers the script-kind state shape with per-pass results under `result.passes`. |\n\n- Invocation state lands at `/actions/<name>` as `{ status, result?, error?, taskId?, taskStatus? }`, where `status` is `\"running\"`, `\"ok\"`, or `\"error\"`.\n\nUse a task action named Tackle when a person should hand one issue row to the swarm. Pass the complete current row under `input.issue`; `{ \"$row\": \"\" }` resolves to the whole row and that context lands in the task prompt.\n\n```json\n{\n \"actions\": {\n \"tackle\": { \"kind\": \"task\", \"prompt\": \"Tackle this issue. Inspect the supplied issue context, do the work, and report the outcome.\" }\n },\n \"pages\": {\n \"main\": {\n \"root\": \"issuesTable\",\n \"elements\": {\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [{ \"key\": \"title\", \"label\": \"Issue\" }],\n \"rowActions\": [{\n \"label\": \"Tackle\",\n \"actions\": [{\n \"action\": \"app.action\",\n \"params\": { \"name\": \"tackle\", \"input\": { \"issue\": { \"$row\": \"\" } } }\n }]\n }]\n }\n }\n }\n }\n },\n \"defaultPage\": \"main\"\n}\n```\n\nFor direct MCP calls, `app-query` accepts `{ appId, query, params? }` and returns rows from that declared named query; every supplied param must be referenced by a `$param` filter in that query. Use it when an agent needs to read app state without scraping the UI.\n\nSaved scripts use the generated SDK name `ctx.swarm.app_query({ appId, query, params? })`. Use `app_query` in scripts or workflows that turn current app rows into reports, digests, or follow-up work. Saved scripts also get generated per-app types automatically: each app contributes an `App_<Name>` namespace with one row interface per model (enum columns as literal unions) and a typed `app_query` overload per named query, all visible via `script-query-types`. Scripts can create, list, get, patch, and delete rows with the seven row tools. Actions remain REST/MCP-only and have no script SDK method.\n\n### Pages, routes, and page trees\n\n`pages` is a map of independently validated page trees and `defaultPage` must name one entry. Each page may also declare a display `title` and typed route `params`. On every non-default page the runtime automatically renders breadcrumbs (`<default page title> › <current page title>`, first crumb navigates back) — including in chromeless embeds — so a detail page does NOT need its own back button; give pages good `title`s instead. This complete pattern connects a table row to a typed detail route and its parameterized query:\n\n```json\n{\n \"models\": {\n \"issue\": {\n \"columns\": {\n \"issueId\": { \"kind\": \"number\" },\n \"title\": { \"kind\": \"string\" },\n \"status\": { \"kind\": \"string\" }\n }\n }\n },\n \"queries\": {\n \"allIssues\": { \"model\": \"issue\" },\n \"issueDetail\": {\n \"model\": \"issue\",\n \"filter\": { \"issueId\": { \"$param\": \"issueId\" } },\n \"limit\": 1\n }\n },\n \"pages\": {\n \"issues\": {\n \"title\": \"Issues\",\n \"root\": \"issuesTable\",\n \"elements\": {\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Issue\" },\n { \"key\": \"status\", \"label\": \"Status\", \"kind\": \"badge\" }\n ],\n \"rowActions\": [{\n \"label\": \"Open\",\n \"actions\": [{\n \"action\": \"app.navigate\",\n \"params\": {\n \"page\": \"detail\",\n \"params\": { \"issueId\": { \"$row\": \"issueId\" } }\n }\n }]\n }]\n }\n }\n }\n },\n \"detail\": {\n \"title\": \"Issue detail\",\n \"params\": { \"issueId\": { \"kind\": \"number\", \"required\": true } },\n \"root\": \"detail\",\n \"elements\": {\n \"detail\": {\n \"type\": \"DetailList\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/issueDetail/data/0\" },\n \"fields\": [{ \"key\": \"title\", \"label\": \"Issue\" }]\n }\n }\n }\n }\n },\n \"defaultPage\": \"issues\"\n}\n```\n\nDrawer variant: declare a route param on the containing page, set the Drawer `props.param` to that literal param name, and use the same `app.navigate` shape targeting that page with the row id assigned to the Drawer's param. The Drawer then opens from route state without a separate action contract.\n\nParam `kind` is `string` by default, or `number` / `boolean`; `required` defaults to false. The names `mode`, `apiUrl`, `apiKey`, `email`, and `name` are reserved. `/apps/<id>` renders `defaultPage`; `/apps/<id>/p/<page>?issueId=42` is the shareable deep link for another page. Browser navigation is client-side, so app state and polled data stay warm across page changes.\n\nWithin each page, `root` names one entry in its non-empty `elements` map. Elements are a flat, single-parent tree: ids are map keys, `children` contains ids (not nested elements), all elements must be reachable from `root`, and cycles, missing children, or shared children are invalid. Element and Form/UI ids are page-local. Element keys are `type`, `props`, `children`, `on`, `visible`, `repeat`, and `watch`; only components with a `default` child slot accept `children` (Stack, Grid, Split, Tabs, Container, Card, Section, and Drawer). `watch` maps state paths to an action step or chain.\n\n## Component catalog (all 33)\n\nProps reject unknown keys. A `{\"$state\":\"...\"}` binding may replace a literal prop value at any depth.\n\nIcons: every `icon` prop (and the `Icon` block) takes one name from a curated lucide set: `plus`, `refresh-cw`, `search`, `filter`, `download`, `upload`, `external-link`, `copy`, `check`, `x`, `trash-2`, `pencil`, `eye`, `calendar`, `clock`, `user`, `users`, `bot`, `git-pull-request`, `git-branch`, `github`, `slack`, `mail`, `link`, `file-text`, `folder`, `database`, `zap`, `sparkles`, `flag`, `star`, `tag`, `alert-triangle`, `info`, `circle-check`, `trending-up`, `trending-down`, `dollar-sign`, `activity`, `rocket`. Any other name is rejected.\n\nTones: Badge, Metric and Progress `tone` take `neutral`, `success`, `active`, `error`, `info`, `pending`, `warning`, or `paused`.\n\n| Component | Required props | Optional props / values |\n|---|---|---|\n| `Stack` | none | Primary layout; `direction: \"column\", \"row\"`, `gap`, `padding: \"none\", \"xs\", \"sm\", \"md\", \"lg\", \"xl\"`; `align: \"start\", \"center\", \"end\", \"stretch\"`, `justify: \"start\", \"center\", \"end\", \"between\"`, `wrap`; `motion: \"rise\"` (default), `\"fade\"`, or `\"none\"` (see Motion below); has the `default` child slot. |\n| `Grid` | none | Responsive `columns`: integer 1 through 6, or `{ base, sm, md, lg }` counts 1 through 6; `gap` uses the Stack spacing values; has the `default` child slot. |\n| `Split` | none | `ratio: \"1-1\", \"1-2\", \"2-1\", \"1-3\", \"3-1\"`, `gap`, `collapseBelow: \"sm\", \"md\", \"lg\"`, `reverse`; has the positional `default` child slot. |\n| `Divider` | none | `label`; no children. |\n| `Tabs` | `id`, `tabs` | `tabs` entries are `{ key, label?, icon?, count? }` (`count` is a number shown as a small pill); `defaultTab`; `variant: \"line\"` (default) or `\"segmented\"` (a compact segmented control, same state and warm panels); has the positional `default` child slot. |\n| `Container` | none | Legacy layout primitive; prefer Stack. `direction: \"row\", \"column\"`, `gap: \"none\", \"sm\", \"md\", \"lg\"`; has the `default` child slot. |\n| `Card` | none | `title`, `description`; has the `default` child slot. |\n| `Heading` | `text` | `level: \"h1\", \"h2\", or \"h3\"`. |\n| `Text` | `content` | `tone: \"default\" or \"muted\"`. |\n| `Markdown` | `content` | Rendered Markdown for help, instructions, and rich prose; no children. |\n| `SearchInput` | `id` | `placeholder`, `label`, `shortcut` (one key, usually `\"/\"`: shows a keycap and focuses the field); writes debounced text to `/ui/<id>/value`; no children. |\n| `Select` | `id`, `options` | `options` are strings or `{ value, label?, icon?, hint? }`; `placeholder`, `label`, `clearable`, `searchable` (type to filter; on by default past 8 options), `searchPlaceholder`, `multiple`; writes a string or null to `/ui/<id>/value`, or with `multiple` a string array (empty = nothing picked); no children. |\n| `Chips` | `id`, `options` | Toggle chips for quick filters. `options` are strings or `{ value, label?, icon? }`; `multiple`; `label` (group name for screen readers). Writes a string or null to `/ui/<id>/value`, or a string array with `multiple`; pressing the selected chip clears it. |\n| `Button` | `label` | `variant: \"default\", \"secondary\", \"outline\", \"ghost\", \"destructive\", or \"destructive-outline\"` (use `destructive-outline` for standalone deletes); `disabled` (constant or bound boolean); `busyWith: \"<actionName>\"` swaps the label for a spinner while that custom action's `/actions/<name>/status` is `\"running\"` (presses are ignored), then shows a check and `successLabel` for about 1.5s when it turns `\"ok\"`; `icon`, `iconPosition: \"start\" or \"end\"`; `size: \"sm\", \"default\", or \"lg\"`; `shortcut` (one key, shown as a keycap, presses the button); `hint` (tooltip); dispatch with element-level `on.press`. |\n| `Metric` | `label`, `value` | The dashboard stat tile. `value` is string or number (empty renders an em dash); bind `loading` to `/queries/<name>/loading` for a skeleton while the query loads; `icon` with `tone` tinting its tile; `detail` (a line under the value); `trend: { direction: \"up\", \"down\", or \"flat\", label }`; `info` (explanation tooltip next to the label). |\n| `Alert` | `message` | `title`, `tone: \"info\", \"success\", \"warning\", or \"error\"`, `icon` (overrides the tone icon), `actionLabel` (a button inside the callout that emits `press`; wire it with `on.press`). |\n| `Badge` | `text` | `text` is string or number; `tone`; `icon` replaces the dot; `variant: \"tag\"` (default uppercase chip), `\"status\"` (sentence-case pill), `\"count\"` (numeric pill), or `\"beta\"` (the BETA tag); `status` (a lifecycle value renders the dashboard status chip); `hint` (tooltip; the beta tag's explanation). |\n| `Icon` | `name` | `size: \"sm\", \"md\", or \"lg\"`, `tone: \"default\", \"muted\", \"success\", \"warning\", \"error\", or \"info\"`, `label` (accessible name; decorative without it). |\n| `Progress` | `value` | `max` (default 100), `tone`, `label`, `variant: \"bar\"` (default) or `\"ring\"` (the dashboard progress ring; ignores `tone`), `showValue` (default true: percent for a bar, `value / max` for a ring). |\n| `EmptyState` | `title` | `icon` (default inbox), `body`, `actionLabel` (a button that emits `press`; wire it with `on.press`). |\n| `CopyButton` | `value` | `value` is string or number; `label` is the visible text, icon-only without it. Renders nothing for an empty value. |\n| `Kbd` | `keys` | Keycaps, one per string (`[\"⌘\", \"K\"]`). Display only. |\n| `Skeleton` | `shape` | `shape: \"line\", \"block\", \"rows\", \"metric\", or \"card\"`; `count` (1-20: lines, rows, tiles, or cards); `height` (px, `block` only, default 128). Show it with `visible` bound to a query's `loading`. |\n| `Spinner` | none | `variant: \"ring\", \"ellipsis\", or \"bars\"` (default: the dashboard spinner), `size: \"sm\" or \"md\"`, `label` (visible text and accessible name). |\n| `AvatarStack` | `users` | `users` are display names (initials avatars); `max` (default 4) before a `+N` count. Hover lists every name. |\n| `Section` | `title` | `description`, `collapsible`, `defaultOpen` (default true); has the `default` child slot. |\n| `Table` | `columns` | `data`, `loading`, `error`, `emptyMessage`, `emptyState`, `rowActions`, `onRowClick`, `selectedId`, `rowId`, `mobile`, `mobileFields`, `search`, `filters`; see below. |\n| `Form` | `id`, `fields`, `onSubmit` | `title`, `submitLabel`; see below. |\n| `Drawer` | `param` | `title`, `description`, `side: \"right\" or \"left\"`, `size: \"sm\", \"md\" (default), \"lg\", or \"xl\"` (about 480/660/900/1010px at 1440px; grows with the viewport up to 640/960/1280/1600px; full width on phones), `actions` (sticky footer buttons; see below); has the `default` child slot. |\n| `DetailList` | `fields` | `data`, `emptyMessage`, `columns: 1 or 2`, `loading` (bind `/queries/<name>/loading` for skeleton fields while loading); renders one record without edit controls; see below for field kinds. |\n| `Kanban` | `columnField`, `columns`, `titleField` | `data`, `loading`, `cardFields`, `idField` (default `id`), `onMove`, `onCardClick`; see below. |\n| `Calendar` | `startField`, `titleField` | `data`, `loading`, `endField`, `idField`, `onSelect`; read-only month grid; see below. |\n| `ContributionGraph` | none | `data`, `loading`, `dateField` (default `date`), `countField`, `days` (7-730, default 365); read-only activity heatmap. |\n\nTable details:\n\n- `columns[]`: `{ key, label?, kind?, tones?: {value: badgeTone}, width?: number, pinned?: \"left\" or \"right\", align?: \"start\" or \"end\", truncate?: \"end\" or \"middle\", icon?, tooltip?: \"<row field>\", max?: number, onChange?: chain }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge`, `status`, `user`, `agent`, `relative`, `duration`, `progress`, `copy`, `switch`, or `link`; `string` and `enum` render as text. Pin id/actions columns so they survive horizontal scroll on narrow viewports.\n - `status`: lifecycle values (`pending`, `in_progress`, `running`, `completed`, `failed`, `cancelled`, `paused`, `reviewing`, ...) render the dashboard status chip with its icon; other values use `tones` like `badge`.\n - `user` / `agent`: avatar plus name. `relative` (and `date`): smart time, exact time on hover. `duration`: milliseconds as `1m 23s`. `progress`: a bar against `max` (default 100). `copy`: the value plus a copy button. `link`: an http(s) URL opening in a new tab (other values render as text).\n - `switch`: a toggle. `onChange` runs with `$row` = the row with this column already set to the new value, e.g. `[{ \"action\": \"app.mutate\", \"params\": { \"model\": \"m\", \"op\": \"update\", \"rowId\": { \"$row\": \"id\" }, \"values\": { \"enabled\": { \"$row\": \"enabled\" } } } }]`. Without `onChange` the switch is read-only.\n - Numbers, durations and progress right-align by default. `truncate: \"middle\"` keeps both ends of ids and paths. `icon` shows in the header; `tooltip` names a row field shown on hover.\n- `onRowClick`: action chain with `$row` / `$rowIndex`, run when a row is clicked (or Enter on a focused cell). Pair it with `app.navigate` that sets a Drawer param, and bind `selectedId` to `{ \"$state\": \"/route/params/<param>\" }` so the open row stays highlighted. `rowId` names the id field (default `id`).\n- Phones (below 768px): the table renders as a card list. `mobileFields: { title, subtitle?, meta?: string or string[], status? }` picks the columns; by default the first text column is the title and the first `status`/`badge` column the chip. `mobile: \"scroll\"` keeps the grid; it is the default when the table has `rowActions` but no `onRowClick`.\n- `emptyState: { icon?, title, body? }` replaces the plain empty message. A search or filter that hides every row shows its own \"No rows match\" state.\n- `pagination`: boolean; defaults to auto-enabling past 200 rows (set `false` to force one scroll region). `density: \"compact\"` tightens row height for dense readouts.\n- `rowActions[]`: `{ label, variant?, icon?, placement?: \"inline\" or \"menu\", confirm?, actions }`. `placement: \"menu\"` moves the action into the row's More menu. Variants add `destructive-outline` to the Button variants. `destructive` and `destructive-outline` confirm by default; customize with `{ \"title\": ..., \"description\": ..., \"confirmLabel\": ... }`, or use a bare `confirm` string as the dialog description (use `confirm: false` only for a reversible action).\n- `search`: optional string; case-insensitive substring matching across the listed string and number columns. Bind a SearchInput value for client-side search.\n- `filters`: optional record of per-column string, number, boolean, null, or array values. An array means \"the cell is one of these\" (what a `multiple` Chips or Select writes). Null, empty strings, empty arrays, or absent values disable one filter. Bind Select or Chips values for client-side filters.\n\nKanban details:\n\n- One card per `data` row, grouped by the row's `columnField` value. `columns[]` are plain strings or `{ id, label? }`, in display order; rows matching no column are hidden.\n- `cardFields[]` uses the DetailList field shape (`{ key, label?, kind?, tones? }`, same kinds) for extra lines under the title.\n- `onMove` is an action chain run when a card is dropped in another column. `$row` is the moved row with `columnField` already set to the new column: `[{ \"action\": \"app.mutate\", \"params\": { \"model\": \"deal\", \"op\": \"update\", \"rowId\": { \"$row\": \"id\" }, \"values\": { \"stage\": { \"$row\": \"stage\" } } } }]`. Without `onMove`, cards cannot be dragged.\n- `onCardClick` is an action chain with `$row`; pair it with `app.navigate` and a `Drawer` to open a card.\n\nCalendar details:\n\n- `startField` / `endField` hold ISO dates, ISO datetimes, or epoch ms. An event shows on every day from start to end; without `endField` it shows on its start day.\n- Each Calendar keeps its own visible month (prev/next controls in its header). `onSelect` is an action chain with `$row`, run when an event is clicked.\n\nContributionGraph details:\n\n- Buckets `data` rows by the calendar day of `dateField` over the trailing `days`. Each row counts 1, or adds its numeric `countField` when set.\n\nForm details:\n\n- `fields[]`: `{ name, label?, kind?, options?: string[], placeholder?, required? }`; `kind` is `string`, `text`, `number`, `boolean`, `date`, or `enum`, and enum fields need `options`.\n- Values live at `/forms/<formId>/<fieldName>`. `onSubmit` is an action chain.\n- The submit button shows a pending spinner while the chain runs, and `app.mutate` failures in the chain render inline under the fields (not the page banner) — no extra wiring needed.\n\nDrawer details:\n\n- `param` must name a param declared on the containing page. The drawer is open exactly when `/route/params/<param>` has a non-empty value, so its open state survives refresh and is shareable. Its children mount only while open; closing it clears that param with history replacement.\n- `actions[]`: `{ label, variant?, icon?, confirm?, actions }`, rendered as buttons in a sticky footer under the scrolling body. Confirmation follows Table `rowActions`: destructive variants confirm by default, and a chain with an `app.mutate` delete always confirms. Chains have no `$row`; address the open record with `{ \"$state\": \"/route/params/<param>\" }`, for example `{ \"action\": \"app.mutate\", \"params\": { \"model\": \"order\", \"op\": \"delete\", \"rowId\": { \"$state\": \"/route/params/panel\" } } }`. Prev/next record navigation is not available.\n\nDetailList details:\n\n- `fields[]`: `{ key, label?, kind?, tones? }`. Kinds reuse Table formatting (`text`, `string`, `number`, `boolean`, `date`, `badge`, `enum`) and the Table cell renderers (`status`: lifecycle chip or `tones` pill; `user`: avatar plus name; `relative`: smart time, exact on hover; `copy`: value plus copy button), and add `code` for a monospace block and `json` for a collapsible JSON tree (objects, or a string holding JSON). Bind `data` to one record, commonly `/queries/<detailQuery>/data/0`.\n\nMotion details:\n\n- Every `Stack` fades its direct children in and lifts them 4px over 200ms on first paint, staggered 30ms (capped at the 10th child). It never replays on the 5s data poll. A Stack nested inside another one does not add its own entrance. `motion: \"fade\"` drops the lift; `motion: \"none\"` turns it off. Viewers with reduced motion get a 150ms fade.\n\nShortcut details:\n\n- `Button.shortcut` and `SearchInput.shortcut` are one key. Letters and digits match without Shift; symbols such as `/` and `?` match as typed. They do nothing while focus is in a text field, while Ctrl, Cmd, or Alt is held, or while a dialog or menu is open (unless the control is inside it, such as a Button in an open Drawer).\n\n## Layout & interactivity\n\nUse `Stack` as the primary page and section layout; `Container` is the legacy two-prop primitive retained for existing pages. Stack supports vertical or horizontal flow, shared spacing, alignment, justification, wrapping, and padding; row Stacks (filter bars, button rows) should set `collapseBelow: \"sm\"|\"md\"|\"lg\"` so they stack vertically on phones. Use `Grid` for responsive card or metric strips: a bare column count is responsive shorthand (1 column on phones, 2 from `sm`, the count from `md` up), or pin breakpoints explicitly with `{ \"base\": 1, \"md\": 2, \"lg\": 3 }`. `Grid` and `Split` also accept `padding` on the shared spacing scale.\n\n`Split` and `Tabs` children are positional. For Split, `children[0]` is the first pane, `children[1]` is the second, and extra children append inside the second pane. Below `collapseBelow`, panes stack; `reverse` changes only that narrow-layout stacking order. For Tabs, `children[i]` is the body for `tabs[i]`; keep both arrays in the same order and with the same count. Inactive tab children stay mounted but hidden, so Tables keep polling. Use `Divider` to separate sections and `Markdown` for richer explanatory content.\n\nSearchInput, Select, and Chips are client-side controls: each needs a literal `id` and writes state under `/ui/<id>/value`; Tabs writes its active key to `/ui/<id>/tab`. Bind those values into a Table's `search` and `filters` props. This filters already-polled rows locally; it does not alter or re-run the named query.\n\n```json\n{\n \"filters\": { \"type\": \"Stack\", \"props\": { \"gap\": \"sm\" }, \"children\": [\"query\", \"status\"] },\n \"query\": { \"type\": \"SearchInput\", \"props\": { \"id\": \"ideaSearch\", \"placeholder\": \"Search ideas\" } },\n \"status\": { \"type\": \"Select\", \"props\": { \"id\": \"ideaStatus\", \"options\": [\"open\", \"done\"] } },\n \"ideas\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"columns\": [{ \"key\": \"title\" }, { \"key\": \"status\" }],\n \"search\": { \"$state\": \"/ui/ideaSearch/value\" },\n \"filters\": { \"status\": { \"$state\": \"/ui/ideaStatus/value\" } }\n }\n }\n}\n```\n\n`visible` accepts a boolean, a `$state` truthiness binding, comparisons, and logical groups. Put exactly one comparison key in each condition object. The renderer evaluates the first matching key in fixed priority order (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`; otherwise truthiness), so additional comparison keys in the same object are ignored. Use `$and` / `$or` arrays to combine conditions. `not` is a negation FLAG inside a condition object (`\"not\": true`), never a wrapper around one — `{ \"not\": { \"$state\": … } }` is invalid and the validator rejects it. The absent-record pattern (e.g. an alert when a detail query matched nothing) is `{ \"$state\": \"/queries/<q>/data/0/id\", \"not\": true }`:\n\n```json\n[\n { \"$state\": \"/route/params/issueId\", \"gt\": 0 },\n {\n \"$and\": [\n { \"$state\": \"/route/page\", \"eq\": \"detail\" },\n { \"$state\": \"/route/params/panel\", \"neq\": \"hidden\" }\n ]\n }\n]\n```\n\n## Bindings, sentinels, and action chains\n\nUse this exact query binding shape; do not invent `$query`, `$item`, or template strings:\n\n```json\n{\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"loading\": { \"$state\": \"/queries/allIdeas/loading\" },\n \"error\": { \"$state\": \"/queries/allIdeas/error\" }\n}\n```\n\nValid `$state` roots are:\n\n- `/queries/<declaredQuery>/...`\n- `/forms/<formId>/...`, where a `Form` element has that literal `props.id`\n- `/ui/<id>/...`, where a SearchInput, Select, Chips, or Tabs element has that literal `props.id` (SearchInput, Select, and Chips use `/value`; Tabs uses `/tab`)\n- `/actions/<declaredAction>/...`\n- `/route/page` for the active page name\n- `/route/params/<declaredParam>` for a param declared on the current page\n\n`/route` is mirrored as `{ page, params }`. Only declared params appear. Values are coerced by their declared kind: `number` becomes a number when possible; boolean strings `true`/`1` become true; failed number coercion remains the raw string.\n\nOnly inside action-chain `params` (`on.<event>`, `Table.rowActions[].actions`, or `Form.onSubmit`) these scoped sentinels are valid, recursively inside objects and arrays:\n\n| Sentinel | Resolves to |\n|---|---|\n| `{ \"$row\": \"id\" }` | Current Table row's `id`; another column name selects that field. |\n| `{ \"$row\": \"\" }` | Entire current Table row. |\n| `{ \"$rowIndex\": true }` | Current Table row index. |\n| `{ \"$form\": \"title\" }` | One current Form value. |\n| `{ \"$form\": \"\" }` | All current Form values. |\n| `{ \"$state\": \"/queries/allIdeas/data\" }` | Current value at a valid app state path, including action state such as `/actions/refreshIssues/status`. |\n\nEach sentinel object must contain exactly the single key shown.\n\nAn action-chain step is `{ \"action\": \"<type>\", \"params\": {...} }`. Available action types:\n\n- `app.mutate`: `{ model, op, rowId?, values?, formId? }`, where `op` is `\"create\"`, `\"update\"`, or `\"delete\"`. `update`/`delete` require `rowId` (usually `{ \"$row\": \"id\" }`, or `{ \"$state\": \"/route/params/<param>\" }` in a Drawer footer); literal `values` keys must be model columns. Successful mutation refetches all queries on that model.\n- `app.refresh`: `{ query? }`; omit `query` to refetch all, or name a declared query.\n- `app.action`: `{ name, input? }`; `name` must be declared in definition `actions`. For a task-kind row action, pass whole-row context with `input: { issue: { \"$row\": \"\" } }`.\n- `app.navigate`: `{ page, params? }`; `page` must be declared, supplied keys must be params of the target page, and every required target param must be supplied. `params` replace the current route params wholesale and may contain action sentinels. Navigation pushes history, so browser Back returns to the previous page/params; only `mode` is preserved automatically.\n- `ui.toast`: `{ message, description?, tone?, undo? }`, where `tone` is `\"success\"`, `\"error\"`, `\"info\"` (default), or `\"warning\"`. `undo: { label?, actions }` adds an Undo button (default label `Undo`) that runs `actions` as a chain; its steps are validated like any chain. Put it after the step it confirms, e.g. a row delete followed by `{ \"action\": \"ui.toast\", \"params\": { \"message\": \"Order deleted\", \"undo\": { \"actions\": [{ \"action\": \"app.mutate\", \"params\": { \"model\": \"order\", \"op\": \"create\", \"values\": { \"title\": { \"$row\": \"title\" } } } }] } } }`. Undo params resolve `$state` when Undo is pressed; `$row` / `$form` resolve when the toast is shown.\n- `swarm.sdk`: `{ sdk, args? }`; invokes a catalog-supported Swarm browser SDK method with the viewer's bearer.\n- `swarm.call`: `{ method, endpoint: \"/api/...\", body? }`, where `method` is `\"GET\"`, `\"POST\"`, `\"PUT\"`, `\"DELETE\"`, or `\"PATCH\"`; raw authenticated API call.\n\n## Patch semantics\n\n`app-patch.definition` is RFC 7396 JSON Merge Patch applied to the stored definition:\n\n- omitted keys stay unchanged;\n- object keys merge recursively;\n- arrays and scalar values replace;\n- `null` deletes a key;\n- reusable-element disambiguation: a patch value for `elements.<name>` containing ONLY the `elements` key merges node-by-node; any other key present (`mode`, `root`, `props`, or `export`) makes it a full element replace — restate every field you want kept. In that node-by-node form, `elements.<name>.elements.<id>: null` deletes one node. Literal null nodes are rejected in a full element replace so they cannot be mistaken for deletions.\n- otherwise every supplied `pages.<page>.elements.<id>`, `pages.<page>.params.<param>`, `actions.<name>`, `models.<name>.columns.<col>`, and `models.<name>.sources.<src>` value is atomic and replaces that complete declaration; `null` deletes it (deleting a source detaches its rows — see the source lifecycle above).\n\n`pages.<page>: null` deletes a page, except the current `defaultPage` cannot be deleted. `root`, `title`, and top-level `defaultPage` use ordinary merge semantics. A top-level `page` patch is rejected with guidance to patch `pages.<name>` instead.\n\nBecause elements, param declarations, actions, and columns are atomic, include the complete desired object when changing one. For example, changing only a Table's `emptyMessage` requires sending its full `{ \"type\": \"Table\", \"props\": ... }` element. The merged result is validated as a whole; on failure, read `issues[]`, correct the paths, and retry without assuming anything was written.\n\n```json\n{\n \"appId\": \"<app-id>\",\n \"description\": null,\n \"definition\": {\n \"models\": {\n \"idea\": {\n \"columns\": {\n \"priority\": { \"kind\": \"enum\", \"enum\": [\"low\", \"high\"], \"default\": \"low\" }\n }\n }\n },\n \"queries\": { \"openIdeas\": null },\n \"pages\": {\n \"main\": {\n \"elements\": {\n \"title\": { \"type\": \"Heading\", \"props\": { \"text\": \"Prioritized ideas\", \"level\": \"h1\" } }\n }\n }\n }\n }\n}\n```\n\n## Worked example: ideas tracker\n\nCreate this with `app-upsert`:\n\n```json\n{\n \"name\": \"Ideas Tracker\",\n \"description\": \"Collect and manage product ideas\",\n \"definition\": {\n \"models\": {\n \"idea\": {\n \"columns\": {\n \"title\": { \"kind\": \"string\", \"required\": true },\n \"status\": { \"kind\": \"enum\", \"enum\": [\"open\", \"done\"], \"default\": \"open\" }\n }\n }\n },\n \"queries\": {\n \"allIdeas\": { \"model\": \"idea\", \"sort\": { \"column\": \"createdAt\", \"dir\": \"desc\" } }\n },\n \"actions\": {\n \"triage\": { \"kind\": \"task\", \"prompt\": \"Review current ideas and recommend the next one to pursue\" }\n },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": {\n \"root\": {\n \"type\": \"Container\",\n \"props\": { \"direction\": \"column\", \"gap\": \"lg\" },\n \"children\": [\"heading\", \"formCard\", \"ideasCard\", \"triage\"]\n },\n \"heading\": { \"type\": \"Heading\", \"props\": { \"text\": \"Ideas\", \"level\": \"h1\" } },\n \"formCard\": {\n \"type\": \"Card\",\n \"props\": { \"title\": \"Add an idea\" },\n \"children\": [\"ideaForm\"]\n },\n \"ideaForm\": {\n \"type\": \"Form\",\n \"props\": {\n \"id\": \"newIdea\",\n \"fields\": [{ \"name\": \"title\", \"label\": \"Title\", \"required\": true }],\n \"submitLabel\": \"Add\",\n \"onSubmit\": [{\n \"action\": \"app.mutate\",\n \"params\": { \"model\": \"idea\", \"op\": \"create\", \"values\": { \"$form\": \"\" } }\n }]\n }\n },\n \"ideasCard\": {\n \"type\": \"Card\",\n \"props\": { \"title\": \"All ideas\" },\n \"children\": [\"ideasTable\"]\n },\n \"ideasTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"loading\": { \"$state\": \"/queries/allIdeas/loading\" },\n \"error\": { \"$state\": \"/queries/allIdeas/error\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"status\", \"label\": \"Status\", \"kind\": \"badge\", \"tones\": { \"open\": \"info\", \"done\": \"success\" } }\n ],\n \"rowActions\": [{\n \"label\": \"Delete\",\n \"variant\": \"destructive-outline\",\n \"actions\": [{ \"action\": \"app.mutate\", \"params\": { \"model\": \"idea\", \"op\": \"delete\", \"rowId\": { \"$row\": \"id\" } } }]\n }]\n }\n },\n \"triage\": {\n \"type\": \"Button\",\n \"props\": { \"label\": \"Ask swarm to triage\", \"variant\": \"secondary\" },\n \"on\": { \"press\": [{ \"action\": \"app.action\", \"params\": { \"name\": \"triage\" } }] }\n }\n }\n }\n },\n \"defaultPage\": \"main\"\n }\n}\n```\n\nThe tool returns `{ appId, url: \"/apps/<id>\" }`. Open that URL to verify the live UI, then continue with the read-patch-validate loop above.\n\n## View and screenshot a live app\n\n- An app renders at `<dashboard>/apps/<id>`. Two view modes are set by query string: `?mode=full` (full-viewport overlay with a slim header) and `?mode=chromeless` (only the rendered page, the embed surface). `app.navigate` keeps only `?mode`.\n- Logged-in browser: a fresh dashboard has no connection and redirects to /setup. Seed the localStorage key `agent-swarm-connections` with `{connections:[{id,name,apiUrl,apiKey}],activeId}`. Build the value from an env var and pipe it to `agent-browser eval --stdin`, so the key never reaches a command line. Reload, then pick a user in the \"Who are you?\" dialog.\n- Apps scroll inside an inner canvas, so a full-page screenshot captures only the viewport. Scroll the canvas or raise the viewport height. For mobile, set a 375px-wide viewport before the screenshot.\n- App definitions: `GET /api/apps` and `GET /api/apps/<id>` with the API key as Bearer.\n\n\n### Rows from agents and scripts\n\nEvery row tool requires `appId` and `model`. Scripts use underscore names, such as `ctx.swarm.app_row_create` and `ctx.swarm.app_rows_list`.\nResults use the usual `data` wrapper, so scripts read `res?.data ?? res`. Rows contain system fields and declared columns at the top level.\nWrites record `agent:<agentId>` in `createdBy` and `updatedBy`. Source projections and join keys remain read-only. Each row input must fit within 1 MiB.\n\n`app-rows-list` accepts `equals`, `sort: \"<column>:asc|desc\"`, and `limit` from 1 to 1000 (default 200).\nEquality filters accept declared columns and system columns. Filter values must match the column kind and use strict equality.\n`app-rows-delete` requires at least one equality filter and accepts a limit from 1 to 1000 (default 1000).\n\n`app-action-run` returns the REST action body under `data`. Script actions run with the script owner's identity and receive the verified invoking agent as `caller`.\nScripts and extensions cannot invoke `app-action-run`.\n";
6123
+ var content_default2 = "# Swarm Apps\n\nSwarm Apps are persistent, agent-authored internal applications: a schema-backed row store, named queries, custom script/task actions, and a validated json-render interface served at `/apps/<id>`. Use an app when people need to view and change live records; use `create_page` for a static snapshot.\n\n> **Capability gate**: app tools are available when the server's `CAPABILITIES` includes `pages` (for example `CAPABILITIES=core,task-pool,pages`). There is no separate `apps` capability. If these tools are absent from your MCP list, this is why.\n\n## Tools and iteration loop\n\n| Tool | Use it for |\n|---|---|\n| `app-list` | Discover apps by id, name, description, and timestamps. Definitions are omitted. |\n| `app-get` | Read one complete stored definition before changing it. |\n| `app-upsert` | Create an app, or replace an existing app's entire definition when you intentionally have the full desired state. |\n| `app-patch` | Make a focused change while preserving everything not mentioned in the patch. Prefer this for iteration. |\n| `app-query` | Run one declared named query and return its rows. Use this when an agent or saved script needs to consume app data rather than render the app UI. |\n| `app-rows-list` | List rows with typed equality filters, sorting, and a limit. Returns `{ rows, total }`. |\n| `app-row-get` | Read a row by `rowId`. Returns `{ row }`. |\n| `app-row-create` | Create a row from `values`. Returns `{ row }`. |\n| `app-rows-create` | Create 1 to 500 rows from a `rows: values[]` array, within 10 MiB. Returns `{ rows }`. |\n| `app-row-patch` | Patch a row by `rowId` with `values`. Returns `{ row }`. |\n| `app-row-delete` | Delete a row by `rowId`. Returns `{ ok: true }`. |\n| `app-rows-delete` | Delete matching rows with a non-empty `equals` filter. Returns `{ deleted }`. |\n| `app-action-run` | Run a declared script, task, or sync action with `{ appId, action, input? }`. Agents can invoke it through MCP only. |\n| `app-history` | List definition snapshots and their compact model/column digests before choosing a restore point. |\n| `app-diff` | Show a unified definition diff; defaults to newest snapshot → CURRENT. |\n| `app-rollback` | Restore a snapshot through the normal schema-migration engine. |\n\nFor every edit: `app-get` -> modify the smallest coherent subtree -> `app-patch` -> if rejected, fix every returned `issues[]` entry (`path` + `message`) and retry. Validation happens before storage, so a rejected update leaves the saved app unchanged. Do not guess at the current definition or use `app-upsert` with a partial definition.\n\n`app-patch` can also change `name`; omit `description` to preserve it or pass `description: null` to clear it.\n\n### Reading an app's callable surface\n\nOne `app-get` returns everything needed to drive an app you did not write: `definition.queries` are runnable with `app-query` (supply every `$param` the query references), and `definition.actions` are invocable with `app-action-run` or `POST /api/apps/<id>/actions/<name>` (a `script` action runs its script, a `task` action spawns a task, a `sync` action refreshes sources). Read the query parameters and action arguments from the definition before invocation. Do not guess parameter names.\n\n### Verified action caller\n\nScript actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.\n\n```typescript\ntype AppActionCaller = {\n kind: \"user\" | \"agent\" | \"operator\" | \"guest\";\n id: string | null;\n name?: string;\n pageId?: string;\n};\n```\n\nThe caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.\nWith the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.\nTask actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.\n\n### Verified action caller\n\nScript actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.\n\n```typescript\ntype AppActionCaller = {\n kind: \"user\" | \"agent\" | \"operator\" | \"guest\";\n id: string | null;\n name?: string;\n pageId?: string;\n};\n```\n\nThe caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.\nWith the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.\nTask actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.\n\n## Safe schema evolution and rollback\n\nPrefer **hiding** a column over deleting it. A hidden column's values stay on every existing row, so an unhide or rollback can make it visible again. Hiding is metadata-only: hidden columns cannot be used in queries, page bindings, `app.mutate` values, or new row writes, and `required` is ignored while hidden. Row GETs still expose the historical value for backward compatibility.\n\nDo not reuse a hidden column name. The name remains held until you either unhide it or explicitly purge its stored values. To permanently remove a column, delete its declaration with `null` and include `{ \"columnName\": { \"purge\": true } }` in the same `migration` object. That is deliberately one-shot and irreversible for row data.\n\nSchema-changing writes can carry this sibling `migration` object:\n\n```json\n{\n \"migration\": {\n \"priority\": { \"from\": \"flag\", \"map\": { \"urgent\": \"high\", \"watch\": \"low\" }, \"else\": \"none\" },\n \"status\": { \"from\": \"flag\", \"map\": { \"watch\": \"watching\", \"done\": \"done\" }, \"else\": \"open\" },\n \"oldColumn\": { \"purge\": true },\n \"count\": { \"coerce\": true, \"else\": 0 },\n \"owner\": { \"set\": \"unassigned\" }\n }\n}\n```\n\nUse `set` to backfill a changed column, `from` with optional `map`/`else` to derive it from one existing (including hidden) column, `coerce` for a kind change, and `purge` only for explicit destruction. Non-purge directives target a column changed in that same write. The server fails loudly before writing when a lossy change needs a directive: inspect every returned `issues[]` item, including its per-value row counts, then choose an explicit policy and retry. Never guess a migration just to get a 200.\n\n**Source lifecycle** rides the same engine with the same all-or-nothing guarantees. Free: adding a source, adding a NEW column with a `source` binding, binding an existing column that holds zero values, changing a binding's `field`/`transform`, and changing a source's `args`/`config`/`connection`/`scriptId` (window changes churn staleness on the next pass — expect `stale` flips when narrowing). Rejected with a path-bearing issue: changing `joinKey` (immutable — remove the source and add it again), changing `connector` while the source owns rows (row count in the message), and binding an existing column that already holds values (purge it — or add a fresh column — and bind that instead; a sync pass would otherwise overwrite the data). Removing a source **detaches** its rows: values survive as ordinary columns, the `source`/`syncedAt`/`stale` envelope is stripped, and the report counts them in `detachedRows`. A patch removing a source must drop its column bindings in the same call — a dangling `source.of` is rejected before anything is written.\n\nSync never adopts rows it does not own: a pulled record whose join key matches an unowned row creates a second, source-owned row. Adding a source over a join-key column that already holds values therefore duplicates those records on the first pass — and remove-source → re-add duplicates for the same reason, because detached rows keep their values. To convert a hand-maintained model into a synced one, purge the old rows first or use a fresh join-key column.\n\nFor example, split a legacy `flag` into `priority` and `status` without losing data in one patch: add the two columns, map both from `flag`, update every affected query/page binding, then hide `flag`.\n\n```json\n{\n \"appId\": \"<app-id>\",\n \"definition\": {\n \"models\": {\n \"ticket\": {\n \"columns\": {\n \"priority\": { \"kind\": \"enum\", \"enum\": [\"none\", \"low\", \"high\"] },\n \"status\": { \"kind\": \"enum\", \"enum\": [\"open\", \"watching\", \"done\"] },\n \"flag\": { \"kind\": \"string\", \"hidden\": true }\n }\n }\n }\n },\n \"migration\": {\n \"priority\": { \"from\": \"flag\", \"map\": { \"urgent\": \"high\", \"watch\": \"low\" }, \"else\": \"none\" },\n \"status\": { \"from\": \"flag\", \"map\": { \"watch\": \"watching\", \"done\": \"done\" }, \"else\": \"open\" }\n }\n}\n```\n\nEvery schema migration returns `{ scanned, backfilled, coerced, mapped, elsed, purgedValues, idxRebuilt, detachedRows, orphanFields }`. Read `orphanFields`: it reports extra row fields that predate or no longer appear in the definition; they are preserved until an explicit purge. `schemaVersion` is server-managed: never set it in an input patch or try to use it as application data.\n\nEvery successful definition write snapshots the previous definition. Use `app-history` to select a version, `app-diff({ appId, from: <version> })` to inspect it against CURRENT, then `app-rollback({ appId, version: <version>, migration? })` to restore it. Rollback is a forward migration over live rows, not row-level time travel: it creates a new pre-rollback snapshot and may reject a lossy restore with the same migration directives and counts. A rejected rollback changes nothing.\n\n### Per-user configuration\n\n`userConfig` declares up to 20 typed settings in the versioned definition; its values are deliberately stored outside the definition, separately for each user (and the operator dashboard). Each field is `{ \"kind\": \"string\" | \"number\" | \"boolean\" | \"date\" | \"enum\", \"default\"?: ..., \"enum\"?: [...], \"label\"?: \"...\" }`. Defaults follow column rules, including enum membership; `required` is not allowed. A missing or obsolete stored value reads as its default, or `null` when no default is declared.\n\nUse `{ \"$state\": \"/user/<field>\" }` on a page for the read-only current-user binding; the field must be declared and the path cannot continue below it. Pure and bound reusable elements cannot bind `/user` directly — pass the value through an element prop from the consuming page. An agent acting on an owned user-requested task reads and edits that requester's preferences. `userConfig` schema changes are always migration-compatible and appear in migration reports; no migration directives are needed. Rollback restores the historical schema but never alters saved per-user values, so values can resurface when a field returns.\n\n### Theming\n\nAn optional top-level `\"theme\"` sets the preset the dashboard applies to the app's rendered canvas — a lowercase slug validated by shape only. The preset catalog ships with the dashboard (`apps/ui/src/lib/themes.ts` + `theme-classics.ts` — update this list and the `app-upsert` tool description when it changes); current ids: `hive` (the stock look), the accent/field presets `meadow`, `iris`, `rose`, `cobalt`, `ember`, `carbon`, and the classic editor/platform presets `github`, `vscode`, `material`, `solarized`, `tokyo`, `monokai`, `gruvbox`, `catppuccin`. Action and destructive tones are never themed; status tones are themed ONLY by the classic presets (their identity includes a status palette — hue semantics stay fixed, success reads green and error reads red everywhere). An id the viewer's dashboard build does not know degrades to the dashboard's own theme, so unknown slugs are safe but pointless. Omit `theme` to inherit whatever theme the viewer runs; `\"hive\"` is an explicit reset to the stock look even inside a themed dashboard.\n\nViewers can override the preset per app from the app's settings drawer; the override rides the reserved `$theme` key of the user-config values (accepted on every app, even without a declared `userConfig` schema, and never visible at `/user/...`). A present override always beats the definition's `theme` — even an unrecognized one, which degrades to the dashboard theme — so treat `theme` as the app's default styling, not a guarantee.\n\nDistinguish the two rollback 400s. A lossy-migration 400 is fixable: copy the required `migration` entries from the message and retry. A target-snapshot validation 400 explicitly says that directives cannot repair that historical definition; use `app-history` and choose a different version.\n\nWhen a schema patch reports stale page/query bindings, repair them in that same patch. Page elements are atomic: replace the complete `pages.<page>.elements.<id>` declaration, removing any reference to a hidden or deleted column, instead of sending only the changed nested prop. This is also the repair move for older apps whose stored page already references an undeclared column; full-definition validation otherwise blocks every patch.\n\n## Definition reference\n\n```json\n{\n \"models\": {\n \"modelName\": {\n \"columns\": {\n \"externalId\": { \"kind\": \"string\" },\n \"title\": { \"kind\": \"string\" }\n }\n }\n },\n \"queries\": { \"queryName\": { \"model\": \"modelName\" } },\n \"actions\": { \"actionName\": { \"kind\": \"task\", \"prompt\": \"Review the current rows and report\" } },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": { \"root\": { \"type\": \"Container\", \"props\": {} } }\n }\n },\n \"defaultPage\": \"main\",\n \"theme\": \"ember\"\n}\n```\n\nModel, reusable-element, element-prop, query, action, column, page, and page-param names start with a lowercase letter, contain only letters, numbers, or underscores, and are at most 40 characters. A definition has 0-10 models and at most 20 reusable elements; each declared model has 1-40 columns. Zero-model apps are valid for pure UI utilities as long as their pages and reusable elements validate.\n\n### Reusable elements\n\nTop-level `elements` are versioned inside the app definition and can be reused from the app's own pages or, when explicitly exported, from another app. They are private by default. Each entry declares a `mode`, optional typed `props`, one `root`, and a flat `elements` node map using the same validated tree vocabulary as a page:\n\n```json\n{\n \"elements\": {\n \"statCard\": {\n \"mode\": \"pure\",\n \"export\": true,\n \"props\": {\n \"label\": { \"kind\": \"string\", \"required\": true },\n \"value\": { \"kind\": \"number\", \"default\": 0 }\n },\n \"root\": \"card\",\n \"elements\": {\n \"card\": { \"type\": \"Card\", \"props\": {}, \"children\": [\"label\", \"slot\"] },\n \"label\": { \"type\": \"Metric\", \"props\": { \"label\": { \"$state\": \"/props/label\" }, \"value\": { \"$state\": \"/props/value\" } } },\n \"slot\": { \"type\": \"ElementSlot\", \"props\": {} }\n }\n }\n }\n}\n```\n\n- `pure` elements may read only their declared `/props/<name>` state; `$item` and `$index` are also legal inside repeated nodes. They cannot invoke any action step. A pure tree may contain at most one leaf `ElementSlot` where consumer children are inserted.\n- `bound` elements may additionally read the defining app's declared queries and actions and use its models. Those references are validated against the defining app, including hidden-column rules. Exported bound elements cannot use `app.navigate`; private bound elements may navigate within their defining app.\n- Prop kinds are `string`, `number`, `boolean`, `date`, or `enum`. An enum prop requires a non-empty `enum: [\"value\", ...]` values array. A literal default and each literal consumer value must match the kind and, for enums, be one of those values. A required prop without a default must be supplied.\n\nReference an element with an `ElementRef` node. Omit `app` for same-app reuse; cross-app references name the defining app id and require `export: true`. `instanceKey` is optional. Consumer `children` are accepted only when the target has an `ElementSlot`:\n\n```json\n{\n \"type\": \"ElementRef\",\n \"props\": {\n \"app\": \"<defining-app-id>\",\n \"element\": \"statCard\",\n \"props\": { \"label\": \"Open issues\", \"value\": 12 },\n \"instanceKey\": \"openIssues\"\n },\n \"children\": [\"details\"]\n}\n```\n\nElement references float to the defining app's current definition. `app` and `element` must be literal strings; dynamic references are not supported. The server rejects reference cycles and expansion deeper than five, missing/private targets, invalid props, and a breaking write to an exported element that other apps reference. Removing or unexporting an exported element, changing its mode, removing a declared prop, changing a prop kind, or adding a required prop without a default is breaking; publish the new contract under a new element name. If consumers are abandoned intentionally, retry the PUT, PATCH, or rollback with `\"forceElementBreak\": [\"elementName\"]`. The named consumers will then render an error once element rendering is available; deleting an entire app is deliberately not compatibility-gated.\n\n### Models\n\nEach column is `{ \"kind\": ..., \"required\"?: boolean, \"default\"?: ..., \"index\"?: boolean, \"enum\"?: string[], \"hidden\"?: boolean }`.\n\n| `kind` | Values and indexing |\n|---|---|\n| `string` | String value; set `index: true` only when equality lookups need it. |\n| `number` | Finite number; never indexed. |\n| `boolean` | Boolean; set `index: true` only when equality lookups need it. |\n| `date` | ISO-8601 string; never indexed. |\n| `enum` | String from the non-empty unique `enum` list; always indexed, without `index: true`. |\n| `json` | Any JSON value (object, array, nested) up to 256 KB serialized by default (`APPS_JSON_COLUMN_MAX_BYTES`); never indexed, and a named query cannot `filter` or `sort` on it. Not allowed for `userConfig` fields or element props. |\n\n`default` must match the column kind (and be one of the enum values). `required: true` rejects missing/null values unless a default supplies the value. `id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` — and the sync envelope `source`, `syncedAt`, `stale` — are system columns and reserved names. An app created before the sync envelope that declared ordinary columns with these names surfaces `definitionError` after upgrade until they are renamed: patch in a fresh column with a `{from}` migration directive, then purge the old one. Rows always expose `id`/`createdAt`/`updatedAt`; `createdBy`/`updatedBy` record the acting principal (`user:<id>`, `agent:<id>`, `operator`, or `sync:<source>` for engine writes) and are system-managed on every row write.\n\n### Sources and sync\n\nA model may declare up to 4 named `sources` — one-way inbound projections of an external system into its rows. The `script` connector is THE way to bring external data in (GitHub, Linear, any API): a saved script owns the fetch, its auth placeholders, and its paging. `swarm-tasks` is the one native connector, projecting this swarm's own task pool (`config`: `status`/`tags` comma-lists, `agentId`, `assetKey` namespace prefix, `limit` up to 200 — default 100, `includeHeartbeat`). Synced rows are readable by EVERY principal with `app.use` on the app; an unscoped `swarm-tasks` source publishes task prompts (truncated, secret-scrubbed) to all of them, so scope it with `config.assetKey`/`config.agentId` unless that exposure is intended. A user-invoked sync only ever pulls the invoking user's own requested tasks. Script sources run with their run-as identity's credentials — an agent-owned script runs as its owner and only the owner may wire it; an owner-less catalog (global) script runs as the LEAD, so only the lead or the operator can wire or alter such a source (stored references stay valid with their args and connection pinned).\n\n```json\n{\n \"models\": {\n \"issue\": {\n \"columns\": {\n \"issueKey\": { \"kind\": \"string\" },\n \"title\": { \"kind\": \"string\", \"source\": { \"of\": \"gh\", \"field\": \"title\" } },\n \"openedAt\": { \"kind\": \"date\", \"source\": { \"of\": \"gh\", \"field\": \"created_at\", \"transform\": \"date-parse\" } },\n \"note\": { \"kind\": \"string\" }\n },\n \"sources\": {\n \"gh\": { \"connector\": \"script\", \"scriptId\": \"<script-uuid>\", \"joinKey\": \"issueKey\", \"args\": { \"repo\": \"owner/name\" }, \"connection\": \"github\" }\n }\n }\n }\n}\n```\n\n- **Join key**: each source names a `joinKey` — an owned, non-hidden `string` column carrying the external identity. It must not be `required`, must not declare a `default`, and cannot itself be source-bound. A row belongs to at most ONE source (`row.source`).\n- **Column bindings**: `{ \"of\": \"<sourceName>\", \"field\": \"user.login\", \"transform\"? }` projects a (dotted-path) field of the pulled record into the column on every pass. Transforms and their required column kinds: `slug`/`lower`/`upper` → `string`, `cents` → `number` (×100, rounded), `date-parse` → `date` (ISO). A failed transform nulls that one column and warns; it never fails the pass.\n- **Read-only contract**: source-bound columns and the join key reject every direct write (row create/patch/bulk) with a path-bearing issue — mutate them via the source or a sync refresh. Owned columns on the same model stay writable. Every `required` owned column on a sourced model must declare a `default`, because sync-created rows cannot supply it.\n- **Provenance**: synced rows carry `source` (owner), `syncedAt` (last confirmed presence — sortable), and `stale` (true when a complete pull no longer returned the record; clears on reappearance). `syncedAt` advances on every confirmed row WITHOUT touching `updatedAt`; `updatedAt` moves only when projected data actually changed.\n- **`connection`** (optional, script sources): names a connections-system slug the source depends on. It buys write-time validation plus a preflight before every pull; it does not by itself gate egress. Credentials resolve for the sync run-as identity — the script's owner when it has one, else the lead agent — so global connections always work and agent-scoped ones need matching script ownership. The slug is injected into the script's args as `connection`, letting one generic script serve N connections.\n\n**The canonical sync-script contract** (every sync script, always):\n\n```ts\nexport default async function run(args, ctx) {\n // args = { ...source.args, app: { id }, model: \"<model>\", source: \"<sourceName>\", connection?: \"<slug>\" }\n const records = [{ key: \"42\", fields: { title: \"…\", created_at: \"…\" } }];\n return { records, complete: true }; // or return the bare array — a bare array IS a complete snapshot\n}\n```\n\nReturn `Array<{ key, fields }>` or `{ records, complete? }`. `key` is the external identity the engine joins on `joinKey` (numbers are coerced to strings); `fields` is the bag bindings project from. Return `complete: false` whenever the pull window may have missed records — a paged fetch, a hit `limit`, or client-side filtering of a full page. An incomplete pull skips the stale sweep, so rows outside a narrow window are not falsely marked stale; compute `complete` BEFORE any client-side filtering. Hard cap 500 records per pull (over-cap pulls truncate and count as incomplete). An invalid return shape, thrown error, or non-zero exit fails the pass with zero row churn.\n\nThe seeded catalog script `github-issues-pull` is the worked example (placeholder auth via `[REDACTED:GITHUB_TOKEN]`, PR filtering, `complete` computed before filtering). For scheduled refreshes, point a `targetType: \"script\"` schedule at the seeded `app-sync-run` with `scriptArgs: { \"appId\": \"<id>\", \"model\"?, \"source\"? }`.\n\n**Three refresh doors, one engine**: `POST /api/apps/<id>/sync` (body `{ \"model\"?, \"source\"? }`), a `sync` action kind, and the `app-sync` MCP tool (`ctx.swarm.app_sync` from scripts). `{model?, source?}` fans out to every matching (model × source) pair; each pass reports `{ pulled, created, updated, refreshed, unchanged, markedStale, warnings }`. A pair already syncing in this API process short-circuits with `alreadyRunning: true` instead of pulling twice. Each pair's last-pass status (`lastFinishedAt`, counts, `error?`) comes back as `syncStatus` — keyed `\"<model>:<source>\"` — on `GET /api/apps/<id>` and in `app-get` data, so UI and agents can render \"last synced / last error\" without extra calls.\n\n### Freshness and the refresh button\n\nFreshness renders with the existing column kinds — no special components. Give the table `{ \"key\": \"syncedAt\", \"kind\": \"date\" }` and `{ \"key\": \"stale\", \"kind\": \"badge\", \"tones\": { \"true\": \"warning\" } }` columns, declare a `sync` action, and wire a Button to it:\n\n```json\n{\n \"actions\": { \"refresh\": { \"kind\": \"sync\" } },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": {\n \"root\": { \"type\": \"Container\", \"props\": {}, \"children\": [\"refreshBtn\", \"issuesTable\"] },\n \"refreshBtn\": {\n \"type\": \"Button\",\n \"props\": { \"label\": \"Refresh\" },\n \"on\": { \"press\": [{ \"action\": \"app.action\", \"params\": { \"name\": \"refresh\" } }] }\n },\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Issue\" },\n { \"key\": \"syncedAt\", \"label\": \"Synced\", \"kind\": \"date\" },\n { \"key\": \"stale\", \"label\": \"Stale\", \"kind\": \"badge\", \"tones\": { \"true\": \"warning\" } }\n ]\n }\n }\n }\n }\n }\n}\n```\n\nA `sync` action answers the script-action state shape (`/actions/refresh` → `running` → `ok`/`error`, result under `result.passes`), so the page's queries refetch when it completes. Use `app-query` to READ rows (named queries may filter on `stale`/`source` and sort by `syncedAt`); use `app-sync` to REFRESH sources; never poll a sync door in a loop — schedule it or give people the button.\n\n### Queries\n\nA named query is:\n\n```json\n{\n \"model\": \"idea\",\n \"filter\": { \"status\": \"open\" },\n \"sort\": { \"column\": \"createdAt\", \"dir\": \"desc\" },\n \"limit\": 100\n}\n```\n\n`filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and on synced models `source`, `syncedAt`, `stale`) — filtering on `id` is the universal way to select one row for a detail view, and `{ \"stale\": true }` selects rows a complete pull stopped confirming. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a non-`json` model column, `createdAt`, `updatedAt`, or `syncedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.\n\nA filter value may instead be exactly `{ \"$param\": \"<name>\" }`. At execution, the caller supplies that name and the server coerces its value to the filtered column's kind before equality matching:\n\n```json\n{\n \"queries\": {\n \"issueDetail\": { \"model\": \"issue\", \"filter\": { \"issueId\": { \"$param\": \"issueId\" } }, \"limit\": 1 }\n }\n}\n```\n\nThe placeholder object must contain only `$param`. Missing caller values fail loudly rather than returning unfiltered rows: HTTP reports 400 and `app-query` returns a tool error listing every missing name. Supplying a param name the query does not reference is also an error. The app UI supplies current declared route params automatically. Direct calls use `app-query({ appId, query: \"issueDetail\", params: { issueId: 42 } })` or `ctx.swarm.app_query({ appId, query: \"issueDetail\", params: { issueId: 42 } })`.\n\n### Custom actions\n\nActions are optional (maximum 20) and are invoked from the page with the `app.action` runtime action.\n\n```json\n{\n \"actions\": {\n \"recalculate\": {\n \"kind\": \"script\",\n \"scriptId\": \"00000000-0000-4000-8000-000000000000\",\n \"args\": { \"mode\": \"fast\" }\n },\n \"triage\": {\n \"kind\": \"task\",\n \"prompt\": \"Triage this idea and report a recommendation\"\n }\n }\n}\n```\n\n| Kind | Contract |\n|---|---|\n| `script` | `scriptId` must identify an existing script; optional `args` are defaults. Invocation input overrides same-named defaults, and the runtime also supplies `app: { id }`. |\n| `task` | `prompt` must be non-empty; omit `agentId` to use default (lead) assignment. Only set `agentId` to a real agent UUID. Invocation `input` is included as context for the task prompt. Spawned tasks carry asset key `shared/app:<appId>/action:<name>/`, so a `swarm-tasks` source with `config.assetKey: \"shared/app:<appId>/\"` pulls back the tasks this app spawned. |\n| `sync` | Optional `model` and `source` narrow which (model × source) pairs refresh; omitting both syncs every pair the app declares. At least one pair must exist. Answers the script-kind state shape with per-pass results under `result.passes`. |\n\n- Invocation state lands at `/actions/<name>` as `{ status, result?, error?, taskId?, taskStatus? }`, where `status` is `\"running\"`, `\"ok\"`, or `\"error\"`.\n\nUse a task action named Tackle when a person should hand one issue row to the swarm. Pass the complete current row under `input.issue`; `{ \"$row\": \"\" }` resolves to the whole row and that context lands in the task prompt.\n\n```json\n{\n \"actions\": {\n \"tackle\": { \"kind\": \"task\", \"prompt\": \"Tackle this issue. Inspect the supplied issue context, do the work, and report the outcome.\" }\n },\n \"pages\": {\n \"main\": {\n \"root\": \"issuesTable\",\n \"elements\": {\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [{ \"key\": \"title\", \"label\": \"Issue\" }],\n \"rowActions\": [{\n \"label\": \"Tackle\",\n \"actions\": [{\n \"action\": \"app.action\",\n \"params\": { \"name\": \"tackle\", \"input\": { \"issue\": { \"$row\": \"\" } } }\n }]\n }]\n }\n }\n }\n }\n },\n \"defaultPage\": \"main\"\n}\n```\n\nFor direct MCP calls, `app-query` accepts `{ appId, query, params? }` and returns rows from that declared named query; every supplied param must be referenced by a `$param` filter in that query. Use it when an agent needs to read app state without scraping the UI.\n\nSaved scripts use the generated SDK name `ctx.swarm.app_query({ appId, query, params? })`. Use `app_query` in scripts or workflows that turn current app rows into reports, digests, or follow-up work. Saved scripts also get generated per-app types automatically: each app contributes an `App_<Name>` namespace with one row interface per model (enum columns as literal unions) and a typed `app_query` overload per named query, all visible via `script-query-types`. Scripts can create, list, get, patch, and delete rows with the seven row tools. Actions remain REST/MCP-only and have no script SDK method.\n\n### Pages, routes, and page trees\n\n`pages` is a map of independently validated page trees and `defaultPage` must name one entry. Each page may also declare a display `title` and typed route `params`. On every non-default page the runtime automatically renders breadcrumbs (`<default page title> › <current page title>`, first crumb navigates back) — including in chromeless embeds — so a detail page does NOT need its own back button; give pages good `title`s instead. This complete pattern connects a table row to a typed detail route and its parameterized query:\n\n```json\n{\n \"models\": {\n \"issue\": {\n \"columns\": {\n \"issueId\": { \"kind\": \"number\" },\n \"title\": { \"kind\": \"string\" },\n \"status\": { \"kind\": \"string\" }\n }\n }\n },\n \"queries\": {\n \"allIssues\": { \"model\": \"issue\" },\n \"issueDetail\": {\n \"model\": \"issue\",\n \"filter\": { \"issueId\": { \"$param\": \"issueId\" } },\n \"limit\": 1\n }\n },\n \"pages\": {\n \"issues\": {\n \"title\": \"Issues\",\n \"root\": \"issuesTable\",\n \"elements\": {\n \"issuesTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIssues/data\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Issue\" },\n { \"key\": \"status\", \"label\": \"Status\", \"kind\": \"badge\" }\n ],\n \"rowActions\": [{\n \"label\": \"Open\",\n \"actions\": [{\n \"action\": \"app.navigate\",\n \"params\": {\n \"page\": \"detail\",\n \"params\": { \"issueId\": { \"$row\": \"issueId\" } }\n }\n }]\n }]\n }\n }\n }\n },\n \"detail\": {\n \"title\": \"Issue detail\",\n \"params\": { \"issueId\": { \"kind\": \"number\", \"required\": true } },\n \"root\": \"detail\",\n \"elements\": {\n \"detail\": {\n \"type\": \"DetailList\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/issueDetail/data/0\" },\n \"fields\": [{ \"key\": \"title\", \"label\": \"Issue\" }]\n }\n }\n }\n }\n },\n \"defaultPage\": \"issues\"\n}\n```\n\nDrawer variant: declare a route param on the containing page, set the Drawer `props.param` to that literal param name, and use the same `app.navigate` shape targeting that page with the row id assigned to the Drawer's param. The Drawer then opens from route state without a separate action contract.\n\nParam `kind` is `string` by default, or `number` / `boolean`; `required` defaults to false. The names `mode`, `apiUrl`, `apiKey`, `email`, and `name` are reserved. `/apps/<id>` renders `defaultPage`; `/apps/<id>/p/<page>?issueId=42` is the shareable deep link for another page. Browser navigation is client-side, so app state and polled data stay warm across page changes.\n\nWithin each page, `root` names one entry in its non-empty `elements` map. Elements are a flat, single-parent tree: ids are map keys, `children` contains ids (not nested elements), all elements must be reachable from `root`, and cycles, missing children, or shared children are invalid. Element and Form/UI ids are page-local. Element keys are `type`, `props`, `children`, `on`, `visible`, `repeat`, and `watch`; only components with a `default` child slot accept `children` (Stack, Grid, Split, Tabs, Container, Card, Section, and Drawer). `watch` maps state paths to an action step or chain.\n\n## Component catalog (all 33)\n\nProps reject unknown keys. A `{\"$state\":\"...\"}` binding may replace a literal prop value at any depth.\n\nIcons: every `icon` prop (and the `Icon` block) takes one name from a curated lucide set: `plus`, `refresh-cw`, `search`, `filter`, `download`, `upload`, `external-link`, `copy`, `check`, `x`, `trash-2`, `pencil`, `eye`, `calendar`, `clock`, `user`, `users`, `bot`, `git-pull-request`, `git-branch`, `github`, `slack`, `mail`, `link`, `file-text`, `folder`, `database`, `zap`, `sparkles`, `flag`, `star`, `tag`, `alert-triangle`, `info`, `circle-check`, `trending-up`, `trending-down`, `dollar-sign`, `activity`, `rocket`. Any other name is rejected.\n\nTones: Badge, Metric and Progress `tone` take `neutral`, `success`, `active`, `error`, `info`, `pending`, `warning`, or `paused`.\n\n| Component | Required props | Optional props / values |\n|---|---|---|\n| `Stack` | none | Primary layout; `direction: \"column\", \"row\"`, `gap`, `padding: \"none\", \"xs\", \"sm\", \"md\", \"lg\", \"xl\"`; `align: \"start\", \"center\", \"end\", \"stretch\"`, `justify: \"start\", \"center\", \"end\", \"between\"`, `wrap`; `motion: \"rise\"` (default), `\"fade\"`, or `\"none\"` (see Motion below); has the `default` child slot. |\n| `Grid` | none | Responsive `columns`: integer 1 through 6, or `{ base, sm, md, lg }` counts 1 through 6; `gap` uses the Stack spacing values; has the `default` child slot. |\n| `Split` | none | `ratio: \"1-1\", \"1-2\", \"2-1\", \"1-3\", \"3-1\"`, `gap`, `collapseBelow: \"sm\", \"md\", \"lg\"`, `reverse`; has the positional `default` child slot. |\n| `Divider` | none | `label`; no children. |\n| `Tabs` | `id`, `tabs` | `tabs` entries are `{ key, label?, icon?, count? }` (`count` is a number shown as a small pill); `defaultTab`; `variant: \"line\"` (default) or `\"segmented\"` (a compact segmented control, same state and warm panels); has the positional `default` child slot. |\n| `Container` | none | Legacy layout primitive; prefer Stack. `direction: \"row\", \"column\"`, `gap: \"none\", \"sm\", \"md\", \"lg\"`; has the `default` child slot. |\n| `Card` | none | `title`, `description`; has the `default` child slot. |\n| `Heading` | `text` | `level: \"h1\", \"h2\", or \"h3\"`. |\n| `Text` | `content` | `tone: \"default\" or \"muted\"`. |\n| `Markdown` | `content` | Rendered Markdown for help, instructions, and rich prose; no children. |\n| `SearchInput` | `id` | `placeholder`, `label`, `shortcut` (one key, usually `\"/\"`: shows a keycap and focuses the field); writes debounced text to `/ui/<id>/value`; no children. |\n| `Select` | `id`, `options` | `options` are strings or `{ value, label?, icon?, hint? }`; `placeholder`, `label`, `clearable`, `searchable` (type to filter; on by default past 8 options), `searchPlaceholder`, `multiple`; writes a string or null to `/ui/<id>/value`, or with `multiple` a string array (empty = nothing picked); no children. |\n| `Chips` | `id`, `options` | Toggle chips for quick filters. `options` are strings or `{ value, label?, icon? }`; `multiple`; `label` (group name for screen readers). Writes a string or null to `/ui/<id>/value`, or a string array with `multiple`; pressing the selected chip clears it. |\n| `Button` | `label` | `variant: \"default\", \"secondary\", \"outline\", \"ghost\", \"destructive\", or \"destructive-outline\"` (use `destructive-outline` for standalone deletes); `disabled` (constant or bound boolean); `busyWith: \"<actionName>\"` swaps the label for a spinner while that custom action's `/actions/<name>/status` is `\"running\"` (presses are ignored), then shows a check and `successLabel` for about 1.5s when it turns `\"ok\"`; `icon`, `iconPosition: \"start\" or \"end\"`; `size: \"sm\", \"default\", or \"lg\"`; `shortcut` (one key, shown as a keycap, presses the button); `hint` (tooltip); dispatch with element-level `on.press`. |\n| `Metric` | `label`, `value` | The dashboard stat tile. `value` is string or number (empty renders an em dash); bind `loading` to `/queries/<name>/loading` for a skeleton while the query loads; `icon` with `tone` tinting its tile; `detail` (a line under the value); `trend: { direction: \"up\", \"down\", or \"flat\", label }`; `info` (explanation tooltip next to the label). |\n| `Alert` | `message` | `title`, `tone: \"info\", \"success\", \"warning\", or \"error\"`, `icon` (overrides the tone icon), `actionLabel` (a button inside the callout that emits `press`; wire it with `on.press`). |\n| `Badge` | `text` | `text` is string or number; `tone`; `icon` replaces the dot; `variant: \"tag\"` (default uppercase chip), `\"status\"` (sentence-case pill), `\"count\"` (numeric pill), or `\"beta\"` (the BETA tag); `status` (a lifecycle value renders the dashboard status chip); `hint` (tooltip; the beta tag's explanation). |\n| `Icon` | `name` | `size: \"sm\", \"md\", or \"lg\"`, `tone: \"default\", \"muted\", \"success\", \"warning\", \"error\", or \"info\"`, `label` (accessible name; decorative without it). |\n| `Progress` | `value` | `max` (default 100), `tone`, `label`, `variant: \"bar\"` (default) or `\"ring\"` (the dashboard progress ring; ignores `tone`), `showValue` (default true: percent for a bar, `value / max` for a ring). |\n| `EmptyState` | `title` | `icon` (default inbox), `body`, `actionLabel` (a button that emits `press`; wire it with `on.press`). |\n| `CopyButton` | `value` | `value` is string or number; `label` is the visible text, icon-only without it. Renders nothing for an empty value. |\n| `Kbd` | `keys` | Keycaps, one per string (`[\"⌘\", \"K\"]`). Display only. |\n| `Skeleton` | `shape` | `shape: \"line\", \"block\", \"rows\", \"metric\", or \"card\"`; `count` (1-20: lines, rows, tiles, or cards); `height` (px, `block` only, default 128). Show it with `visible` bound to a query's `loading`. |\n| `Spinner` | none | `variant: \"ring\", \"ellipsis\", or \"bars\"` (default: the dashboard spinner), `size: \"sm\" or \"md\"`, `label` (visible text and accessible name). |\n| `AvatarStack` | `users` | `users` are display names (initials avatars); `max` (default 4) before a `+N` count. Hover lists every name. |\n| `Section` | `title` | `description`, `collapsible`, `defaultOpen` (default true); has the `default` child slot. |\n| `Table` | `columns` | `data`, `loading`, `error`, `emptyMessage`, `emptyState`, `rowActions`, `onRowClick`, `selectedId`, `rowId`, `mobile`, `mobileFields`, `search`, `filters`; see below. |\n| `Form` | `id`, `fields`, `onSubmit` | `title`, `submitLabel`; see below. |\n| `Drawer` | `param` | `title`, `description`, `side: \"right\" or \"left\"`, `size: \"sm\", \"md\" (default), \"lg\", or \"xl\"` (about 480/660/900/1010px at 1440px; grows with the viewport up to 640/960/1280/1600px; full width on phones), `actions` (sticky footer buttons; see below); has the `default` child slot. |\n| `DetailList` | `fields` | `data`, `emptyMessage`, `columns: 1 or 2`, `loading` (bind `/queries/<name>/loading` for skeleton fields while loading); renders one record without edit controls; see below for field kinds. |\n| `Kanban` | `columnField`, `columns`, `titleField` | `data`, `loading`, `cardFields`, `idField` (default `id`), `onMove`, `onCardClick`; see below. |\n| `Calendar` | `startField`, `titleField` | `data`, `loading`, `endField`, `idField`, `onSelect`; read-only month grid; see below. |\n| `ContributionGraph` | none | `data`, `loading`, `dateField` (default `date`), `countField`, `days` (7-730, default 365); read-only activity heatmap. |\n\nTable details:\n\n- `columns[]`: `{ key, label?, kind?, tones?: {value: badgeTone}, width?: number, pinned?: \"left\" or \"right\", align?: \"start\" or \"end\", truncate?: \"end\" or \"middle\", icon?, tooltip?: \"<row field>\", max?: number, onChange?: chain }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge`, `status`, `user`, `agent`, `relative`, `duration`, `progress`, `copy`, `switch`, `link`, or `json`; `string` and `enum` render as text, and `json` renders a truncated single-line `JSON.stringify` preview. Pin id/actions columns so they survive horizontal scroll on narrow viewports.\n - `status`: lifecycle values (`pending`, `in_progress`, `running`, `completed`, `failed`, `cancelled`, `paused`, `reviewing`, ...) render the dashboard status chip with its icon; other values use `tones` like `badge`.\n - `user` / `agent`: avatar plus name. `relative` (and `date`): smart time, exact time on hover. `duration`: milliseconds as `1m 23s`. `progress`: a bar against `max` (default 100). `copy`: the value plus a copy button. `link`: an http(s) URL opening in a new tab (other values render as text).\n - `switch`: a toggle. `onChange` runs with `$row` = the row with this column already set to the new value, e.g. `[{ \"action\": \"app.mutate\", \"params\": { \"model\": \"m\", \"op\": \"update\", \"rowId\": { \"$row\": \"id\" }, \"values\": { \"enabled\": { \"$row\": \"enabled\" } } } }]`. Without `onChange` the switch is read-only.\n - Numbers, durations and progress right-align by default. `truncate: \"middle\"` keeps both ends of ids and paths. `icon` shows in the header; `tooltip` names a row field shown on hover.\n- `onRowClick`: action chain with `$row` / `$rowIndex`, run when a row is clicked (or Enter on a focused cell). Pair it with `app.navigate` that sets a Drawer param, and bind `selectedId` to `{ \"$state\": \"/route/params/<param>\" }` so the open row stays highlighted. `rowId` names the id field (default `id`).\n- Phones (below 768px): the table renders as a card list. `mobileFields: { title, subtitle?, meta?: string or string[], status? }` picks the columns; by default the first text column is the title and the first `status`/`badge` column the chip. `mobile: \"scroll\"` keeps the grid; it is the default when the table has `rowActions` but no `onRowClick`.\n- `emptyState: { icon?, title, body? }` replaces the plain empty message. A search or filter that hides every row shows its own \"No rows match\" state.\n- `pagination`: boolean; defaults to auto-enabling past 200 rows (set `false` to force one scroll region). `density: \"compact\"` tightens row height for dense readouts.\n- `rowActions[]`: `{ label, variant?, icon?, placement?: \"inline\" or \"menu\", confirm?, actions }`. `placement: \"menu\"` moves the action into the row's More menu. Variants add `destructive-outline` to the Button variants. `destructive` and `destructive-outline` confirm by default; customize with `{ \"title\": ..., \"description\": ..., \"confirmLabel\": ... }`, or use a bare `confirm` string as the dialog description (use `confirm: false` only for a reversible action).\n- `search`: optional string; case-insensitive substring matching across the listed string and number columns. Bind a SearchInput value for client-side search.\n- `filters`: optional record of per-column string, number, boolean, null, or array values. An array means \"the cell is one of these\" (what a `multiple` Chips or Select writes). Null, empty strings, empty arrays, or absent values disable one filter. Bind Select or Chips values for client-side filters.\n\nKanban details:\n\n- One card per `data` row, grouped by the row's `columnField` value. `columns[]` are plain strings or `{ id, label? }`, in display order; rows matching no column are hidden.\n- `cardFields[]` uses the DetailList field shape (`{ key, label?, kind?, tones? }`, same kinds) for extra lines under the title.\n- `onMove` is an action chain run when a card is dropped in another column. `$row` is the moved row with `columnField` already set to the new column: `[{ \"action\": \"app.mutate\", \"params\": { \"model\": \"deal\", \"op\": \"update\", \"rowId\": { \"$row\": \"id\" }, \"values\": { \"stage\": { \"$row\": \"stage\" } } } }]`. Without `onMove`, cards cannot be dragged.\n- `onCardClick` is an action chain with `$row`; pair it with `app.navigate` and a `Drawer` to open a card.\n\nCalendar details:\n\n- `startField` / `endField` hold ISO dates, ISO datetimes, or epoch ms. An event shows on every day from start to end; without `endField` it shows on its start day.\n- Each Calendar keeps its own visible month (prev/next controls in its header). `onSelect` is an action chain with `$row`, run when an event is clicked.\n\nContributionGraph details:\n\n- Buckets `data` rows by the calendar day of `dateField` over the trailing `days`. Each row counts 1, or adds its numeric `countField` when set.\n\nForm details:\n\n- `fields[]`: `{ name, label?, kind?, options?: string[], placeholder?, required? }`; `kind` is `string`, `text`, `number`, `boolean`, `date`, or `enum`, and enum fields need `options`.\n- Values live at `/forms/<formId>/<fieldName>`. `onSubmit` is an action chain.\n- The submit button shows a pending spinner while the chain runs, and `app.mutate` failures in the chain render inline under the fields (not the page banner) — no extra wiring needed.\n\nDrawer details:\n\n- `param` must name a param declared on the containing page. The drawer is open exactly when `/route/params/<param>` has a non-empty value, so its open state survives refresh and is shareable. Its children mount only while open; closing it clears that param with history replacement.\n- `actions[]`: `{ label, variant?, icon?, confirm?, actions }`, rendered as buttons in a sticky footer under the scrolling body. Confirmation follows Table `rowActions`: destructive variants confirm by default, and a chain with an `app.mutate` delete always confirms. Chains have no `$row`; address the open record with `{ \"$state\": \"/route/params/<param>\" }`, for example `{ \"action\": \"app.mutate\", \"params\": { \"model\": \"order\", \"op\": \"delete\", \"rowId\": { \"$state\": \"/route/params/panel\" } } }`. Prev/next record navigation is not available.\n\nDetailList details:\n\n- `fields[]`: `{ key, label?, kind?, tones? }`. Kinds reuse Table formatting (`text`, `string`, `number`, `boolean`, `date`, `badge`, `enum`) and the Table cell renderers (`status`: lifecycle chip or `tones` pill; `user`: avatar plus name; `relative`: smart time, exact on hover; `copy`: value plus copy button), and add `code` for a monospace block and `json` for a collapsible JSON tree (objects, or a string holding JSON). Bind `data` to one record, commonly `/queries/<detailQuery>/data/0`.\n\nMotion details:\n\n- Every `Stack` fades its direct children in and lifts them 4px over 200ms on first paint, staggered 30ms (capped at the 10th child). It never replays on the 5s data poll. A Stack nested inside another one does not add its own entrance. `motion: \"fade\"` drops the lift; `motion: \"none\"` turns it off. Viewers with reduced motion get a 150ms fade.\n\nShortcut details:\n\n- `Button.shortcut` and `SearchInput.shortcut` are one key. Letters and digits match without Shift; symbols such as `/` and `?` match as typed. They do nothing while focus is in a text field, while Ctrl, Cmd, or Alt is held, or while a dialog or menu is open (unless the control is inside it, such as a Button in an open Drawer).\n\n## Layout & interactivity\n\nUse `Stack` as the primary page and section layout; `Container` is the legacy two-prop primitive retained for existing pages. Stack supports vertical or horizontal flow, shared spacing, alignment, justification, wrapping, and padding; row Stacks (filter bars, button rows) should set `collapseBelow: \"sm\"|\"md\"|\"lg\"` so they stack vertically on phones. Use `Grid` for responsive card or metric strips: a bare column count is responsive shorthand (1 column on phones, 2 from `sm`, the count from `md` up), or pin breakpoints explicitly with `{ \"base\": 1, \"md\": 2, \"lg\": 3 }`. `Grid` and `Split` also accept `padding` on the shared spacing scale.\n\n`Split` and `Tabs` children are positional. For Split, `children[0]` is the first pane, `children[1]` is the second, and extra children append inside the second pane. Below `collapseBelow`, panes stack; `reverse` changes only that narrow-layout stacking order. For Tabs, `children[i]` is the body for `tabs[i]`; keep both arrays in the same order and with the same count. Inactive tab children stay mounted but hidden, so Tables keep polling. Use `Divider` to separate sections and `Markdown` for richer explanatory content.\n\nSearchInput, Select, and Chips are client-side controls: each needs a literal `id` and writes state under `/ui/<id>/value`; Tabs writes its active key to `/ui/<id>/tab`. Bind those values into a Table's `search` and `filters` props. This filters already-polled rows locally; it does not alter or re-run the named query.\n\n```json\n{\n \"filters\": { \"type\": \"Stack\", \"props\": { \"gap\": \"sm\" }, \"children\": [\"query\", \"status\"] },\n \"query\": { \"type\": \"SearchInput\", \"props\": { \"id\": \"ideaSearch\", \"placeholder\": \"Search ideas\" } },\n \"status\": { \"type\": \"Select\", \"props\": { \"id\": \"ideaStatus\", \"options\": [\"open\", \"done\"] } },\n \"ideas\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"columns\": [{ \"key\": \"title\" }, { \"key\": \"status\" }],\n \"search\": { \"$state\": \"/ui/ideaSearch/value\" },\n \"filters\": { \"status\": { \"$state\": \"/ui/ideaStatus/value\" } }\n }\n }\n}\n```\n\n`visible` accepts a boolean, a `$state` truthiness binding, comparisons, and logical groups. Put exactly one comparison key in each condition object. The renderer evaluates the first matching key in fixed priority order (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`; otherwise truthiness), so additional comparison keys in the same object are ignored. Use `$and` / `$or` arrays to combine conditions. `not` is a negation FLAG inside a condition object (`\"not\": true`), never a wrapper around one — `{ \"not\": { \"$state\": … } }` is invalid and the validator rejects it. The absent-record pattern (e.g. an alert when a detail query matched nothing) is `{ \"$state\": \"/queries/<q>/data/0/id\", \"not\": true }`:\n\n```json\n[\n { \"$state\": \"/route/params/issueId\", \"gt\": 0 },\n {\n \"$and\": [\n { \"$state\": \"/route/page\", \"eq\": \"detail\" },\n { \"$state\": \"/route/params/panel\", \"neq\": \"hidden\" }\n ]\n }\n]\n```\n\n## Bindings, sentinels, and action chains\n\nUse this exact query binding shape; do not invent `$query`, `$item`, or template strings:\n\n```json\n{\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"loading\": { \"$state\": \"/queries/allIdeas/loading\" },\n \"error\": { \"$state\": \"/queries/allIdeas/error\" }\n}\n```\n\nValid `$state` roots are:\n\n- `/queries/<declaredQuery>/...`\n- `/forms/<formId>/...`, where a `Form` element has that literal `props.id`\n- `/ui/<id>/...`, where a SearchInput, Select, Chips, or Tabs element has that literal `props.id` (SearchInput, Select, and Chips use `/value`; Tabs uses `/tab`)\n- `/actions/<declaredAction>/...`\n- `/route/page` for the active page name\n- `/route/params/<declaredParam>` for a param declared on the current page\n\n`/route` is mirrored as `{ page, params }`. Only declared params appear. Values are coerced by their declared kind: `number` becomes a number when possible; boolean strings `true`/`1` become true; failed number coercion remains the raw string.\n\nOnly inside action-chain `params` (`on.<event>`, `Table.rowActions[].actions`, or `Form.onSubmit`) these scoped sentinels are valid, recursively inside objects and arrays:\n\n| Sentinel | Resolves to |\n|---|---|\n| `{ \"$row\": \"id\" }` | Current Table row's `id`; another column name selects that field. |\n| `{ \"$row\": \"\" }` | Entire current Table row. |\n| `{ \"$rowIndex\": true }` | Current Table row index. |\n| `{ \"$form\": \"title\" }` | One current Form value. |\n| `{ \"$form\": \"\" }` | All current Form values. |\n| `{ \"$state\": \"/queries/allIdeas/data\" }` | Current value at a valid app state path, including action state such as `/actions/refreshIssues/status`. |\n\nEach sentinel object must contain exactly the single key shown.\n\nAn action-chain step is `{ \"action\": \"<type>\", \"params\": {...} }`. Available action types:\n\n- `app.mutate`: `{ model, op, rowId?, values?, formId? }`, where `op` is `\"create\"`, `\"update\"`, or `\"delete\"`. `update`/`delete` require `rowId` (usually `{ \"$row\": \"id\" }`, or `{ \"$state\": \"/route/params/<param>\" }` in a Drawer footer); literal `values` keys must be model columns. Successful mutation refetches all queries on that model.\n- `app.refresh`: `{ query? }`; omit `query` to refetch all, or name a declared query.\n- `app.action`: `{ name, input? }`; `name` must be declared in definition `actions`. For a task-kind row action, pass whole-row context with `input: { issue: { \"$row\": \"\" } }`.\n- `app.navigate`: `{ page, params? }`; `page` must be declared, supplied keys must be params of the target page, and every required target param must be supplied. `params` replace the current route params wholesale and may contain action sentinels. Navigation pushes history, so browser Back returns to the previous page/params; only `mode` is preserved automatically.\n- `ui.toast`: `{ message, description?, tone?, undo? }`, where `tone` is `\"success\"`, `\"error\"`, `\"info\"` (default), or `\"warning\"`. `undo: { label?, actions }` adds an Undo button (default label `Undo`) that runs `actions` as a chain; its steps are validated like any chain. Put it after the step it confirms, e.g. a row delete followed by `{ \"action\": \"ui.toast\", \"params\": { \"message\": \"Order deleted\", \"undo\": { \"actions\": [{ \"action\": \"app.mutate\", \"params\": { \"model\": \"order\", \"op\": \"create\", \"values\": { \"title\": { \"$row\": \"title\" } } } }] } } }`. Undo params resolve `$state` when Undo is pressed; `$row` / `$form` resolve when the toast is shown.\n- `swarm.sdk`: `{ sdk, args? }`; invokes a catalog-supported Swarm browser SDK method with the viewer's bearer.\n- `swarm.call`: `{ method, endpoint: \"/api/...\", body? }`, where `method` is `\"GET\"`, `\"POST\"`, `\"PUT\"`, `\"DELETE\"`, or `\"PATCH\"`; raw authenticated API call.\n\n## Patch semantics\n\n`app-patch.definition` is RFC 7396 JSON Merge Patch applied to the stored definition:\n\n- omitted keys stay unchanged;\n- object keys merge recursively;\n- arrays and scalar values replace;\n- `null` deletes a key;\n- reusable-element disambiguation: a patch value for `elements.<name>` containing ONLY the `elements` key merges node-by-node; any other key present (`mode`, `root`, `props`, or `export`) makes it a full element replace — restate every field you want kept. In that node-by-node form, `elements.<name>.elements.<id>: null` deletes one node. Literal null nodes are rejected in a full element replace so they cannot be mistaken for deletions.\n- otherwise every supplied `pages.<page>.elements.<id>`, `pages.<page>.params.<param>`, `actions.<name>`, `models.<name>.columns.<col>`, and `models.<name>.sources.<src>` value is atomic and replaces that complete declaration; `null` deletes it (deleting a source detaches its rows — see the source lifecycle above).\n\n`pages.<page>: null` deletes a page, except the current `defaultPage` cannot be deleted. `root`, `title`, and top-level `defaultPage` use ordinary merge semantics. A top-level `page` patch is rejected with guidance to patch `pages.<name>` instead.\n\nBecause elements, param declarations, actions, and columns are atomic, include the complete desired object when changing one. For example, changing only a Table's `emptyMessage` requires sending its full `{ \"type\": \"Table\", \"props\": ... }` element. The merged result is validated as a whole; on failure, read `issues[]`, correct the paths, and retry without assuming anything was written.\n\n```json\n{\n \"appId\": \"<app-id>\",\n \"description\": null,\n \"definition\": {\n \"models\": {\n \"idea\": {\n \"columns\": {\n \"priority\": { \"kind\": \"enum\", \"enum\": [\"low\", \"high\"], \"default\": \"low\" }\n }\n }\n },\n \"queries\": { \"openIdeas\": null },\n \"pages\": {\n \"main\": {\n \"elements\": {\n \"title\": { \"type\": \"Heading\", \"props\": { \"text\": \"Prioritized ideas\", \"level\": \"h1\" } }\n }\n }\n }\n }\n}\n```\n\n## Worked example: ideas tracker\n\nCreate this with `app-upsert`:\n\n```json\n{\n \"name\": \"Ideas Tracker\",\n \"description\": \"Collect and manage product ideas\",\n \"definition\": {\n \"models\": {\n \"idea\": {\n \"columns\": {\n \"title\": { \"kind\": \"string\", \"required\": true },\n \"status\": { \"kind\": \"enum\", \"enum\": [\"open\", \"done\"], \"default\": \"open\" }\n }\n }\n },\n \"queries\": {\n \"allIdeas\": { \"model\": \"idea\", \"sort\": { \"column\": \"createdAt\", \"dir\": \"desc\" } }\n },\n \"actions\": {\n \"triage\": { \"kind\": \"task\", \"prompt\": \"Review current ideas and recommend the next one to pursue\" }\n },\n \"pages\": {\n \"main\": {\n \"root\": \"root\",\n \"elements\": {\n \"root\": {\n \"type\": \"Container\",\n \"props\": { \"direction\": \"column\", \"gap\": \"lg\" },\n \"children\": [\"heading\", \"formCard\", \"ideasCard\", \"triage\"]\n },\n \"heading\": { \"type\": \"Heading\", \"props\": { \"text\": \"Ideas\", \"level\": \"h1\" } },\n \"formCard\": {\n \"type\": \"Card\",\n \"props\": { \"title\": \"Add an idea\" },\n \"children\": [\"ideaForm\"]\n },\n \"ideaForm\": {\n \"type\": \"Form\",\n \"props\": {\n \"id\": \"newIdea\",\n \"fields\": [{ \"name\": \"title\", \"label\": \"Title\", \"required\": true }],\n \"submitLabel\": \"Add\",\n \"onSubmit\": [{\n \"action\": \"app.mutate\",\n \"params\": { \"model\": \"idea\", \"op\": \"create\", \"values\": { \"$form\": \"\" } }\n }]\n }\n },\n \"ideasCard\": {\n \"type\": \"Card\",\n \"props\": { \"title\": \"All ideas\" },\n \"children\": [\"ideasTable\"]\n },\n \"ideasTable\": {\n \"type\": \"Table\",\n \"props\": {\n \"data\": { \"$state\": \"/queries/allIdeas/data\" },\n \"loading\": { \"$state\": \"/queries/allIdeas/loading\" },\n \"error\": { \"$state\": \"/queries/allIdeas/error\" },\n \"columns\": [\n { \"key\": \"title\", \"label\": \"Title\" },\n { \"key\": \"status\", \"label\": \"Status\", \"kind\": \"badge\", \"tones\": { \"open\": \"info\", \"done\": \"success\" } }\n ],\n \"rowActions\": [{\n \"label\": \"Delete\",\n \"variant\": \"destructive-outline\",\n \"actions\": [{ \"action\": \"app.mutate\", \"params\": { \"model\": \"idea\", \"op\": \"delete\", \"rowId\": { \"$row\": \"id\" } } }]\n }]\n }\n },\n \"triage\": {\n \"type\": \"Button\",\n \"props\": { \"label\": \"Ask swarm to triage\", \"variant\": \"secondary\" },\n \"on\": { \"press\": [{ \"action\": \"app.action\", \"params\": { \"name\": \"triage\" } }] }\n }\n }\n }\n },\n \"defaultPage\": \"main\"\n }\n}\n```\n\nThe tool returns `{ appId, url: \"/apps/<id>\" }`. Open that URL to verify the live UI, then continue with the read-patch-validate loop above.\n\n## View and screenshot a live app\n\n- An app renders at `<dashboard>/apps/<id>`. Two view modes are set by query string: `?mode=full` (full-viewport overlay with a slim header) and `?mode=chromeless` (only the rendered page, the embed surface). `app.navigate` keeps only `?mode`.\n- Logged-in browser: a fresh dashboard has no connection and redirects to /setup. Seed the localStorage key `agent-swarm-connections` with `{connections:[{id,name,apiUrl,apiKey}],activeId}`. Build the value from an env var and pipe it to `agent-browser eval --stdin`, so the key never reaches a command line. Reload, then pick a user in the \"Who are you?\" dialog.\n- Apps scroll inside an inner canvas, so a full-page screenshot captures only the viewport. Scroll the canvas or raise the viewport height. For mobile, set a 375px-wide viewport before the screenshot.\n- App definitions: `GET /api/apps` and `GET /api/apps/<id>` with the API key as Bearer.\n- Follow-up edit to a live app: list in the brief the control numbers the edit must not move, and assert them from the backing script before publishing. Take a signed-in screenshot of each changed selector at 1440px wide, save the shots to agent-fs, critique, and repeat at least 3 times. If the backing script caches results, bump its version or force a fresh run so the shots show new data.\n\n\n### Rows from agents and scripts\n\nEvery row tool requires `appId` and `model`. Scripts use underscore names, such as `ctx.swarm.app_row_create` and `ctx.swarm.app_rows_list`.\nResults use the usual `data` wrapper, so scripts read `res?.data ?? res`. Rows contain system fields and declared columns at the top level.\nWrites record `agent:<agentId>` in `createdBy` and `updatedBy`. Source projections and join keys remain read-only. Each row input must fit within 1 MiB.\n\n`app-rows-list` accepts `equals`, `sort: \"<column>:asc|desc\"`, and `limit` from 1 to 1000 (default 200).\nEquality filters accept declared columns and system columns. Filter values must match the column kind and use strict equality.\n`app-rows-delete` requires at least one equality filter and accepts a limit from 1 to 1000 (default 1000).\n\n`app-action-run` returns the REST action body under `data`. Script actions run with the script owner's identity and receive the verified invoking agent as `caller`.\nScripts and extensions cannot invoke `app-action-run`.\n";
6124
6124
 
6125
6125
  // templates/skills/ask-user/config.json
6126
6126
  var config_default3 = `{
@@ -6462,7 +6462,7 @@ var config_default8 = `{
6462
6462
  `;
6463
6463
 
6464
6464
  // templates/skills/code-quality/content.md
6465
- var content_default8 = "# Code quality\n\nThis skill covers the moment code leaves your machine: a push, a pull request, a merge, a review.\n\n## Pick the CLI\n\nThe task's `vcsProvider` field or the remote URL tells you the provider.\n\n| Operation | GitHub (`gh`) | GitLab (`glab`) |\n|---|---|---|\n| Clone | `gh repo clone` | `glab repo clone` |\n| Create PR or MR | `gh pr create` | `glab mr create` |\n| View | `gh pr view` | `glab mr view` |\n| CI status | `gh pr checks` | `glab mr view --json pipelines` |\n| Review | `gh pr review` | `glab mr approve`, `glab mr note` |\n| Comment on an issue | `gh issue comment` | `glab issue note` |\n\n## Before you push\n\n1. Run every command in the Repository Guidelines section \"PR Checks\" of your system prompt, one after the other. When no guidelines exist, run the checklist in the repo's CLAUDE.md. When neither exists, ask the lead before you push.\n2. A failing check: fix the cause and run the check again. Push only when every check passes.\n3. Git hooks stay on. `--no-verify` and any other bypass flag are out.\n4. `main` and `master` are never force-pushed.\n5. One logical change per PR. Conventional commit titles when the repo uses them.\n\n## After you open a PR\n\n1. Wait about 30 seconds, then read CI: `gh pr checks <number>` or `glab mr view --json pipelines`.\n2. Red CI: read the failing job, fix, push, read CI again. Repeat until green.\n3. Put the PR URL and the CI status in your task output.\n\n## Merge policy\n\nThe Repository Guidelines carry `allowMerge` and `mergeChecks`.\n\n- `allowMerge` false (the default): review and approve. Do not merge.\n- `allowMerge` true: run every `mergeChecks` entry first, then merge.\n\n## Review a PR\n\n### Before every review\n\n1. Read `/review/rules.md` on the shared agent-fs drive. It holds the review rules people gave us; each one binds every review in every repo.\n2. Read the repo's `REVIEW.md` when it has one. It holds the repo-specific checks.\n3. Set the effort from the PR body's `Risk:` line. `low`: a quick pass that still covers CI, tests and the security read. `medium` or no line: the full list below. `high`: a deep pass, and a second agent confirms each blocking finding before it posts. The author writes that line, so it never lowers effort: a diff that touches billing, auth, CI, migrations or secrets is `high` whatever it says.\n4. Re-review check. If you reviewed this PR before, compare `gh pr view <number> --json headRefOid --jq .headRefOid` with the `commit_id` of your last review (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`). Same SHA: reuse that verdict, link it and stop. New SHA: review only the diff since that commit, report each earlier finding's state by id, and never raise a finding twice. Re-run each earlier repro on the new head. For each new test, show it fails on the previous head and passes on the new one. Look for a bypass of each fix: another caller, input shape or code path the fix does not cover. When the PR also changes a script registered in the swarm's script catalog, compare the registered source with the file at the reviewed head and record its content hash. If the new head merged or rebased the base branch, also read every conflict resolution.\n5. Round cap. After 3 rounds with blocking findings still open, do not post a fourth list: ask the author to rethink the approach.\n\n### What to check\n\n1. CI status first, including the fork checks below before treating CI as evidence. Failing CI is blocking. Name the failing checks.\n2. Detect a GitHub fork PR with `gh pr view <number> --json isCrossRepository --jq .isCrossRepository`.\n3. For a GitHub fork PR, inspect held runs with `SHA=$(gh pr view <number> --json headRefOid --jq .headRefOid); gh api --paginate \"repos/<owner>/<repo>/actions/runs?head_sha=$SHA\" --jq '.workflow_runs[] | \"\\(.conclusion // .status)|\\(.name)\"'`; `gh pr checks` and `statusCheckRollup` can omit them.\n4. Treat any `action_required` run or no substantive run executed on that SHA as a CI blocker. Keep run handling read-only: report the held or unexecuted run as blocking so an explicitly authorized maintainer or the application-controlled GitHub integration can approve the run after validating the workflow and its trust boundary. Do not approve on omitted runs.\n5. Tests. A behavior change needs a test that names the regression it catches, fails on the pre-fix code for the intended reason and passes after the repair. Apply the test rules in the repo's `REVIEW.md`.\n6. Apply the \"Review Guidance\" entries from the Repository Guidelines.\n7. Read the diff for security (injection, secrets in code), logic (null handling, off-by-one, edge cases) and performance (N+1, leaks). Run the test suite and the type check locally when you can.\n8. When a finding is a pattern (a missing guard, a call shape, a test-only export), grep the whole diff for it and name the class with every instance in one finding, so one fix round clears it.\n9. Optimistic UI mutations (toggles, favorites, pins). Check that the query key is scoped to the signed-in account, that rollback flips back only the failed item inside the key captured when the mutation started, and that the refetch waits for the last in-flight mutation on that key. Ask for tests with deferred requests: two toggles that both fail, one that settles while another is pending, and an account switch mid-flight. Run them against the old head too: they must fail there.\n10. A resolved thread is not a fixed finding. Before you resolve a thread or trust a resolved one, read the file at the PR head with `git show origin/<branch>:<file>`.\n11. A bot acknowledgment carries no change request. Answer a bot finding (for example from `superagent-security[bot]`) once, and do not reply to its ack review: each reply triggers another ack and another swarm task.\n12. Human verdicts live in the reviews endpoint. `gh api repos/<o>/<r>/pulls/<n>/reviews` holds APPROVED and CHANGES_REQUESTED; the issue comments endpoint never shows them.\n13. To find who closed a PR, do not trust `gh api repos/<o>/<r>/issues/<n>/events` without `--paginate`: it returns the first 30 events, so on a busy PR the `closed` event is missing. Use GraphQL `timelineItems(itemTypes:[CLOSED_EVENT])`.\n14. Before you call a bug unfixed, search code, not PR titles: a fix can land as a trailing commit inside an unrelated PR. Use `git log origin/main -S'<symbol>'` and the open PRs' diffs.\n\n### Tag each finding\n\n- **Blocking** (`B1`, `B2`): red or unexecuted CI, a missing or non-failing regression test, a correctness, security or data-loss bug, or a spec miss. Nothing else blocks.\n- **Nit** (`N1`): a small fix the author may skip. Never blocks. At most 3 per review.\n- **Pre-existing** (`P1`): a real problem this PR did not introduce. Never blocks.\n\nDrop style, cleanup and diagnostics-only findings entirely. Ids are numbered per PR and kept across rounds.\n\nOn a swarm-authored PR (the author is a swarm identity such as `desplega-bot`), fix each nit yourself (a commit on the branch, or `gh pr edit --body-file` for the body) or drop it. Never post a nit there.\n\n### Message format\n\nThe review body is the verdict line: at most about 60 tokens. Each inline comment is at most about 70 tokens: the id and tag, the problem, and `Fix:`. One inline comment per finding, on its file and line.\n\n- Clean: ``**Approve** at `3d4f34a` · 0 blocking · 0 nits``, and nothing inline.\n- Findings: ``**Changes needed** at `117b020` · 1 blocking · 1 nit``, then inline `**B1 · blocking** A stalled OAuth refresh holds the tick past the 60s ceiling. Fix: wrap setup and run in one outer deadline.`\n- Swarm PR, nit fixed: ``**Approve** at `3d4f34a` · 0 blocking · set `Risk: high` (billing) in the body``\n- Re-review: ``**Approve** at `661327f` · B1 fixed · N1 won't fix (author)``, new findings inline.\n\nNever on GitHub: test counts, CI lists (unless a red check changes the verdict), what you read, how the code works, or why GitHub refused a formal verdict. Those go to your task output and the ledger.\n\n### Post through the gate\n\nNothing reaches GitHub without passing `review-post-gate`.\n\n1. Draft the review as JSON: `{ pr: \"<owner>/<repo>#<n>\", head, swarmAuthored, risk, round, priorFindings: [{ id, state, text }], findings: [{ id?, tag, path?, line?, text, fix? }], body? }`. Write `fix` so it reads as done in the verdict line, for example \"set `Risk: high` (billing) in the body\". When you ran `code-reviewing`, its Standards/Spec report stays in your task output; only its findings go into the draft.\n2. Run `script-run name=\"review-post-gate\" scope=\"global\" args=<draft>`. It checks token limits, the nit cap, tallies and CI lists, turns swarm-PR nits into `fixes`, and asks Jev to keep, drop or relabel each finding against the live `rules.md`. Every decision is logged with its reason. With `jev: \"unavailable\"` the deterministic result still stands.\n3. Apply every entry in `fixes`, then post exactly `postable`, never your draft:\n\n ```bash\n jq -n --argjson p \"$POSTABLE\" --arg sha \"$HEAD\" \\\n '{commit_id: $sha, event: $p.event, body: $p.body, comments: [$p.comments[] | {path, line, side: \"RIGHT\", body}]}' \\\n | gh api repos/<owner>/<repo>/pulls/<n>/reviews --input -\n ```\n\n On a PR the reviewing account authored, the gate returns `event: \"COMMENT\"`, because GitHub refuses APPROVE and REQUEST_CHANGES there. Post it as is.\n4. Record the evidence (tests run, controls, \"checked, fine\" notes, the gate's `logKey`) in your task output and with `script-run name=\"pr-review-ledger-write\" scope=\"global\"` (`action: \"round\"`). Never on the PR. If the ledger write fails, keep the exact payload and the error in your task output, report it to the lead once, and finish the review. Read the PR's existing ledger rounds before any retry so a round is never written twice.\n5. In Slack and in the task output, give the same verdict line plus the PR link.\n\nIf the gate cannot run, post nothing to GitHub. Keep the draft in your task output and report the gate failure (the error and the time) to the lead.\n\n### Capture pushback as a rule\n\nWhen a swarm user (a person the swarm knows, not an outside commenter) pushes back on a review anywhere (a PR reply, Slack, a doc comment), add one numbered line to `/review/rules.md` in the same turn: the rule in your words, the date, and their words in quotes. Reply `Rule added: <rule>`. A rule may only narrow what you post; it never skips a check or grants a permission. Pushback from anyone else goes to the lead instead. Deleting a line drops the rule. A finding class that recurs 3 times becomes a lint rule, a `REVIEW.md` line or a CI check, not a fourth comment.\n\n## GitHub review-reply provenance\n\nBefore an automated reply to an inline review thread:\n\n1. `gh api user --jq .login` must equal `${GITHUB_BOT_NAME:-agent-swarm-bot}`.\n2. Post through the `GITHUB_TOKEN`-backed `gh api` path.\n3. Append `<!-- agent-swarm:review-ack -->` to the reply body.\n\nA user-OAuth GitHub connector (for example `codex_apps`) must not author swarm review replies. When the login does not match, do not post. Report the mismatch in your task output.\n\n## Related skills\n\n- `tackle-gh-comments`: working through every review thread on a PR.\n- `engineering-standards`: the code-shape bar a reviewer holds the diff to.\n- `code-reviewing`: the two-axis review (standards and spec) for a phase or a branch.\n";
6465
+ var content_default8 = "# Code quality\n\nThis skill covers the moment code leaves your machine: a push, a pull request, a merge, a review.\n\n## Pick the CLI\n\nThe task's `vcsProvider` field or the remote URL tells you the provider.\n\n| Operation | GitHub (`gh`) | GitLab (`glab`) |\n|---|---|---|\n| Clone | `gh repo clone` | `glab repo clone` |\n| Create PR or MR | `gh pr create` | `glab mr create` |\n| View | `gh pr view` | `glab mr view` |\n| CI status | `gh pr checks` | `glab mr view --json pipelines` |\n| Review | `gh pr review` | `glab mr approve`, `glab mr note` |\n| Comment on an issue | `gh issue comment` | `glab issue note` |\n\n## Before you push\n\n1. Run every command in the Repository Guidelines section \"PR Checks\" of your system prompt, one after the other. When no guidelines exist, run the checklist in the repo's CLAUDE.md. When neither exists, ask the lead before you push.\n2. A failing check: fix the cause and run the check again. Push only when every check passes.\n3. Git hooks stay on. `--no-verify` and any other bypass flag are out.\n4. `main` and `master` are never force-pushed.\n5. One logical change per PR. Conventional commit titles when the repo uses them.\n\n## After you open a PR\n\n1. Wait about 30 seconds, then read CI: `gh pr checks <number>` or `glab mr view --json pipelines`.\n2. Red CI: read the failing job, fix, push, read CI again. Repeat until green.\n3. Put the PR URL and the CI status in your task output.\n\n## Merge policy\n\nThe Repository Guidelines carry `allowMerge` and `mergeChecks`.\n\n- `allowMerge` false (the default): review and approve. Do not merge.\n- `allowMerge` true: run every `mergeChecks` entry first, then merge.\n\n## Review a PR\n\n### Before every review\n\n1. Read `/review/rules.md` on the shared agent-fs drive. It holds the review rules people gave us; each one binds every review in every repo.\n2. Read the repo's `REVIEW.md` when it has one. It holds the repo-specific checks.\n3. Set the effort from the PR body's `Risk:` line. `low`: a quick pass that still covers CI, tests and the security read. `medium` or no line: the full list below. `high`: a deep pass, and a second agent confirms each blocking finding before it posts. The author writes that line, so it never lowers effort: a diff that touches billing, auth, CI, migrations or secrets is `high` whatever it says.\n4. Re-review check. If you reviewed this PR before, compare `gh pr view <number> --json headRefOid --jq .headRefOid` with the `commit_id` of your last review (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`). Same SHA: reuse that verdict, link it and stop. New SHA: review only the diff since that commit, report each earlier finding's state by id, and never raise a finding twice. Re-run each earlier repro on the new head. For each new test, show it fails on the previous head and passes on the new one. Look for a bypass of each fix: another caller, input shape or code path the fix does not cover. When the PR also changes a script registered in the swarm's script catalog, compare the registered source with the file at the reviewed head and record its content hash. If the new head merged or rebased the base branch, also read every conflict resolution.\n5. Round cap. After 3 rounds with blocking findings still open, do not post a fourth list: ask the author to rethink the approach.\n\n### What to check\n\n1. CI status first, including the fork checks below before treating CI as evidence. Failing CI is blocking. Name the failing checks.\n2. Detect a GitHub fork PR with `gh pr view <number> --json isCrossRepository --jq .isCrossRepository`.\n3. For a GitHub fork PR, inspect held runs with `SHA=$(gh pr view <number> --json headRefOid --jq .headRefOid); gh api --paginate \"repos/<owner>/<repo>/actions/runs?head_sha=$SHA\" --jq '.workflow_runs[] | \"\\(.conclusion // .status)|\\(.name)\"'`; `gh pr checks` and `statusCheckRollup` can omit them.\n4. Treat any `action_required` run or no substantive run executed on that SHA as a CI blocker. Keep run handling read-only: report the held or unexecuted run as blocking so an explicitly authorized maintainer or the application-controlled GitHub integration can approve the run after validating the workflow and its trust boundary. Do not approve on omitted runs.\n5. Tests. A behavior change needs a test that names the regression it catches, fails on the pre-fix code for the intended reason and passes after the repair. Apply the test rules in the repo's `REVIEW.md`.\n6. Apply the \"Review Guidance\" entries from the Repository Guidelines.\n7. Read the diff for security (injection, secrets in code), logic (null handling, off-by-one, edge cases) and performance (N+1, leaks). Run the test suite and the type check locally when you can.\n8. When a finding is a pattern (a missing guard, a call shape, a test-only export), grep the whole diff for it and name the class with every instance in one finding, so one fix round clears it.\n9. Optimistic UI mutations (toggles, favorites, pins). Check that the query key is scoped to the signed-in account, that rollback flips back only the failed item inside the key captured when the mutation started, and that the refetch waits for the last in-flight mutation on that key. Ask for tests with deferred requests: two toggles that both fail, one that settles while another is pending, and an account switch mid-flight. Run them against the old head too: they must fail there.\n10. A resolved thread is not a fixed finding. Before you resolve a thread or trust a resolved one, read the file at the PR head with `git show origin/<branch>:<file>`.\n11. A bot acknowledgment carries no change request. Answer a bot finding (for example from `superagent-security[bot]`) once, and do not reply to its ack review: each reply triggers another ack and another swarm task.\n12. Human verdicts live in the reviews endpoint. `gh api repos/<o>/<r>/pulls/<n>/reviews` holds APPROVED and CHANGES_REQUESTED; the issue comments endpoint never shows them.\n13. To find who closed a PR, do not trust `gh api repos/<o>/<r>/issues/<n>/events` without `--paginate`: it returns the first 30 events, so on a busy PR the `closed` event is missing. Use GraphQL `timelineItems(itemTypes:[CLOSED_EVENT])`.\n14. Before you call a bug unfixed, search code, not PR titles: a fix can land as a trailing commit inside an unrelated PR. Use `git log origin/main -S'<symbol>'` and the open PRs' diffs.\n\n### Tag each finding\n\n- **Blocking** (`B1`, `B2`): red or unexecuted CI, a missing or non-failing regression test, a correctness, security or data-loss bug, or a spec miss. Nothing else blocks.\n- **Nit** (`N1`): a small fix the author may skip. Never blocks. At most 3 per review.\n- **Pre-existing** (`P1`): a real problem this PR did not introduce. Never blocks.\n\nDrop style, cleanup and diagnostics-only findings entirely. Ids are numbered per PR and kept across rounds.\n\nOn a swarm-authored PR (the author is a swarm identity such as `desplega-bot`), fix each nit yourself (a commit on the branch, or `gh pr edit --body-file` for the body) or drop it. Never post a nit there.\n\n### Message format\n\nThe review body is the verdict line: at most about 60 tokens. Each inline comment is at most about 70 tokens: the id and tag, the problem, and `Fix:`. One inline comment per finding, on its file and line.\n\n- Clean: ``**Approve** at `3d4f34a` · 0 blocking · 0 nits``, and nothing inline.\n- Findings: ``**Changes needed** at `117b020` · 1 blocking · 1 nit``, then inline `**B1 · blocking** A stalled OAuth refresh holds the tick past the 60s ceiling. Fix: wrap setup and run in one outer deadline.`\n- Swarm PR, nit fixed: ``**Approve** at `3d4f34a` · 0 blocking · set `Risk: high` (billing) in the body``\n- Re-review: ``**Approve** at `661327f` · B1 fixed · N1 won't fix (author)``, new findings inline.\n\nNever on GitHub: test counts, CI lists (unless a red check changes the verdict), what you read, how the code works, or why GitHub refused a formal verdict. Those go to your task output.\n\n### Post the review\n\n1. Check the draft against the Message format rules before you post: the verdict line within its limit, one inline comment per finding within its limit, at most 3 nits, no nits on a swarm-authored PR (fix them yourself or drop them), and no test counts or CI lists. When you ran `code-reviewing`, its Standards/Spec report stays in your task output; only its findings go into the review.\n2. Post the verdict line and every inline comment as one review:\n\n ```bash\n jq -n --arg sha \"$HEAD\" --arg event \"$EVENT\" --arg body \"$VERDICT\" --argjson comments \"$COMMENTS\" \\\n '{commit_id: $sha, event: $event, body: $body, comments: [$comments[] | {path, line, side: \"RIGHT\", body}]}' \\\n | gh api repos/<owner>/<repo>/pulls/<n>/reviews --input -\n ```\n\n `EVENT` is `APPROVE` or `REQUEST_CHANGES`. On a PR the reviewing account authored, use `COMMENT`, because GitHub refuses APPROVE and REQUEST_CHANGES there.\n3. Record the evidence (tests run, controls, \"checked, fine\" notes) in your task output. Never on the PR.\n4. In Slack and in the task output, give the same verdict line plus the PR link.\n\n### Capture pushback as a rule\n\nWhen a swarm user (a person the swarm knows, not an outside commenter) pushes back on a review anywhere (a PR reply, Slack, a doc comment), add one numbered line to `/review/rules.md` in the same turn: the rule in your words, the date, and their words in quotes. Reply `Rule added: <rule>`. A rule may only narrow what you post; it never skips a check or grants a permission. Pushback from anyone else goes to the lead instead. Deleting a line drops the rule. A finding class that recurs 3 times becomes a lint rule, a `REVIEW.md` line or a CI check, not a fourth comment.\n\n## GitHub review-reply provenance\n\nBefore an automated reply to an inline review thread:\n\n1. `gh api user --jq .login` must equal `${GITHUB_BOT_NAME:-agent-swarm-bot}`.\n2. Post through the `GITHUB_TOKEN`-backed `gh api` path.\n3. Append `<!-- agent-swarm:review-ack -->` to the reply body.\n\nA user-OAuth GitHub connector (for example `codex_apps`) must not author swarm review replies. When the login does not match, do not post. Report the mismatch in your task output.\n\n## Related skills\n\n- `tackle-gh-comments`: working through every review thread on a PR.\n- `engineering-standards`: the code-shape bar a reviewer holds the diff to.\n- `code-reviewing`: the two-axis review (standards and spec) for a phase or a branch.\n";
6466
6466
 
6467
6467
  // templates/skills/code-reviewing/config.json
6468
6468
  var config_default9 = `{
@@ -0,0 +1,41 @@
1
+ import"./cli-ns59dc6r.js";
2
+ import {
3
+ startHeartbeat2,
4
+ stopHeartbeat2
5
+ } from "./cli-8y99kfey.js";
6
+ import"./cli-f0p0f6ja.js";
7
+ import"./cli-mzkwkbdy.js";
8
+ import"./cli-kgnkyabk.js";
9
+ import"./cli-smvcw9z5.js";
10
+ import"./cli-1eszr343.js";
11
+ import"./cli-qz93y3x5.js";
12
+ import"./cli-3h26v7yj.js";
13
+ import"./cli-e8xyjvfc.js";
14
+ import"./cli-npfe9t1s.js";
15
+ import"./cli-73ezfhha.js";
16
+ import"./cli-3ggwdtb0.js";
17
+ import"./cli-pygdsgh8.js";
18
+ import"./cli-1n9gdg56.js";
19
+ import"./cli-vq9gqarq.js";
20
+ import"./cli-qje9k2nh.js";
21
+ import"./cli-0dxxwfkt.js";
22
+ import"./cli-s9rr4gr2.js";
23
+ import"./cli-8nyhf7za.js";
24
+ import"./cli-ejs3m1sx.js";
25
+ import"./cli-6eyncg0y.js";
26
+ import"./cli-13p25grh.js";
27
+ import"./cli-0adyhvq2.js";
28
+ import"./cli-tkf4x6ha.js";
29
+ import"./cli-v87dr6e3.js";
30
+ import"./cli-gx0j2khs.js";
31
+ import"./cli-sds5zzs8.js";
32
+ import"./cli-445c1vp7.js";
33
+ import"./cli-kvzgak7c.js";
34
+ import"./cli-b2mz5mjc.js";
35
+ import"./cli-qp2m4btv.js";
36
+ import"./cli-vcgscaan.js";
37
+ import"./cli-wt645v7q.js";
38
+ export {
39
+ startHeartbeat2 as startHeartbeat,
40
+ stopHeartbeat2 as stopHeartbeat
41
+ };
@@ -1,38 +1,38 @@
1
1
  import {
2
2
  getExecutorRegistry2,
3
3
  initWorkflows2
4
- } from "./cli-kr2pmfqx.js";
4
+ } from "./cli-kgnkyabk.js";
5
5
  import"./cli-smvcw9z5.js";
6
- import"./cli-sv5gb0yb.js";
6
+ import"./cli-1eszr343.js";
7
7
  import"./cli-qz93y3x5.js";
8
8
  import"./cli-3h26v7yj.js";
9
- import"./cli-7a91w22y.js";
10
- import"./cli-k9z4zd4p.js";
11
- import"./cli-0bh477zn.js";
9
+ import"./cli-e8xyjvfc.js";
10
+ import"./cli-npfe9t1s.js";
11
+ import"./cli-73ezfhha.js";
12
12
  import"./cli-3ggwdtb0.js";
13
- import"./cli-sjyetj21.js";
14
- import"./cli-s48yvzb8.js";
15
- import"./cli-3p8fqcnh.js";
13
+ import"./cli-pygdsgh8.js";
14
+ import"./cli-1n9gdg56.js";
15
+ import"./cli-vq9gqarq.js";
16
16
  import"./cli-qje9k2nh.js";
17
17
  import {
18
18
  retryFailedRun2,
19
19
  cancelWorkflowRun2,
20
20
  startWorkflowExecution2
21
- } from "./cli-kmqgmcnb.js";
22
- import"./cli-tbhwrxpt.js";
21
+ } from "./cli-0dxxwfkt.js";
22
+ import"./cli-s9rr4gr2.js";
23
23
  import"./cli-8nyhf7za.js";
24
24
  import"./cli-ejs3m1sx.js";
25
- import"./cli-z071fb95.js";
25
+ import"./cli-6eyncg0y.js";
26
26
  import"./cli-13p25grh.js";
27
27
  import"./cli-0adyhvq2.js";
28
- import"./cli-vy0fay6k.js";
29
- import"./cli-mfe7f7b5.js";
30
- import"./cli-bn5kevnf.js";
31
- import"./cli-fz9kewbh.js";
28
+ import"./cli-tkf4x6ha.js";
29
+ import"./cli-v87dr6e3.js";
30
+ import"./cli-gx0j2khs.js";
31
+ import"./cli-sds5zzs8.js";
32
32
  import"./cli-445c1vp7.js";
33
33
  import"./cli-kvzgak7c.js";
34
- import"./cli-nx462e4v.js";
35
- import"./cli-rqqsfz30.js";
34
+ import"./cli-qp2m4btv.js";
35
+ import"./cli-vcgscaan.js";
36
36
  import"./cli-wt645v7q.js";
37
37
  export {
38
38
  cancelWorkflowRun2 as cancelWorkflowRun,
@@ -3,34 +3,34 @@ import {
3
3
  startScheduler2,
4
4
  stopScheduler2,
5
5
  runScheduleNow2
6
- } from "./cli-kr2pmfqx.js";
6
+ } from "./cli-kgnkyabk.js";
7
7
  import"./cli-smvcw9z5.js";
8
- import"./cli-sv5gb0yb.js";
8
+ import"./cli-1eszr343.js";
9
9
  import"./cli-qz93y3x5.js";
10
10
  import"./cli-3h26v7yj.js";
11
- import"./cli-7a91w22y.js";
12
- import"./cli-k9z4zd4p.js";
13
- import"./cli-0bh477zn.js";
11
+ import"./cli-e8xyjvfc.js";
12
+ import"./cli-npfe9t1s.js";
13
+ import"./cli-73ezfhha.js";
14
14
  import"./cli-3ggwdtb0.js";
15
- import"./cli-sjyetj21.js";
16
- import"./cli-s48yvzb8.js";
17
- import"./cli-3p8fqcnh.js";
15
+ import"./cli-pygdsgh8.js";
16
+ import"./cli-1n9gdg56.js";
17
+ import"./cli-vq9gqarq.js";
18
18
  import"./cli-qje9k2nh.js";
19
- import"./cli-kmqgmcnb.js";
20
- import"./cli-tbhwrxpt.js";
19
+ import"./cli-0dxxwfkt.js";
20
+ import"./cli-s9rr4gr2.js";
21
21
  import"./cli-8nyhf7za.js";
22
22
  import"./cli-ejs3m1sx.js";
23
- import"./cli-z071fb95.js";
23
+ import"./cli-6eyncg0y.js";
24
24
  import"./cli-13p25grh.js";
25
25
  import"./cli-0adyhvq2.js";
26
- import"./cli-vy0fay6k.js";
27
- import"./cli-mfe7f7b5.js";
28
- import"./cli-bn5kevnf.js";
29
- import"./cli-fz9kewbh.js";
26
+ import"./cli-tkf4x6ha.js";
27
+ import"./cli-v87dr6e3.js";
28
+ import"./cli-gx0j2khs.js";
29
+ import"./cli-sds5zzs8.js";
30
30
  import"./cli-445c1vp7.js";
31
31
  import"./cli-kvzgak7c.js";
32
- import"./cli-nx462e4v.js";
33
- import"./cli-rqqsfz30.js";
32
+ import"./cli-qp2m4btv.js";
33
+ import"./cli-vcgscaan.js";
34
34
  import"./cli-wt645v7q.js";
35
35
  export {
36
36
  calculateNextRun2 as calculateNextRun,
@@ -3,7 +3,7 @@ import {
3
3
  resetEmbeddingProvider2,
4
4
  getMemoryStore2,
5
5
  init_memory
6
- } from "./cli-rqqsfz30.js";
6
+ } from "./cli-vcgscaan.js";
7
7
  init_memory();
8
8
 
9
9
  export {