@desplega.ai/agent-swarm 2.7.5 → 2.7.7

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 (319) hide show
  1. package/dist/{acp-adapter-g3ads55s.js → acp-adapter-7b4vk3h5.js} +5 -4
  2. package/dist/{actions-zwfykqqk.js → actions-3tt153af.js} +18 -17
  3. package/dist/{amp-adapter-z3s0ssdt.js → amp-adapter-njpts0yr.js} +1 -1
  4. package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
  5. package/dist/{app-vmp4fgep.js → app-nky4378f.js} +3 -3
  6. package/dist/{assistant-vwde653f.js → assistant-jnb669pa.js} +24 -22
  7. package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
  8. package/dist/{boot-reembed-375t7tbk.js → boot-reembed-9mgvaztt.js} +1 -1
  9. package/dist/{boot-reembed-gq9rcpnj.js → boot-reembed-qs2djdej.js} +1 -1
  10. package/dist/{boot-scrub-sweep-nd938kyf.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
  11. package/dist/{boot-seeds-aykx51v3.js → boot-seeds-5cm6ambd.js} +4 -4
  12. package/dist/{boxd-k1j26g3h.js → boxd-nv507va8.js} +5 -5
  13. package/dist/{claude-adapter-8j4hm099.js → claude-adapter-fn287zmm.js} +5 -4
  14. package/dist/{claude-managed-setup-0wyynpn1.js → claude-managed-setup-wtdz2mj2.js} +1 -1
  15. package/dist/{claude-sdk-session-q6wj5hmy.js → claude-sdk-session-x91kqyhm.js} +9677 -9514
  16. package/dist/{cli-j3jvnpe8.js → cli-08bmz9z9.js} +1 -1
  17. package/dist/{cli-gjr2y2xk.js → cli-0agcv1v5.js} +4 -1
  18. package/dist/{cli-y24sc29k.js → cli-1c6tzt8g.js} +30 -8
  19. package/dist/{cli-mjzynrse.js → cli-35rsp8ye.js} +1 -1
  20. package/dist/{cli-rpm2sgv0.js → cli-3fg0bdcv.js} +19 -2
  21. package/dist/{cli-nn4d8dne.js → cli-5b456bpf.js} +2 -2
  22. package/dist/{cli-bhjt02p8.js → cli-5jnhv72d.js} +1 -1
  23. package/dist/{cli-fqzx7gwa.js → cli-5zx699j9.js} +2 -2
  24. package/dist/{cli-hf6zt5m6.js → cli-69szn7w7.js} +17 -1
  25. package/dist/{cli-vqypxzh4.js → cli-6g0hb4cp.js} +8 -8
  26. package/dist/{cli-tqgg40ek.js → cli-6pfd54vx.js} +9 -8
  27. package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
  28. package/dist/{cli-zppwg9p9.js → cli-72v799xn.js} +1 -1
  29. package/dist/cli-82j36f2m.js +45 -0
  30. package/dist/{cli-hn9hxmz9.js → cli-8305gbq0.js} +29 -16
  31. package/dist/{cli-z8eh3ex8.js → cli-8sckt74b.js} +8 -8
  32. package/dist/{cli-bnz7a7qf.js → cli-95jzxcp3.js} +1 -1
  33. package/dist/{cli-0bxqnvq9.js → cli-adeegh7d.js} +29 -7
  34. package/dist/{cli-g61qxmge.js → cli-afkqkrtw.js} +3 -3
  35. package/dist/{cli-7a42nfy0.js → cli-ayayvfg4.js} +24 -4
  36. package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
  37. package/dist/{cli-z6jkx23q.js → cli-bfv7vdg6.js} +9 -9
  38. package/dist/{cli-pywf3eh5.js → cli-caj33vcq.js} +5 -5
  39. package/dist/{cli-5pag6rmn.js → cli-cb2a8pna.js} +1 -1
  40. package/dist/{cli-wvaawacd.js → cli-cppkf404.js} +5236 -72
  41. package/dist/{cli-mry0qzym.js → cli-ddaanqkd.js} +123 -10
  42. package/dist/{cli-0gey5fzy.js → cli-e0kmphd6.js} +2 -2
  43. package/dist/{cli-zh3fp8v2.js → cli-e46hg0xw.js} +1 -1
  44. package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
  45. package/dist/{cli-qxnf8z2z.js → cli-excnnjg2.js} +165 -153
  46. package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
  47. package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
  48. package/dist/{cli-rm6hqj3z.js → cli-fyt2g4wd.js} +33 -139
  49. package/dist/cli-g08zdaec.js +55 -0
  50. package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
  51. package/dist/{cli-82ae98pz.js → cli-gz6q4eve.js} +3 -3
  52. package/dist/{cli-82jg7fp9.js → cli-he5nxaa8.js} +1 -1
  53. package/dist/{cli-rqkxrmgw.js → cli-je518txm.js} +39 -14
  54. package/dist/{cli-8hs41zy2.js → cli-jtjtabxe.js} +1 -1
  55. package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
  56. package/dist/{cli-nzxr556s.js → cli-k1nje3v3.js} +38 -43
  57. package/dist/{cli-3h4nhjn8.js → cli-k8196sh2.js} +9 -7
  58. package/dist/{cli-afrrppsw.js → cli-k899ehvh.js} +2 -2
  59. package/dist/{cli-0731hvbw.js → cli-m0da8j35.js} +23 -9
  60. package/dist/cli-m2z8jagn.js +12078 -0
  61. package/dist/{cli-8veytvbh.js → cli-mkk9rnzz.js} +5 -5
  62. package/dist/{cli-7wspm5gm.js → cli-n6bv12jn.js} +528 -6
  63. package/dist/{cli-spknvxbq.js → cli-nmzhp1b5.js} +14 -12
  64. package/dist/{cli-p599f1qm.js → cli-p82cnnjq.js} +1 -1
  65. package/dist/{cli-xtjhfmpc.js → cli-pc6n7fre.js} +1 -1
  66. package/dist/{cli-vbyfwh92.js → cli-pjctyea3.js} +2 -2
  67. package/dist/{cli-nmbmy64z.js → cli-pq73h5mz.js} +3 -3
  68. package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
  69. package/dist/{cli-7wcyk2s5.js → cli-q3tkegby.js} +1 -1
  70. package/dist/cli-qghe0h66.js +67 -0
  71. package/dist/{cli-6s7vdcta.js → cli-qh4d4tvk.js} +4 -4
  72. package/dist/{cli-mmwm7zbf.js → cli-rtt8g4v9.js} +1 -1
  73. package/dist/{cli-kg8hchyg.js → cli-sa02jsqt.js} +1 -1
  74. package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
  75. package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
  76. package/dist/{cli-a47xy8eq.js → cli-vz22fyqv.js} +186 -54
  77. package/dist/{cli-1ayk1eas.js → cli-wk5yrfwd.js} +285 -42
  78. package/dist/{cli-1338796d.js → cli-ws19768n.js} +1755 -119
  79. package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
  80. package/dist/{cli-gf5hbmdr.js → cli-yjdgea32.js} +3 -3
  81. package/dist/{cli-xbkdm710.js → cli-yndmfxb3.js} +3 -3
  82. package/dist/{cli-rhw40jgb.js → cli-yyj3j5m4.js} +2 -2
  83. package/dist/cli.js +26 -25
  84. package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
  85. package/dist/{codex-adapter-q6k3gnyg.js → codex-adapter-cf2yxe9m.js} +3 -2
  86. package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
  87. package/dist/{codex-session-runner-3mwmk3d9.js → codex-session-runner-6vfzdgbd.js} +3 -2
  88. package/dist/{commands-evrenjn9.js → commands-t3mfy7vn.js} +9 -3
  89. package/dist/{controller-xjp8vswh.js → controller-q7xrbaz0.js} +8 -3
  90. package/dist/{controllers-connect-pp5jnxvc.js → controllers-connect-c6nm70kg.js} +20 -19
  91. package/dist/{cursor-adapter-yf47w4t0.js → cursor-adapter-ftd81ga8.js} +1 -1
  92. package/dist/{db-zxpw6aht.js → db-dk572x25.js} +3 -3
  93. package/dist/{db-rgfsh91f.js → db-vemtpmcc.js} +14 -4
  94. package/dist/{devtools-82fzqra8.js → devtools-qxfgbsqz.js} +1 -1
  95. package/dist/{dispatcher-smasgws7.js → dispatcher-kh7ecxh9.js} +17 -16
  96. package/dist/{docker-rrjhaqt9.js → docker-c1a3gqmf.js} +89 -22
  97. package/dist/{dsh-adapter-ftyy8dnp.js → dsh-adapter-v8628r89.js} +1 -1
  98. package/dist/{e2b-w1p0bnpm.js → e2b-fr12yccx.js} +5 -5
  99. package/dist/{e2b-qcaj675y.js → e2b-x6ywzak3.js} +3 -3
  100. package/dist/{easter-egg-3d-yyts7p16.js → easter-egg-3d-nenw4x63.js} +1 -1
  101. package/dist/{embedding-space-1z0mbg95.js → embedding-space-0jepb239.js} +6 -6
  102. package/dist/{embeddings-ygeg7tm7.js → embeddings-xxt49yxg.js} +1 -1
  103. package/dist/{execute-14qhqp4d.js → execute-1mhw6e4x.js} +1 -1
  104. package/dist/{external-channel-ttgrs3sx.js → external-channel-55v12e60.js} +2 -2
  105. package/dist/{fx-adapter-gt6pncj2.js → fx-adapter-bs8wqrk5.js} +6 -5
  106. package/dist/{google-generative-ai-yvmdxyxa.js → google-generative-ai-v94hr7h0.js} +5 -5
  107. package/dist/{google-vertex-jrtdh9gs.js → google-vertex-g7vr6cf8.js} +5 -5
  108. package/dist/{grok-adapter-sm4m5k4g.js → grok-adapter-gpjqmkcq.js} +6 -5
  109. package/dist/handlers-zxmp1ta4.js +32 -0
  110. package/dist/{heartbeat-qahz4p62.js → heartbeat-k42svnr0.js} +20 -19
  111. package/dist/{hook-4fd5wq5w.js → hook-114m7hap.js} +4 -3
  112. package/dist/{hook-kj3d398m.js → hook-6nphweh0.js} +24 -9
  113. package/dist/{http-1ted0j1k.js → http-r8cc4wz7.js} +1196 -211
  114. package/dist/index-a8phxqnk.js +37 -0
  115. package/dist/{index-q4b917zs.js → index-e3s8wmxy.js} +1 -1
  116. package/dist/{index-6rq3r5zb.js → index-fpk4bcd9.js} +1 -1
  117. package/dist/{index-5rk6rfzr.js → index-fvzke5kx.js} +1 -1
  118. package/dist/index-k6h00t4c.js +30 -0
  119. package/dist/{index-7v51awbn.js → index-nh3wyqdt.js} +1027 -577
  120. package/dist/{index-yqhz88yy.js → index-pxn30mse.js} +8 -8
  121. package/dist/index-xrz7te8h.js +33 -0
  122. package/dist/{keepalive-6cf311t5.js → keepalive-x060nt9p.js} +3 -3
  123. package/dist/lead-956sd2yc.js +71 -0
  124. package/dist/{maintenance-a4bm1bmt.js → maintenance-2xxfexhf.js} +1 -1
  125. package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
  126. package/dist/{oauth-refresh-sweep-3h0dfw1k.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
  127. package/dist/{onboard-myn5hraa.js → onboard-zc48j237.js} +5 -5
  128. package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
  129. package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
  130. package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
  131. package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
  132. package/dist/{opencode-adapter-p2nkb5am.js → opencode-adapter-r690h1cr.js} +4 -3
  133. package/dist/{otel-impl-a1a0ef6b.js → otel-impl-r5xbh81q.js} +4 -4
  134. package/dist/{pi-mono-adapter-rzmpb72a.js → pi-mono-adapter-c7v8sdec.js} +7 -6
  135. package/dist/{pricing-refresh-83drrnxj.js → pricing-refresh-3pmre11v.js} +3 -3
  136. package/dist/{rbac-roles-s8y6ecb5.js → rbac-roles-3vbshwsb.js} +1 -1
  137. package/dist/{rbac-roles-0fb6e7pj.js → rbac-roles-910mmkgz.js} +2 -2
  138. package/dist/{render-v2-twmbgrz4.js → render-v2-pxykkbab.js} +3 -3
  139. package/dist/{resume-zfqc36xj.js → resume-adda0bwy.js} +6 -6
  140. package/dist/{runtime-qwggdpqr.js → runtime-fnbm7stk.js} +1 -1
  141. package/dist/{session-access-sc9pn9da.js → session-access-2fvamrdc.js} +4 -4
  142. package/dist/{session-source-backfill-qh07xr8g.js → session-source-backfill-08xvv0sm.js} +2 -2
  143. package/dist/{setup-4fr0m7a1.js → setup-1aqmvmh1.js} +3 -3
  144. package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
  145. package/dist/{virtual-modules-fh4ha15b.js → virtual-modules-d2pjk9j1.js} +5 -5
  146. package/dist/{work-objects-0wr8zxct.js → work-objects-9s0yrm5j.js} +20 -19
  147. package/dist/{worker-follow-up-ktyyazyp.js → worker-follow-up-3q78fqmr.js} +17 -16
  148. package/dist/worker-y15hy08h.js +71 -0
  149. package/openapi.json +289 -4
  150. package/package.json +9 -8
  151. package/src/apps/definition.ts +6 -5
  152. package/src/be/db/page-theme-tokens.ts +12 -0
  153. package/src/be/db/script-runs.ts +23 -0
  154. package/src/be/db/tasks/read.ts +131 -4
  155. package/src/be/db/tasks/write.ts +19 -0
  156. package/src/be/db-client.ts +114 -11
  157. package/src/be/db.ts +49 -6
  158. package/src/be/memory/providers/sqlite-store.ts +49 -12
  159. package/src/be/migrations/260_runtime_pool_cache.sql +20 -0
  160. package/src/be/migrations/262_session_visibility_indexes.sql +18 -0
  161. package/src/be/migrations/263_task_list_filter_index.sql +11 -0
  162. package/src/be/migrations/264_slack_thread_cover_index.sql +9 -0
  163. package/src/be/migrations/266_script_runs_source_task.sql +11 -0
  164. package/src/be/migrations/269_pages_design_system.sql +23 -0
  165. package/src/be/runtime-pools/cache-cleanup.ts +109 -0
  166. package/src/be/runtime-pools/db.ts +65 -6
  167. package/src/be/runtime-pools/remote-controller.ts +5 -0
  168. package/src/be/runtime-pools/supervisor.ts +33 -2
  169. package/src/be/scripts/typecheck.ts +22 -0
  170. package/src/be/seed/schedules-seeder.ts +0 -10
  171. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
  172. package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
  173. package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
  174. package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
  175. package/src/be/seed-scripts/catalog/task-failure-audit.ts +5 -1
  176. package/src/be/seed-scripts/index.ts +9 -0
  177. package/src/be/seed-skills/bundled-files.generated.json +3 -3
  178. package/src/be/swarm-config-guard.ts +32 -0
  179. package/src/be/task-auto-title-listener.ts +127 -0
  180. package/src/commands/controller.ts +4 -0
  181. package/src/commands/entrypoint-api-readiness.ts +74 -0
  182. package/src/commands/onboard/agent-fs-version.ts +1 -1
  183. package/src/commands/profile-sync.ts +151 -16
  184. package/src/commands/runner.ts +15 -7
  185. package/src/hooks/hook.ts +20 -9
  186. package/src/http/index.ts +4 -1
  187. package/src/http/list-total-cache.ts +14 -7
  188. package/src/http/memory.ts +38 -9
  189. package/src/http/pages-public.ts +93 -129
  190. package/src/http/pages.ts +94 -21
  191. package/src/http/script-runs.ts +174 -5
  192. package/src/http/status.ts +10 -2
  193. package/src/http/tasks.ts +4 -2
  194. package/src/pages/browser-sdk.ts +48 -21
  195. package/src/pages/color-literals.ts +115 -0
  196. package/src/pages/page-head.ts +766 -0
  197. package/src/pages/version.ts +3 -0
  198. package/src/providers/amp-adapter.ts +1 -1
  199. package/src/providers/codex-adapter.ts +1 -0
  200. package/src/providers/codex-app-server.ts +27 -1
  201. package/src/providers/fx-adapter.ts +1 -1
  202. package/src/providers/pi-mono-extension.ts +30 -15
  203. package/src/runtime-providers/controller-protocol.ts +14 -1
  204. package/src/runtime-providers/controller.ts +5 -0
  205. package/src/runtime-providers/docker.ts +108 -23
  206. package/src/runtime-providers/types.ts +16 -0
  207. package/src/script-workflows/workflow-ctx.ts +8 -0
  208. package/src/scripts-runtime/types/stdlib.d.ts +22 -0
  209. package/src/scripts-runtime/types/swarm-sdk.d.ts +22 -0
  210. package/src/server-user.ts +1 -0
  211. package/src/slack/commands.ts +5 -1
  212. package/src/slack/handlers.ts +37 -2
  213. package/src/slack/left-threads.ts +27 -4
  214. package/src/tasks/follow-up-gate.ts +318 -0
  215. package/src/tasks/follow-up-redelegation-guard.ts +155 -0
  216. package/src/tasks/worker-follow-up.ts +21 -1
  217. package/src/tests/claude-artifacts-read-script.test.ts +209 -0
  218. package/src/tests/codex-app-server.test.ts +59 -0
  219. package/src/tests/codex-gateway-route.test.ts +50 -1
  220. package/src/tests/create-page-tool.test.ts +501 -0
  221. package/src/tests/db-client-busy-retry.test.ts +81 -0
  222. package/src/tests/db-client.test.ts +21 -0
  223. package/src/tests/db-task-read-contract.test.ts +9 -2
  224. package/src/tests/db-task-write-contract.test.ts +7 -1
  225. package/src/tests/db-tasks-read.test.ts +21 -0
  226. package/src/tests/entrypoint-api-readiness.test.ts +416 -210
  227. package/src/tests/explicit-model-validation.test.ts +14 -8
  228. package/src/tests/fixtures/pages/head-v1.html +56 -0
  229. package/src/tests/follow-up-gate-shadow.test.ts +513 -0
  230. package/src/tests/follow-up-redelivery-guard.test.ts +432 -202
  231. package/src/tests/identity-field-budget.test.ts +1 -1
  232. package/src/tests/internal-ai/credentials.test.ts +62 -0
  233. package/src/tests/internal-ai/task-title.test.ts +283 -0
  234. package/src/tests/list-total-cache.test.ts +27 -2
  235. package/src/tests/memory-fts-populate.test.ts +11 -9
  236. package/src/tests/memory-retrieval-purge.test.ts +80 -0
  237. package/src/tests/merge-gate-detect-changes.test.ts +145 -0
  238. package/src/tests/migration-260-runtime-pool-cache.test.ts +118 -0
  239. package/src/tests/page-proxy.test.ts +1 -1
  240. package/src/tests/page-session-dev-cookie.test.ts +188 -0
  241. package/src/tests/pages-frame-ancestors.test.ts +28 -1
  242. package/src/tests/pages-head-v2.test.ts +534 -0
  243. package/src/tests/pages-http.test.ts +330 -0
  244. package/src/tests/pages-public-html.test.ts +43 -0
  245. package/src/tests/pages-storage.test.ts +182 -0
  246. package/src/tests/pages-versioning.test.ts +72 -0
  247. package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
  248. package/src/tests/profile-sync.test.ts +11 -15
  249. package/src/tests/query-audit-plans.test.ts +27 -0
  250. package/src/tests/runtime-cache-cleanup.test.ts +367 -0
  251. package/src/tests/runtime-e2b-pools.test.ts +41 -0
  252. package/src/tests/runtime-pools-db.test.ts +98 -0
  253. package/src/tests/runtime-pools-http.test.ts +29 -0
  254. package/src/tests/runtime-providers-docker.test.ts +253 -0
  255. package/src/tests/runtime-remote-controller.test.ts +66 -2
  256. package/src/tests/runtime-supervisor.test.ts +66 -2
  257. package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
  258. package/src/tests/script-connections.test.ts +13 -0
  259. package/src/tests/script-runs-http.test.ts +684 -2
  260. package/src/tests/seed-registry.test.ts +28 -6
  261. package/src/tests/seed-schedules.test.ts +61 -8
  262. package/src/tests/seed-scripts.test.ts +168 -2
  263. package/src/tests/slack-leave-thread.test.ts +145 -5
  264. package/src/tests/slack-render-v2-query-plans.test.ts +24 -2
  265. package/src/tests/status.test.ts +11 -0
  266. package/src/tests/swarm-diff.test.ts +121 -2
  267. package/src/tests/task-auto-title-config-guard.test.ts +37 -0
  268. package/src/tests/task-auto-title-listener.test.ts +165 -0
  269. package/src/tests/tasks-readable-row.test.ts +155 -0
  270. package/src/tests/theme-import.test.ts +758 -0
  271. package/src/tests/themes-page-theme.test.ts +155 -0
  272. package/src/tests/themes-presets.test.ts +172 -0
  273. package/src/tests/workflow-script-principal.test.ts +2 -1
  274. package/src/themes/classics.ts +949 -0
  275. package/src/themes/import.ts +420 -0
  276. package/src/themes/page-fields.ts +40 -0
  277. package/src/themes/page-theme.ts +102 -0
  278. package/src/themes/presets.ts +561 -0
  279. package/src/themes/tokens.ts +218 -0
  280. package/src/tools/app-upsert.ts +1 -1
  281. package/src/tools/create-page.ts +118 -12
  282. package/src/tools/send-task.ts +35 -47
  283. package/src/tools/slack-leave-thread.ts +1 -1
  284. package/src/tools/task-dedup.ts +45 -21
  285. package/src/tools/utils.ts +33 -0
  286. package/src/types.ts +83 -3
  287. package/src/utils/constants.ts +19 -0
  288. package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
  289. package/src/utils/internal-ai/credentials.ts +146 -93
  290. package/src/utils/internal-ai/index.ts +10 -0
  291. package/src/utils/internal-ai/models.ts +18 -0
  292. package/src/utils/internal-ai/task-title.ts +282 -0
  293. package/src/utils/page-session.ts +37 -0
  294. package/templates/schedules/daily-status-report/content.md +88 -2
  295. package/templates/schedules/daily-swarm-update-check/content.md +16 -40
  296. package/templates/skills/apps/SKILL.md +1 -1
  297. package/templates/skills/apps/content.md +1 -1
  298. package/templates/skills/composio-google-docs/content.md +10 -0
  299. package/templates/skills/heartbeat-runbook/content.md +1 -1
  300. package/templates/skills/pages/config.json +1 -1
  301. package/templates/skills/pages/content.md +539 -86
  302. package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
  303. package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
  304. package/templates/skills/pages/files/examples/report-page.html +125 -124
  305. package/templates/skills/script-workflows/content.md +19 -1
  306. package/templates/skills/swarm-docs/content.md +1 -0
  307. package/templates/skills/swarm-scripts/SKILL.md +2 -0
  308. package/templates/skills/swarm-scripts/content.md +2 -0
  309. package/dist/cli-2pnswgh8.js +0 -189
  310. package/dist/cli-dznrgaep.js +0 -30
  311. package/dist/cli-w3hcrxht.js +0 -6985
  312. package/dist/handlers-vwmba1m2.js +0 -31
  313. package/dist/index-5r71rf98.js +0 -32
  314. package/dist/index-anmwxs87.js +0 -29
  315. package/dist/index-spp0ra5c.js +0 -36
  316. package/dist/lead-fjy418cw.js +0 -69
  317. package/dist/worker-0c5tyzqe.js +0 -69
  318. package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
  319. package/templates/schedules/daily-workflow-health-audit/content.md +0 -191
@@ -17,13 +17,13 @@ import {
17
17
  deleteSkill2,
18
18
  getSkillByName2,
19
19
  init_db4
20
- } from "./cli-1ayk1eas.js";
21
- import"./cli-8hs41zy2.js";
20
+ } from "./cli-wk5yrfwd.js";
21
+ import"./cli-jtjtabxe.js";
22
22
  import {
23
23
  agentFsProvisionSeeder
24
- } from "./cli-vbyfwh92.js";
25
- import"./cli-mmwm7zbf.js";
26
- import"./cli-wvaawacd.js";
24
+ } from "./cli-pjctyea3.js";
25
+ import"./cli-rtt8g4v9.js";
26
+ import"./cli-cppkf404.js";
27
27
  import {
28
28
  buildSkillContent
29
29
  } from "./cli-691vz9mk.js";
@@ -48,11 +48,11 @@ import {
48
48
  init_typecheck,
49
49
  parseSkillContent,
50
50
  init_skill_parser
51
- } from "./cli-0bxqnvq9.js";
52
- import"./cli-7wcyk2s5.js";
53
- import"./cli-spknvxbq.js";
54
- import"./cli-pywf3eh5.js";
55
- import"./cli-bhjt02p8.js";
51
+ } from "./cli-adeegh7d.js";
52
+ import"./cli-q3tkegby.js";
53
+ import"./cli-nmzhp1b5.js";
54
+ import"./cli-caj33vcq.js";
55
+ import"./cli-5jnhv72d.js";
56
56
  import {
57
57
  __toESM
58
58
  } from "./cli-242037k3.js";
@@ -1084,6 +1084,8 @@ export async function publishCatalogReportPage(report: CatalogReport, ctx: any):
1084
1084
  description: report.description,
1085
1085
  contentType: "text/html",
1086
1086
  authMode: "authed",
1087
+ // The report ships its own palette, written for the v1 page head.
1088
+ designVersion: 1,
1087
1089
  body: renderCatalogReportPage(report),
1088
1090
  });
1089
1091
  const payload = response?.data ?? response;
@@ -11333,6 +11335,8 @@ export default async function memoryEval(args: any, ctx: any) {
11333
11335
  description: \`3-axis memory quality dashboard: carry-forward \${report.axis1.score}%, preferences usefulness \${report.axis2.avgUsefulness}, freshness \${report.axis3.freshScore}%. \${history.length} data points.\`,
11334
11336
  contentType: "text/html",
11335
11337
  authMode: "authed",
11338
+ // The dashboard ships its own palette, written for the v1 page head.
11339
+ designVersion: 1,
11336
11340
  body: html,
11337
11341
  });
11338
11342
  const payload = response?.data ?? response;
@@ -12938,6 +12942,159 @@ export default async function swarmOverview(_args: any, ctx: any) {
12938
12942
  }
12939
12943
  `;
12940
12944
 
12945
+ // src/be/seed-scripts/catalog/swarm-update-check.ts
12946
+ var swarm_update_check_default = `import { z } from "zod";
12947
+
12948
+ export const argsSchema = z.object({
12949
+ dryRun: z
12950
+ .boolean()
12951
+ .optional()
12952
+ .describe("Compute the verdict but do not notify the lead when an update is available"),
12953
+ });
12954
+
12955
+ const RELEASES_URL = "https://api.github.com/repos/desplega-ai/agent-swarm/releases/latest";
12956
+
12957
+ /** Parse \`major.minor.patch\` (a leading \`v\` and a pre-release/build suffix are ignored). */
12958
+ export function parseVersion(value: unknown): [number, number, number] | null {
12959
+ if (typeof value !== "string") return null;
12960
+ const m = value
12961
+ .trim()
12962
+ .replace(/^v/i, "")
12963
+ .match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+].*)?$/);
12964
+ if (!m) return null;
12965
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
12966
+ }
12967
+
12968
+ /** Numeric compare of two parsed versions: negative, zero or positive. Never a string compare. */
12969
+ export function compareVersions(a: [number, number, number], b: [number, number, number]): number {
12970
+ for (let i = 0; i < 3; i++) {
12971
+ const diff = (a[i] as number) - (b[i] as number);
12972
+ if (diff !== 0) return diff;
12973
+ }
12974
+ return 0;
12975
+ }
12976
+
12977
+ function errorText(error: unknown): string {
12978
+ return String((error as any)?.message ?? error).slice(0, 200);
12979
+ }
12980
+
12981
+ /** The version the API server is running, from its own unauthenticated \`/health\` route. */
12982
+ async function runningVersion(ctx: any): Promise<string | null> {
12983
+ try {
12984
+ const base = ctx.stdlib.Redacted.value(ctx.swarm.config.mcpBaseUrl).replace(/\\/+$/, "");
12985
+ const res: any = await ctx.stdlib.fetch(base + "/health", { retries: 1, timeoutMs: 10_000 });
12986
+ if (!res.ok) return null;
12987
+ const body: any = await res.json();
12988
+ return typeof body?.version === "string" && body.version.trim() ? body.version.trim() : null;
12989
+ } catch {
12990
+ return null;
12991
+ }
12992
+ }
12993
+
12994
+ async function resolveLeadId(ctx: any): Promise<string | null> {
12995
+ const res: any = await ctx.swarm.swarm_get({});
12996
+ const data = res?.data ?? res;
12997
+ const agents: any[] = Array.isArray(data?.agents) ? data.agents : Array.isArray(data) ? data : [];
12998
+ const lead = agents.find((agent) => agent?.isLead === true || agent?.isLead === 1);
12999
+ return typeof lead?.id === "string" ? lead.id : null;
13000
+ }
13001
+
13002
+ /**
13003
+ * Compare the running agent-swarm version with the latest published GitHub
13004
+ * release and, only when a newer release exists, hand the lead one task to
13005
+ * tell the operator through its admin delivery channel.
13006
+ */
13007
+ export default async function swarmUpdateCheck(args: any, ctx: any) {
13008
+ const parsed = argsSchema.safeParse(args ?? {});
13009
+ if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
13010
+ const dryRun = parsed.data.dryRun === true;
13011
+
13012
+ const running = await runningVersion(ctx);
13013
+ const runningParsed = parseVersion(running);
13014
+ if (!running || !runningParsed) {
13015
+ // Unknown is not "up to date": say so and stop, never guess a version.
13016
+ return {
13017
+ checked: false,
13018
+ status: "unknown",
13019
+ running,
13020
+ message: \`Unable to determine running version: \${running ? \`unparseable "\${running}"\` : "not reported by /health"}.\`,
13021
+ };
13022
+ }
13023
+
13024
+ let release: any;
13025
+ try {
13026
+ // One attempt: a failed GitHub call is reported, never retried in a loop.
13027
+ const res: any = await ctx.stdlib.fetch(RELEASES_URL, {
13028
+ retries: 1,
13029
+ timeoutMs: 15_000,
13030
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "agent-swarm-update-check" },
13031
+ });
13032
+ if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
13033
+ release = await res.json();
13034
+ } catch (error) {
13035
+ return {
13036
+ checked: false,
13037
+ status: "github-unreachable",
13038
+ running,
13039
+ message: \`Could not reach GitHub releases API: \${errorText(error)}\`,
13040
+ };
13041
+ }
13042
+
13043
+ const latest = typeof release?.tag_name === "string" ? release.tag_name.replace(/^v/i, "") : null;
13044
+ const latestParsed = parseVersion(latest);
13045
+ if (!latest || !latestParsed) {
13046
+ return {
13047
+ checked: false,
13048
+ status: "github-unreachable",
13049
+ running,
13050
+ message: \`Could not read a version from the latest release tag: \${JSON.stringify(release?.tag_name ?? null)}.\`,
13051
+ };
13052
+ }
13053
+ const releaseUrl =
13054
+ typeof release?.html_url === "string"
13055
+ ? release.html_url
13056
+ : \`https://github.com/desplega-ai/agent-swarm/releases/tag/v\${latest}\`;
13057
+
13058
+ if (compareVersions(latestParsed, runningParsed) <= 0) {
13059
+ return {
13060
+ checked: true,
13061
+ status: "up-to-date",
13062
+ running,
13063
+ latest,
13064
+ message: \`Up to date: running \${running}, latest published \${latest}.\`,
13065
+ };
13066
+ }
13067
+
13068
+ const message = \`Update available: running \${running}, latest \${latest} (\${releaseUrl})\`;
13069
+ if (dryRun) {
13070
+ return { checked: true, status: "update-available", running, latest, releaseUrl, notified: false, message };
13071
+ }
13072
+
13073
+ const leadId = await resolveLeadId(ctx);
13074
+ // A detected update that reaches nobody is a failed run, not a quiet success.
13075
+ if (!leadId) throw new Error(\`\${message}, but the swarm has no lead to notify.\`);
13076
+ const sent: any = await ctx.swarm.task_send({
13077
+ agentId: leadId,
13078
+ routingReason: "skill",
13079
+ routingNote: "Lead owns the operator's admin delivery channel for release notices.",
13080
+ tags: ["swarm-update-check"],
13081
+ task: [
13082
+ \`A newer agent-swarm release is published: running \${running}, latest \${latest}.\`,
13083
+ \`Release: \${releaseUrl}\`,
13084
+ "",
13085
+ "Post one short message to your configured admin delivery channel (the in-app fallback if none is configured) naming the running version, the new version and the release URL. Then complete with that message as the output.",
13086
+ "Do not attempt the upgrade yourself: this notice only detects and reports.",
13087
+ ].join("\\n"),
13088
+ });
13089
+ const taskId = sent?.data?.task?.id ?? null;
13090
+ if (sent?.success === false || sent?.data?.success === false || !taskId) {
13091
+ const reason = sent?.data?.message ?? sent?.data?.error ?? \`status \${sent?.status}\`;
13092
+ throw new Error(\`\${message}, but notifying the lead failed: \${errorText(reason)}\`);
13093
+ }
13094
+ return { checked: true, status: "update-available", running, latest, releaseUrl, notified: true, leadTaskId: taskId, message };
13095
+ }
13096
+ `;
13097
+
12941
13098
  // src/be/seed-scripts/catalog/task-context-gathering.ts
12942
13099
  var task_context_gathering_default = `import { z } from "zod";
12943
13100
 
@@ -13066,7 +13223,11 @@ const REASON_PATTERNS: any[] = [
13066
13223
  { key: "context-window", re: /context (window|limit|saturat)|peakcontext|compact/i },
13067
13224
  { key: "reboot-sweep", re: /reboot sweep/i },
13068
13225
  { key: "not-found", re: /not found|404|missing|no such/i },
13069
- { key: "auth/credentials", re: /unauthorized|401|403|credential|token|forbidden/i },
13226
+ {
13227
+ key: "output-limit",
13228
+ re: /\\bmax_(?:output_)?tokens\\b|\\b(?:maximum|max) output (?:length|tokens)\\b|\\boutput (?:token|length) limit\\b/i,
13229
+ },
13230
+ { key: "auth/credentials", re: /unauthorized|401|403|credential|\\btoken\\b|forbidden/i },
13070
13231
  { key: "ci/checks-failed", re: /\\bci\\b|check.?s? fail|lint|tsc|test.?s? fail/i },
13071
13232
  { key: "network", re: /network|econn|fetch failed|socket|dns|502|503|504/i },
13072
13233
  { key: "cancelled", re: /cancel|aborted/i },
@@ -13535,6 +13696,12 @@ var SEED_SCRIPTS = [
13535
13696
  intent: "Task onboarding: one call instead of task_get plus multiple memory_search calls. Pass the task description split into 2-4 natural-language queries.",
13536
13697
  source: asText(task_context_gathering_default)
13537
13698
  },
13699
+ {
13700
+ name: "swarm-update-check",
13701
+ description: "Compare the running agent-swarm version (API /health) with the latest GitHub release of desplega-ai/agent-swarm as numeric major.minor.patch, and hand the lead one notify task only when a newer release exists.",
13702
+ intent: "Daily zero-config upgrade watchdog behind the daily-swarm-update-check schedule; dryRun reports the verdict without notifying.",
13703
+ source: asText(swarm_update_check_default)
13704
+ },
13538
13705
  {
13539
13706
  name: "schedule-health",
13540
13707
  description: "Per-schedule failure rate check over recent tasks — flags schedules with failure rates above a configurable threshold.",
@@ -13756,7 +13923,7 @@ var config_default2 = `{
13756
13923
  `;
13757
13924
 
13758
13925
  // templates/skills/apps/content.md
13759
- 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 34)\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); `fill` (grow to fill a `fill`-aware parent; see Layout 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`, `fill` (same contract as Stack); 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`, `defaultValue` (seeds `/ui/<id>/value` once, before any interaction; a one-entry array with `multiple`); writes a string or null to `/ui/<id>/value`, or with `multiple` a string array (empty = nothing picked); no children. |\n| `NumberInput` | `id` | `label`, `placeholder`, `min`, `max`; a text field with a decimal keyboard that writes the parsed number to `/ui/<id>/value` on every change (no debounce), or null when the field is empty; non-numeric text (a bare `-` mid-edit, letters) leaves the stored value untouched; `min`/`max` clamp the stored value and the visible text snaps to it when the field loses focus; 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| `CardGrid` | `titleKey` | `data`, `loading`, `error`, `emptyMessage`, `imageKey`, `fields`, `columns`, `onClick`; one card per row for record browsers; see below. |\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| `Tree` | `id`, `nodes` | `label`, `maxHeight` (default `480px`), `fill`; collapsible outline of a doc-shaped value that writes the clicked node key to `/ui/<id>/value`; see below. |\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, format?: \"<template>\", 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 - `format`: a `{{field}}` template that combines several row fields into one cell, e.g. `\"{{name}} ({{status}})\"`. Placeholder names match `[\\w.]+` (letters, digits, `_` and `.`; no hyphens or spaces). Each placeholder reads that row field as-is (a dotted name such as `{{owner.name}}` is a literal column name, not a nested path); a missing or null field becomes an empty string and an object or array becomes a JSON preview cut to 120 characters. The whole cell is capped at 500 characters. A templated column ignores `kind`, shows the same text in the phone list, and sorts by the composed text. `search` and `filters` still read the column's `key`.\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\nTree details:\n\n- `nodes` is a doc-shaped value: `{ root: string | string[], elements: { <key>: { type, children?: string[] } } }`, the json-render flat element map. Usually bind it to a json column: `{ \"$state\": \"/queries/<name>/data/0/<column>\" }`. A malformed or absent value renders an empty state; a `children` key missing from `elements` shows as `key — ?`. Outlines are cut at 2,000 rows with a notice; branches deeper than 32 levels keep a disabled chevron.\n- Each row shows `key — type`. Clicking a row writes its key to `/ui/<id>/value`, and the row bound to that path renders selected; read the selection with `{ \"$state\": \"/ui/<id>/value\" }`. The Tree never writes `null`: when the selected key leaves the doc (a refetch, an edit) the stored key stays and no row renders selected. Selection and collapse are keyed by node key, so a key reachable through two parents selects and collapses in both places.\n- Rows scroll inside `maxHeight` (default `480px`). Set `fill: true` inside a `fill` Stack or Split pane to grow to that height instead and scroll within it (ignores `maxHeight`).\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\nCardGrid details — the visual counterpart to Table for record browsers (projects, documents, media):\n\n- `titleKey` (required) picks the row field shown as the card title (an object value shows a JSON preview; titles are capped at 500 characters).\n- `imageKey` (optional) picks a row field holding an absolute http(s) image URL and adds an image tile to every card. When it is set, a non-URL value, a missing value, or a failed load shows a placeholder tile. Omit it for text-only cards with no tile.\n- `fields[]`: `{ key, kind?, tones? }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge` (with `tones`), or `json` (no `label`, no `format`). A field with no `kind` whose value is an object renders like `json`. They render as an unlabeled `·`-joined meta line under the title, e.g. a `date` field for \"created\" and a `text` field for the author.\n- `columns`: same responsive shorthand as `Grid.columns` (default `{ \"base\": 1, \"sm\": 2, \"md\": 3 }`).\n- `onClick`: an action chain with `$row` / `$rowIndex`, run when a card is clicked or activated with Enter or Space. Commonly `app.navigate` into a detail page, the CardGrid equivalent of Table's `onRowClick`.\n- Bind `data` / `loading` / `error` to a named query like Table. Loading shows skeleton cards, an error shows a callout, and no rows shows `emptyMessage`.\n- It renders 200 cards at a time with a Show more button below the grid that reveals the next 200.\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`Stack` and `Split` size to their content by default, so a page scrolls as a whole. Set `fill: true` to make one grow to fill the remaining height of a `fill`-aware parent (another `fill` Stack or Split) instead; chain it from the page root down through every container on the path to the element that should scroll, and that element scrolls within whatever height is left while the page itself does not. A non-`fill` sibling (a toolbar, a header) keeps its natural height and the `fill` sibling absorbs the rest. A `fill` Split stretches both panes to the full row height, so nesting a `fill` Split inside one pane of another `fill` Split is the way to build a three-or-more column viewport-height layout (`ratio` is only ever two panes).\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, NumberInput, Chips, and Tree are client-side controls: each needs a literal `id` and writes state under `/ui/<id>/value` (Tree writes the clicked node key); 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| `{ \"$chain\": \"leafTaskId\" }` | One chain-scope value, published by a component that runs a task chain; it carries `leafTaskId` and `newTaskId`. Where no chain scope is published, the sentinel is left in place and the action fails instead of silently dropping the parent. |\n| `{ \"$chain\": \"\" }` | The whole chain scope. |\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?, parentTaskId? }`; `name` must be declared in definition `actions`. For a task-kind row action, pass whole-row context with `input: { issue: { \"$row\": \"\" } }`. `parentTaskId` chains the created task under an existing one for task-kind actions (typically `{ \"$chain\": \"leafTaskId\" }`; a literal or `$state` task id also works); a script or sync action rejects it with 400.\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";
13926
+ 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 and lives in `src/themes/presets.ts` and `src/themes/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`, `paper` (the warm editorial paper look), 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 34)\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); `fill` (grow to fill a `fill`-aware parent; see Layout 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`, `fill` (same contract as Stack); 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`, `defaultValue` (seeds `/ui/<id>/value` once, before any interaction; a one-entry array with `multiple`); writes a string or null to `/ui/<id>/value`, or with `multiple` a string array (empty = nothing picked); no children. |\n| `NumberInput` | `id` | `label`, `placeholder`, `min`, `max`; a text field with a decimal keyboard that writes the parsed number to `/ui/<id>/value` on every change (no debounce), or null when the field is empty; non-numeric text (a bare `-` mid-edit, letters) leaves the stored value untouched; `min`/`max` clamp the stored value and the visible text snaps to it when the field loses focus; 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| `CardGrid` | `titleKey` | `data`, `loading`, `error`, `emptyMessage`, `imageKey`, `fields`, `columns`, `onClick`; one card per row for record browsers; see below. |\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| `Tree` | `id`, `nodes` | `label`, `maxHeight` (default `480px`), `fill`; collapsible outline of a doc-shaped value that writes the clicked node key to `/ui/<id>/value`; see below. |\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, format?: \"<template>\", 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 - `format`: a `{{field}}` template that combines several row fields into one cell, e.g. `\"{{name}} ({{status}})\"`. Placeholder names match `[\\w.]+` (letters, digits, `_` and `.`; no hyphens or spaces). Each placeholder reads that row field as-is (a dotted name such as `{{owner.name}}` is a literal column name, not a nested path); a missing or null field becomes an empty string and an object or array becomes a JSON preview cut to 120 characters. The whole cell is capped at 500 characters. A templated column ignores `kind`, shows the same text in the phone list, and sorts by the composed text. `search` and `filters` still read the column's `key`.\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\nTree details:\n\n- `nodes` is a doc-shaped value: `{ root: string | string[], elements: { <key>: { type, children?: string[] } } }`, the json-render flat element map. Usually bind it to a json column: `{ \"$state\": \"/queries/<name>/data/0/<column>\" }`. A malformed or absent value renders an empty state; a `children` key missing from `elements` shows as `key — ?`. Outlines are cut at 2,000 rows with a notice; branches deeper than 32 levels keep a disabled chevron.\n- Each row shows `key — type`. Clicking a row writes its key to `/ui/<id>/value`, and the row bound to that path renders selected; read the selection with `{ \"$state\": \"/ui/<id>/value\" }`. The Tree never writes `null`: when the selected key leaves the doc (a refetch, an edit) the stored key stays and no row renders selected. Selection and collapse are keyed by node key, so a key reachable through two parents selects and collapses in both places.\n- Rows scroll inside `maxHeight` (default `480px`). Set `fill: true` inside a `fill` Stack or Split pane to grow to that height instead and scroll within it (ignores `maxHeight`).\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\nCardGrid details — the visual counterpart to Table for record browsers (projects, documents, media):\n\n- `titleKey` (required) picks the row field shown as the card title (an object value shows a JSON preview; titles are capped at 500 characters).\n- `imageKey` (optional) picks a row field holding an absolute http(s) image URL and adds an image tile to every card. When it is set, a non-URL value, a missing value, or a failed load shows a placeholder tile. Omit it for text-only cards with no tile.\n- `fields[]`: `{ key, kind?, tones? }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge` (with `tones`), or `json` (no `label`, no `format`). A field with no `kind` whose value is an object renders like `json`. They render as an unlabeled `·`-joined meta line under the title, e.g. a `date` field for \"created\" and a `text` field for the author.\n- `columns`: same responsive shorthand as `Grid.columns` (default `{ \"base\": 1, \"sm\": 2, \"md\": 3 }`).\n- `onClick`: an action chain with `$row` / `$rowIndex`, run when a card is clicked or activated with Enter or Space. Commonly `app.navigate` into a detail page, the CardGrid equivalent of Table's `onRowClick`.\n- Bind `data` / `loading` / `error` to a named query like Table. Loading shows skeleton cards, an error shows a callout, and no rows shows `emptyMessage`.\n- It renders 200 cards at a time with a Show more button below the grid that reveals the next 200.\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`Stack` and `Split` size to their content by default, so a page scrolls as a whole. Set `fill: true` to make one grow to fill the remaining height of a `fill`-aware parent (another `fill` Stack or Split) instead; chain it from the page root down through every container on the path to the element that should scroll, and that element scrolls within whatever height is left while the page itself does not. A non-`fill` sibling (a toolbar, a header) keeps its natural height and the `fill` sibling absorbs the rest. A `fill` Split stretches both panes to the full row height, so nesting a `fill` Split inside one pane of another `fill` Split is the way to build a three-or-more column viewport-height layout (`ratio` is only ever two panes).\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, NumberInput, Chips, and Tree are client-side controls: each needs a literal `id` and writes state under `/ui/<id>/value` (Tree writes the clicked node key); 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| `{ \"$chain\": \"leafTaskId\" }` | One chain-scope value, published by a component that runs a task chain; it carries `leafTaskId` and `newTaskId`. Where no chain scope is published, the sentinel is left in place and the action fails instead of silently dropping the parent. |\n| `{ \"$chain\": \"\" }` | The whole chain scope. |\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?, parentTaskId? }`; `name` must be declared in definition `actions`. For a task-kind row action, pass whole-row context with `input: { issue: { \"$row\": \"\" } }`. `parentTaskId` chains the created task under an existing one for task-kind actions (typically `{ \"$chain\": \"leafTaskId\" }`; a literal or `$state` task id also works); a script or sync action rejects it with 400.\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";
13760
13927
 
13761
13928
  // templates/skills/ask-user/config.json
13762
13929
  var config_default3 = `{
@@ -14605,7 +14772,7 @@ var config_default14 = `{
14605
14772
  `;
14606
14773
 
14607
14774
  // templates/skills/composio-google-docs/content.md
14608
- var content_default14 = '# Composio · Google Docs\n\nToolkit slug: **`googledocs`**. Read the [[composio]] hub first for the call model.\nA document is identified by its `document_id` (the id in the Docs URL).\n\n```bash\nagent-swarm x composio POST /tools/execute/<SLUG> \\\n --body \'{"user_id":"<connected-account-email>","connected_account_id":"<active-connected-account-id>","arguments":{ … }}\'\n```\n\nFollow the hub\'s **Resolve the user and connected account** procedure, then\nselect the resolved user\'s `googledocs` account whose status is `ACTIVE`.\n\n## Headline tools\n\n| Slug | What | Key args |\n|---|---|---|\n| `GOOGLEDOCS_SEARCH_DOCUMENTS` | Find docs (Drive search) | `query`, `max_results` (def 10), `order_by` (def `modifiedTime desc`), `modified_after`, `created_after`, `starred_only`, `shared_with_me`, `response_detail` (def `minimal`) |\n| `GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT` | Read doc as text | **`document_id`**, `include_tables` (def true), `include_headers`, `include_footers`, `include_footnotes`, `include_tabs_content` |\n| `GOOGLEDOCS_GET_DOCUMENT_BY_ID` | Full structured doc JSON | `document_id` |\n| `GOOGLEDOCS_CREATE_DOCUMENT` | Create blank/with text | `title`, `text` |\n| `GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN` | Create from markdown | **`title`**, `markdown_text`, `image_assets` |\n| `GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN` | Replace body with markdown | `document_id`, `markdown_text` |\n| `GOOGLEDOCS_INSERT_TEXT_ACTION` | Insert text at index | `document_id`, `text`, `index` |\n| `GOOGLEDOCS_REPLACE_ALL_TEXT` | Find & replace | `document_id`, `find`, `replace` |\n| `GOOGLEDOCS_COPY_DOCUMENT` | Duplicate a doc | `document_id`, `title` |\n| `GOOGLEDOCS_EXPORT_DOCUMENT_AS_PDF` | Export to PDF | `document_id` |\n\nFull set: 35 tools — `agent-swarm x composio GET "/tools?toolkit_slug=googledocs&limit=100" | jq -r \'.items[]|"\\(.slug)\\t\\(.name)"\'`.\nPrefer the `*_MARKDOWN` create/update tools for authoring; the granular\n`INSERT_*`/`DELETE_*`/table tools are for surgical structural edits.\n\n## Search recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_SEARCH_DOCUMENTS \\\n --body \'{"connected_account_id":"ca_…","arguments":{"query":"workshop","max_results":5}}\'\n# → results under .data.files[] (id, name, modifiedTime …)\n```\n- Results are Drive file entries at **`.data.files[]`** — grab `.id` to read.\n- `order_by` defaults to `modifiedTime desc` (most recent first).\n- Use `modified_after` / `shared_with_me` to narrow.\n\n## Read recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT \\\n --body \'{"connected_account_id":"ca_…","arguments":{"document_id":"<id>","include_tables":true}}\'\n```\nUse `GET_DOCUMENT_PLAINTEXT` for reading content; only reach for\n`GET_DOCUMENT_BY_ID` when you need the structured JSON (styles, indices) for an\nedit.\n\n## Create-from-markdown recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN \\\n --body \'{"connected_account_id":"ca_…","arguments":{\n "title":"Weekly updates","markdown_text":"# Heading\\n\\n- point one\\n- point two"\n }}\'\n```\n\n## Google Doc to agent-fs\n\n1. Read the source with `GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT` and retain its\n returned title and display URL as provenance.\n2. Write the converted content to your own agent-fs namespace with a short\n provenance header naming the source document and import method.\n3. Resolve the destination org and drive from the environment or\n `agent-fs stat <path> --json`; the `agent-fs` skill documents the canonical\n storage and share-link workflow. Never copy org or drive IDs from an example.\n4. Read the stored file back and assert several markers: the title, a middle\n phrase, and the final sentence. A successful byte-count response alone does\n not prove fidelity.\n\nPlaintext is intentionally lossy. Inline images may become bare `[IMAGE]`\nmarkers, and headings may be flattened into adjacent paragraphs. If those\nfeatures matter, use `GOOGLEDOCS_GET_DOCUMENT_BY_ID` for structured JSON or a\nGoogle Drive export, then preserve or reconstruct the structure explicitly.\nThere is no `GET_DOCUMENT_MARKDOWN` tool; markdown exists on create/update, not\non read.\n\n## Gotchas\n\n- Search returns Drive metadata, not document content — do a second\n `GET_DOCUMENT_PLAINTEXT` call with the `.id` to read.\n- Doc bodies can be long/token-heavy and may contain secrets — read only what you\n need (`include_headers/footers/footnotes` default off for a reason).\n- Create/update/replace are **write actions** — only on explicit request.\n';
14775
+ var content_default14 = '# Composio · Google Docs\n\nToolkit slug: **`googledocs`**. Read the [[composio]] hub first for the call model.\nA document is identified by its `document_id` (the id in the Docs URL).\n\n```bash\nagent-swarm x composio POST /tools/execute/<SLUG> \\\n --body \'{"user_id":"<connected-account-email>","connected_account_id":"<active-connected-account-id>","arguments":{ … }}\'\n```\n\nFollow the hub\'s **Resolve the user and connected account** procedure, then\nselect the resolved user\'s `googledocs` account whose status is `ACTIVE`.\n\n## Headline tools\n\n| Slug | What | Key args |\n|---|---|---|\n| `GOOGLEDOCS_SEARCH_DOCUMENTS` | Find docs (Drive search) | `query`, `max_results` (def 10), `order_by` (def `modifiedTime desc`), `modified_after`, `created_after`, `starred_only`, `shared_with_me`, `response_detail` (def `minimal`) |\n| `GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT` | Read doc as text | **`document_id`**, `include_tables` (def true), `include_headers`, `include_footers`, `include_footnotes`, `include_tabs_content` |\n| `GOOGLEDOCS_GET_DOCUMENT_BY_ID` | Full structured doc JSON | `document_id` |\n| `GOOGLEDOCS_CREATE_DOCUMENT` | Create blank/with text | `title`, `text` |\n| `GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN` | Create from markdown | **`title`**, `markdown_text`, `image_assets` |\n| `GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN` | Replace body with markdown | `document_id`, `markdown_text` |\n| `GOOGLEDOCS_INSERT_TEXT_ACTION` | Insert text at index | `document_id`, `text`, `index` |\n| `GOOGLEDOCS_REPLACE_ALL_TEXT` | Find & replace | `document_id`, `find`, `replace` |\n| `GOOGLEDOCS_COPY_DOCUMENT` | Duplicate a doc | `document_id`, `title` |\n| `GOOGLEDOCS_EXPORT_DOCUMENT_AS_PDF` | Export to PDF | `document_id` |\n\nFull set: 35 tools — `agent-swarm x composio GET "/tools?toolkit_slug=googledocs&limit=100" | jq -r \'.items[]|"\\(.slug)\\t\\(.name)"\'`.\nPrefer the `*_MARKDOWN` create/update tools for authoring; the granular\n`INSERT_*`/`DELETE_*`/table tools are for surgical structural edits.\n\n## Search recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_SEARCH_DOCUMENTS \\\n --body \'{"connected_account_id":"ca_…","arguments":{"query":"workshop","max_results":5}}\'\n# → results under .data.files[] (id, name, modifiedTime …)\n```\n- Results are Drive file entries at **`.data.files[]`** — grab `.id` to read.\n- `order_by` defaults to `modifiedTime desc` (most recent first).\n- Use `modified_after` / `shared_with_me` to narrow.\n\n## Read recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT \\\n --body \'{"connected_account_id":"ca_…","arguments":{"document_id":"<id>","include_tables":true}}\'\n```\nUse `GET_DOCUMENT_PLAINTEXT` for reading content; only reach for\n`GET_DOCUMENT_BY_ID` when you need the structured JSON (styles, indices) for an\nedit.\n\n## Create-from-markdown recipe\n\n```bash\nagent-swarm x composio POST /tools/execute/GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN \\\n --body \'{"connected_account_id":"ca_…","arguments":{\n "title":"Weekly updates","markdown_text":"# Heading\\n\\n- point one\\n- point two"\n }}\'\n```\n\n## Google Doc to agent-fs\n\n1. Read the source with `GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT` and retain its\n returned title and display URL as provenance.\n2. Write the converted content to your own agent-fs namespace with a short\n provenance header naming the source document and import method.\n3. Resolve the destination org and drive from the environment or\n `agent-fs stat <path> --json`; the `agent-fs` skill documents the canonical\n storage and share-link workflow. Never copy org or drive IDs from an example.\n4. Read the stored file back and assert several markers: the title, a middle\n phrase, and the final sentence. A successful byte-count response alone does\n not prove fidelity.\n\nPlaintext is intentionally lossy. Inline images may become bare `[IMAGE]`\nmarkers, and headings may be flattened into adjacent paragraphs. If those\nfeatures matter, use `GOOGLEDOCS_GET_DOCUMENT_BY_ID` for structured JSON or a\nGoogle Drive export, then preserve or reconstruct the structure explicitly.\nThere is no `GET_DOCUMENT_MARKDOWN` tool; markdown exists on create/update, not\non read.\n\n## Comments\n\nDoc comments are a Drive feature, not a Docs one: there is no `GOOGLEDOCS_*`\ncomment slug. List them with `GOOGLEDRIVE_LIST_COMMENTS` (replies:\n`GOOGLEDRIVE_LIST_REPLIES`) from the `googledrive` toolkit, so resolve the\nuser\'s `googledrive` connected account instead of their `googledocs` one.\nListing only works when the Drive read scopes are authorized on that account.\n`GOOGLEDRIVE_CREATE_COMMENT` and the update/delete tools are write actions,\nonly on explicit request.\n\n## Gotchas\n\n- Search returns Drive metadata, not document content — do a second\n `GET_DOCUMENT_PLAINTEXT` call with the `.id` to read.\n- Doc bodies can be long/token-heavy and may contain secrets — read only what you\n need (`include_headers/footers/footnotes` default off for a reason).\n- Create/update/replace are **write actions** — only on explicit request.\n';
14609
14776
 
14610
14777
  // templates/skills/db-query-guidance/config.json
14611
14778
  var config_default15 = `{
@@ -15059,7 +15226,7 @@ var config_default21 = `{
15059
15226
  `;
15060
15227
 
15061
15228
  // templates/skills/heartbeat-runbook/content.md
15062
- var content_default21 = "# Heartbeat runbook\n\nLead only. The heartbeat runbook is your `heartbeatMd` profile field. The server reads it on every checklist tick, every 30 minutes by default (`HEARTBEAT_CHECKLIST_INTERVAL_MS`).\n\n## How the heartbeat works\n\n- `heartbeatMd` has content: the server creates a `heartbeat-checklist` task with the current system status and a snapshot of your runbook. It skips the tick while you still have an open checklist task.\n- `heartbeatMd` is empty, or holds only headers and comments: no checklist task, no cost.\n- After a server restart: a `boot-triage` task arrives about 90 seconds later, only when the restart cut off work or left failures, orphans or superseded tasks to check. A clean boot creates none.\n- The system status is gathered for you. You never create a checklist or boot-triage task yourself.\n- The runbook text is part of every checklist task prompt. Every line you keep costs tokens on every tick.\n\nEdit the runbook with a native Edit of its mirror `/workspace/HEARTBEAT.md`. The PostToolUse hook PUTs the file to the `heartbeatMd` field. An edit to any of the four mirrors (`SOUL.md`, `IDENTITY.md`, `TOOLS.md`, `HEARTBEAT.md`) PUTs all four, so an `update-profile` write to `heartbeatMd` is overwritten by the stale mirror the next time you edit one of them in the same session. The hook logs a refused write (for example a 400 from the tracked-item date check) to stderr only, so verify every edit by DB anchor: `SELECT instr(heartbeatMd, '<fragment of the new line>') FROM agents WHERE id = '<your agent id>'`. A 0 means the write was refused: PUT `{\"heartbeatMd\": <file text>}` to `$MCP_BASE_URL/api/agents/$AGENT_ID/profile` with `Authorization: Bearer $API_KEY` and `X-Agent-ID: $AGENT_ID` to read the error, fix the line, and re-check.\n\n## Shape of the runbook\n\nTwo kinds of sections:\n\n- Reference sections, not capped: Standing Orders, Governance, Playbook index. Evergreen rules and pointers.\n- Tracked sections, capped: Active Blockers, Watch Items, Open Discussion. Live items with an end.\n\n## The cap\n\n- Tracked items across all tracked sections: 10. Absolute maximum 20, only during a real surge.\n- Every tracked item states its lift trigger and a date. An item with no removal condition does not go in. Write the date as a bare `MM-DD` (`check 10-20`) or `DELETE MM-DD` for a past-date prune: those are the only forms the gate can see.\n- Write an age trigger as a calendar date (opened date + N days), never as a day count computed at check time. A floored age fires a day late.\n- Never arm a defer chain to re-check a tracked item. The next heartbeat already checks it. Defer only for work no scheduled pass owns.\n- Incident detail goes to memory: `memory-store` at `swarm` scope, one line of pointer stays in the runbook.\n- A tracked item that closes is deleted. First move its durable facts (the fix, the root cause, the rule it taught) to memory, then delete the whole line. Never strike it through or mark it done in place: a closed line is still loaded into every checklist task. Tracked lines are for live items only.\n- At or over the cap: prune before you add, or instead of adding.\n- Identify an item by its full leading text (marker, number, title), never by its number alone. Numbers repeat after pruning, so \"delete item 278\" can hit two items.\n- A summary line that each sweep regenerates (a section header, a count) is a digest of the previous session. When it disagrees with an item, the item wins. Never cite the header as state.\n\n## Handle a heartbeat-checklist task\n\n0. Quiet check. Take the fast path when three things hold: every failed, stalled or reboot-interrupted task in the system status was already named in the previous heartbeat-checklist task's output (newest completed one via `get-tasks`); no mention has gone unanswered since the last heartbeat; and no tracked item in the task's runbook snapshot is at or past its date. On the fast path, run the two step 3 scripts. If they report nothing the previous heartbeat had not already triaged, complete with exactly one line, `Quiet. Same as <previousTaskId>: <N> failures (triaged), schedule-health <M>/<total>, controls ok.`, and skip steps 1, 2 and 4-7. A longer quiet output is a defect: no failure-set recap, no per-script bullets. Anything new means continue at step 1.\n1. Read the latest runbook. The snapshot in the task may be stale.\n2. Prune first. Check every tracked item against its lift trigger. Remove resolved, stale, and past-date items. A trigger tied to a pull request (\"lift when #N merges\") fires silently: read the state of every PR a trigger names. Merged means the lift fired, so act on it now. Closed without merge means the item is dead, so drop it.\n3. Run the seeded scripts: `script-run` with name `schedule-health`, then `task-failure-audit`, both with `args: { days: 1, publishPage: false }`. They report failing schedules and failure clusters. Check any other standing order in the runbook yourself.\n4. Read the system status and the script results for stalled tasks, idle workers next to open work, and anomalies.\n5. Reboot-interrupted failures: the failure reason \"Auto-failed by reboot sweep: worker session not found after server restart\" means a restart cut the task off. For each one: `get-task-details` on the task, confirm a retry task tagged `reboot-retry` exists and moves, recreate the task when no retry exists and the work is still needed. Heartbeat and triage tasks get no retry. A worker crash outside a restart (\"worker session not found\" or \"worker session heartbeat is stale\" from the stall sweep) normally leaves a `superseded` task with a `resume` child instead; check that the child moves. None of these are \"expected cleanup\".\n6. Act with your tools: create tasks, cancel stuck ones, post to Slack under the `slack-interaction` skill rules. Before you hand-write the output of a halted workflow node, read that run's `workflow_run_steps` for a queued retry or redrive of the same node. Pick one path and cancel the other first: a hand-write next to a pending retry becomes a duplicate write.\n7. Update the runbook after pruning. Delete closed items as described in the cap; do not strike them through.\n8. Complete the task with what you found and what you did. \"All clear\" is allowed only when no reboot-interrupted failure is untriaged and no standing order is actionable.\n\n## Handle a boot-triage task\n\n1. Prune first, as above.\n2. Run the seeded triage: `script-run` with name `boot-triage`. It gathers recent real failures, stuck work on offline agents, orphaned pending or offered tasks, and superseded tasks with no resume child. Pass `repo` to also get the deploy PR context.\n3. Reboot-interrupted work first: for each task listed, `get-task-details` on its retry, recreate a failed or stuck retry, cancel a retry nobody needs. Every item gets a verdict.\n4. Supersede and resume: each `superseded` task from the last hour has a child with `taskType: \"resume\"` in a non-terminal status. A superseded task without one is lost work; recreate it.\n5. Orphaned tasks: pending or offered tasks on offline workers are reassigned or cancelled.\n6. Agents: every expected worker is online, or you name the missing ones.\n7. Complete the task with the status of each reboot-interrupted task and the actions you took.\n\n## The checklist gate\n\nA gate script can run before each checklist tick and decide whether the tick needs you. It never runs for boot triage.\n\n- `HEARTBEAT_CHECKLIST_GATE_SCRIPT` names a global script. Unset means the seeded `heartbeat-jev-gate` runs when a global `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY` is set (an OpenRouter key behind an `OPENROUTER_BASE_URL` gateway does not count), or `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`) with `CLOUDFLARE_ACCOUNT_ID`. `off` or an empty value turns the gate off.\n- `heartbeat-jev-gate` sends a compact state (new failures, tracked items due or past their date, the previous checklist result, task and agent counts) to a SystemOne decision model, Jev or Clef (Clef only when it is the sole provider or `HEARTBEAT_JEV_GATE_PROVIDER=cloudflare`), and returns `{ quiet, reason, summary }`.\n- `HEARTBEAT_CHECKLIST_GATE_MODE=shadow` (default): every checklist task is still created, tagged `heartbeat-gate:quiet`, `heartbeat-gate:wake` or `heartbeat-gate:error`. The prompt is unchanged. Handle the task as usual; your outcome is the check on the gate's verdict.\n- `HEARTBEAT_CHECKLIST_GATE_MODE=enforce`: a quiet verdict skips the task. A wake verdict adds the gate's summary as a Gate Findings section. Enforce is opt-in; turn it on only after shadow tags agree with your own quiet outcomes.\n- The gate fails open. A timeout (60 s), a script error or a malformed result is an `error` verdict, and the task is created in both modes.\n- Preview a verdict with `script-run` on `heartbeat-jev-gate`, `args: { dryRun: true }`.\n- The gate sees a tracked item as due when its line contains today's `MM-DD` as a bare token (`check 10-20`) or a `DELETE MM-DD` marker on or before today. A full `YYYY-MM-DD` date does not count: a line that only carries a re-verification stamp (`2026-10-20 ~10:45Z`) is invisible to the gate, so write the due date bare. A line with no date is invisible too, and a date already past is only caught through `DELETE MM-DD`, so the prune in step 2 still matters.\n\n## Example standing orders\n\n```markdown\n## Standing Orders\n- Stalled task (no progress for 45 min): read its last progress, steer it or cancel and recreate.\n- Idle workers next to unassigned pool tasks: find out why the pool does not drain.\n- Slack requests older than 1 hour without a task: create the task.\n\n## Watch Items\n- Linear webhook retries since 2026-08-18. Lift when zero retries for 48 h (check 08-22).\n```\n";
15229
+ var content_default21 = "# Heartbeat runbook\n\nLead only. The heartbeat runbook is your `heartbeatMd` profile field. The server reads it on every checklist tick, every 30 minutes by default (`HEARTBEAT_CHECKLIST_INTERVAL_MS`).\n\n## How the heartbeat works\n\n- `heartbeatMd` has content: the server creates a `heartbeat-checklist` task with the current system status and a snapshot of your runbook. It skips the tick while you still have an open checklist task.\n- `heartbeatMd` is empty, or holds only headers and comments: no checklist task, no cost.\n- After a server restart: a `boot-triage` task arrives about 90 seconds later, only when the restart cut off work or left failures, orphans or superseded tasks to check. A clean boot creates none.\n- The system status is gathered for you. You never create a checklist or boot-triage task yourself.\n- The runbook text is part of every checklist task prompt. Every line you keep costs tokens on every tick.\n\nEdit the runbook with a native Edit of its mirror `/workspace/HEARTBEAT.md`. The PostToolUse hook PUTs the file to the `heartbeatMd` field. An edit to any of the four mirrors (`SOUL.md`, `IDENTITY.md`, `TOOLS.md`, `HEARTBEAT.md`) PUTs all four, so an `update-profile` write to `heartbeatMd` is overwritten by the stale mirror the next time you edit one of them in the same session. The hook logs a refused write (for example a 400 from the tracked-item date check) to stderr only, so verify every edit by DB anchor: `SELECT instr(heartbeatMd, '<fragment of the new line>') FROM agents WHERE id = '<your agent id>'`. A 0 means the write was refused: PUT `{\"heartbeatMd\": <file text>}` to `$MCP_BASE_URL/api/agents/$AGENT_ID/profile` with `Authorization: Bearer $API_KEY` and `X-Agent-ID: $AGENT_ID` to read the error, fix the line, and re-check.\n\n## Shape of the runbook\n\nTwo kinds of sections:\n\n- Reference sections, not capped: Standing Orders, Governance, Playbook index. Evergreen rules and pointers.\n- Tracked sections, capped: Active Blockers, Watch Items, Open Discussion. Live items with an end.\n\n## The cap\n\n- Tracked items across all tracked sections: 10. Absolute maximum 20, only during a real surge.\n- Every tracked item states its lift trigger and a date. An item with no removal condition does not go in. Write the date as a bare `MM-DD` (`check 10-20`) or `DELETE MM-DD` for a past-date prune: those are the only forms the gate can see.\n- Write an age trigger as a calendar date (opened date + N days), never as a day count computed at check time. A floored age fires a day late.\n- Never arm a defer chain to re-check a tracked item. The next heartbeat already checks it. Defer only for work no scheduled pass owns.\n- Incident detail goes to memory: `memory-store` at `swarm` scope, one line of pointer stays in the runbook.\n- A tracked item that closes is deleted. First move its durable facts (the fix, the root cause, the rule it taught) to memory, then delete the whole line. Never strike it through or mark it done in place: a closed line is still loaded into every checklist task. Tracked lines are for live items only.\n- At or over the cap: prune before you add, or instead of adding.\n- Identify an item by its full leading text (marker, number, title), never by its number alone. Numbers repeat after pruning, so \"delete item 278\" can hit two items.\n- A summary line that each sweep regenerates (a section header, a count) is a digest of the previous session. When it disagrees with an item, the item wins. Never cite the header as state.\n\n## Handle a heartbeat-checklist task\n\n0. Quiet check. Take the fast path when three things hold: every failed, stalled or reboot-interrupted task in the system status was already named in the previous heartbeat-checklist task's output (newest completed one via `get-tasks`); no mention has gone unanswered since the last heartbeat; and no tracked item in the task's runbook snapshot is at or past its date. On the fast path, run the two step 3 scripts. If they report nothing the previous heartbeat had not already triaged, complete with exactly one line, `Quiet. Same as <previousTaskId>: <N> failures (triaged), schedule-health <M>/<total>, controls ok.`, and skip steps 1, 2 and 4-7. A longer quiet output is a defect: no failure-set recap, no per-script bullets. Anything new means continue at step 1.\n1. Read the latest runbook. The snapshot in the task may be stale.\n2. Prune first. Check every tracked item against its lift trigger. Remove resolved, stale, and past-date items. A trigger tied to a pull request (\"lift when #N merges\") fires silently: read the state of every PR a trigger names. Merged means the lift fired, so act on it now. Closed without merge means the item is dead, so drop it. A PR's `mergeable` field reads `null` while the host is still computing it: `null` is unknown, never clean, so re-read until a value comes back and never treat it as a cleared blocker or as ready to merge.\n3. Run the seeded scripts: `script-run` with name `schedule-health`, then `task-failure-audit`, both with `args: { days: 1, publishPage: false }`. They report failing schedules and failure clusters. Check any other standing order in the runbook yourself.\n4. Read the system status and the script results for stalled tasks, idle workers next to open work, and anomalies.\n5. Reboot-interrupted failures: the failure reason \"Auto-failed by reboot sweep: worker session not found after server restart\" means a restart cut the task off. For each one: `get-task-details` on the task, confirm a retry task tagged `reboot-retry` exists and moves, recreate the task when no retry exists and the work is still needed. Heartbeat and triage tasks get no retry. A worker crash outside a restart (\"worker session not found\" or \"worker session heartbeat is stale\" from the stall sweep) normally leaves a `superseded` task with a `resume` child instead; check that the child moves. None of these are \"expected cleanup\".\n6. Act with your tools: create tasks, cancel stuck ones, post to Slack under the `slack-interaction` skill rules. Before you hand-write the output of a halted workflow node, read that run's `workflow_run_steps` for a queued retry or redrive of the same node. Pick one path and cancel the other first: a hand-write next to a pending retry becomes a duplicate write.\n7. Update the runbook after pruning. Delete closed items as described in the cap; do not strike them through.\n8. Complete the task with what you found and what you did. \"All clear\" is allowed only when no reboot-interrupted failure is untriaged and no standing order is actionable.\n\n## Handle a boot-triage task\n\n1. Prune first, as above.\n2. Run the seeded triage: `script-run` with name `boot-triage`. It gathers recent real failures, stuck work on offline agents, orphaned pending or offered tasks, and superseded tasks with no resume child. Pass `repo` to also get the deploy PR context.\n3. Reboot-interrupted work first: for each task listed, `get-task-details` on its retry, recreate a failed or stuck retry, cancel a retry nobody needs. Every item gets a verdict.\n4. Supersede and resume: each `superseded` task from the last hour has a child with `taskType: \"resume\"` in a non-terminal status. A superseded task without one is lost work; recreate it.\n5. Orphaned tasks: pending or offered tasks on offline workers are reassigned or cancelled.\n6. Agents: every expected worker is online, or you name the missing ones.\n7. Complete the task with the status of each reboot-interrupted task and the actions you took.\n\n## The checklist gate\n\nA gate script can run before each checklist tick and decide whether the tick needs you. It never runs for boot triage.\n\n- `HEARTBEAT_CHECKLIST_GATE_SCRIPT` names a global script. Unset means the seeded `heartbeat-jev-gate` runs when a global `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY` is set (an OpenRouter key behind an `OPENROUTER_BASE_URL` gateway does not count), or `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`) with `CLOUDFLARE_ACCOUNT_ID`. `off` or an empty value turns the gate off.\n- `heartbeat-jev-gate` sends a compact state (new failures, tracked items due or past their date, the previous checklist result, task and agent counts) to a SystemOne decision model, Jev or Clef (Clef only when it is the sole provider or `HEARTBEAT_JEV_GATE_PROVIDER=cloudflare`), and returns `{ quiet, reason, summary }`.\n- `HEARTBEAT_CHECKLIST_GATE_MODE=shadow` (default): every checklist task is still created, tagged `heartbeat-gate:quiet`, `heartbeat-gate:wake` or `heartbeat-gate:error`. The prompt is unchanged. Handle the task as usual; your outcome is the check on the gate's verdict.\n- `HEARTBEAT_CHECKLIST_GATE_MODE=enforce`: a quiet verdict skips the task. A wake verdict adds the gate's summary as a Gate Findings section. Enforce is opt-in; turn it on only after shadow tags agree with your own quiet outcomes.\n- The gate fails open. A timeout (60 s), a script error or a malformed result is an `error` verdict, and the task is created in both modes.\n- Preview a verdict with `script-run` on `heartbeat-jev-gate`, `args: { dryRun: true }`.\n- The gate sees a tracked item as due when its line contains today's `MM-DD` as a bare token (`check 10-20`) or a `DELETE MM-DD` marker on or before today. A full `YYYY-MM-DD` date does not count: a line that only carries a re-verification stamp (`2026-10-20 ~10:45Z`) is invisible to the gate, so write the due date bare. A line with no date is invisible too, and a date already past is only caught through `DELETE MM-DD`, so the prune in step 2 still matters.\n\n## Example standing orders\n\n```markdown\n## Standing Orders\n- Stalled task (no progress for 45 min): read its last progress, steer it or cancel and recreate.\n- Idle workers next to unassigned pool tasks: find out why the pool does not drain.\n- Slack requests older than 1 hour without a task: create the task.\n\n## Watch Items\n- Linear webhook retries since 2026-08-18. Lift when zero retries for 48 h (check 08-22).\n```\n";
15063
15230
 
15064
15231
  // templates/skills/implementing/config.json
15065
15232
  var config_default22 = `{
@@ -16530,7 +16697,7 @@ var config_default29 = `{
16530
16697
  "displayName": "Pages",
16531
16698
  "slug": "pages",
16532
16699
  "title": "Pages",
16533
- "description": "Publish human-readable task outputs as DB-backed shareable pages (HTML or JSON) via \`create_page\` — no server, no tunnel. Covers auth modes, share URLs, versioning, the injected browser SDK, the \`<swarm-diff>\` primitive, print/PDF export, limits, and the minimalist taste baseline plus report-density layer every page should follow.",
16700
+ "description": "Publish human-readable task outputs as DB-backed shareable pages (HTML or JSON) via \`create_page\`, with no server and no tunnel. Covers auth modes, share URLs, versioning, the injected browser SDK, the \`<swarm-diff>\` web component, print/PDF export, and limits. Also covers the swarm design system every page should use: theme tokens, \`swarm-*\` primitives, Tailwind v4 utilities, theme presets, dark mode, and the minimalist taste and report-density rules.",
16534
16701
  "version": "2.0.0",
16535
16702
  "category": "skills",
16536
16703
  "placeholders": [],
@@ -16547,15 +16714,15 @@ var config_default29 = `{
16547
16714
  `;
16548
16715
 
16549
16716
  // templates/skills/pages/content.md
16550
- var content_default29 = `# Pages — DB-backed Static Artifacts
16717
+ var content_default29 = `# Pages: DB-backed Static Artifacts
16551
16718
 
16552
16719
  DB-backed static content (HTML or JSON) served by the API directly. Cheap,
16553
16720
  versioned, share-able by URL. No server process, no tunnel, no port
16554
16721
  allocation.
16555
16722
 
16556
16723
  A page should be a clean human-facing artifact, not a raw dump with a URL. This
16557
- skill covers both halves: **how to publish** a page, and **the taste baseline**
16558
- every page should meet.
16724
+ skill covers both halves: **how to publish** a page, and **the design system**
16725
+ every page should use.
16559
16726
 
16560
16727
  > **Capability gate**: the \`create_page\` MCP tool is only available when the
16561
16728
  > agent's \`CAPABILITIES\` env var includes \`pages\`
@@ -16578,10 +16745,10 @@ in a single call), use pages. If it needs stored data and actions, use apps.
16578
16745
 
16579
16746
  Do **not** use pages for:
16580
16747
 
16581
- - In-flight progress notes — use \`store-progress.progress\`
16748
+ - In-flight progress notes: use \`store-progress.progress\`
16582
16749
  - Secrets, private credentials, or unapproved personal data
16583
- - Large binary files — use agent-fs for PNG/MP4
16584
- - Raw verbose logs — summarize, and link the raw file as an artifact
16750
+ - Large binary files: use agent-fs for PNG/MP4
16751
+ - Raw verbose logs: summarize, and link the raw file as an artifact
16585
16752
 
16586
16753
  ## Quick Start
16587
16754
 
@@ -16596,7 +16763,7 @@ Do **not** use pages for:
16596
16763
  "authMode": "authed",
16597
16764
  "body": "<!doctype html><html><body><h1>Q2 Status</h1>...</body></html>"
16598
16765
  }
16599
- // → { id, version: 1, app_url, api_url }
16766
+ // → { id, version: 1, designVersion: 2, app_url, api_url }
16600
16767
  \`\`\`
16601
16768
 
16602
16769
  Share \`app_url\` (the SPA route) for the general case; share \`api_url\` for a
@@ -16605,8 +16772,8 @@ no-SPA-required direct link.
16605
16772
  Use \`authMode: "public"\` only when the page is intentionally safe for anyone
16606
16773
  with the URL to view.
16607
16774
 
16608
- A complete, styled report body that follows the baseline below is bundled at
16609
- **\`examples/report-page.html\`** — copy it as your starting point rather than
16775
+ A complete report built from the design system below is bundled at
16776
+ **\`examples/report-page.html\`**. Copy it as your starting point instead of
16610
16777
  writing CSS from scratch.
16611
16778
 
16612
16779
  ### Authed JSON dashboard
@@ -16629,28 +16796,451 @@ swarm API.
16629
16796
 
16630
16797
  ---
16631
16798
 
16632
- # Design baseline
16799
+ # Design system
16800
+
16801
+ HTML pages with \`designVersion: 2\` get the swarm design system. It is the
16802
+ dashboard's own look (the Hive preset), built from the dashboard's theme
16803
+ tokens. One theme switch restyles the whole page. New pages get
16804
+ \`designVersion: 2\` by default.
16805
+
16806
+ The design system styles HTML pages only. JSON pages render inside the
16807
+ dashboard and use the dashboard theme.
16808
+
16809
+ Before you create a page, write a one-line design read for yourself:
16810
+
16811
+ > Reading this as: \`<page kind>\` for \`<audience>\`, using the design system plus \`<density needs>\`.
16812
+
16813
+ ## What a designVersion 2 page gets
16814
+
16815
+ The API injects these into the \`<head>\` of every designVersion 2 HTML page:
16816
+
16817
+ - **Element defaults**: plain HTML elements look finished with zero CSS. See Element defaults below for the full list.
16818
+ - **Theme tokens**: CSS variables such as \`var(--color-primary)\`. Each color token has a light value and a dark value.
16819
+ - **Primitives**: \`swarm-*\` classes for page structure, metrics, panels, badges, tables, buttons, and callouts.
16820
+ - **Tailwind v4 utilities** that map to the tokens, such as \`bg-card\` and \`text-muted-foreground\`. Preflight stays off, so the element defaults stay.
16821
+ - **Fonts**: Space Grotesk for text and Space Mono for code.
16822
+ - **Light print**: print always uses the light tokens.
16823
+
16824
+ The element defaults, the primitives, and the Tailwind utilities sit in CSS
16825
+ cascade layers. A Tailwind utility beats a primitive, and a primitive beats an
16826
+ element default. Your own \`<style>\` rules are not in a layer, so they beat all
16827
+ three. Use your own CSS for layout, and take every color from a token. For an
16828
+ edge-to-edge layout, set \`body { padding: 0 }\`.
16829
+
16830
+ ## Element defaults
16831
+
16832
+ The head styles these elements. They need no class. Do not restyle an element
16833
+ only to get the look that it already has.
16834
+
16835
+ | Element | Default look |
16836
+ |---|---|
16837
+ | \`html\`, \`body\` | The background and foreground tokens. The body uses \`--font-sans\` at 15px with a 1.55 line height. It has 24px of padding, and 16px at 640px wide and below. |
16838
+ | \`h1\` to \`h6\` | Weight 600. \`h1\` is 30px, \`h2\` is 20px, \`h3\` is 16px, \`h4\` is 15px, and \`h5\` and \`h6\` are 13px. |
16839
+ | \`p\`, \`ul\`, \`ol\`, \`li\` | Paragraphs and lists have a 12px bottom margin. List markers are muted. |
16840
+ | \`dl\`, \`dt\`, \`dd\` | \`dt\` is weight 500. For a key and value grid, use \`.swarm-kv\`. |
16841
+ | \`a\` | The primary color with a soft underline. The underline gets stronger on hover. |
16842
+ | \`strong\`, \`b\` | Weight 600. |
16843
+ | \`mark\` | A soft highlight in the pending tone. |
16844
+ | \`hr\` | A 1px \`--color-border-subtle\` rule, with 32px above and below. |
16845
+ | \`blockquote\` | Muted text with a 2px left rule. |
16846
+ | \`code\`, \`samp\` | \`--font-mono\` at 0.85em. \`code\` has a muted fill. |
16847
+ | \`kbd\` | A key with a 1px border and a thicker bottom edge, in \`--font-mono\`. |
16848
+ | \`pre\` | A bordered surface block in \`--font-mono\`. It scrolls when a line is too long. |
16849
+ | \`img\`, \`video\` | Block elements that are never wider than their container. |
16850
+ | \`figure\`, \`figcaption\` | \`figcaption\` is 13px muted text, 8px below the content. |
16851
+ | \`table\`, \`th\`, \`td\` | Full width, 14px, collapsed borders, and tabular digits. \`th\` is an 11px uppercase muted label. Each row has a subtle bottom rule. |
16852
+ | \`caption\` | 13px muted text, aligned left. |
16853
+ | \`tfoot\` | A totals row: weight 600 with a \`--color-border\` rule above it. Print shows it once, at the end of the table. |
16854
+ | \`th scope="row"\` | A row header that looks like a normal cell. |
16855
+ | \`details\`, \`summary\` | \`summary\` is weight 500 with a muted marker. |
16856
+ | Text \`input\`, \`select\`, \`textarea\` | The \`.swarm-input\` look. \`select\` has its own arrow. \`textarea\` is at least 80px tall and resizes vertically. |
16857
+ | \`button\`, and \`input\` of type \`button\`, \`submit\`, or \`reset\` | The outline \`.swarm-button\` look. A plain \`<button>\` needs no class. |
16858
+ | \`input type="file"\` | A small outline button opens the file picker. |
16859
+ | Checkbox, radio, range, \`progress\` | The primary color. |
16860
+ | \`fieldset\`, \`legend\` | A bordered group with a 13px muted legend. |
16861
+ | Form states | Placeholders are muted. Disabled controls have 50% opacity. \`aria-invalid="true"\` makes the border destructive. |
16862
+ | \`:focus-visible\` | A 2px ring in \`--color-ring\`. |
16863
+ | \`::selection\` | The primary fill. |
16864
+ | Every element | \`border: 0 solid var(--color-border)\`. A bare \`border\` class draws a 1px line in the border token. |
16865
+
16866
+ Blocks such as \`p\`, lists, \`table\`, \`pre\`, and \`figure\` have a 12px or 16px
16867
+ bottom margin. A heading that is the first child of its container has no top
16868
+ margin. The last block in a container has no bottom margin.
16869
+
16870
+ The head sets \`<base target="_blank">\`, so a link to another page opens in a
16871
+ new tab. A link inside the page (\`href="#id"\`) without a \`target\` stays in the
16872
+ page.
16873
+
16874
+ ## No hardcoded colors
16875
+
16876
+ Take every color from a token. Inside the dashboard, the page follows the
16877
+ viewer's theme and mode. A hardcoded color stays the same in every theme and
16878
+ mode, so it can break contrast.
16879
+
16880
+ - Do not write color literals: hex values (\`#2563eb\`), \`rgb()\`, \`rgba()\`, \`hsl()\`, \`hsla()\`, \`oklch()\`, or named colors such as \`white\` and \`black\`.
16881
+ - Do not use Tailwind palette classes such as \`bg-blue-500\`, \`text-zinc-600\`, \`bg-white\`, or \`text-black\`, or arbitrary colors such as \`bg-[#fff]\`. They ignore the theme. For example, a \`bg-white\` card in dark mode puts light text on white.
16882
+ - In CSS, use \`var(--color-*)\`. In markup, use the Tailwind class of the token, such as \`bg-primary\` or \`text-muted-foreground\`.
16883
+ - For a tint, add an opacity modifier: \`bg-primary/10\`. In CSS, write \`color-mix(in oklab, var(--color-primary) 10%, transparent)\`.
16884
+ - In inline SVG, use \`fill-chart-1\`, \`stroke-border\`, or \`currentColor\`.
16885
+ - For a canvas chart, read the token value at runtime: \`getComputedStyle(document.documentElement).getPropertyValue("--color-chart-1")\`.
16886
+
16887
+ \`create_page\` counts the hardcoded colors in your \`<style>\` blocks, \`style\`
16888
+ attributes, and \`class\` attributes. At 8 or more, its result tells you to use
16889
+ tokens. A page with 8 or more hardcoded colors stays light, also when the
16890
+ viewer uses dark mode.
16891
+
16892
+ To restyle a page, set \`theme\` or \`themeTokens\` (see Theming). Do not write
16893
+ your own palette.
16894
+
16895
+ ## Theme tokens
16896
+
16897
+ The tokens use the dashboard's names. The font and radius tokens have one
16898
+ value for both modes.
16899
+
16900
+ | CSS variable | Tailwind class | Meaning |
16901
+ |---|---|---|
16902
+ | \`--color-background\` | \`bg-background\` | The page canvas. |
16903
+ | \`--color-foreground\` | \`text-foreground\` | Main text. |
16904
+ | \`--color-card\`, \`--color-card-foreground\` | \`bg-card\`, \`text-card-foreground\` | Panels and the text on them. |
16905
+ | \`--color-surface\` | \`bg-surface\` | A recessed step between canvas and card, for nested blocks. |
16906
+ | \`--color-popover\`, \`--color-popover-foreground\` | \`bg-popover\`, \`text-popover-foreground\` | Menus, tooltips, and other floating layers. |
16907
+ | \`--color-primary\`, \`--color-primary-foreground\` | \`bg-primary\`, \`text-primary\`, \`text-primary-foreground\` | The one accent: links, primary buttons, and active state. The foreground is the text on a primary fill. |
16908
+ | \`--color-secondary\`, \`--color-secondary-foreground\` | \`bg-secondary\`, \`text-secondary-foreground\` | Secondary buttons and quiet fills. |
16909
+ | \`--color-muted\`, \`--color-muted-foreground\` | \`bg-muted\`, \`text-muted-foreground\` | Quiet resting fills. Secondary text, labels, and captions. |
16910
+ | \`--color-accent\`, \`--color-accent-foreground\` | \`hover:bg-accent\`, \`text-accent-foreground\` | The hover and highlight fill. For the brand accent, use \`primary\`. |
16911
+ | \`--color-destructive\`, \`--color-destructive-foreground\` | \`bg-destructive\`, \`text-destructive\` | Destructive actions only. |
16912
+ | \`--color-border\` | \`border-border\` | Structure lines: panels, inputs, table frames. |
16913
+ | \`--color-border-subtle\` | \`border-border-subtle\`, \`divide-border-subtle\` | Quiet lines: row rules and separators. |
16914
+ | \`--color-input\` | \`border-input\` | Form control borders. |
16915
+ | \`--color-ring\` | \`ring-ring\`, \`outline-ring\` | The focus ring. |
16916
+ | \`--color-chart-1\` to \`--color-chart-5\` | \`bg-chart-1\`, \`fill-chart-2\`, \`stroke-chart-3\` | Chart series, in order. |
16917
+ | \`--color-status-<tone>\` | \`bg-status-success\` | A pale status fill. |
16918
+ | \`--color-status-<tone>-strong\` | \`text-status-error-strong\` | Status text on a neutral surface. |
16919
+ | \`--color-status-<tone>-foreground\` | \`text-status-success-foreground\` | The text on a pale status fill. |
16920
+ | \`--color-status-success-solid\`, \`--color-status-active-solid\` | \`bg-status-success-solid\` | Saturated stops for small dots and icons. |
16921
+ | \`--font-sans\` | \`font-sans\` | Text. Space Grotesk by default. |
16922
+ | \`--font-mono\` | \`font-mono\` | Code, IDs, logs, and other machine output. Space Mono by default. |
16923
+ | \`--radius-sm\`, \`--radius-md\`, \`--radius-lg\`, \`--radius-xl\` | \`rounded-sm\`, \`rounded-md\`, \`rounded-lg\`, \`rounded-xl\` | Corner radii, from tags (\`sm\`) to panels (\`xl\`). |
16924
+
16925
+ \`<tone>\` is \`success\`, \`active\`, \`error\`, \`info\`, \`pending\`, \`warning\`,
16926
+ \`paused\`, or \`neutral\`. Status colors carry meaning. Use them for states, not
16927
+ for decoration.
16928
+
16929
+ The old \`--swarm-bg\`, \`--swarm-card\`, \`--swarm-border\`, \`--swarm-text\`,
16930
+ \`--swarm-muted\`, and \`--swarm-primary\` variables still work. On designVersion
16931
+ 2 pages, they are aliases of the tokens. Use the token names in new pages.
16932
+
16933
+ ## Primitives
16934
+
16935
+ The primitives are plain CSS classes. They use only tokens, so they work in
16936
+ both modes and under every preset. Add Tailwind utilities for spacing and
16937
+ layout.
16938
+
16939
+ **\`.swarm-page\`**: the reading spine. It centers the content at a maximum width of 1120px.
16940
+
16941
+ \`\`\`html
16942
+ <main class="swarm-page">...</main>
16943
+ \`\`\`
16944
+
16945
+ **\`.swarm-eyebrow\`**: a small uppercase label above a title.
16946
+
16947
+ \`\`\`html
16948
+ <p class="swarm-eyebrow">Weekly report · 2026-10-31</p>
16949
+ \`\`\`
16950
+
16951
+ **\`.swarm-lede\`**: the summary paragraph under the title. It is larger, muted, and about 68 characters wide.
16952
+
16953
+ \`\`\`html
16954
+ <p class="swarm-lede">Error rates fell 40% after the retry fix shipped.</p>
16955
+ \`\`\`
16956
+
16957
+ **\`.swarm-section\`**: a titled block with vertical rhythm.
16958
+
16959
+ \`\`\`html
16960
+ <section class="swarm-section"><h2>Findings</h2><p>...</p></section>
16961
+ \`\`\`
16962
+
16963
+ **\`.swarm-metrics\`** and **\`.swarm-metric\`**: a responsive grid of key numbers. Each metric holds a \`.swarm-metric-value\` (large, tabular digits) and a \`.swarm-metric-label\` (muted). Add a \`data-tone\` (see Tones) to a metric to color its value. Without it, the value uses the foreground color.
16964
+
16965
+ \`\`\`html
16966
+ <div class="swarm-metrics">
16967
+ <div class="swarm-metric"><div class="swarm-metric-value">18.6k</div><div class="swarm-metric-label">Clicks</div></div>
16968
+ <div class="swarm-metric" data-tone="error"><div class="swarm-metric-value">7</div><div class="swarm-metric-label">Failed runs</div></div>
16969
+ </div>
16970
+ \`\`\`
16971
+
16972
+ **\`.swarm-panel\`** (alias **\`.swarm-card\`**): a flat surface with a 1px border, the \`--radius-xl\` radius, and padding. Do not put a panel inside a panel.
16973
+
16974
+ \`\`\`html
16975
+ <div class="swarm-panel"><h3>Owner</h3><p>Platform team</p></div>
16976
+ \`\`\`
16977
+
16978
+ **\`.swarm-badge\`**: a soft status pill that never wraps. Set \`data-tone\` to a tone (see Tones). The default is \`neutral\`. Add \`data-dot\` for a leading dot in the tone color. The badge has a 6px gap between the dot and the text, so do not add margins inside a badge.
16979
+
16980
+ \`\`\`html
16981
+ <span class="swarm-badge" data-tone="success" data-dot>Healthy</span>
16982
+ \`\`\`
16983
+
16984
+ For a category label (a team, a region, a product line), do not use a status tone. Use a neutral badge with a dot, and set the dot color with \`--swarm-dot\` and a chart token. Give each category its own token, from \`--color-chart-1\` to \`--color-chart-5\`.
16985
+
16986
+ \`\`\`html
16987
+ <span class="swarm-badge" data-dot style="--swarm-dot: var(--color-chart-1)">Construction</span>
16988
+ <span class="swarm-badge" data-dot style="--swarm-dot: var(--color-chart-2)">Interiors</span>
16989
+ \`\`\`
16990
+
16991
+ **\`.swarm-table-wrap\`** and **\`.swarm-table\`**: a scroll container and a compact table. The table has a sticky header, subtle row rules, and row hover. Add \`.num\` to number cells: right aligned, tabular, no wrap. Add \`.nowrap\` to keep any cell on one line.
16992
+
16993
+ The wrap is already a bordered card surface: the card fill, a 1px border, and the \`--radius-xl\` radius. Do not put it inside a panel. Its maximum height is \`min(70vh, 720px)\`. A taller table scrolls inside the wrap, and the header stays in view. To remove the cap, add \`max-h-none\` to the wrap. Then the page scrolls instead, and the header does not stay in view. Print always removes the cap. When the table is wider than the wrap, a soft shadow shows at each side that hides columns.
16994
+
16995
+ \`\`\`html
16996
+ <div class="swarm-table-wrap">
16997
+ <table class="swarm-table">
16998
+ <thead><tr><th>Route</th><th class="num">p95 ms</th></tr></thead>
16999
+ <tbody><tr><td class="nowrap">/api/tasks</td><td class="num">182</td></tr></tbody>
17000
+ </table>
17001
+ </div>
17002
+ \`\`\`
17003
+
17004
+ **\`.swarm-button\`**: a button look for \`<button>\` and \`<a>\`. Set \`data-variant\` to \`outline\` (the default), \`primary\`, or \`ghost\`. A plain \`<button>\` also gets the outline look. For a bare icon button, remove it with utilities: \`border-0 bg-transparent p-0\`.
17005
+
17006
+ \`\`\`html
17007
+ <button class="swarm-button" data-variant="primary" type="button">Rerun checks</button>
17008
+ \`\`\`
17009
+
17010
+ **\`.swarm-input\`**: the look for text inputs and selects. Plain form controls get the same base look.
17011
+
17012
+ \`\`\`html
17013
+ <input class="swarm-input" type="search" placeholder="Filter rows" aria-label="Filter rows">
17014
+ \`\`\`
17015
+
17016
+ **\`.swarm-toolbar\`**: a row for search, filters, and actions. It wraps on narrow screens. A text or search input placed directly in the toolbar grows to fill the free space, so do not give it a fixed width.
17017
+
17018
+ \`\`\`html
17019
+ <div class="swarm-toolbar">
17020
+ <input class="swarm-input" type="search" placeholder="Filter" aria-label="Filter">
17021
+ <button class="swarm-button" type="button">Reset</button>
17022
+ </div>
17023
+ \`\`\`
17024
+
17025
+ **\`.swarm-segmented\`**: a segmented control. Mark the active item with \`aria-pressed="true"\` or \`data-active\`.
17026
+
17027
+ \`\`\`html
17028
+ <div class="swarm-segmented" role="group" aria-label="Range">
17029
+ <button type="button" aria-pressed="true">Week</button>
17030
+ <button type="button" aria-pressed="false">Month</button>
17031
+ </div>
17032
+ \`\`\`
17033
+
17034
+ **\`.swarm-callout\`**: a box for a finding or a note. Set \`data-tone\` to a tone (see Tones). The default is \`info\`.
17035
+
17036
+ \`\`\`html
17037
+ <div class="swarm-callout" data-tone="warning"><strong>Retry storm.</strong> Three agents retried one task 40 times.</div>
17038
+ \`\`\`
17039
+
17040
+ **\`.swarm-kv\`**: a key and value grid for a \`<dl>\`. Each \`<dt>\` is a muted key, and the \`<dd>\` after it holds the value.
17041
+
17042
+ \`\`\`html
17043
+ <dl class="swarm-kv"><dt>Owner</dt><dd>Platform team</dd><dt>Window</dt><dd>7 days</dd></dl>
17044
+ \`\`\`
17045
+
17046
+ ### Tones
17047
+
17048
+ \`.swarm-badge\`, \`.swarm-callout\`, and \`.swarm-metric\` share one set of
17049
+ \`data-tone\` values:
17050
+
17051
+ | \`data-tone\` | Use it for | Color token |
17052
+ |---|---|---|
17053
+ | \`neutral\` | Offline, backlog, cancelled, skipped, or not reviewed. The badge default. | \`--color-status-neutral\` |
17054
+ | \`primary\` | A highlight that is not a state, in the page accent. | \`--color-primary\` |
17055
+ | \`success\` | Completed, healthy, approved, or passed. | \`--color-status-success\` |
17056
+ | \`warning\` | Timeouts, threshold warnings, and risks to fix soon. | \`--color-status-warning\` |
17057
+ | \`error\` | Failed, unhealthy, rejected, or blocked. | \`--color-status-error\` |
17058
+ | \`info\` | Facts and notes. The callout default. | \`--color-status-info\` |
17059
+ | \`pending\` | Pending, waiting, or starting. | \`--color-status-pending\` |
17060
+ | \`paused\` | Paused or in review. | \`--color-status-paused\` |
17061
+ | \`active\` | Running, busy, or in progress. | \`--color-status-active\` |
17062
+
17063
+ Each status tone uses its token for fills and borders, and its \`-strong\` token
17064
+ for text. \`primary\` uses \`--color-primary\` for both. A badge takes its text,
17065
+ fill, and border from the tone. A callout takes only its fill and border from
17066
+ the tone, so its text keeps the foreground color. A metric colors its value.
17067
+
17068
+ The bundled **\`examples/report-page.html\`** puts these together, with zero
17069
+ color literals.
17070
+
17071
+ ## Theming
17072
+
17073
+ The API picks the preset of a designVersion 2 page in this order:
17074
+
17075
+ 1. The page's \`theme\`, when it is set.
17076
+ 2. The viewer's dashboard preset, when the page opens inside the dashboard.
17077
+ 3. \`hive\`.
17078
+
17079
+ Then the page's \`themeTokens\` apply on top of the preset. The mode (light or
17080
+ dark) always comes from the viewer. \`create_page\`, \`POST /api/pages\`, and
17081
+ \`PUT /api/pages/:id\` accept both fields.
17082
+
17083
+ ### \`theme\`: a preset id
17084
+
17085
+ | Id | Look |
17086
+ |---|---|
17087
+ | \`hive\` | The default: a zinc field with one amber accent. |
17088
+ | \`meadow\`, \`iris\`, \`rose\` | The Hive field with an emerald, violet, or deep rose accent. |
17089
+ | \`cobalt\` | A cool slate field with a saturated blue accent. |
17090
+ | \`ember\` | A warm stone field with a burnt orange accent. |
17091
+ | \`carbon\` | Pure monochrome. |
17092
+ | \`paper\` | The warm editorial paper look: an off-white canvas, white cards, warm borders, and a blue accent. |
17093
+ | \`github\`, \`vscode\`, \`material\`, \`solarized\`, \`tokyo\`, \`monokai\`, \`gruvbox\`, \`catppuccin\` | Classic editor and platform palettes, with their own status colors. |
17094
+
17095
+ An unknown id fails the write, and the error lists the valid ids. Set \`theme\`
17096
+ only when the page needs a fixed look. Without it, the page matches the
17097
+ viewer's dashboard. On an update, \`theme: null\` clears it. An update or upsert
17098
+ that omits \`theme\` keeps the stored preset.
17099
+
17100
+ ### \`themeTokens\`: token overrides
17101
+
17102
+ \`themeTokens\` overrides individual tokens on top of the preset. Tokens that
17103
+ you do not set keep their preset values. The input has two formats: a token
17104
+ map (the default) and a CSS block (\`"format": "css"\`). Another \`format\` fails
17105
+ the write.
17106
+
17107
+ **Set colors for both modes.** A color that you set only in \`light\` does not
17108
+ reach dark mode. Dark mode keeps the preset's dark value for that token. So
17109
+ set each color in both the \`light\` and the \`dark\` map, or add a \`.dark\` block
17110
+ in the css format. Fonts and radius have one value for both modes. A font or
17111
+ radius token that you set only in \`light\` applies to dark mode too.
17112
+
17113
+ **Send the full map.** A write that sends \`themeTokens\` replaces the stored
17114
+ map. It does not merge with it. A token that you leave out goes back to its
17115
+ preset value. An update or upsert that omits \`themeTokens\` keeps the stored
17116
+ map. On an update, \`themeTokens: null\` clears it.
17117
+
17118
+ A token map:
17119
+
17120
+ \`\`\`jsonc
17121
+ // Tool call: create_page
17122
+ {
17123
+ "title": "Launch review",
17124
+ "contentType": "text/html",
17125
+ "body": "<!doctype html>...",
17126
+ "theme": "paper",
17127
+ "themeTokens": {
17128
+ "light": { "--color-primary": "oklch(0.52 0.15 150)", "--color-ring": "oklch(0.52 0.15 150)", "--radius-xl": "14px" },
17129
+ "dark": { "--color-primary": "oklch(0.72 0.14 150)", "--color-ring": "oklch(0.72 0.14 150)" }
17130
+ }
17131
+ }
17132
+ \`\`\`
17133
+
17134
+ A CSS block takes the output of shadcn, tweakcn, or a Tailwind v4 \`@theme\`
17135
+ block without changes. A \`@theme\` block holds light values only, so add a
17136
+ \`.dark\` block for dark mode. Send the block as one string:
17137
+ \`"themeTokens": { "format": "css", "css": "<the block>" }\`. For example, this
17138
+ shadcn paste:
17139
+
17140
+ \`\`\`css
17141
+ :root {
17142
+ --radius: 0.5rem;
17143
+ --primary: oklch(0.205 0 0);
17144
+ --primary-foreground: oklch(0.985 0 0);
17145
+ --sidebar: oklch(0.985 0 0);
17146
+ }
17147
+ .dark {
17148
+ --primary: oklch(0.922 0 0);
17149
+ --primary-foreground: oklch(0.205 0 0);
17150
+ }
17151
+ \`\`\`
17152
+
17153
+ sets the primary colors for both modes and all four radius tokens. \`--sidebar\`
17154
+ is not a page token, so the response lists it in \`ignoredTokens\`.
17155
+
17156
+ The importer rules:
17157
+
17158
+ - \`--color-primary\`, \`--primary\`, \`color-primary\`, and \`primary\` all name the same token. Font and radius names work the same way (\`--font-sans\`, \`font-sans\`).
17159
+ - In a CSS block, \`:root\`, \`:host\`, \`html\`, \`@theme\`, and \`@theme inline\` set the light values. \`.dark\`, \`:root.dark\`, \`html.dark\`, and \`[data-theme=dark]\` (quoted or not) set the dark values. So does \`:root\`, \`:host\`, or \`html\` inside \`@media (prefers-color-scheme: dark)\`. A selector list such as \`:root, :host\` also works.
17160
+ - The importer skips comments and blocks under other selectors. The response lists the custom properties of a skipped block in \`ignoredTokens\` with the block, for example \`--ring (in .card)\`. A CSS block with no light or dark block fails the write.
17161
+ - A shadcn \`--radius\` sets all four radius tokens. \`sm\` is the radius minus 4px, \`md\` is minus 2px, \`lg\` is the radius, and \`xl\` is plus 4px.
17162
+ - The importer wraps a bare HSL triplet from an older shadcn theme (\`222.2 47.4% 11.2%\`) as \`hsl(222.2 47.4% 11.2%)\`.
17163
+ - The importer ignores names outside the token contract, such as \`--sidebar\`, \`--shadow-sm\`, and \`--font-serif\`. It does not reject them. The response lists them in \`ignoredTokens\`.
17164
+ - The importer drops a token whose value references a variable outside the contract, such as \`--font-sans: var(--font-geist-sans)\`. The response lists it in \`ignoredTokens\` with the variable that it references, for example \`--font-sans (references --font-geist-sans)\`. Write the value itself instead, for example a font stack.
17165
+ - A value has 1 to 200 characters. It must not contain \`;\`, \`{\`, \`}\`, \`<\`, \`>\`, \`\\\`, or a line break. It must not contain \`url(\`, \`expression(\`, \`@import\`, or \`javascript:\`. A bad value fails the write.
17166
+ - The API stores the tokens in normalized form.
17167
+
17168
+ When you change \`--color-primary\`, also set \`--color-ring\`. If the text on the
17169
+ new accent needs more contrast, set \`--color-primary-foreground\` too.
17170
+
17171
+ Do not set tokens in your own \`:root\` rule. The theme's dark rule is more
17172
+ specific, so your value reaches light mode only.
17173
+
17174
+ To use another font, load it from Google Fonts in your page \`<head>\`. Then set
17175
+ \`--font-sans\` in \`themeTokens\`.
17176
+
17177
+ ## Dark mode
17178
+
17179
+ Inside the dashboard, a designVersion 2 page follows the viewer's mode. In dark
17180
+ mode, the injected head adds the \`dark\` class to \`<html>\`, and the tokens
17181
+ switch to their dark values. A page built on tokens needs no extra dark-mode
17182
+ code.
17183
+
17184
+ - Do not set \`color-scheme\` yourself. The theme sets it.
17185
+ - A \`themeTokens\` color that you set only in \`light\` does not reach dark mode. Set it in both maps (see \`themeTokens\`).
17186
+ - Use the \`dark:\` variant only for a rare mode-specific change, such as a logo pair: \`<img class="dark:hidden" ...>\` and \`<img class="hidden dark:block" ...>\`.
17187
+ - A direct link (\`api_url\`) renders in light mode, with the page \`theme\` or Hive.
17188
+ - To check a page in dark mode, add \`?__swarm-mode=dark\` to its \`api_url\`. On a page without a \`theme\`, add \`__swarm-theme=<preset id>\` to try a preset.
17189
+ - Do not use \`__swarm-mode\` or \`__swarm-theme\` as your own query parameters.
17190
+
17191
+ ## Design versions
17192
+
17193
+ Each page has a \`designVersion\`. It is separate from the edit \`version\`
17194
+ counter.
17195
+
17196
+ - \`1\`: the old head, with the Tailwind v3 Play CDN and the \`--swarm-*\` hex tokens, in light mode only. Pages created before the design system have version 1. They keep their old look and ignore the viewer's theme and mode.
17197
+ - \`2\`: the design system. New pages get version 2 by default.
17198
+
17199
+ An upsert or update that omits \`designVersion\` keeps the stored version. The
17200
+ \`create_page\` result reports the saved \`designVersion\`. A page created before
17201
+ the design system stays on version 1, also when you upsert it with design
17202
+ system markup. So pass \`designVersion: 2\` when you rewrite an old page or
17203
+ upsert a recurring report. On a version 2 page, it changes nothing.
16633
17204
 
16634
- Before creating a page, write a one-line design read for yourself:
17205
+ To move a version 1 page, rewrite it and pass \`designVersion: 2\`. Then fix these
17206
+ Tailwind v3 to v4 changes:
16635
17207
 
16636
- > Reading this as: \`<page kind>\` for \`<audience>\`, using minimalist taste plus \`<density needs>\`.
17208
+ | v3 class (version 1) | v4 class (version 2) |
17209
+ |---|---|
17210
+ | \`shadow-sm\`, \`shadow\` | \`shadow-xs\`, \`shadow-sm\` |
17211
+ | \`rounded-sm\`, \`rounded\` | \`rounded-xs\`, \`rounded-sm\` |
17212
+ | \`ring\` (3px) | \`ring-3\`. A bare \`ring\` is now 1px. |
17213
+ | \`outline-none\` | \`outline-hidden\`. \`outline-none\` now sets \`outline-style: none\`. |
17214
+ | \`bg-opacity-50\` and the other \`*-opacity-*\` classes | Removed. Use an opacity modifier, such as \`bg-primary/50\`. |
17215
+ | \`bg-[--my-var]\` | \`bg-(--my-var)\` |
17216
+
17217
+ Also check these points:
17218
+
17219
+ - A bare \`border\` draws a \`--color-border\` line (Tailwind v3 drew gray). For another line, name its token, such as \`border-border-subtle\`.
17220
+ - Remove any Tailwind \`<script>\` or \`tailwind.config\` that the page loads itself. The head already loads Tailwind v4.
17221
+ - Do not write any \`@import\` in a \`text/tailwindcss\` block. \`@import "tailwindcss"\` enables Preflight again, and the browser also requests every \`@import\` path as a file next to the page.
17222
+ - Replace the color literals with tokens.
16637
17223
 
16638
17224
  ## Universal baseline
16639
17225
 
16640
- The baseline is \`taste-minimalist-skill\`. Apply it to every page — reports,
16641
- dashboards, tables, audits, public explainers, data-heavy summaries.
17226
+ The design system overrides \`taste-minimalist-skill\`. It replaces every color,
17227
+ font, spacing, and motion value in the taste skill. Use the taste skill only
17228
+ for restraint in layout and copy.
16642
17229
 
16643
- - Use a warm monochrome canvas (\`#FFFFFF\`, \`#FBFBFA\`, \`#F7F6F3\`) with charcoal text and scarce muted accents.
16644
- - Build a clear type hierarchy with premium/system typography; use monospace for code, metadata, and \`<kbd>\`.
16645
- - Use macro-whitespace and strong alignment. Give summaries room to breathe and keep tables compact enough to scan.
16646
- - Prefer flat surfaces: crisp \`1px\` borders, \`8px\` or \`12px\` radii, and practically no shadows.
16647
- - Use restrained motion only where it clarifies hierarchy or interaction state.
17230
+ - Keep the canvas quiet. Let the theme supply the field and the text colors, and use the accent sparingly.
17231
+ - Build a clear type hierarchy with the built-in heading scale. Use \`font-mono\` for IDs, metadata, and machine output. The head already styles \`<code>\`, \`<pre>\`, and \`<kbd>\`.
17232
+ - Use whitespace and strong alignment. Give summaries room to breathe and keep tables compact enough to scan.
17233
+ - Prefer flat surfaces: crisp \`1px\` borders and practically no shadows.
17234
+ - Use motion only where it clarifies hierarchy or interaction state. Do not animate content blocks on scroll.
16648
17235
  - Keep visual language quiet and precise: no AI-purple gradient mesh, centered hero plus three equal cards, decorative glassmorphism, emojis as UI decoration, fake screenshots made from rectangles, oversized decorative art, nested cards, or low-contrast text.
16649
17236
  - Make responsive behavior explicit: readable type, stable grids, horizontal table scroll where needed, and no overlapping content.
16650
17237
 
16651
- If the full minimalist source is available, use \`taste-minimalist-skill\` for the
16652
- deeper primitives: bento grids, status badges, \`<kbd>\` keys, flat tables, code
16653
- blocks, and the detailed anti-slop checks.
17238
+ When the full \`taste-minimalist-skill\` source is available, take only its
17239
+ restraint rules: the banned elements, and plain, specific copy with realistic
17240
+ names and no AI clichés. Ignore its hex colors, font stacks, \`py-24\` and
17241
+ \`py-32\` section padding, scroll animations, background gradients, fake window
17242
+ chrome, and uppercase badges. The tokens, the element defaults, and the
17243
+ \`swarm-*\` primitives replace them.
16654
17244
 
16655
17245
  ## Report density layer
16656
17246
 
@@ -16662,16 +17252,14 @@ information-architecture layer, not a separate aesthetic.
16662
17252
  Every dense page should be useful at a glance.
16663
17253
 
16664
17254
  - Put the page's point in the first viewport: title, one-sentence summary, and 3-6 key numbers or statuses.
16665
- - Use a single-column reading spine with \`max-width: 1120px\`; keep prose measure around 65-75 characters and reserve denser grids for metrics/evidence.
16666
- - Use premium/system typography unless there is a clear brand reason not to: \`"SF Pro Display", "Geist Sans", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif\`.
16667
- - Use the minimalist palette: warm off-white background, charcoal text, white or near-white panels, light borders, one scarce accent, and semantic colors for statuses only.
16668
- - Use a consistent spacing scale: \`8, 12, 16, 24, 32, 48, 72\`.
16669
- - Use clear type hierarchy: page title 36-48px desktop / 30-36px mobile, section titles 22-28px, body 15-16px, supporting text 13-14px.
16670
- - Keep tables readable: sticky/scannable headers where useful, padded cells, zebra-free or very subtle row borders, \`tabular-nums\` for numbers, horizontal scroll on narrow screens.
16671
- - Prefer flat bordered cards only for repeated records or metrics. Do not nest cards inside cards.
16672
- - Use shadows only when they solve a hierarchy problem; keep them ultra-diffuse and below \`0.05\` opacity.
17255
+ - Use a single-column reading spine (\`.swarm-page\`, 1120px wide). Keep prose near 65-75 characters, and use denser grids only for metrics and evidence.
17256
+ - For your own gaps and margins, use the steps that the primitives use most: 4, 8, 12, 16, 24, and 40px. In Tailwind units, that is \`1\`, \`2\`, \`3\`, \`4\`, \`6\`, and \`10\` (for example, \`gap-4\` is 16px).
17257
+ - Keep the built-in type scale (\`h1\` about 30px, body 15px). Do not inflate titles to hero sizes.
17258
+ - Keep tables readable: \`.swarm-table\` gives a sticky header, padded cells, subtle row rules, and tabular numbers. Put it in a \`.swarm-table-wrap\` so it scrolls on narrow screens.
17259
+ - Prefer flat bordered panels only for repeated records or metrics. Do not nest panels inside panels. A \`.swarm-table-wrap\` is already a panel.
17260
+ - Use a shadow only when it solves a hierarchy problem, and keep it barely visible (\`shadow-xs\`).
16673
17261
  - Hide raw JSON behind a collapsed \`<details>\` block at the bottom.
16674
- - Make mobile explicit with media queries: single-column grids, reduced padding, no overflow except intentional table scroll.
17262
+ - Mobile is built in. At 640px wide and below, the body padding drops to 16px. The metric grid and the toolbar wrap, and a toolbar search input grows to fill its row. Add responsive variants only for your own grids, such as \`grid-cols-1 md:grid-cols-2\`. Allow no overflow except table scroll.
16675
17263
 
16676
17264
  ## Content structure
16677
17265
 
@@ -16699,9 +17287,9 @@ Before publishing:
16699
17287
  - Raw JSON/logs are collapsed or linked, not the primary experience.
16700
17288
  - No nested cards, decorative gradients, oversized art, or cramped default browser styles.
16701
17289
  - No text overlaps, clipped buttons, or unreadable low-contrast text.
16702
-
16703
- Pages inject a default body theme. For a custom palette, set \`body\` background, color, font, and padding explicitly in your stylesheet.
16704
- Setting these properties only on \`:root\` does not replace the injected body styles.
17290
+ - No color literals. Every color comes from a token, in CSS or as a Tailwind class.
17291
+ - The page reads well in light and dark mode. Add \`?__swarm-mode=dark\` to the \`api_url\` to check.
17292
+ - The \`create_page\` result shows \`designVersion: 2\`. If it shows 1, publish again with \`designVersion: 2\`.
16705
17293
 
16706
17294
  ---
16707
17295
 
@@ -16729,7 +17317,7 @@ To check a page by screenshot, open it from an origin its data endpoints allow.
16729
17317
  | URL | Shape | Notes |
16730
17318
  |---|---|---|
16731
17319
  | \`app_url\` | \`\${APP_URL}/pages/:id\` | SPA route. Renders HTML in a sandboxed iframe, JSON via \`@json-render/react\`. Default share target. |
16732
- | \`app_url\` (full mode) | \`\${APP_URL}/pages/:id?mode=full\` | Same SPA route, maximized — hides the SPA sidebar/header so the page body gets the full viewport. Slim header with title + Exit-Full button. Useful for embeds + standalone dashboards. |
17320
+ | \`app_url\` (full mode) | \`\${APP_URL}/pages/:id?mode=full\` | Same SPA route, maximized: hides the SPA sidebar/header so the page body gets the full viewport. Slim header with title + Exit-Full button. Useful for embeds + standalone dashboards. |
16733
17321
  | \`api_url\` | \`\${MCP_BASE_URL}/p/:id\` | Direct API render. HTML inlines and serves; JSON 302-redirects to \`app_url\`. Useful for no-SPA-required links. |
16734
17322
 
16735
17323
  \`\${APP_URL}\` is the deployment's SPA origin and \`\${MCP_BASE_URL}\` is its API
@@ -16748,14 +17336,14 @@ where the unfurl preview only follows the API origin).
16748
17336
  Every overwrite (update via \`update_page\` or \`PUT /api/pages/:id\`) snapshots
16749
17337
  the **pre-update** state into \`page_versions\` and writes the new state to the
16750
17338
  parent row. The wire \`version\` field is a monotonically-increasing
16751
- "edit counter" — version 1 is the initial create.
17339
+ "edit counter": version 1 is the initial create.
16752
17340
 
16753
17341
  | Operation | Endpoint | Returns |
16754
17342
  |---|---|---|
16755
17343
  | List versions | \`GET /api/pages/:id/versions\` | \`{ versions: PageVersion[] }\` newest first |
16756
17344
  | Read a version | \`GET /api/pages/:id/versions/:version\` | Single snapshot |
16757
17345
 
16758
- Snapshots are full body copies — keep this in mind for large pages (the
17346
+ Snapshots are full body copies. Keep this in mind for large pages (the
16759
17347
  per-version body cap is 5 MiB).
16760
17348
 
16761
17349
  ## Browser SDK
@@ -16763,8 +17351,8 @@ per-version body cap is 5 MiB).
16763
17351
  Every HTML page automatically gets \`window.SwarmSDK\` (the class) and
16764
17352
  \`window.swarmSdk\` (a ready-to-use singleton) injected. The SDK routes through
16765
17353
  the \`/@swarm/api/*\` proxy, which resolves the page's own \`page_session_<id>\`
16766
- cookie to a user identity and forwards with proper auth headers server-side —
16767
- your page never sees or handles tokens. The SDK names its page in the
17354
+ cookie to a user identity and forwards with proper auth headers server-side.
17355
+ Your page never sees or handles tokens. The SDK names its page in the
16768
17356
  \`X-Swarm-Page-Id\` header. A direct \`fetch('/@swarm/api/...')\` from the page
16769
17357
  works too: the proxy falls back to the \`/p/<id>\` Referer.
16770
17358
 
@@ -16788,7 +17376,7 @@ Inline usage:
16788
17376
 
16789
17377
  \`\`\`html
16790
17378
  <script>
16791
- // Singleton is ready immediately — no \`new SwarmSDK()\` needed.
17379
+ // Singleton is ready immediately: no \`new SwarmSDK()\` needed.
16792
17380
  const tasks = await window.swarmSdk.tasks.list({ status: 'in_progress' });
16793
17381
  const agents = await window.swarmSdk.agents.list();
16794
17382
 
@@ -16812,7 +17400,7 @@ HTTP status.
16812
17400
 
16813
17401
  ### Full signature
16814
17402
 
16815
- This is the entire surface — copy it into your page if you want autocomplete
17403
+ This is the entire surface. Copy it into your page if you want autocomplete
16816
17404
  hints in an editor. The runtime version is auto-injected; you don't need to
16817
17405
  include this in the page source.
16818
17406
 
@@ -16872,21 +17460,25 @@ class SwarmSDK {
16872
17460
 
16873
17461
  For the full list of fields each endpoint accepts/returns, see
16874
17462
  [**docs.agent-swarm.dev/docs/api-reference**](https://docs.agent-swarm.dev/docs/api-reference).
16875
- The SDK is a thin domain wrapper — anything documented there is reachable.
17463
+ The SDK is a thin domain wrapper: anything documented there is reachable.
16876
17464
 
16877
- ## Built-in primitives
17465
+ ## Built-in web components
16878
17466
 
16879
- Every HTML page automatically gets a small set of zero-dep web components
16880
- auto-injected alongside the Browser SDK. Drop them into your page body —
16881
- no \`<script>\` import, no bundling, no Tailwind required (though Tailwind
16882
- Play CDN is loaded, so utility classes work too).
17467
+ Every HTML page automatically gets a small set of zero-dep web components,
17468
+ injected beside the Browser SDK. Put them in your page body. They need no
17469
+ \`<script>\` import, no bundling, and no Tailwind. Tailwind utilities also work:
17470
+ Tailwind v4 on designVersion 2 pages, and the Tailwind v3 Play CDN on
17471
+ designVersion 1 pages. For the \`swarm-*\` CSS classes, see Primitives under
17472
+ Design system.
16883
17473
 
16884
- ### \`<swarm-diff>\` — unified diff renderer
17474
+ ### \`<swarm-diff>\`: unified diff renderer
16885
17475
 
16886
17476
  Render a unified diff with a two-column gutter, severity annotations, and a
16887
17477
  deterministic anchor id per hunk (so deep-linking + jump lists work). The
16888
17478
  element reads its payload from its \`textContent\` as JSON of shape
16889
17479
  \`{ hunks: [{ old_start, old_lines, new_start, new_lines, lines, annotations? }] }\`.
17480
+ On designVersion 2 pages, its colors and fonts follow the theme tokens and dark
17481
+ mode.
16890
17482
 
16891
17483
  \`\`\`html
16892
17484
  <swarm-diff
@@ -16894,21 +17486,25 @@ element reads its payload from its \`textContent\` as JSON of shape
16894
17486
  base-sha="abc123"
16895
17487
  head-sha="def456">
16896
17488
  { "hunks": [
16897
- { "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
17489
+ { "old_start": 10, "old_lines": 2, "new_start": 10, "new_lines": 3,
16898
17490
  "lines": [
16899
- { "type": "context", "text": " const x = 1;" },
16900
- { "type": "del", "text": "- console.log(x);" },
16901
- { "type": "add", "text": "+ logger.info({ x });" },
16902
- { "type": "add", "text": "+ return x;" }
17491
+ { "type": "context", "text": "export function load(id) {" },
17492
+ { "type": "del", "text": " console.log(id);" },
17493
+ { "type": "add", "text": " logger.info({ id });" },
17494
+ { "type": "add", "text": " return cache.get(id);" }
16903
17495
  ],
16904
17496
  "annotations": [
16905
- { "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
17497
+ { "line": 12, "severity": "warn", "text": "Returns undefined on a cache miss" }
16906
17498
  ]
16907
17499
  }
16908
17500
  ] }
16909
17501
  </swarm-diff>
16910
17502
  \`\`\`
16911
17503
 
17504
+ The browser reads the JSON as HTML text first. Inside the JSON, write \`<\` as
17505
+ \`\\u003c\` and \`&\` as \`\\u0026\`. Otherwise the browser turns
17506
+ \`Promise<void>\` into a tag, and \`&amp;\` into \`&\`.
17507
+
16912
17508
  **Inputs**
16913
17509
 
16914
17510
  | Attribute | Required | Notes |
@@ -16917,28 +17513,47 @@ element reads its payload from its \`textContent\` as JSON of shape
16917
17513
  | \`base-sha\` | no | Pre-change SHA. Rendered in the header next to \`head-sha\`. |
16918
17514
  | \`head-sha\` | no | Post-change SHA. |
16919
17515
 
17516
+ **Hunk shape**
17517
+
17518
+ | Field | Notes |
17519
+ |---|---|
17520
+ | \`old_start\`, \`new_start\` | The first line number on the old side and on the new side. |
17521
+ | \`old_lines\` | The number of context and deleted lines in the hunk. |
17522
+ | \`new_lines\` | The number of context and added lines in the hunk. |
17523
+
17524
+ The hunk header shows \`@@ -old_start,old_lines +new_start,new_lines @@\`. The
17525
+ component prints the counts that you send. It does not check them.
17526
+
16920
17527
  **Line shape**
16921
17528
 
16922
17529
  | Field | Values | Notes |
16923
17530
  |---|---|---|
16924
17531
  | \`type\` | \`context\` \\| \`add\` \\| \`del\` | Drives row tint (green / red / neutral) and gutter line numbering. |
16925
- | \`text\` | string | Rendered verbatim (HTML-escaped). |
17532
+ | \`text\` | string | The line without its \`+\`, \`-\`, or space marker. The component draws the marker in its own column. Keep the indentation. The text is HTML-escaped. |
16926
17533
 
16927
- **Annotation shape** — attaches to a NEW-side line by line number; rendered
16928
- as a margin badge on that row.
17534
+ **Annotation shape**: a margin badge on one row.
16929
17535
 
16930
17536
  | Field | Values | Notes |
16931
17537
  |---|---|---|
16932
- | \`line\` | integer | New-side line number (falls back to old-side if no add for that line). |
17538
+ | \`line\` | integer | The new-side line number of an added or context row. For a deleted row, use its old-side number. If a deleted row and another row have that number, the badge shows on both. |
16933
17539
  | \`severity\` | \`error\` \\| \`warn\` \\| \`info\` | Drives badge color. |
16934
17540
  | \`text\` | string | Badge body. |
16935
17541
 
16936
17542
  **Anchor id**
16937
17543
 
16938
- Each hunk gets \`id="swarm-diff-<file-slug>-<old_start>"\` for deep-linking.
16939
- Use \`<swarm-diff-jumps></swarm-diff-jumps>\` anywhere in the page body to
16940
- render a tiny "Jump to" navigation of every diff hunk on the page —
16941
- handy when an agent ships a multi-file annotated PR.
17544
+ Each hunk gets an anchor with \`id="swarm-diff-<slug>"\`. The slug is
17545
+ \`<file>-<old_start>\` in lowercase. Each run of characters other than \`a-z\` and
17546
+ \`0-9\` becomes one \`-\`, and a \`-\` at the start or end is removed. For example,
17547
+ \`file="src/foo.ts"\` with \`old_start: 10\` gives \`id="swarm-diff-src-foo-ts-10"\`.
17548
+ Without \`file\`, the slug starts with \`hunk\`. When \`old_start\` is 0 or missing,
17549
+ the slug ends with the hunk's position (1 for the first hunk). Two hunks with
17550
+ the same file and \`old_start\` get the same id, so keep them unique.
17551
+
17552
+ Link to a hunk with \`href="#swarm-diff-src-foo-ts-10"\`. On a designVersion 1
17553
+ page, add \`target="_self"\` to the link, or it opens in a new tab. Use
17554
+ \`<swarm-diff-jumps></swarm-diff-jumps>\` anywhere in the page body to render a
17555
+ tiny "Jump to" navigation of every diff hunk on the page. It is handy when an
17556
+ agent ships a multi-file annotated PR.
16942
17557
 
16943
17558
  **Programmatic form**
16944
17559
 
@@ -16958,21 +17573,26 @@ A full annotated-PR page using \`<swarm-diff>\` is bundled at
16958
17573
 
16959
17574
  ## Print / PDF export
16960
17575
 
16961
- Every HTML page also gets a \`@media print\` rule baked into the head defaults:
16962
- - Light theme on print (white background, black text, underlined black links).
16963
- - Anything with the \`.no-print\` class is hidden (annotation badges and the
16964
- jump list already carry this class — use it on agent-emitted chrome you
16965
- want suppressed in PDF exports).
16966
- - \`.swarm-card\` and \`<swarm-diff>\` get \`break-inside: avoid\` so they don't
16967
- split mid-element across pages.
16968
-
16969
- Trigger the export from the SPA's "Export PDF" button on \`/pages/:id\` — it
17576
+ Every HTML page also gets \`@media print\` rules in its injected head. Print is
17577
+ always light:
17578
+ - A designVersion 2 page prints with its light tokens, also when the viewer
17579
+ runs dark mode.
17580
+ - The page prints on a white background, with black text and underlined
17581
+ links.
17582
+ - Anything with the \`.no-print\` class is hidden. Annotation badges and the
17583
+ jump list already carry this class. Add it to page chrome that you want out
17584
+ of the PDF.
17585
+ - \`.swarm-panel\`, \`.swarm-card\`, \`.swarm-metric\`, and \`<swarm-diff>\` get
17586
+ \`break-inside: avoid\`, so they do not split across pages. (On designVersion
17587
+ 1 pages, only \`.swarm-card\` and \`<swarm-diff>\` get it.)
17588
+
17589
+ Trigger the export from the SPA's "Export PDF" button on \`/pages/:id\`. It
16970
17590
  opens the iframe's native print dialog (HTML pages) or the SPA's print
16971
- dialog (JSON pages). The browser's "Print → Save as PDF" handles the actual
16972
- file. No headless Chromium, no server-side rendering — zero infra weight.
17591
+ dialog (JSON pages). The browser's "Print → Save as PDF" makes the actual
17592
+ file. No headless Chromium, no server-side rendering, zero infra weight.
16973
17593
 
16974
- > Want a custom print layout? Override the print styles in your page's own
16975
- > \`<style>\` block — agent CSS always wins over the head defaults.
17594
+ > Want a custom print layout? Add \`@media print\` rules to your page's own
17595
+ > \`<style>\` block.
16976
17596
 
16977
17597
  ## Viewer feedback
16978
17598
 
@@ -17007,7 +17627,7 @@ redirecting to the SPA), \`401\`/\`403\` (auth gate), and \`404\` do NOT bump.
17007
17627
  The count is exposed on \`GET /api/pages\` listing (\`viewCount\` field) and the
17008
17628
  SPA \`/pages\` index renders it as a small eye-count badge per row.
17009
17629
 
17010
- No per-viewer dedup — this is a coarse popularity signal, not analytics.
17630
+ No per-viewer dedup: this is a coarse popularity signal, not analytics.
17011
17631
  Bumps are best-effort (wrapped in try/catch so a counter write never fails
17012
17632
  the response).
17013
17633
 
@@ -17048,7 +17668,7 @@ See the \`@json-render/core\` docs for the supported node types (\`text\`,
17048
17668
  \`passwordHash\`) and the page's own KV. Every other route returns 403, and every RBAC verb denies a guest.
17049
17669
  - Browser SDK memory operations use the page owner's agent scope through verified page execution context.
17050
17670
  Viewer authentication, permissions, and audit identity remain separate from this resource scope.
17051
- - Treat agent-generated HTML / JSON like trusted code — the agent already
17671
+ - Treat agent-generated HTML / JSON like trusted code: the agent already
17052
17672
  has equivalent MCP access, so a malicious page is no worse than a
17053
17673
  malicious tool call. But: don't ship pages to **external** users (via
17054
17674
  \`password\`) without reviewing the body first.
@@ -17058,14 +17678,14 @@ See the \`@json-render/core\` docs for the supported node types (\`text\`,
17058
17678
  page still has the SwarmSDK access of its session: full for user and
17059
17679
  operator sessions, page record and KV only for password guests.
17060
17680
  - All page bodies pass through \`scrubSecrets\` at the egress boundary
17061
- (\`/p/:id\`, \`/p/:id.json\`, listing endpoint) — accidental secrets in
17681
+ (\`/p/:id\`, \`/p/:id.json\`, listing endpoint). Accidental secrets in
17062
17682
  the body get masked at serve time, not at write time. Don't rely on
17063
- scrubbing as a security boundary — keep secrets out of bodies.
17683
+ scrubbing as a security boundary. Keep secrets out of bodies.
17064
17684
 
17065
17685
  ## Limits
17066
17686
 
17067
17687
  - **Body size**: 5 MiB per version (HTML or JSON). Bumping requires careful
17068
- thought about SQLite write-amplification — full bodies are snapshotted on
17688
+ thought about SQLite write-amplification: full bodies are snapshotted on
17069
17689
  every update.
17070
17690
  - **TTL**: none. Pages persist until explicitly deleted via \`DELETE
17071
17691
  /api/pages/:id\` (or the SPA listing UI when it gains a delete affordance).
@@ -17087,11 +17707,11 @@ human-facing summary.
17087
17707
 
17088
17708
  ## See Also
17089
17709
 
17090
- - The **\`apps\`** skill — schema-backed records, named queries, and actions.
17091
- - The **\`agent-fs\`** skill — durable file storage and share links.
17092
- - The **\`kv-storage\`** skill — page-scoped state via \`swarmSdk.kv\`.
17093
- - \`runbooks/secret-scrubbing.md\` — egress scrubbing details.
17094
- - SPA listing: \`\${APP_URL}/pages\` — the same \`APP_URL\` used for share links above.
17710
+ - The **\`apps\`** skill: schema-backed records, named queries, and actions.
17711
+ - The **\`agent-fs\`** skill: durable file storage and share links.
17712
+ - The **\`kv-storage\`** skill: page-scoped state via \`swarmSdk.kv\`.
17713
+ - \`runbooks/secret-scrubbing.md\`: egress scrubbing details.
17714
+ - SPA listing: \`\${APP_URL}/pages\`, the same \`APP_URL\` used for share links above.
17095
17715
  Prefer the \`app_url\` that \`create_page\` returns; build URLs by hand only when
17096
17716
  you have no page id.
17097
17717
 
@@ -17158,7 +17778,7 @@ Array insertion takes \`path\`, \`index\`, and \`values\`. Text editing takes \`
17158
17778
  Presence updates default to one transmission per 50 milliseconds. Presence never enters the persisted document.
17159
17779
  Use \`await room.close()\` when the page no longer needs a room.
17160
17780
 
17161
- ### \`<swarm-cursors>\` — shared page pointers
17781
+ ### \`<swarm-cursors>\`: shared page pointers
17162
17782
 
17163
17783
  Add this element to opt a page into named cursors for an existing room:
17164
17784
 
@@ -18703,7 +19323,7 @@ var config_default41 = `{
18703
19323
  `;
18704
19324
 
18705
19325
  // templates/skills/script-workflows/content.md
18706
- var content_default41 = "Use this skill when a user asks to launch, monitor, inspect, or debug a durable script workflow run. This is for the Script Workflows v1 runtime: one-off TypeScript workflow source with journaled `swarm-script`, `raw-llm`, and `agent-task` steps.\n\n## Tool Flow\n\nLoad the script workflow tools with ToolSearch when they are not already visible:\n\n```text\nlaunch-script-run\nget-script-run\nlist-script-runs\n```\n\nUse `launch-script-run` to start a one-off run. It calls the same `/api/script-runs` API as the dashboard, preserves the invoking agent identity, and starts the run in the background.\n\nUse `get-script-run` to read terminal status and journal entries. Poll it when needed, but keep polling bounded and report progress for long runs.\n\nUse `list-script-runs` to find recent runs or filter by `status` / `agentId`.\n\nDo not hand-roll raw HTTP for this flow unless the tool itself is broken and you are explicitly debugging the API. The tool handles auth and `X-Agent-ID` like the existing inline `script-run` tool family.\n\n## Source Shape\n\nAuthor TypeScript workflow source as a default export. The runtime provides `args` and `ctx`.\n\n```ts\nexport default async function main(args, ctx) {\n const lookup = await ctx.step.swarmScript(\"lookup-data\", {\n scriptName: \"fetch-readable\",\n args: { url: args.url },\n });\n\n const summary = await ctx.step.rawLlm(\"summarize\", {\n prompt: `Summarize this for an operator:\\n${JSON.stringify(lookup)}`,\n });\n\n const task = await ctx.step.agentTask(\"operator-review\", {\n task: `Review this summary and flag risks:\\n${JSON.stringify(summary)}`,\n tags: [\"script-run\"],\n priority: 50,\n });\n\n return { lookup, summary, task };\n}\n```\n\n`ctx.step.agentTask` blocks until the dispatched task reaches a terminal status (default `waitForCompletion: true`, default `timeoutMs` 2h) and journals its real output — a sequential `plan → implement → review` chain will not fan out. Pass `waitForCompletion: false` for the legacy fire-and-poll-yourself shape, or `failOnTaskFailure: false` to receive `{taskId, status: \"failed\", error}` instead of a throw when the task fails/is cancelled.\n\n## Label Rules\n\nStep labels are durability keys. They must be stable and unique for each logical step. Do not reuse the same literal label inside a loop; launch will fail with `label_lint_violation`.\n\nFor looped work, include an item identifier in the label:\n\n```ts\nfor (const item of args.items) {\n await ctx.step.agentTask(`review-${item.id}`, { task: item.prompt });\n}\n```\n\n## Statuses\n\nTerminal statuses to surface clearly:\n\n- `completed` — run finished and `output` may be present.\n- `failed` — run ended with `error`.\n- `cancelled` — run was cancelled before completion.\n- `aborted_limit` — runtime guardrail stopped the run, usually step count, agent-task count, or wall-clock cap.\n- `label_lint_violation` — launch-time rejection, not a persisted run status.\n\nWhen a run is not terminal, report the current status, journal count, and latest heartbeat if present.\n";
19326
+ var content_default41 = "Use this skill when a user asks to launch, monitor, inspect, or debug a durable script workflow run. This is for the Script Workflows v1 runtime: one-off TypeScript workflow source with journaled `swarm-script`, `raw-llm`, and `agent-task` steps.\n\n## Tool Flow\n\nLoad the script workflow tools with ToolSearch when they are not already visible:\n\n```text\nlaunch-script-run\nget-script-run\nlist-script-runs\n```\n\nUse `launch-script-run` to start a one-off run. It calls the same `/api/script-runs` API as the dashboard, preserves the invoking agent identity, and starts the run in the background.\n\nUse `get-script-run` to read terminal status and journal entries. Poll it when needed, but keep polling bounded and report progress for long runs.\n\nUse `list-script-runs` to find recent runs or filter by `status` / `agentId`.\n\nDo not hand-roll raw HTTP for this flow unless the tool itself is broken and you are explicitly debugging the API. The tool handles auth and `X-Agent-ID` like the existing inline `script-run` tool family.\n\n## Source Shape\n\nAuthor TypeScript workflow source as a default export. The runtime provides `args` and `ctx`.\n\n```ts\nexport default async function main(args, ctx) {\n const lookup = await ctx.step.swarmScript(\"lookup-data\", {\n scriptName: \"fetch-readable\",\n args: { url: args.url },\n });\n\n const summary = await ctx.step.rawLlm(\"summarize\", {\n prompt: `Summarize this for an operator:\\n${JSON.stringify(lookup)}`,\n });\n\n const task = await ctx.step.agentTask(\"operator-review\", {\n task: `Review this summary and flag risks:\\n${JSON.stringify(summary)}`,\n tags: [\"script-run\"],\n priority: 50,\n });\n\n return { lookup, summary, task };\n}\n```\n\n`ctx.step.agentTask` blocks until the dispatched task reaches a terminal status (default `waitForCompletion: true`, default `timeoutMs` 2h) and journals its real output — a sequential `plan → implement → review` chain will not fan out. Pass `failOnTaskFailure: false` to receive `{taskId, status: \"failed\", error}` instead of a throw when the task fails/is cancelled.\n\nA run launched from a `follow-up` task in a Slack thread passes the same re-delegation guard as `send-task`, decided once for the whole run at its first new step: a step that repeats completed thread work, or a run past the thread's dispatch cap (a run counts once), throws with the rule's message, and every later new step of that run throws too. Pass `allowRedelegation: true` on a step only for a deliberate re-run. Do not wrap a single dispatch in a durable run; use `send-task`.\n\n`waitForCompletion: false` does not return right after dispatch. The first call still waits up to ~30s while the server polls the task, then returns one of three shapes:\n\n- The task finished in that window: its output string or `null`, as on success.\n- The task failed or was cancelled in that window: a throw, or `{taskId, status: \"failed\", error}` with `failOnTaskFailure: false`.\n- The task is still running: `{taskId, status}`.\n\nOn success, `ctx.step.agentTask` resolves to the worker's output string (JSON text under `outputSchema`, so parse it yourself; `null` when the task stored none), not a task record. On a string, `step.taskId` and `step.status` are undefined, so a guard such as `step.status !== \"completed\"` or `step?.taskId ?? \"unknown\"` silently misfires. On `null`, `step.taskId` throws a TypeError. Only `failOnTaskFailure: false` with a failed or cancelled task returns `{taskId, status: \"failed\", error}`, and only `waitForCompletion: false` on a task still running returns `{taskId, status}`. To get a step's task id, query by its context key (the label is the step label you passed):\n\n```sql\nSELECT id, status, output FROM agent_tasks\nWHERE contextKey = 'script-run:<runId>:<label>' AND taskType = 'script-run-step'\nORDER BY createdAt DESC, rowid DESC LIMIT 1\n```\n\nInside the run, `<runId>` is `ctx.run.id`.\n\n## Label Rules\n\nStep labels are durability keys. They must be stable and unique for each logical step. Do not reuse the same literal label inside a loop; launch will fail with `label_lint_violation`.\n\nFor looped work, include an item identifier in the label:\n\n```ts\nfor (const item of args.items) {\n await ctx.step.agentTask(`review-${item.id}`, { task: item.prompt });\n}\n```\n\n## Statuses\n\nTerminal statuses to surface clearly:\n\n- `completed` — run finished and `output` may be present.\n- `failed` — run ended with `error`.\n- `cancelled` — run was cancelled before completion.\n- `aborted_limit` — runtime guardrail stopped the run, usually step count, agent-task count, or wall-clock cap.\n- `label_lint_violation` — launch-time rejection, not a persisted run status.\n\nWhen a run is not terminal, report the current status, journal count, and latest heartbeat if present.\n";
18707
19327
 
18708
19328
  // templates/skills/slack-interaction/config.json
18709
19329
  var config_default42 = '{\n "kind": "skill",\n "name": "slack-interaction",\n "displayName": "Slack Interaction",\n "slug": "slack-interaction",\n "title": "Slack Interaction",\n "description": "Slack: reply, post, read, upload, and the thread rules. What the engine already posts, the one-message rule, `slack-reply`, `slack-post`, `slack-start-thread`, `slack-read`, `slack-upload-file`, `slack-download-file`, `slack-list-channels`, `slack-update`, `slack-delete`, unknown-user registration with `manage-user`, and Slack standing orders. Use before any Slack message.",\n "version": "1.0.0",\n "category": "skills",\n "placeholders": [],\n "runAllSeedersCandidate": true,\n "systemDefault": true,\n "tags": ["slack", "communication"]\n}\n';
@@ -18846,6 +19466,7 @@ Fetch the page as markdown: \`curl -fsSL https://docs.agent-swarm.dev/llms.mdx/d
18846
19466
  - [One-off Script Workflow Runs](https://docs.agent-swarm.dev/docs/guides/script-workflow-runs). Reference.
18847
19467
  - [Dreaming](https://docs.agent-swarm.dev/docs/guides/dreaming). When: Someone asks what the nightly dreaming run changed, how to configure or turn it off, or how to undo one of its changes.
18848
19468
  - [Claude Bridge](https://docs.agent-swarm.dev/docs/guides/claude-bridge-experimental). Reference.
19469
+ - [Reading claude.ai Artifacts headless](https://docs.agent-swarm.dev/docs/guides/claude-artifacts-read). When: Someone wants an agent to read a claude.ai Artifact link, such as a design and its canvas.json, from a headless session.
18849
19470
  - [Worker Credential Recovery](https://docs.agent-swarm.dev/docs/guides/worker-credential-recovery). Reference.
18850
19471
  - [Model Gateways](https://docs.agent-swarm.dev/docs/guides/provider-auth/model-gateways). Reference.
18851
19472
  - [Codex OAuth](https://docs.agent-swarm.dev/docs/guides/provider-auth/codex-oauth). Reference.
@@ -19125,6 +19746,8 @@ Named script only when the logic will run two or more times, by you, another age
19125
19746
 
19126
19747
  Reference point: a workflow triage that took about 26 tool calls returns as one result of about 4k tokens in about 13 seconds.
19127
19748
 
19749
+ Pick the run mode before you author. A short inline \`script-run\` is for small lookups only. A job that scans a whole population or may run past the inline cap of about 30 s starts as a durable \`launch-script-run\` from the start. A single \`ctx.step.swarmScript\` step has the same cap, so collect in bounded steps: each step takes a cursor, collects one slice in well under 30 s, and returns its rows plus the next cursor. Label each step with its slice (\`collect-\${page}\`) so the journal checkpoints it, then compute from the collected slices in a final step. Restarting a long inline run throws away the work it already did.
19750
+
19128
19751
  ## Loading the tools
19129
19752
 
19130
19753
  The script tools are deferred. Load them with your harness tool search before the first call: \`script-search\`, \`script-run\`, \`script-upsert\`, \`script-query-types\`, \`script-delete\`, and for durable runs \`launch-script-run\`, \`get-script-run\`, \`list-script-runs\`.
@@ -21063,32 +21686,41 @@ promoted_to: null
21063
21686
 
21064
21687
  No <script> import and no bundling: <swarm-diff>, <swarm-diff-jumps> and
21065
21688
  window.swarmUi.renderDiff are injected into every HTML page automatically.
21066
- Each hunk gets id="swarm-diff-<file-slug>-<old_start>" so <swarm-diff-jumps>
21067
- can build the jump list and external links can deep-link a specific hunk.
21689
+
21690
+ Each "text" is the code line WITHOUT its +, - or space marker: the
21691
+ component draws the marker in its own column. old_lines counts the context
21692
+ and deleted lines, and new_lines counts the context and added lines. Inside
21693
+ the JSON, write < as \\u003c and & as \\u0026.
21694
+
21695
+ Each hunk gets id="swarm-diff-<slug>". The slug is <file>-<old_start> in
21696
+ lowercase, with each run of characters other than a-z and 0-9 changed to
21697
+ one "-". So src/foo.ts at old_start 10 gets id="swarm-diff-src-foo-ts-10".
21698
+ <swarm-diff-jumps> builds its jump list from these ids, and external links
21699
+ can deep-link a specific hunk.
21068
21700
  -->
21069
21701
  <html lang="en">
21070
21702
  <head>
21071
21703
  <meta charset="utf-8">
21072
21704
  <meta name="viewport" content="width=device-width, initial-scale=1">
21073
- <title>PR #1234 — console.log cleanup</title>
21705
+ <title>PR #1234: console.log cleanup</title>
21074
21706
  </head>
21075
21707
  <body>
21076
- <h1>PR #1234 — cleanup raw <code>console.log</code> calls</h1>
21708
+ <h1>PR #1234: cleanup raw <code>console.log</code> calls</h1>
21077
21709
  <p>Replaces ad-hoc logging with the project logger.</p>
21078
21710
 
21079
21711
  <swarm-diff-jumps></swarm-diff-jumps>
21080
21712
 
21081
21713
  <swarm-diff file="src/foo.ts" base-sha="abc123" head-sha="def456">
21082
21714
  { "hunks": [
21083
- { "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
21715
+ { "old_start": 10, "old_lines": 2, "new_start": 10, "new_lines": 3,
21084
21716
  "lines": [
21085
- { "type": "context", "text": " const x = 1;" },
21086
- { "type": "del", "text": "- console.log(x);" },
21087
- { "type": "add", "text": "+ logger.info({ x });" },
21088
- { "type": "add", "text": "+ return x;" }
21717
+ { "type": "context", "text": "export function load(id) {" },
21718
+ { "type": "del", "text": " console.log(id);" },
21719
+ { "type": "add", "text": " logger.info({ id });" },
21720
+ { "type": "add", "text": " return cache.get(id);" }
21089
21721
  ],
21090
21722
  "annotations": [
21091
- { "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
21723
+ { "line": 12, "severity": "warn", "text": "Returns undefined on a cache miss" }
21092
21724
  ]
21093
21725
  }
21094
21726
  ] }
@@ -21098,8 +21730,8 @@ promoted_to: null
21098
21730
  { "hunks": [
21099
21731
  { "old_start": 5, "old_lines": 1, "new_start": 5, "new_lines": 1,
21100
21732
  "lines": [
21101
- { "type": "del", "text": "- console.error('boom');" },
21102
- { "type": "add", "text": "+ logger.error('boom');" }
21733
+ { "type": "del", "text": "console.error('boom');" },
21734
+ { "type": "add", "text": "logger.error('boom');" }
21103
21735
  ]
21104
21736
  }
21105
21737
  ] }
@@ -21118,9 +21750,9 @@ promoted_to: null
21118
21750
  <title>Shared board</title>
21119
21751
  <style>
21120
21752
  :root {
21121
- color: #252522;
21122
- background: #f7f6f3;
21123
- font-family: "SF Pro Display", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif;
21753
+ color: var(--color-foreground);
21754
+ background: var(--color-background);
21755
+ font-family: var(--font-sans);
21124
21756
  font-synthesis: none;
21125
21757
  }
21126
21758
 
@@ -21130,24 +21762,24 @@ promoted_to: null
21130
21762
  margin: 0;
21131
21763
  min-width: 320px;
21132
21764
  padding: 0;
21133
- background: #f7f6f3;
21134
- color: #252522;
21135
- font-family: "SF Pro Display", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif;
21765
+ background: var(--color-background);
21766
+ color: var(--color-foreground);
21767
+ font-family: var(--font-sans);
21136
21768
  }
21137
21769
 
21138
21770
  button, input, textarea { font: inherit; }
21139
21771
 
21140
21772
  button {
21141
- border: 1px solid #252522;
21142
- border-radius: 8px;
21143
- background: #252522;
21144
- color: #fff;
21773
+ border: 1px solid var(--color-primary);
21774
+ border-radius: var(--radius-md);
21775
+ background: var(--color-primary);
21776
+ color: var(--color-primary-foreground);
21145
21777
  cursor: pointer;
21146
21778
  padding: 10px 14px;
21147
21779
  }
21148
21780
 
21149
- button:hover { background: #3c3c37; }
21150
- button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #e1a52c; outline-offset: 2px; }
21781
+ button:hover { background: color-mix(in oklab, var(--color-primary) 90%, transparent); }
21782
+ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid var(--color-ring); outline-offset: 2px; }
21151
21783
  button:disabled { cursor: not-allowed; opacity: .5; }
21152
21784
 
21153
21785
  .page { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }
@@ -21157,26 +21789,26 @@ promoted_to: null
21157
21789
  align-items: flex-start;
21158
21790
  justify-content: space-between;
21159
21791
  gap: 24px;
21160
- border-bottom: 1px solid #d9d7d0;
21792
+ border-bottom: 1px solid var(--color-border);
21161
21793
  padding-bottom: 24px;
21162
21794
  }
21163
21795
 
21164
21796
  h1, h2, p { margin-top: 0; }
21165
21797
  h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin-bottom: 8px; }
21166
21798
  h2 { font-size: 1.15rem; letter-spacing: -.02em; margin-bottom: 16px; }
21167
- .lede, .muted { color: #67665f; }
21799
+ .lede, .muted { color: var(--color-muted-foreground); }
21168
21800
  .lede { max-width: 46rem; margin-bottom: 0; }
21169
21801
 
21170
21802
  .viewer {
21171
21803
  min-width: 170px;
21172
- border: 1px solid #d9d7d0;
21173
- border-radius: 8px;
21174
- background: #fff;
21804
+ border: 1px solid var(--color-border);
21805
+ border-radius: var(--radius-md);
21806
+ background: var(--color-card);
21175
21807
  padding: 12px;
21176
21808
  }
21177
21809
 
21178
21810
  .eyebrow {
21179
- color: #67665f;
21811
+ color: var(--color-muted-foreground);
21180
21812
  display: block;
21181
21813
  font-size: .72rem;
21182
21814
  font-weight: 700;
@@ -21188,28 +21820,28 @@ promoted_to: null
21188
21820
  .viewer-name { font-weight: 700; overflow-wrap: anywhere; }
21189
21821
 
21190
21822
  .notice {
21191
- border: 1px solid #d9d7d0;
21192
- border-left: 4px solid #e1a52c;
21193
- border-radius: 8px;
21194
- background: #fffdf8;
21823
+ border: 1px solid var(--color-border);
21824
+ border-left: 4px solid var(--color-primary);
21825
+ border-radius: var(--radius-md);
21826
+ background: var(--color-card);
21195
21827
  margin: 24px 0;
21196
21828
  min-height: 47px;
21197
21829
  padding: 12px 14px;
21198
21830
  }
21199
21831
 
21200
- .notice[data-tone="error"] { border-left-color: #a34736; background: #fff9f7; }
21201
- .notice[data-tone="stale"] { border-left-color: #e1a52c; background: #fffdf8; }
21832
+ .notice[data-tone="error"] { border-left-color: var(--color-status-error); background: color-mix(in oklab, var(--color-status-error) 10%, var(--color-card)); }
21833
+ .notice[data-tone="stale"] { border-left-color: var(--color-status-warning); background: color-mix(in oklab, var(--color-status-warning) 10%, var(--color-card)); }
21202
21834
  .notice:empty { display: none; }
21203
21835
 
21204
21836
  .grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.65fr) minmax(260px, .85fr); }
21205
- .panel { border: 1px solid #d9d7d0; border-radius: 12px; background: #fff; padding: 20px; }
21837
+ .panel { border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-card); padding: 20px; }
21206
21838
  .panel + .panel { margin-top: 24px; }
21207
21839
 
21208
21840
  .add-row { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 16px; }
21209
21841
  input, textarea {
21210
- border: 1px solid #bdbbb4;
21211
- border-radius: 8px;
21212
- background: #fff;
21842
+ border: 1px solid var(--color-input);
21843
+ border-radius: var(--radius-md);
21844
+ background: var(--color-card);
21213
21845
  color: inherit;
21214
21846
  padding: 10px 12px;
21215
21847
  width: 100%;
@@ -21219,10 +21851,10 @@ promoted_to: null
21219
21851
  label { display: block; font-size: .88rem; font-weight: 650; margin-bottom: 8px; }
21220
21852
 
21221
21853
  .cards, .peers { list-style: none; margin: 0; padding: 0; }
21222
- .cards { border-top: 1px solid #e7e5df; }
21854
+ .cards { border-top: 1px solid var(--color-border-subtle); }
21223
21855
  .card {
21224
21856
  align-items: center;
21225
- border-bottom: 1px solid #e7e5df;
21857
+ border-bottom: 1px solid var(--color-border-subtle);
21226
21858
  display: flex;
21227
21859
  gap: 12px;
21228
21860
  justify-content: space-between;
@@ -21231,31 +21863,31 @@ promoted_to: null
21231
21863
 
21232
21864
  .card-title { font-weight: 650; overflow-wrap: anywhere; }
21233
21865
  .card-status {
21234
- background: #fff;
21235
- border-color: #bdbbb4;
21236
- color: #252522;
21866
+ background: var(--color-card);
21867
+ border-color: var(--color-input);
21868
+ color: var(--color-foreground);
21237
21869
  flex: 0 0 auto;
21238
21870
  font-size: .82rem;
21239
21871
  padding: 7px 10px;
21240
21872
  }
21241
21873
 
21242
- .card-status[data-done="true"] { background: #f6efd9; border-color: #c89429; }
21243
- .empty { color: #67665f; padding: 16px 0 4px; }
21244
- .agent-note { background: #f7f6f3; border-radius: 8px; min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
21245
- .agent-note:empty::before { color: #67665f; content: "No note yet. A server-side agent can write one here."; }
21874
+ .card-status[data-done="true"] { background: var(--color-status-success); border-color: var(--color-status-success-strong); color: var(--color-status-success-foreground); }
21875
+ .empty { color: var(--color-muted-foreground); padding: 16px 0 4px; }
21876
+ .agent-note { background: var(--color-muted); border-radius: var(--radius-md); min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
21877
+ .agent-note:empty::before { color: var(--color-muted-foreground); content: "No note yet. A server-side agent can write one here."; }
21246
21878
 
21247
21879
  .peers { display: grid; gap: 8px; }
21248
21880
  .peer {
21249
- border: 1px solid #e7e5df;
21250
- border-radius: 8px;
21881
+ border: 1px solid var(--color-border-subtle);
21882
+ border-radius: var(--radius-md);
21251
21883
  display: grid;
21252
21884
  gap: 3px;
21253
21885
  padding: 10px;
21254
21886
  }
21255
21887
 
21256
21888
  .peer-name { font-weight: 650; overflow-wrap: anywhere; }
21257
- .room-meta { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; margin: 0 0 16px; }
21258
- .save-state { color: #67665f; font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }
21889
+ .room-meta { color: var(--color-muted-foreground); font-family: var(--font-mono); font-size: .75rem; margin: 0 0 16px; }
21890
+ .save-state { color: var(--color-muted-foreground); font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }
21259
21891
 
21260
21892
  @media (max-width: 720px) {
21261
21893
  .page { padding: 24px 16px 40px; }
@@ -21523,13 +22155,12 @@ promoted_to: null
21523
22155
  path: "examples/report-page.html",
21524
22156
  content: `<!doctype html>
21525
22157
  <!--
21526
- Reference report page — minimalist taste baseline + report density layer.
22158
+ Reference report page for the swarm design system (designVersion 2).
21527
22159
 
21528
- Copy this as the starting point for any dense report/dashboard page, then
21529
- replace the content. The CSS already encodes the baseline documented in the
21530
- \`pages\` skill: warm monochrome canvas, single-column 1120px spine, 8/12/16/
21531
- 24/32/48/72 spacing scale, tabular numerals, flat bordered cards, table
21532
- horizontal scroll on mobile, and no decorative gradients or nested cards.
22160
+ Copy this as the starting point for a dense report or dashboard page, then
22161
+ replace the content. It uses only the swarm-* primitives, the theme tokens,
22162
+ and a few Tailwind layout utilities. It has zero color literals, so it
22163
+ follows the viewer's theme and dark mode inside the dashboard.
21533
22164
 
21534
22165
  Publish with:
21535
22166
  create_page({
@@ -21546,144 +22177,146 @@ promoted_to: null
21546
22177
  <meta charset="utf-8">
21547
22178
  <meta name="viewport" content="width=device-width, initial-scale=1">
21548
22179
  <title>Q2 SEO Performance</title>
21549
- <style>
21550
- :root {
21551
- color-scheme: light;
21552
- --bg: #f6f4f0;
21553
- --panel: #ffffff;
21554
- --ink: #18181b;
21555
- --muted: #62646a;
21556
- --line: #dedbd2;
21557
- --accent: #2563eb;
21558
- --danger: #b42318;
21559
- --warn: #b54708;
21560
- --ok: #067647;
21561
- --radius: 8px;
21562
- --shadow: 0 1px 2px rgba(24, 24, 27, 0.04);
21563
- }
21564
- * { box-sizing: border-box; }
21565
- body {
21566
- margin: 0;
21567
- background: var(--bg);
21568
- color: var(--ink);
21569
- font-family: "SF Pro Display", "Geist Sans", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif;
21570
- font-size: 16px;
21571
- line-height: 1.55;
21572
- }
21573
- main {
21574
- width: min(1120px, calc(100% - 32px));
21575
- margin: 0 auto;
21576
- padding: 48px 0 72px;
21577
- }
21578
- header { margin-bottom: 32px; }
21579
- .eyebrow {
21580
- margin: 0 0 8px;
21581
- color: var(--muted);
21582
- font-size: 13px;
21583
- font-weight: 700;
21584
- letter-spacing: 0.08em;
21585
- text-transform: uppercase;
21586
- }
21587
- h1 {
21588
- margin: 0;
21589
- max-width: 780px;
21590
- font-size: clamp(2rem, 4vw, 3rem);
21591
- line-height: 1.05;
21592
- letter-spacing: 0;
21593
- }
21594
- .lede {
21595
- max-width: 760px;
21596
- margin: 16px 0 0;
21597
- color: var(--muted);
21598
- font-size: 18px;
21599
- }
21600
- .metrics {
21601
- display: grid;
21602
- grid-template-columns: repeat(4, minmax(0, 1fr));
21603
- gap: 12px;
21604
- margin: 32px 0;
21605
- }
21606
- .metric, .section {
21607
- background: var(--panel);
21608
- border: 1px solid var(--line);
21609
- border-radius: var(--radius);
21610
- box-shadow: var(--shadow);
21611
- }
21612
- .metric { padding: 18px; }
21613
- .metric strong {
21614
- display: block;
21615
- font-size: 32px;
21616
- line-height: 1;
21617
- font-variant-numeric: tabular-nums;
21618
- }
21619
- .metric span {
21620
- display: block;
21621
- margin-top: 8px;
21622
- color: var(--muted);
21623
- font-size: 13px;
21624
- font-weight: 650;
21625
- }
21626
- .section {
21627
- margin-top: 18px;
21628
- padding: 24px;
21629
- }
21630
- h2 { margin: 0 0 12px; font-size: 24px; line-height: 1.2; }
21631
- h3 { margin: 0 0 8px; font-size: 17px; line-height: 1.3; }
21632
- p { margin: 0 0 12px; }
21633
- .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
21634
- table { width: 100%; border-collapse: collapse; min-width: 640px; }
21635
- th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
21636
- th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
21637
- td { font-size: 14px; font-variant-numeric: tabular-nums; }
21638
- code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
21639
- details { margin-top: 24px; }
21640
- summary { cursor: pointer; font-weight: 700; }
21641
- pre { overflow: auto; padding: 16px; background: #111827; color: #f9fafb; border-radius: var(--radius); }
21642
- @media (max-width: 760px) {
21643
- main { width: min(100% - 24px, 1120px); padding-top: 32px; }
21644
- .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
21645
- .section { padding: 18px; }
21646
- }
21647
- @media (max-width: 520px) {
21648
- .metrics { grid-template-columns: 1fr; }
21649
- .lede { font-size: 16px; }
21650
- }
21651
- </style>
21652
- </head>
21653
- <body>
21654
- <main>
21655
- <header>
21656
- <p class="eyebrow">Generated 2026-06-04</p>
21657
- <h1>Q2 SEO Performance</h1>
21658
- <p class="lede">Organic traffic grew sharply, but the next gains depend on fixing thin page metadata and publishing three high-intent pillar pages.</p>
21659
- </header>
21660
-
21661
- <section class="metrics" aria-label="Key metrics">
21662
- <div class="metric"><strong>18.6k</strong><span>Organic clicks</span></div>
21663
- <div class="metric"><strong>+50%</strong><span>Quarter over quarter</span></div>
21664
- <div class="metric"><strong>23</strong><span>Metadata fixes</span></div>
21665
- <div class="metric"><strong>3</strong><span>Priority pages</span></div>
21666
- </section>
21667
-
21668
- <section class="section">
21669
- <h2>Summary</h2>
21670
- <p>Start with the conclusion. Add tables only after the reader understands what changed and what to do next.</p>
21671
- <div class="table-wrap">
21672
- <table>
21673
- <thead><tr><th>Metric</th><th>Q1</th><th>Q2</th><th>Change</th></tr></thead>
21674
- <tbody><tr><td>Organic clicks</td><td>12,400</td><td>18,600</td><td>+50%</td></tr></tbody>
21675
- </table>
21676
- </div>
21677
- </section>
21678
-
21679
- <details>
21680
- <summary>Raw evidence</summary>
21681
- <pre>{ "source": "search-console", "range": "2026-04-01/2026-06-30" }</pre>
21682
- </details>
21683
- </main>
21684
- </body>
21685
- </html>
21686
- `
22180
+ </head>
22181
+ <body>
22182
+ <main class="swarm-page">
22183
+ <header>
22184
+ <p class="swarm-eyebrow">Search Console · Generated 2026-06-04</p>
22185
+ <h1>Q2 SEO Performance</h1>
22186
+ <p class="swarm-lede">Organic clicks grew 50% this quarter. The next gains depend on fixing thin page metadata and on three high-intent pillar pages.</p>
22187
+ <span class="swarm-badge" data-tone="success" data-dot>On track</span>
22188
+ </header>
22189
+
22190
+ <section class="swarm-metrics" aria-label="Key metrics">
22191
+ <div class="swarm-metric">
22192
+ <div class="swarm-metric-value">18.6k</div>
22193
+ <div class="swarm-metric-label">Organic clicks</div>
22194
+ </div>
22195
+ <div class="swarm-metric" data-tone="success">
22196
+ <div class="swarm-metric-value">+50%</div>
22197
+ <div class="swarm-metric-label">Quarter over quarter</div>
22198
+ </div>
22199
+ <div class="swarm-metric" data-tone="warning">
22200
+ <div class="swarm-metric-value">23</div>
22201
+ <div class="swarm-metric-label">Metadata fixes</div>
22202
+ </div>
22203
+ <div class="swarm-metric">
22204
+ <div class="swarm-metric-value">3</div>
22205
+ <div class="swarm-metric-label">Priority pages</div>
22206
+ </div>
22207
+ </section>
22208
+
22209
+ <section class="swarm-section" aria-labelledby="findings-heading">
22210
+ <h2 id="findings-heading">Findings</h2>
22211
+ <div class="swarm-callout" data-tone="warning">
22212
+ <strong>Thin metadata caps growth.</strong> 23 ranking pages share a generic title and description. Fix them before new content ships.
22213
+ </div>
22214
+ <div class="swarm-callout">
22215
+ <strong>One post drives the growth.</strong> The agent swarms post brought about a quarter of the new clicks.
22216
+ </div>
22217
+ </section>
22218
+
22219
+ <section class="swarm-section" aria-labelledby="pages-heading">
22220
+ <h2 id="pages-heading">Top pages</h2>
22221
+ <div class="swarm-toolbar">
22222
+ <input class="swarm-input" id="page-filter" type="search" placeholder="Filter pages" aria-label="Filter pages">
22223
+ <div class="swarm-segmented" role="group" aria-label="Status">
22224
+ <button type="button" data-status-filter="all" aria-pressed="true">All</button>
22225
+ <button type="button" data-status-filter="fix" aria-pressed="false">Needs fix</button>
22226
+ </div>
22227
+ <button class="swarm-button" id="filter-reset" type="button">Reset</button>
22228
+ </div>
22229
+ <div class="swarm-table-wrap">
22230
+ <table class="swarm-table" id="pages-table">
22231
+ <thead>
22232
+ <tr><th>Page</th><th>Status</th><th class="num">Q1 clicks</th><th class="num">Q2 clicks</th><th class="num">Change</th></tr>
22233
+ </thead>
22234
+ <tbody>
22235
+ <tr data-status="ok"><td class="nowrap">/pricing</td><td><span class="swarm-badge" data-tone="success">Healthy</span></td><td class="num">4,100</td><td class="num">6,900</td><td class="num">+68%</td></tr>
22236
+ <tr data-status="fix"><td class="nowrap">/docs/install</td><td><span class="swarm-badge" data-tone="warning">Thin title</span></td><td class="num">3,200</td><td class="num">3,350</td><td class="num">+5%</td></tr>
22237
+ <tr data-status="ok"><td class="nowrap">/blog/agent-swarms</td><td><span class="swarm-badge" data-tone="success">Healthy</span></td><td class="num">1,500</td><td class="num">3,100</td><td class="num">+107%</td></tr>
22238
+ <tr data-status="fix"><td class="nowrap">/integrations/slack</td><td><span class="swarm-badge" data-tone="error">No description</span></td><td class="num">1,900</td><td class="num">1,700</td><td class="num text-status-error-strong">-11%</td></tr>
22239
+ <tr data-status="review"><td class="nowrap">/changelog</td><td><span class="swarm-badge">Not reviewed</span></td><td class="num">400</td><td class="num">1,150</td><td class="num">+188%</td></tr>
22240
+ </tbody>
22241
+ </table>
22242
+ </div>
22243
+ <p class="text-muted-foreground" id="filter-empty" hidden>No pages match the filter.</p>
22244
+ </section>
22245
+
22246
+ <section class="swarm-section" aria-labelledby="actions-heading">
22247
+ <h2 id="actions-heading">Next actions</h2>
22248
+ <div class="grid gap-4 md:grid-cols-2">
22249
+ <article class="swarm-panel">
22250
+ <h3>Fix thin metadata</h3>
22251
+ <dl class="swarm-kv">
22252
+ <dt>Owner</dt><dd>Content team</dd>
22253
+ <dt>Pages</dt><dd>23</dd>
22254
+ <dt>Due</dt><dd>2026-07-15</dd>
22255
+ </dl>
22256
+ </article>
22257
+ <article class="swarm-panel">
22258
+ <h3>Publish three pillar pages</h3>
22259
+ <dl class="swarm-kv">
22260
+ <dt>Owner</dt><dd>Growth team</dd>
22261
+ <dt>Topics</dt><dd>Agent orchestration, task routing, swarm costs</dd>
22262
+ <dt>Due</dt><dd>2026-08-01</dd>
22263
+ </dl>
22264
+ </article>
22265
+ </div>
22266
+ <div class="mt-4">
22267
+ <a class="swarm-button" data-variant="primary" href="https://search.google.com/search-console">Open Search Console</a>
22268
+ </div>
22269
+ </section>
22270
+
22271
+ <details>
22272
+ <summary>Raw evidence</summary>
22273
+ <pre>{ "source": "search-console", "range": "2026-04-01/2026-06-30" }</pre>
22274
+ </details>
22275
+ </main>
22276
+
22277
+ <script>
22278
+ (() => {
22279
+ const filter = document.getElementById("page-filter");
22280
+ const reset = document.getElementById("filter-reset");
22281
+ const empty = document.getElementById("filter-empty");
22282
+ const statusButtons = document.querySelectorAll("[data-status-filter]");
22283
+ const rows = document.querySelectorAll("#pages-table tbody tr");
22284
+ let status = "all";
22285
+
22286
+ function applyFilter() {
22287
+ const query = filter.value.trim().toLowerCase();
22288
+ let shown = 0;
22289
+ for (const row of rows) {
22290
+ const visible =
22291
+ row.textContent.toLowerCase().includes(query) &&
22292
+ (status === "all" || row.dataset.status === status);
22293
+ row.style.display = visible ? "" : "none";
22294
+ if (visible) shown += 1;
22295
+ }
22296
+ empty.hidden = shown > 0;
22297
+ }
22298
+
22299
+ function setStatus(next) {
22300
+ status = next;
22301
+ for (const button of statusButtons) {
22302
+ button.setAttribute("aria-pressed", String(button.dataset.statusFilter === next));
22303
+ }
22304
+ applyFilter();
22305
+ }
22306
+
22307
+ for (const button of statusButtons) {
22308
+ button.addEventListener("click", () => setStatus(button.dataset.statusFilter));
22309
+ }
22310
+ filter.addEventListener("input", applyFilter);
22311
+ reset.addEventListener("click", () => {
22312
+ filter.value = "";
22313
+ setStatus("all");
22314
+ });
22315
+ })();
22316
+ </script>
22317
+ </body>
22318
+ </html>
22319
+ `
21687
22320
  }
21688
22321
  ],
21689
22322
  planning: [
@@ -24344,201 +24977,9 @@ You are Lead. Produce one digest covering workflows, schedule runs, agents, task
24344
24977
  Call \`GET $MCP_BASE_URL/status\` and read \`status.automations\`. List every automation whose state is \`needs_setup\`, its missing parameters/integrations, and its \`fixUrl\`. This is the first digest section and the first failure classification; it is setup work, not an automation failure.
24345
24978
 
24346
24979
  ### 1B. Workflows and schedules
24347
- Run the same six-query set as \`daily-workflow-health-audit\` Phase 1 (hard-failed workflow runs, hard-failed schedule-spawned tasks, halted >24h runs, silent-empty-output completions, cron-didn't-fire, schedules with ≥3 consecutive errors — all against \`workflow_runs\` / \`agent_tasks\` / \`scheduled_tasks\` via \`db-query\`), plus:
24348
- - Enabled/disabled counts: \`SELECT enabled, COUNT(*) FROM scheduled_tasks GROUP BY enabled\` and the workflow equivalent.
24349
- - 7-day rollup, not just 24h: repeat the hard-failure and cron-didn't-fire queries with a \`-7 days\` window for a weekly trend line.
24350
-
24351
- ### 1C. Agents
24352
- Use global script \`swarm-overview\` for the roster + swarm-wide status counts. Supplement with \`db-query\`:
24353
- \`\`\`sql
24354
- SELECT a.id, a.name, a.status, a.maxTasks,
24355
- COUNT(t.id) FILTER (WHERE t.status = 'in_progress') AS activeCount,
24356
- COUNT(t.id) FILTER (WHERE t.status = 'failed' AND datetime(t.lastUpdatedAt) > datetime('now','-7 days')) AS failures7d
24357
- FROM agents a
24358
- LEFT JOIN agent_tasks t ON t.agentId = a.id
24359
- GROUP BY a.id;
24360
- \`\`\`
24361
- Report capacity as \`activeCount / maxTasks\` per agent, and flag any agent at or over capacity.
24362
-
24363
- ### 1D. Task failures — classify, don't just count
24364
- Pull the last 7 days of failed tasks (\`get-tasks\` with \`status=failed\`, or the equivalent \`db-query\`). For each, read \`failureReason\` and bucket by pattern, not by exact string:
24365
- - **Gate refusal**: reason mentions a lint/test/CI gate, a pre-push hook, \`argsSchema validation\`, or an explicit policy block.
24366
- - **Infra**: reason mentions timeout, 5xx, rate limit, \`ECONNRESET\`, provider overload (529/503), or sandbox/ulimit failure.
24367
- - **Real defect**: everything else with a non-empty reason — genuine bugs or wrong output.
24368
- - **Unclassified (empty reason)**: report this bucket's count explicitly rather than folding it into "real defect" — a large unclassified bucket is itself a finding (tasks are failing without a \`failureReason\` being set, which is a data-quality gap worth surfacing, not hiding).
24369
-
24370
- ### 1E. Current integration setup
24371
- Use the setup milestone data already retrieved from \`/status\`. Report only integrations that are not verified; do not describe automations as disabled.
24372
-
24373
- ---
24374
-
24375
- ## Phase 2: Post one digest — EXCEPTION-ONLY, HARD CEILING 600 CHARACTERS
24376
-
24377
- Operator feedback, 2026-09-23, verbatim: "too verbose every day". Brevity is a requirement of this job, not a preference. Count the characters before posting; over 600 ⇒ cut until it fits. A long report is a failed run, not a thorough one.
24378
-
24379
- Post ONE message to your configured admin channel (in-app fallback if none configured).
24380
-
24381
- **Everything healthy ⇒ exactly this one line, nothing else:**
24382
-
24383
- \`\`\`
24384
- 📊 <MM-DD> — all clear. Workflows <X>/<Y> · schedules <X>/<Y> · agents <X>/<Y> · <N> failures 7d, <N> real defects. Nothing for you.
24385
- \`\`\`
24386
-
24387
- **Add a line ONLY for something both TRUE and ACTIONABLE. Max 3 lines, one sentence each, no sub-bullets:**
24388
- - an automation in \`needs_setup\` or an unverified integration — name + \`fixUrl\`
24389
- - a workflow run halted >24h (Phase 1B)
24390
- - a real defect, or a failure cluster on one agent / schedule / provider
24391
- - a decision only the operator can make — state the options, end on the question
24392
-
24393
- More than 3 qualify ⇒ post the 3 that matter and put the rest in the task output.
24394
-
24395
- **NEVER in the Slack post** — all of this belongs in \`store-progress\` output, where the audit trail lives:
24396
- - row counts and "read-only report" notes
24397
- - methodology, query names, script names, phase narration
24398
- - any zero ("0 halted", "all verified", "no data-quality gap") — silence means zero
24399
- - week-over-week trend, unless a class crossed a threshold you name in the same line
24400
- - a finding yesterday's report already carried unchanged
24401
- - numbered explainers or paragraphs of reasoning
24402
-
24403
- ## Phase 3: Complete
24404
- \`store-progress\` with \`status: "completed"\`. **This is where the detail goes** — every count from Phase 1, the per-class failure breakdown, and the Slack message ts. The Slack post is the headline; the task output is the record. Cutting the post does not mean cutting the work.
24405
-
24406
- ## Anti-patterns
24407
- - ❌ Modifying anything — this schedule reports, it doesn't fix (that's \`daily-workflow-health-audit\` for plumbing and \`daily-compounding-reflection\` for agent/skill/memory evolution).
24408
- - ❌ Folding the unclassified-failure bucket into "real defect" to make the number look more actionable than it is.
24409
- - ❌ Narrating a zero. The all-clear line already covers \`needs_setup\` and integrations; only a non-verified integration or a real \`needs_setup\` automation earns its own line.
24410
- - ❌ Exceeding 600 characters, or posting more than one message.
24411
- `;
24412
-
24413
- // templates/schedules/daily-swarm-update-check/config.json
24414
- var config_default61 = `{
24415
- "kind": "schedule",
24416
- "name": "daily-swarm-update-check",
24417
- "displayName": "Daily Agent-Swarm Update Check",
24418
- "slug": "daily-swarm-update-check",
24419
- "title": "Daily Agent-Swarm Update Check",
24420
- "description": "Compare the running agent-swarm version against the latest published GitHub release and flag the operator when an upgrade is available. Zero-config: no repo clone, no integration required.",
24421
- "version": "1.0.0",
24422
- "category": "schedules",
24423
- "placeholders": [],
24424
- "requires": [],
24425
- "runAllSeedersCandidate": true,
24426
- "autoEnableCandidate": true,
24427
- "tags": [
24428
- "operations",
24429
- "release",
24430
- "upgrade"
24431
- ]
24432
- }
24433
- `;
24434
-
24435
- // templates/schedules/daily-swarm-update-check/content.md
24436
- var content_default61 = `# Daily Agent-Swarm Update Check
24980
+ Run these six failure-mode queries with \`db-query\`:
24437
24981
 
24438
- Zero-config auto-update watchdog: compares the swarm's own running version against the latest published GitHub release of \`desplega-ai/agent-swarm\`, and flags the operator when an upgrade is available. No placeholders and no integration required — every install gets this by default.
24439
-
24440
- ## Schedule
24441
-
24442
- \`\`\`json
24443
- {"cron":"0 9 * * *","timezone":"UTC","agentRole":"lead","enabled":true}
24444
- \`\`\`
24445
-
24446
- ## Scheduled Task
24447
-
24448
- This schedule runs by default. It needs no repository clone and no integration — it only reads its own task metadata and calls the public GitHub API.
24449
-
24450
- Task Type: Daily Agent-Swarm Update Check
24451
-
24452
- You are Lead. Determine whether a newer \`desplega-ai/agent-swarm\` release is published than the version currently running on this install, and tell the operator if so.
24453
-
24454
- ## Phase 1 — Determine the running version
24455
-
24456
- Call \`get-task-details\` for THIS task's own id and read \`task.swarmVersion\` — the API server stamps every task with the \`package.json\` version it was running at task-creation time, so this is the version currently deployed on this install.
24457
-
24458
- If \`swarmVersion\` is missing or empty (an older install, or the field predates this schedule), complete with \`output\`: \`"Unable to determine running version — swarmVersion not set on this task."\` and stop. Do not guess a version.
24459
-
24460
- ## Phase 2 — Fetch the latest published release
24461
-
24462
- \`\`\`bash
24463
- curl -fsSL https://api.github.com/repos/desplega-ai/agent-swarm/releases/latest
24464
- \`\`\`
24465
-
24466
- Read \`.tag_name\` (strip a leading \`v\` if present) and \`.html_url\`. If the request fails (network, rate limit, non-200), complete with \`output\`: \`"Could not reach GitHub releases API: <error>"\` and stop — do not retry in a loop.
24467
-
24468
- ## Phase 3 — Compare and act
24469
-
24470
- Compare the two versions as dotted numeric triples (major.minor.patch) — never as plain strings (\`"1.9.0" < "1.10.0"\` is false as strings).
24471
-
24472
- - **Latest <= running:** complete with \`output\`: \`"Up to date: running <running>, latest published <latest>."\` No further action.
24473
- - **Latest > running:** raise it for the operator:
24474
- 1. Post a short message to your configured admin delivery channel (or the in-app fallback if none is configured) naming the running version, the new version, and the release URL.
24475
- 2. Complete \`store-progress\` with \`status: "completed"\` and \`output\`: \`"Update available: running <running>, latest <latest> — <releaseUrl>"\`.
24476
-
24477
- Do not attempt the upgrade yourself — this schedule only detects and reports. \`weekly-harness-upgrade-check\` is the separate routine that bumps pinned harness versions inside the repo; this schedule is about the agent-swarm release itself.
24478
-
24479
- ## Anti-patterns
24480
-
24481
- - ❌ Cloning the repo or reading \`package.json\` from disk — this schedule must work on any install, including ones where the API pod has no repo checkout.
24482
- - ❌ String-comparing versions.
24483
- - ❌ Treating a missing \`swarmVersion\` as "no update available" — it's "unknown", say so explicitly.
24484
- - ❌ Retrying a failed GitHub API call in a loop.
24485
- `;
24486
-
24487
- // templates/schedules/daily-workflow-health-audit/config.json
24488
- var config_default62 = `{
24489
- "kind": "schedule",
24490
- "name": "daily-workflow-health-audit",
24491
- "displayName": "Daily Workflow Health Audit",
24492
- "slug": "daily-workflow-health-audit",
24493
- "title": "Daily Workflow Health Audit",
24494
- "description": "Check scheduled jobs and workflows for repeated failures, stale runs, and silent drift.",
24495
- "version": "1.0.0",
24496
- "category": "schedules",
24497
- "placeholders": [],
24498
- "requires": [],
24499
- "runAllSeedersCandidate": true,
24500
- "autoEnableCandidate": true,
24501
- "tags": [
24502
- "workflows",
24503
- "schedules",
24504
- "reliability"
24505
- ]
24506
- }
24507
- `;
24508
-
24509
- // templates/schedules/daily-workflow-health-audit/content.md
24510
- var content_default62 = `# Daily Workflow Health Audit
24511
-
24512
- Delivery uses configured admin channels with an in-app fallback.
24513
-
24514
- Check scheduled jobs and workflows for repeated failures, stale runs, and silent drift.
24515
-
24516
- ## Schedule
24517
-
24518
- \`\`\`json
24519
- {
24520
- "cron": "0 8 * * *",
24521
- "timezone": "UTC",
24522
- "agentRole": "lead",
24523
- "enabled": true
24524
- }
24525
- \`\`\`
24526
-
24527
- ## Scheduled Task
24528
-
24529
- This is the full task prompt the schedule runs on each fire. Adapt the channel IDs, mentions, app URLs, and escalation rules to your environment before enabling. As you learn from real incidents, expand this prompt with your own local failure modes and recovery notes.
24530
-
24531
- Task Type: Daily Workflow + Schedule Health Audit
24532
-
24533
- You are Lead. Run this audit and post a single Slack digest. Cadence: daily at 08:00 UTC. Purpose: surface any workflow run or scheduled-task fire from the last 24h that hard-failed or silently failed (completed but produced nothing useful) so the team catches broken cron/workflow plumbing before it ages out.
24534
-
24535
- ---
24536
-
24537
- ## Phase 1 — Query the six failure modes
24538
-
24539
- Use \`db-query\` for each.
24540
-
24541
- ### 1A. Hard-failed workflow runs (last 24h)
24982
+ **1.** Hard-failed workflow runs (last 24h)
24542
24983
 
24543
24984
  \`\`\`sql
24544
24985
  SELECT wr.id, w.name AS workflowName, wr.status,
@@ -24551,7 +24992,7 @@ WHERE wr.status = 'failed'
24551
24992
  ORDER BY wr.lastUpdatedAt DESC;
24552
24993
  \`\`\`
24553
24994
 
24554
- ### 1B. Hard-failed schedule-spawned tasks (last 24h)
24995
+ **2.** Hard-failed schedule-spawned tasks (last 24h)
24555
24996
 
24556
24997
  \`\`\`sql
24557
24998
  SELECT t.id, s.name AS scheduleName, t.status,
@@ -24566,7 +25007,7 @@ WHERE t.status = 'failed'
24566
25007
  ORDER BY t.lastUpdatedAt DESC;
24567
25008
  \`\`\`
24568
25009
 
24569
- ### 1C. Halted >24h workflow runs (silent stuck)
25010
+ **3.** Halted >24h workflow runs (silent stuck)
24570
25011
 
24571
25012
  \`\`\`sql
24572
25013
  SELECT wr.id, w.name AS workflowName, wr.status, wr.lastUpdatedAt
@@ -24577,7 +25018,7 @@ WHERE wr.status IN ('running', 'waiting')
24577
25018
  ORDER BY wr.lastUpdatedAt ASC;
24578
25019
  \`\`\`
24579
25020
 
24580
- ### 1D. Silent: schedule-spawned task completed with empty/sentinel output
25021
+ **4.** Silent: schedule-spawned task completed with empty/sentinel output
24581
25022
 
24582
25023
  \`\`\`sql
24583
25024
  SELECT t.id, s.name AS scheduleName, t.status,
@@ -24598,7 +25039,7 @@ WHERE t.status = 'completed'
24598
25039
  ORDER BY t.lastUpdatedAt DESC;
24599
25040
  \`\`\`
24600
25041
 
24601
- ### 1E. Cron didn't fire (nextRunAt in the past)
25042
+ **5.** Cron didn't fire (nextRunAt in the past)
24602
25043
 
24603
25044
  \`\`\`sql
24604
25045
  SELECT s.id, s.name, s.cronExpression, s.lastRunAt, s.nextRunAt, s.consecutiveErrors,
@@ -24611,7 +25052,7 @@ WHERE s.enabled = 1
24611
25052
  ORDER BY s.nextRunAt ASC;
24612
25053
  \`\`\`
24613
25054
 
24614
- ### 1F. Schedules with consecutive errors (defensive)
25055
+ **6.** Schedules with consecutive errors (defensive)
24615
25056
 
24616
25057
  \`\`\`sql
24617
25058
  SELECT s.id, s.name, s.cronExpression, s.consecutiveErrors, s.lastErrorAt,
@@ -24622,86 +25063,99 @@ WHERE s.enabled = 1
24622
25063
  ORDER BY s.consecutiveErrors DESC;
24623
25064
  \`\`\`
24624
25065
 
24625
- ### 1G. Totals (for the "all clear" denominator)
25066
+ Plus:
25067
+ - Enabled/disabled counts: \`SELECT enabled, COUNT(*) FROM scheduled_tasks GROUP BY enabled\` and the workflow equivalent.
25068
+ - 7-day rollup, not just 24h: repeat the hard-failure and cron-didn't-fire queries with a \`-7 days\` window for a weekly trend line.
24626
25069
 
25070
+ ### 1C. Agents
25071
+ Use global script \`swarm-overview\` for the roster + swarm-wide status counts. Supplement with \`db-query\`:
24627
25072
  \`\`\`sql
24628
- SELECT
24629
- (SELECT COUNT(*) FROM workflow_runs WHERE datetime(lastUpdatedAt) > datetime('now','-24 hours')) AS workflowRuns24h,
24630
- (SELECT COUNT(*) FROM agent_tasks WHERE scheduleId IS NOT NULL AND datetime(lastUpdatedAt) > datetime('now','-24 hours')) AS scheduledFires24h;
25073
+ SELECT a.id, a.name, a.status, a.maxTasks,
25074
+ COUNT(t.id) FILTER (WHERE t.status = 'in_progress') AS activeCount,
25075
+ COUNT(t.id) FILTER (WHERE t.status = 'failed' AND datetime(t.lastUpdatedAt) > datetime('now','-7 days')) AS failures7d
25076
+ FROM agents a
25077
+ LEFT JOIN agent_tasks t ON t.agentId = a.id
25078
+ GROUP BY a.id;
24631
25079
  \`\`\`
25080
+ Report capacity as \`activeCount / maxTasks\` per agent, and flag any agent at or over capacity.
24632
25081
 
24633
- ---
24634
-
24635
- ## Phase 2 — Render the digest
24636
-
24637
- Each bullet must include a clickable URL.
24638
-
24639
- - Workflow run URL: \`https://app.agent-swarm.dev/workflow-runs/<id>\` → Slack format: \`<https://app.agent-swarm.dev/workflow-runs/<id>|workflow:<workflowName>>\`
24640
- - Task URL: \`https://app.agent-swarm.dev/tasks/<id>\` → Slack format: \`<https://app.agent-swarm.dev/tasks/<id>|schedule:<scheduleName>>\`
25082
+ ### 1D. Task failures — classify, don't just count
25083
+ Pull the last 7 days of failed tasks (\`get-tasks\` with \`status=failed\`, or the equivalent \`db-query\`). For each, read \`failureReason\` and bucket by pattern, not by exact string:
25084
+ - **Gate refusal**: reason mentions a lint/test/CI gate, a pre-push hook, \`argsSchema validation\`, or an explicit policy block.
25085
+ - **Infra**: reason mentions timeout, 5xx, rate limit, \`ECONNRESET\`, provider overload (529/503), or sandbox/ulimit failure.
25086
+ - **Real defect**: everything else with a non-empty reason — genuine bugs or wrong output.
25087
+ - **Unclassified (empty reason)**: report this bucket's count explicitly rather than folding it into "real defect" — a large unclassified bucket is itself a finding (tasks are failing without a \`failureReason\` being set, which is a data-quality gap worth surfacing, not hiding).
24641
25088
 
24642
- Truncate error/output snippets to 200 chars + \`…\` if longer. Replace newlines with \` ⏎ \`.
25089
+ ### 1E. Current integration setup
25090
+ Use the setup milestone data already retrieved from \`/status\`. Report only integrations that are not verified; do not describe automations as disabled.
24643
25091
 
24644
- ### Template
25092
+ ---
24645
25093
 
24646
- If TOTAL issues across 1A–1F is zero:
25094
+ ## Phase 2: Post one digest — EXCEPTION-ONLY, HARD CEILING 600 CHARACTERS
24647
25095
 
24648
- \`\`\`
24649
- :white_check_mark: *Daily Workflow + Schedule Health Audit* — <YYYY-MM-DD>
25096
+ Operator feedback, 2026-09-23, verbatim: "too verbose every day". Brevity is a requirement of this job, not a preference. Count the characters before posting; over 600 ⇒ cut until it fits. A long report is a failed run, not a thorough one.
24650
25097
 
24651
- <OWNER_OR_TEAM_MENTION> All clear — <workflowRuns24h> workflow runs + <scheduledFires24h> scheduled fires in the last 24h, all produced expected output.
24652
- \`\`\`
25098
+ Post ONE message to your configured admin channel (in-app fallback if none configured).
24653
25099
 
24654
- Otherwise:
25100
+ **Everything healthy ⇒ exactly this one line, nothing else:**
24655
25101
 
24656
25102
  \`\`\`
24657
- :stethoscope: *Daily Workflow + Schedule Health Audit* — <YYYY-MM-DD>
24658
-
24659
- <OWNER_OR_TEAM_MENTION> Audit window: last 24h. Totals: <workflowRuns24h> workflow runs · <scheduledFires24h> scheduled fires · *<TOTAL_ISSUES> issues*
24660
-
24661
- *Hard failures — workflow runs* (<N1A>)
24662
- • <url|workflow:name> — failed <relative-time>
24663
- ↳ <errSnippet>
24664
-
24665
- *Hard failures — scheduled tasks* (<N1B>)
24666
- • <url|schedule:name> — failed <relative-time>
24667
- ↳ <reasonSnippet OR outSnippet OR "(no failureReason set)">
24668
-
24669
- *Silent: halted >24h* (<N1C>)
24670
- • <url|workflow:name> — status=<status>, no progress since <timestamp>
24671
-
24672
- *Silent: empty output* (<N1D>)
24673
- • <url|schedule:name> — completed, output=<"empty" | first-N-chars>
24674
-
24675
- *Cron didn't fire on time* (<N1E>)
24676
- • schedule:<name> (cron \`<expr>\`) — nextRunAt=<past-timestamp>, lastRunAt=<timestamp or "never">
24677
-
24678
- *Schedules with ≥3 consecutive errors* (<N1F>)
24679
- • schedule:<name> — consecutiveErrors=<n>, last error: <lastErrSnippet>
25103
+ 📊 <MM-DD> — all clear. Workflows <X>/<Y> · schedules <X>/<Y> · agents <X>/<Y> · <N> failures 7d, <N> real defects. Nothing for you.
24680
25104
  \`\`\`
24681
25105
 
24682
- Omit any section whose count is 0. **HARD CEILING 700 characters — not the 4000-char Slack limit.** (Operator feedback, 2026-09-23: "too verbose every day".) Keep at most the top 3 items across ALL sections combined, one line each; everything else goes in the \`store-progress\` output, not the post. Over 700 chars ⇒ cut until it fits. A long digest is a failed run, not a thorough one.
25106
+ **Add a line ONLY for something both TRUE and ACTIONABLE. Max 3 lines, one sentence each, no sub-bullets:**
25107
+ - an automation in \`needs_setup\` or an unverified integration — name + \`fixUrl\`
25108
+ - a workflow run halted >24h (Phase 1B)
25109
+ - a real defect, or a failure cluster on one agent / schedule / provider
25110
+ - a decision only the operator can make — state the options, end on the question
24683
25111
 
24684
- ---
25112
+ More than 3 qualify ⇒ post the 3 that matter and put the rest in the task output.
24685
25113
 
24686
- ## Phase 3 — Post to Slack and complete
25114
+ **NEVER in the Slack post** — all of this belongs in \`store-progress\` output, where the audit trail lives:
25115
+ - row counts and "read-only report" notes
25116
+ - methodology, query names, script names, phase narration
25117
+ - any zero ("0 halted", "all verified", "no data-quality gap") — silence means zero
25118
+ - week-over-week trend, unless a class crossed a threshold you name in the same line
25119
+ - a finding yesterday's report already carried unchanged
25120
+ - numbered explainers or paragraphs of reasoning
24687
25121
 
24688
- 1. Post \`message=<rendered digest>\` through configured admin delivery channels, using the in-app fallback when no external channel is configured. Prefer a top-level daily fire unless your team's convention is to thread recurring audit messages.
24689
- 2. Call \`store-progress\` with \`status: "completed"\` and a one-paragraph \`output\` summary:
24690
- - \`Issues found: hard-fail-wf=<N1A>, hard-fail-task=<N1B>, halted-24h=<N1C>, silent-empty=<N1D>, cron-stuck=<N1E>, consec-err=<N1F>.\`
24691
- - \`Totals: workflowRuns24h=<X>, scheduledFires24h=<Y>.\`
24692
- - \`Slack message ts: <ts from slack-post response>.\`
25122
+ ## Phase 3: Complete
25123
+ \`store-progress\` with \`status: "completed"\`. **This is where the detail goes** — every count from Phase 1, the per-class failure breakdown, and the Slack message ts. The Slack post is the headline; the task output is the record. Cutting the post does not mean cutting the work.
24693
25124
 
24694
25125
  ## Anti-patterns
25126
+ - ❌ Modifying anything — this schedule reports, it doesn't fix (that's \`daily-compounding-reflection\` for agent/skill/memory evolution).
25127
+ - ❌ Folding the unclassified-failure bucket into "real defect" to make the number look more actionable than it is.
25128
+ - ❌ Narrating a zero. The all-clear line already covers \`needs_setup\` and integrations; only a non-verified integration or a real \`needs_setup\` automation earns its own line.
25129
+ - ❌ Exceeding 600 characters, or posting more than one message.
25130
+ `;
24695
25131
 
24696
- - ❌ Posting a separate Slack message per failure mode — ONE digest.
24697
- - ❌ Raw IDs without clickable URLs.
24698
- - ❌ Dumping full \`error\` / \`output\` content — truncate to 220 chars per item.
24699
- - ❌ Threading the daily digest somewhere your team will not scan.
24700
- - ❌ Skipping the "all clear" message when zero issues — the heartbeat itself is the signal that the audit ran.
25132
+ // templates/schedules/daily-swarm-update-check/config.json
25133
+ var config_default61 = `{
25134
+ "kind": "schedule",
25135
+ "name": "daily-swarm-update-check",
25136
+ "displayName": "Daily Agent-Swarm Update Check",
25137
+ "slug": "daily-swarm-update-check",
25138
+ "title": "Daily Agent-Swarm Update Check",
25139
+ "description": "Compare the running agent-swarm version against the latest published GitHub release and flag the operator when an upgrade is available. Zero-config: no repo clone, no integration required.",
25140
+ "version": "1.0.0",
25141
+ "category": "schedules",
25142
+ "placeholders": [],
25143
+ "requires": [],
25144
+ "runAllSeedersCandidate": true,
25145
+ "autoEnableCandidate": true,
25146
+ "tags": [
25147
+ "operations",
25148
+ "release",
25149
+ "upgrade"
25150
+ ]
25151
+ }
24701
25152
  `;
24702
25153
 
25154
+ // templates/schedules/daily-swarm-update-check/content.md
25155
+ var content_default61 = '# Daily Agent-Swarm Update Check\n\nZero-config auto-update watchdog: compares the swarm\'s own running version against the latest published GitHub release of `desplega-ai/agent-swarm`, and flags the operator when an upgrade is available. No placeholders and no integration required — every install gets this by default.\n\nThis is a script schedule: each fire runs the global `swarm-update-check` script directly, with no agent task in between. The script:\n\n1. Reads the running version from the API server\'s own `/health` route.\n2. Fetches `https://api.github.com/repos/desplega-ai/agent-swarm/releases/latest` once and reads `tag_name` and `html_url`.\n3. Compares the two as numeric `major.minor.patch`, never as strings (`"1.9.0" < "1.10.0"` is false as strings).\n4. Only when the latest release is newer, sends the lead one task to post the notice to its configured admin delivery channel (the in-app fallback if none is configured).\n\nUp to date, a missing running version (reported as `unknown`, never as up to date) and an unreachable GitHub API all end the run without any agent task. A failed GitHub call is reported once and never retried in a loop. The check never attempts the upgrade; `weekly-harness-upgrade-check` is the separate routine that bumps pinned harness versions inside the repo.\n\n## Schedule\n\n```json\n{\n "cron": "0 9 * * *",\n "timezone": "UTC",\n "enabled": true,\n "targetType": "script",\n "scriptName": "swarm-update-check",\n "scriptArgs": {}\n}\n```\n';
25156
+
24703
25157
  // templates/schedules/dreaming-daily/config.json
24704
- var config_default63 = `{
25158
+ var config_default62 = `{
24705
25159
  "kind": "schedule",
24706
25160
  "name": "dreaming-daily",
24707
25161
  "displayName": "Dreaming",
@@ -24723,7 +25177,7 @@ var config_default63 = `{
24723
25177
  `;
24724
25178
 
24725
25179
  // templates/schedules/dreaming-daily/content.md
24726
- var content_default63 = `# Dreaming Daily Trigger
25180
+ var content_default62 = `# Dreaming Daily Trigger
24727
25181
 
24728
25182
  Trigger the \`dreaming\` workflow once a day. It replaces the Daily Evolution schedule (\`daily-compounding-reflection\`), which now seeds disabled.
24729
25183
 
@@ -24745,7 +25199,7 @@ The \`dreaming\` workflow carries the double-run guard as a 20-hour cooldown: a
24745
25199
  `;
24746
25200
 
24747
25201
  // templates/schedules/gtm-weekly-review/config.json
24748
- var config_default64 = `{
25202
+ var config_default63 = `{
24749
25203
  "kind": "schedule",
24750
25204
  "name": "gtm-weekly-review",
24751
25205
  "displayName": "Weekly GTM Metrics Review",
@@ -24762,7 +25216,7 @@ var config_default64 = `{
24762
25216
  `;
24763
25217
 
24764
25218
  // templates/schedules/gtm-weekly-review/content.md
24765
- var content_default64 = `Template parameters: {{REPO_URL}}, {{GSC_PROPERTY}}
25219
+ var content_default63 = `Template parameters: {{REPO_URL}}, {{GSC_PROPERTY}}
24766
25220
 
24767
25221
  # Weekly GTM Metrics Review
24768
25222
 
@@ -24843,7 +25297,7 @@ This is part of your team's GTM goal; update the goal statement before enabling
24843
25297
  `;
24844
25298
 
24845
25299
  // templates/schedules/weekly-code-health-reports/config.json
24846
- var config_default65 = `{
25300
+ var config_default64 = `{
24847
25301
  "kind": "schedule",
24848
25302
  "name": "weekly-code-health-reports",
24849
25303
  "displayName": "Weekly Code Health Reports",
@@ -24860,7 +25314,7 @@ var config_default65 = `{
24860
25314
  `;
24861
25315
 
24862
25316
  // templates/schedules/weekly-code-health-reports/content.md
24863
- var content_default65 = `# Weekly Code Health Reports
25317
+ var content_default64 = `# Weekly Code Health Reports
24864
25318
 
24865
25319
  Template parameters: {{REPO_URL}}, {{BRANCH}}, {{SCOPE_PATH}}, {{REPORT_NAME}}, {{PAGE_ID}}
24866
25320
 
@@ -24925,7 +25379,7 @@ Call \`store-progress\` with status \`completed\` and an output summary that inc
24925
25379
  `;
24926
25380
 
24927
25381
  // templates/schedules/weekly-dependabot-triage/config.json
24928
- var config_default66 = `{
25382
+ var config_default65 = `{
24929
25383
  "kind": "schedule",
24930
25384
  "name": "weekly-dependabot-triage",
24931
25385
  "displayName": "Weekly Dependency Triage",
@@ -24942,7 +25396,7 @@ var config_default66 = `{
24942
25396
  `;
24943
25397
 
24944
25398
  // templates/schedules/weekly-dependabot-triage/content.md
24945
- var content_default66 = `# Weekly Dependency Triage
25399
+ var content_default65 = `# Weekly Dependency Triage
24946
25400
 
24947
25401
  Template parameters: {{SLACK_CHANNEL_ID}}, {{REPO_URL}}, {{TIMEZONE}}
24948
25402
 
@@ -24996,7 +25450,7 @@ Call \`store-progress\` with status \`completed\` and an output summary listing
24996
25450
  `;
24997
25451
 
24998
25452
  // templates/schedules/weekly-dora-metrics/config.json
24999
- var config_default67 = `{
25453
+ var config_default66 = `{
25000
25454
  "kind": "schedule",
25001
25455
  "name": "weekly-dora-metrics",
25002
25456
  "displayName": "Weekly DORA Metrics",
@@ -25013,7 +25467,7 @@ var config_default67 = `{
25013
25467
  `;
25014
25468
 
25015
25469
  // templates/schedules/weekly-dora-metrics/content.md
25016
- var content_default67 = `# Weekly DORA Metrics
25470
+ var content_default66 = `# Weekly DORA Metrics
25017
25471
 
25018
25472
  Template parameters: {{REPO_URL}}, {{BRANCH}}, {{TAG_PATTERN}}, {{REPORT_NAME}}, {{PAGE_ID}}
25019
25473
 
@@ -25079,7 +25533,7 @@ Call \`store-progress\` with status \`completed\` and an output summary that inc
25079
25533
  `;
25080
25534
 
25081
25535
  // templates/schedules/weekly-harness-upgrade-check/config.json
25082
- var config_default68 = `{
25536
+ var config_default67 = `{
25083
25537
  "kind": "schedule",
25084
25538
  "name": "weekly-harness-upgrade-check",
25085
25539
  "displayName": "Weekly Harness Upgrade Check",
@@ -25096,7 +25550,7 @@ var config_default68 = `{
25096
25550
  `;
25097
25551
 
25098
25552
  // templates/schedules/weekly-harness-upgrade-check/content.md
25099
- var content_default68 = '# Weekly Harness Upgrade Check\n\nThis is a topology-preserving, anonymized port of the live Sunday schedule. It keeps its bundle, deduplication, baseline, partial-fallback, and CI acceptance behavior.\n\n```json\n{"cron":"0 16 * * 0","timezone":"UTC","agentRole":"lead","enabled":true}\n```\n\n**Weekly Harness Upgrade — self-hosted repository Dockerfile.worker**\n\nRepo: `{{REPO_URL}}` (working dir: `/workspace/repo`)\nFile: `Dockerfile.worker`\n\nThe worker image pins these harnesses via `ARG`. Each week, check upstream for new versions and open ONE PR bundling ALL outdated harness bumps together ({{PR_REVIEWER}}\'s preference — bump ALL in one PR per run, do NOT stagger one-per-run).\n\n| ARG | Source | Sync requirement |\n|---|---|---|\n| `CLAUDE_CODE_VERSION` | npm `@anthropic-ai/claude-code` | none |\n| `PI_CODING_AGENT_VERSION` | npm `@earendil-works/pi-coding-agent` | MUST match `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`, and `@earendil-works/pi-coding-agent` in `package.json` (family-pin, all same version) — bump all together |\n| `CODEX_VERSION` | npm `@openai/codex` | MUST match direct `@openai/codex` in `package.json` (enforced by `scripts/check-codex-default-model.sh`) |\n| `OPENCODE_VERSION` | npm `@opencode/sdk` | Installs both `@opencode/cli` and `@opencode/sdk`. MUST match `@opencode/sdk` and `@opencode/plugin` in `package.json` devDependencies. OpenCode 2 ships under the `@opencode/*` scope; never take a version from `opencode-ai`, `@opencode-ai/sdk`, or the `sst/opencode` GitHub releases (still the 1.x line, which would be a downgrade) |\n\n## Steps\n\n1. `cd /workspace/repo && git fetch origin && git checkout main && git pull --ff-only`\n2. Read current pinned versions from `Dockerfile.worker` (the `ARG` lines) and from `package.json` (for the pi family + `@openai/codex` + `@opencode/sdk`).\n3. For each harness, fetch the latest version:\n - npm claude-code: `npm view @anthropic-ai/claude-code version`\n - npm pi: `npm view @earendil-works/pi-coding-agent version` (use the SAME version for `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` — they\'re a family pin)\n - npm codex: `npm view @openai/codex version`\n - npm opencode: `npm view @opencode/sdk version` (one version for `@opencode/cli`, `@opencode/sdk`, `@opencode/plugin`)\n4. Check for an existing open bundled PR — `gh pr list --search "harness upgrade in:title" --state open --repo {{REPO_URL}}`. Look at the branch prefix `harness-upgrade/`. If one is already open AND was created within the last 48h (sibling schedule may have just opened it), skip and complete with `"Skipped: sibling harness upgrade PR already open"` + the existing PR URL.\n5. Collect ALL outdated harnesses. If none are outdated, complete with output `"All harnesses up to date as of YYYY-MM-DD"` (no PR).\n6. Branch: `harness-upgrade/weekly-YYYY-MM-DD` (e.g. `harness-upgrade/weekly-2026-06-03`).\n7. Edit `Dockerfile.worker` — bump every outdated ARG. Also update `package.json`:\n - Codex: bump direct `@openai/codex` to match `CODEX_VERSION`.\n - Pi family: bump `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`, and `@earendil-works/pi-coding-agent` to the same new version as `PI_CODING_AGENT_VERSION`.\n - Opencode: bump `@opencode/sdk` and `@opencode/plugin` (devDependencies) to match `OPENCODE_VERSION`.\n Then re-run `bun install` so `bun.lock` updates.\n8. Local verification (all must pass before pushing):\n ```bash\n bun install --frozen-lockfile\n bun run lint\n bun run tsc:check\n bun test\n bash scripts/check-db-boundary.sh\n ```\n For Codex bumps: also run `bash scripts/check-codex-default-model.sh` (CI runs it too). It fails when `ARG CODEX_VERSION` differs from the direct `@openai/codex` pin in `package.json`, or when the Dockerfile\'s baseline `model = "..."` differs from `CODEX_DEFAULT_MODEL` in `src/providers/codex-models.ts`.\n Note: do NOT run `docker build -f Dockerfile.worker .` locally — the runner lacks docker. CI\'s `docker-worker-build` job is the acceptance gate.\n9. If any local check fails, apply the **partial-bundle fallback** before giving up — one harness must never block the others:\n **BASELINE FIRST — do this before ANY isolation work.** A red gate does NOT mean the bumps broke anything. Re-run the EXACT failing command on a pristine `origin/main` worktree with zero bumps applied (`git worktree add /tmp/hb-baseline origin/main && cd /tmp/hb-baseline && bun install --frozen-lockfile`) and compare. If the SAME checks fail with the SAME assertions and the same pass/skip/fail totals, the failure is **PRE-EXISTING and unrelated to the bumps** ⇒ it does NOT block this PR, and you must NOT attempt isolation for it. Proceed to step 10, open the PR, and add a `## Pre-existing failures (also fail on origin/main)` section quoting the verbatim error plus both totals (baseline vs branch) so the reviewer can see they match. Only a failure that is RED on your branch and GREEN on baseline was caused by a bump — apply a–d below to that failure ONLY. A gate that is red on origin/main is never a reason to skip the weekly bump.\n a. Isolate which harness\'s bump caused it. The error usually names the package; if not, revert bumps one at a time until the step-8 gate passes.\n b. Revert ONLY that harness — its `Dockerfile.worker` ARG **and** its `package.json` pins (respect the family/sync requirements in the table above) — then re-run `bun install` and the FULL step-8 gate.\n c. Gate green ⇒ open the PR with the remaining harnesses and add a `## Held back` section naming the excluded harness, current → attempted version, and the VERBATIM error. Name it in your `store-progress` output too.\n d. Gate still red with that harness reverted, OR the failure cannot be isolated to one harness ⇒ do NOT open a PR; report via `store-progress` with `failureReason`.\n Migrating an upstream API break (a removed option, a renamed export, a moved type) is still OUT OF SCOPE for this routine — hold that harness back per (c) rather than changing code here.\n10. Push the branch and open a PR:\n - Title: `chore(docker): bump harnesses (weekly YYYY-MM-DD)` — or list the bumps, e.g. `chore(docker): bump claude-code, pi, opencode (weekly YYYY-MM-DD)`.\n - Body: include a table of current → new versions for every harness bumped, link to each upstream changelog (e.g. https://github.com/anthropics/claude-code/releases/tag/v<X.Y.Z>), and the local verification output (or "all green").\n - Add `{{PR_REVIEWER}}` as a reviewer.\n - DO NOT merge — {{PR_REVIEWER}} reviews and merges.\n11. Verify CI on the PR — wait for the merge-gate workflow to start, confirm at least the `docker-worker-build` job is queued/passing. If CI fails, comment on the PR with the failure log and leave the branch for human review.\n\n## Constraints\n\n- **ONE bundled PR per run.** Bump every outdated harness in the same PR — do not stagger one-per-run. The ONLY permitted exclusion is a harness held back by the step-9 partial-bundle fallback, which must be declared in the PR body.\n- **No `--no-verify`, no force pushes.**\n- If a harness has a major-version bump (e.g. 2.x → 3.x), still bundle it but flag clearly in the PR description that this is a major bump and may need extra review.\n- Pi family-pin: all three pi packages MUST be the same version. If they drift in the lockfile, fix the drift in the same PR.\n\n## Sibling-schedule coordination\nThis schedule has a sibling — `weekly-harness-upgrade-check-wed` (Wednesday 05:00 BCN). If a sibling-opened PR is OPEN and `< 48h` old, step 4 skips. This is the dedup gate. Don\'t change branch-naming heuristics in a way that breaks the dedup.\n\n## Output\n\n`store-progress` with `status: "completed"` and `output` containing:\n- The PR URL (if one was opened) + the table of harnesses bumped, OR\n- "No-op: all harnesses up to date as of YYYY-MM-DD" with the version table, OR\n- "Skipped: sibling harness upgrade PR already open" with the existing PR URL.\n';
25553
+ var content_default67 = '# Weekly Harness Upgrade Check\n\nThis is a topology-preserving, anonymized port of the live Sunday schedule. It keeps its bundle, deduplication, baseline, partial-fallback, and CI acceptance behavior.\n\n```json\n{"cron":"0 16 * * 0","timezone":"UTC","agentRole":"lead","enabled":true}\n```\n\n**Weekly Harness Upgrade — self-hosted repository Dockerfile.worker**\n\nRepo: `{{REPO_URL}}` (working dir: `/workspace/repo`)\nFile: `Dockerfile.worker`\n\nThe worker image pins these harnesses via `ARG`. Each week, check upstream for new versions and open ONE PR bundling ALL outdated harness bumps together ({{PR_REVIEWER}}\'s preference — bump ALL in one PR per run, do NOT stagger one-per-run).\n\n| ARG | Source | Sync requirement |\n|---|---|---|\n| `CLAUDE_CODE_VERSION` | npm `@anthropic-ai/claude-code` | none |\n| `PI_CODING_AGENT_VERSION` | npm `@earendil-works/pi-coding-agent` | MUST match `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`, and `@earendil-works/pi-coding-agent` in `package.json` (family-pin, all same version) — bump all together |\n| `CODEX_VERSION` | npm `@openai/codex` | MUST match direct `@openai/codex` in `package.json` (enforced by `scripts/check-codex-default-model.sh`) |\n| `OPENCODE_VERSION` | npm `@opencode/sdk` | Installs both `@opencode/cli` and `@opencode/sdk`. MUST match `@opencode/sdk` and `@opencode/plugin` in `package.json` devDependencies. OpenCode 2 ships under the `@opencode/*` scope; never take a version from `opencode-ai`, `@opencode-ai/sdk`, or the `sst/opencode` GitHub releases (still the 1.x line, which would be a downgrade) |\n\n## Steps\n\n1. `cd /workspace/repo && git fetch origin && git checkout main && git pull --ff-only`\n2. Read current pinned versions from `Dockerfile.worker` (the `ARG` lines) and from `package.json` (for the pi family + `@openai/codex` + `@opencode/sdk`).\n3. For each harness, fetch the latest version:\n - npm claude-code: `npm view @anthropic-ai/claude-code version`\n - npm pi: `npm view @earendil-works/pi-coding-agent version` (use the SAME version for `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` — they\'re a family pin)\n - npm codex: `npm view @openai/codex version`\n - npm opencode: `npm view @opencode/sdk version` (one version for `@opencode/cli`, `@opencode/sdk`, `@opencode/plugin`)\n4. Check for an existing open bundled PR — `gh pr list --search "harness upgrade in:title" --state open --repo {{REPO_URL}}`. Look at the branch prefix `harness-upgrade/`. If one is already open AND was created within the last 48h (sibling schedule may have just opened it), skip and complete with `"Skipped: sibling harness upgrade PR already open"` + the existing PR URL.\n5. Collect ALL outdated harnesses. If none are outdated, complete with output `"All harnesses up to date as of YYYY-MM-DD"` (no PR).\n6. Branch: `harness-upgrade/weekly-YYYY-MM-DD` (e.g. `harness-upgrade/weekly-2026-06-03`).\n7. Edit `Dockerfile.worker` — bump every outdated ARG. Also update `package.json`:\n - Codex: bump direct `@openai/codex` to match `CODEX_VERSION`.\n - Pi family: bump `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`, and `@earendil-works/pi-coding-agent` to the same new version as `PI_CODING_AGENT_VERSION`.\n - Opencode: bump `@opencode/sdk` and `@opencode/plugin` (devDependencies) to match `OPENCODE_VERSION`.\n Then re-run `bun install` so `bun.lock` updates.\n8. Local verification (all must pass before pushing):\n ```bash\n bun install --frozen-lockfile\n bun run lint\n bun run tsc:check\n bun test\n bash scripts/check-db-boundary.sh\n ```\n For Codex bumps: also run `bash scripts/check-codex-default-model.sh` (CI runs it too). It fails when `ARG CODEX_VERSION` differs from the direct `@openai/codex` pin in `package.json`, or when the Dockerfile\'s baseline `model = "..."` differs from `CODEX_DEFAULT_MODEL` in `src/providers/codex-models.ts`.\n Note: do NOT run `docker build -f Dockerfile.worker .` locally — the runner lacks docker. CI\'s `docker-worker-build` job is the acceptance gate.\n9. If any local check fails, apply the **partial-bundle fallback** before giving up — one harness must never block the others:\n **BASELINE FIRST — do this before ANY isolation work.** A red gate does NOT mean the bumps broke anything. Re-run the EXACT failing command on a pristine `origin/main` worktree with zero bumps applied (`git worktree add /tmp/hb-baseline origin/main && cd /tmp/hb-baseline && bun install --frozen-lockfile`) and compare. If the SAME checks fail with the SAME assertions and the same pass/skip/fail totals, the failure is **PRE-EXISTING and unrelated to the bumps** ⇒ it does NOT block this PR, and you must NOT attempt isolation for it. Proceed to step 10, open the PR, and add a `## Pre-existing failures (also fail on origin/main)` section quoting the verbatim error plus both totals (baseline vs branch) so the reviewer can see they match. Only a failure that is RED on your branch and GREEN on baseline was caused by a bump — apply a–d below to that failure ONLY. A gate that is red on origin/main is never a reason to skip the weekly bump.\n a. Isolate which harness\'s bump caused it. The error usually names the package; if not, revert bumps one at a time until the step-8 gate passes.\n b. Revert ONLY that harness — its `Dockerfile.worker` ARG **and** its `package.json` pins (respect the family/sync requirements in the table above) — then re-run `bun install` and the FULL step-8 gate.\n c. Gate green ⇒ open the PR with the remaining harnesses and add a `## Held back` section naming the excluded harness, current → attempted version, and the VERBATIM error. Name it in your `store-progress` output too.\n d. Gate still red with that harness reverted, OR the failure cannot be isolated to one harness ⇒ do NOT open a PR; report via `store-progress` with `failureReason`.\n Migrating an upstream API break (a removed option, a renamed export, a moved type) is still OUT OF SCOPE for this routine — hold that harness back per (c) rather than changing code here.\n10. Push the branch and open a PR:\n - Title: `chore(docker): bump harnesses (weekly YYYY-MM-DD)` — or list the bumps, e.g. `chore(docker): bump claude-code, pi, opencode (weekly YYYY-MM-DD)`.\n - Body: include a table of current → new versions for every harness bumped, link to each upstream changelog (e.g. https://github.com/anthropics/claude-code/releases/tag/v<X.Y.Z>), and the local verification output (or "all green").\n - Add `{{PR_REVIEWER}}` as a reviewer.\n - DO NOT merge — {{PR_REVIEWER}} reviews and merges.\n11. Verify CI on the PR — wait for the merge-gate workflow to start, confirm at least the `docker-worker-build` job is queued/passing. If CI fails, comment on the PR with the failure log and leave the branch for human review.\n\n## Constraints\n\n- **ONE bundled PR per run.** Bump every outdated harness in the same PR — do not stagger one-per-run. The ONLY permitted exclusion is a harness held back by the step-9 partial-bundle fallback, which must be declared in the PR body.\n- **No `--no-verify`, no force pushes.**\n- If a harness has a major-version bump (e.g. 2.x → 3.x), still bundle it but flag clearly in the PR description that this is a major bump and may need extra review.\n- Pi family-pin: all three pi packages MUST be the same version. If they drift in the lockfile, fix the drift in the same PR.\n\n## Sibling-schedule coordination\nThis schedule has a sibling — `weekly-harness-upgrade-check-wed` (Wednesday 05:00 BCN). If a sibling-opened PR is OPEN and `< 48h` old, step 4 skips. This is the dedup gate. Don\'t change branch-naming heuristics in a way that breaks the dedup.\n\n## Output\n\n`store-progress` with `status: "completed"` and `output` containing:\n- The PR URL (if one was opened) + the table of harnesses bumped, OR\n- "No-op: all harnesses up to date as of YYYY-MM-DD" with the version table, OR\n- "Skipped: sibling harness upgrade PR already open" with the existing PR URL.\n';
25100
25554
 
25101
25555
  // src/be/seed/schedules-seeder.ts
25102
25556
  init_db4();
@@ -25117,24 +25571,20 @@ var BUILT_IN_SCHEDULE_SOURCES = [
25117
25571
  config: asText2(config_default61),
25118
25572
  content: asText2(content_default61)
25119
25573
  },
25574
+ { config: asText2(config_default62), content: asText2(content_default62) },
25575
+ { config: asText2(config_default63), content: asText2(content_default63) },
25120
25576
  {
25121
- config: asText2(config_default62),
25122
- content: asText2(content_default62)
25577
+ config: asText2(config_default64),
25578
+ content: asText2(content_default64)
25123
25579
  },
25124
- { config: asText2(config_default63), content: asText2(content_default63) },
25125
- { config: asText2(config_default64), content: asText2(content_default64) },
25126
25580
  {
25127
25581
  config: asText2(config_default65),
25128
25582
  content: asText2(content_default65)
25129
25583
  },
25584
+ { config: asText2(config_default66), content: asText2(content_default66) },
25130
25585
  {
25131
- config: asText2(config_default66),
25132
- content: asText2(content_default66)
25133
- },
25134
- { config: asText2(config_default67), content: asText2(content_default67) },
25135
- {
25136
- config: asText2(config_default68),
25137
- content: asText2(content_default68)
25586
+ config: asText2(config_default67),
25587
+ content: asText2(content_default67)
25138
25588
  }
25139
25589
  ];
25140
25590
  function parseScheduleSource(source) {