@akanjs/cli 3.0.0-alpha.6 → 3.0.0-alpha.60

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 (111) hide show
  1. package/.build-stamp +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/{abstractCompactor-e5yq60qz.js → abstractCompactor-20e0cwbs.js} +2 -2
  5. package/{agent.command-b0fcx3r2.js → agent.command-tsx1nsdj.js} +9 -7
  6. package/{application.command-cezdnvtq.js → application.command-n8ex8g9h.js} +8 -6
  7. package/{applicationBuildRunner-ha3y8ew8.js → applicationBuildRunner-x8h12rp0.js} +7 -7
  8. package/{applicationReleasePackager-gg8ccbeh.js → applicationReleasePackager-93nzdfsy.js} +1 -1
  9. package/buildBatch.proc.js +7 -5
  10. package/{capacitorApp-jaksw4cy.js → capacitorApp-8mv9e537.js} +29 -27
  11. package/{cloud.command-6xvsd487.js → cloud.command-f0rhw014.js} +12 -10
  12. package/{context.command-ajn1b683.js → context.command-s7a41qjs.js} +17 -15
  13. package/{dependencyScanner-f6grrep3.js → dependencyScanner-rbs2407b.js} +1 -1
  14. package/getDirname-ka11hm7e.js +8 -0
  15. package/{guideline.command-9ew6aqhm.js → guideline.command-qrw84ttb.js} +8 -6
  16. package/guidelines/agentRule/agentRule.generate.json +31 -0
  17. package/guidelines/agentRule/agentRule.instruction.md +525 -0
  18. package/guidelines/conventions/conventions.generate.json +25 -0
  19. package/guidelines/conventions/conventions.instruction.md +811 -0
  20. package/guidelines/cssRule/cssRule.instruction.md +7 -0
  21. package/guidelines/framework/framework.instruction.md +1 -0
  22. package/guidelines/mcpRule/mcpRule.generate.json +25 -0
  23. package/guidelines/mcpRule/mcpRule.instruction.md +176 -0
  24. package/guidelines/workspaceOnboarding/workspaceOnboarding.generate.json +24 -0
  25. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +638 -0
  26. package/incrementalBuilder.proc.js +8 -6
  27. package/{index-zr2wvx69.js → index-0g9ajdz8.js} +6 -6
  28. package/index-1577bej2.js +7 -0
  29. package/{index-wrrb8rxk.js → index-4mtyaqj2.js} +4 -4
  30. package/{index-bjpxzr6s.js → index-5vy2jzar.js} +1 -1
  31. package/{index-47qxw1tz.js → index-681sx6nt.js} +176 -66
  32. package/{index-zbx0jheb.js → index-8md8zszv.js} +14 -13
  33. package/{index-pfrt7qwh.js → index-cc1mgv1f.js} +4 -4
  34. package/{index-hk9rbcsz.js → index-ce9755av.js} +1 -1
  35. package/{index-jkynkyfk.js → index-ckpwq052.js} +5 -3
  36. package/{index-c94nq3c1.js → index-dvg362vq.js} +3 -3
  37. package/{index-vkqxw0ek.js → index-ek4mkwxp.js} +13 -13
  38. package/{index-exhpp31y.js → index-f0t3wjyr.js} +15 -7
  39. package/index-f39512t6.js +255 -0
  40. package/{index-m4kpmmtj.js → index-g44fykfw.js} +1 -1
  41. package/{index-nfxq39kj.js → index-h0mnt0b0.js} +37 -6
  42. package/{index-qp089jp0.js → index-j37qq1f2.js} +1 -1
  43. package/{index-hrwzz2wf.js → index-kkya2ped.js} +1 -1
  44. package/{index-j9cxndc0.js → index-qnv0aqmm.js} +9 -6
  45. package/{index-r7nqv8qj.js → index-r8z20vnj.js} +14 -14
  46. package/{index-6npsfpxr.js → index-ryns915y.js} +3 -3
  47. package/{index-0cj2zxbm.js → index-sh01r3pm.js} +1 -1
  48. package/{index-60a3jfcz.js → index-tn46rr6w.js} +8 -8
  49. package/{index-jn9vfpwz.js → index-wc19q8mb.js} +54 -15
  50. package/{index-meh7mmz9.js → index-x1dbhd80.js} +8 -4
  51. package/{index-qb61h0k7.js → index-xj5b8c0w.js} +193 -565
  52. package/index.js +21 -19
  53. package/{library.command-aa1g8nsa.js → library.command-p3jgf5h2.js} +6 -4
  54. package/{localRegistry.command-jha3sn50.js → localRegistry.command-bms0qn6t.js} +11 -9
  55. package/{module.command-b1zzgqde.js → module.command-hb3xm07r.js} +10 -8
  56. package/{package.command-9qv6vr9s.js → package.command-fbpa6r4w.js} +6 -4
  57. package/package.json +3 -4
  58. package/{page.command-k16pw244.js → page.command-92vrmw96.js} +6 -4
  59. package/{primitive.command-s60dn1ae.js → primitive.command-k6b9jfxr.js} +11 -9
  60. package/{quality.command-p3vaze52.js → quality.command-89vwmjvf.js} +194 -112
  61. package/{recipeScanner-tghkzrrt.js → recipeScanner-zrzev25x.js} +3 -3
  62. package/{repair.command-mfv749ha.js → repair.command-nh9jn8rh.js} +8 -6
  63. package/{routeSourceValidator-cncd92fv.js → routeSourceValidator-7fycdqyv.js} +3 -39
  64. package/{scalar.command-hs3nwzv1.js → scalar.command-zcsjgvax.js} +9 -7
  65. package/templates/app/env/env.server.debug.ts.template +1 -5
  66. package/templates/app/env/env.server.develop.ts.template +1 -5
  67. package/templates/app/env/env.server.local.ts.template +1 -5
  68. package/templates/app/env/env.server.main.ts.template +1 -5
  69. package/templates/app/page/styles.css.template +3 -3
  70. package/templates/appSample/srvkit/AuthGuard.ts +9 -5
  71. package/templates/appSample/srvkit/SessionInternalArg.ts +4 -3
  72. package/templates/crudPages/[__model__Id]/edit/page.tsx +1 -1
  73. package/templates/crudPages/new/page.tsx +1 -1
  74. package/templates/crudSinglePage/page.tsx +1 -1
  75. package/templates/env/_env.server.type.ts +7 -5
  76. package/templates/lib/db.ts +1 -1
  77. package/templates/lib/dict.ts +1 -1
  78. package/templates/lib/sig.ts +1 -1
  79. package/templates/lib/srv.ts +6 -2
  80. package/templates/module/__Model__.Util.tsx +1 -1
  81. package/templates/module/__Model__.Zone.tsx +3 -5
  82. package/templates/workspaceRoot/AGENTS.md.template +0 -971
  83. package/templates/workspaceRoot/CLAUDE.md.template +19 -0
  84. package/templates/workspaceRoot/biome.json.template +4 -239
  85. package/templates/workspaceRoot/docs/AI-DEVELOPMENT.md.template +10 -14
  86. package/templates/workspaceRoot/docs/GENERATED.md.template +12 -10
  87. package/{typeChecker-kravn7ns.js → typeChecker-g6bh0xfv.js} +1 -1
  88. package/typecheck.proc.js +1 -1
  89. package/{workflow.command-z0e0fk0z.js → workflow.command-jj0cphfm.js} +16 -14
  90. package/{workspace.command-3ydyy991.js → workspace.command-ma3g994j.js} +71 -52
  91. package/templates/workspaceRoot/.cursor/rules/akan-page-routing.mdc.template +0 -43
  92. package/templates/workspaceRoot/.cursor/rules/akan-sync-conventions.mdc.template +0 -34
  93. package/templates/workspaceRoot/.cursor/rules/application-test-commands.mdc.template +0 -16
  94. package/templates/workspaceRoot/.cursor/rules/change-scope.mdc.template +0 -12
  95. package/templates/workspaceRoot/.cursor/rules/client-server-boundaries.mdc.template +0 -18
  96. package/templates/workspaceRoot/.cursor/rules/coding-comments.mdc.template +0 -25
  97. package/templates/workspaceRoot/.cursor/rules/coding-style.mdc.template +0 -44
  98. package/templates/workspaceRoot/.cursor/rules/domain-module-conventions.mdc.template +0 -17
  99. package/templates/workspaceRoot/.cursor/rules/format-lint.mdc.template +0 -12
  100. package/templates/workspaceRoot/.cursor/rules/lint-enforced-rules.mdc.template +0 -18
  101. package/templates/workspaceRoot/.cursor/rules/module-file-playbook.mdc.template +0 -61
  102. package/templates/workspaceRoot/.cursor/rules/naming-conventions.mdc.template +0 -15
  103. package/templates/workspaceRoot/.cursor/rules/react-component-style.mdc.template +0 -21
  104. package/templates/workspaceRoot/.cursor/rules/repo-overview.mdc.template +0 -17
  105. package/templates/workspaceRoot/.cursor/rules/scalar-modeling.mdc.template +0 -58
  106. package/templates/workspaceRoot/.cursor/rules/secrets-and-env.mdc.template +0 -13
  107. package/templates/workspaceRoot/.cursor/rules/service-signal-conventions.mdc.template +0 -83
  108. package/templates/workspaceRoot/.cursor/rules/srvkit-adapters-and-guards.mdc.template +0 -50
  109. package/templates/workspaceRoot/.cursor/rules/ssr-first.mdc.template +0 -59
  110. package/templates/workspaceRoot/.cursor/rules/typescript-imports.mdc.template +0 -16
  111. /package/{index-r24hmh0q.js → index-yb2wsc7f.js} +0 -0
@@ -0,0 +1,525 @@
1
+ # Akan In-Page Agent Guideline
2
+
3
+ ## Purpose
4
+ Use this when mounting `<Agent.Chat />`, publishing an `st.tool`, or deciding what an in-page agent may read and
5
+ drive. The always-loaded convention set carries the short version; this is the full contract.
6
+
7
+ ## The Surface Is The Declaration
8
+
9
+ Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
10
+ declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
11
+ store key readable while the reading component is mounted. Nothing is derived from a store class — declaring a
12
+ method on a `store(...)` gives an agent nothing at all, because a lever the screen does not offer the user is not
13
+ one an agent may pull in their place, and a module's whole vocabulary published at once was noise the model paid
14
+ for. `Load` scopes, the route, and the live keys complete the context. The React core is the `use-agentic` package;
15
+ apps and libs never import it directly (`no-import-external-library`) — everything reaches them through `st.*` and
16
+ `akanjs/ui`.
17
+
18
+ ## Mounting The Chat
19
+ - **Mount `<Agent.Chat />` once in a layout.** That is the floating chat, the approval card, and the client-side
20
+ loop. The default runner drives `runAgentTurn`, which the **framework serves on every app** — no lib to mount,
21
+ `AKAN_AGENT=false` takes it off — and negotiates streaming via `accept`, so assistant text arrives as it is
22
+ generated with zero app code. The endpoint is a stateless relay and **never executes tools**: every tool runs in
23
+ the caller's own browser session, gated by guards and the approval card. Its guard is `AgentRelayAccess`, which
24
+ **refuses every call until the app names a guard of its own** — the same answer `None` gives, with no boot
25
+ warning. A product with accounts names it in its `option.ts`, `option.setAgentAccess(SignedIn)`, taking the same
26
+ guard classes every endpoint takes (an array is ANDed, `null` clears what a library set); without one the chat
27
+ cannot spend the LLM key.
28
+ `persist` keeps the transcript across reloads (sessionStorage; `{ storage: "local" }` to outlive the tab),
29
+ default off, and `shortcut={false}` gives the browser back the Cmd/Ctrl+L the launcher otherwise captures.
30
+ **`persist` also takes a `SessionHistory` of the app's own** — `{ load, save, clear }` — which is how a
31
+ transcript lives on a server instead of in web storage. All three may answer asynchronously; a `load` still in
32
+ flight when the user sends the first message is **dropped, not merged**, because the conversation on screen is
33
+ the one they are having — which makes **sending on mount a race the restore loses**, so an opening prompt waits
34
+ for `session.isRestoring` to clear or goes into `defaultDraft` and lets the user send it. Saves are chained rather than fired per change, so a slow store cannot land an older
35
+ transcript last. **Every session option on the chat — `runner`, `instructions`, `maxTurns`, `compact`,
36
+ `builtins`, `persist`, `onCompact` — is ignored when an enclosing `Agent.Zone` or `AgentProvider` already holds
37
+ a session**: the chat binds to that one, and the options belong to whoever built it.
38
+ **A session the chat made ends when the chat unmounts** — nothing renders its approvals once it is gone, so a
39
+ turn left running would drive a screen the user has navigated away from; a session handed down by an
40
+ `AgentProvider` or an `Agent.Zone` belongs to whoever provided it.
41
+ - **The panel is customized in three widening steps, and most designs stop at the first.** Props first:
42
+ `open` / `onOpenChange` drive it from the app's own control (`launcher={false}` then draws no launcher of its
43
+ own), `launcherClassName` / `panelClassName` reach one surface each where `className` reaches both, `intro`
44
+ replaces the empty-state line — where starter questions go — and `header` adds controls beside the built-in
45
+ clear and close. `chrome={false}` drops the header bar whole, for an `inline` chat inside a panel the app
46
+ already titles, and `defaultDraft` opens the composer with text in it without sending it. A panel driven by a
47
+ controlled `open` with **no** `onOpenChange` draws no close button at all rather than an inert one. Then the
48
+ slots: `AgentLauncher`, `AgentBubble`, `AgentComposer`, `AgentApproval`,
49
+ `AgentQuestion`, `AgentMenu`, `AgentMarkdown` and `AgentCode` each replace one part in `_overrides.tsx`, and
50
+ `akanjs/ui` exports every default beside them (`DefaultBubble`, `DefaultComposer`, …) so a skin composes the
51
+ one it is replacing. `AgentCode` is where a highlighter binds — the fence's language reaches it — and a
52
+ replacement for `AgentBubble` carries its own `memo`, since the transcript re-renders on every delta. Only then
53
+ `AgentChat`, which replaces the panel whole: it also replaces the slash commands, the compaction notice and the
54
+ approval gate, so reach for it when the *layout* differs, not when the look does.
55
+ A manifest reaches a layout-mounted chat like any other component: the override provider wraps the whole layout
56
+ stack, root layouts included. `UiOverrideProvider` is also a public `akanjs/ui` export, for giving one mount site
57
+ a different slot map than its route would.
58
+ - **The composer is a textarea**: Enter sends, Shift+Enter writes a newline, and it grows to a few lines before
59
+ it scrolls. The vertical arrows still walk what was sent, but only from the first or last line — anywhere else
60
+ the caret is the textarea's own. On a phone the panel is the whole screen (it is a card from `sm:` up) and it
61
+ lifts above the on-screen keyboard, which only `visualViewport` reports.
62
+ - **The LLM is configured in `option.ts`, never through the environment.** `option.setLlm({ apiKey, model, host })`
63
+ — or `setLlm((options) => …)` to read the key out of the app's own env object, which is where a secret belongs —
64
+ fills whichever adaptor holds `LlmAdaptorRole`, reaching it as the `llmOption` use. The settings are the role's
65
+ rather than one provider's, so they survive a swap. **DeepSeek is the built-in default** (`deepseek-v4-flash` at
66
+ `https://api.deepseek.com`); with no `apiKey` the app still boots and the chat answers `llmUnavailable`. Swap
67
+ providers the way middleware is applied: `option.applyAdaptor(LlmAdaptorRole, ClaudeLlm)`, where the
68
+ implementation is an `adapt()` class in a `srvkit/` implementing `LlmAdaptor.chat(request, onDelta?)` — ignore
69
+ `onDelta` and the chat still answers whole.
70
+ - **An adaptor answers `null` for "not configured" and *throws* for a refusal it can explain.** The two are
71
+ different things to be told: collapsing both into `null`, the way the adapter convention otherwise reads, left a
72
+ user reading `llmUnavailable` — "no model is configured" — about a conversation that had merely outgrown the
73
+ context window. A thrown `Err` reaches the chat as its own text, so the reason the provider gave is what the
74
+ user sees. It travels as the dictionary key plus the values that key interpolates, on both the JSON and the SSE
75
+ path, and **`fetchRunner` resolves it against the dictionary one step before the transcript** — the endpoint has
76
+ no language to resolve it in and the browser does, which is why the key was reaching the screen raw.
77
+
78
+ ## Attachments And Speech
79
+ - **A file the user attaches rides the message, and nothing is stored.** The composer takes a paperclip, a drop and
80
+ a paste; an image rides as bytes and a text file as text, which is all a browser reads with no dependency.
81
+ Everything else is the app's own reader — `<Agent.Chat attach={…} />`, one `File` in, a `MessageAttachment` or
82
+ `null` out — because extracting a PDF needs a parser and the framework carries attachments without depending on
83
+ one. It runs ahead of the built-in, so it is also where an image is downscaled before it costs a megabyte of
84
+ prompt. **What the provider cannot read is replaced by a note naming the file**, never dropped: an attachment the
85
+ model never saw is one it answers about from the filename. An adaptor declares `accepts: { image, document }` and
86
+ `AgentService.readable` degrades the rest, so a text-only provider needs no attachment code at all — DeepSeek
87
+ declares none, which is why an image against the default provider is refused out loud while an extracted PDF
88
+ works, `text` being readable by every model there is. **A `prompt()`'s `Msg.image` is the same wire shape** and
89
+ reaches the chat as an attachment rather than the literal `[image]` it used to become. Persisting keeps each
90
+ attachment's name and drops its content: web storage is a few megabytes, one screenshot fills a chunk of it, and
91
+ a save that fails is silent — so keeping the bytes would quietly stop keeping the transcript.
92
+ **The ceilings are the message's, not the file's**: 4 MB per file, 8 MB and five files per message, and the same
93
+ file twice is refused by name. The bytes ride inside one turn's JSON, so what a provider refuses is the sum —
94
+ and a request that cannot be sent is one the user has to empty the composer to escape, which is why the refusal
95
+ happens at the paperclip and names the file it dropped.
96
+ - **Speech is one engine contract and the framework's own policy.** `<Agent.Chat voice={engine} />` takes a
97
+ `VoiceEngine` — `listen(handlers)` and `speak(sentence)`, both cancellable — and the chat decides everything
98
+ else: a press-to-talk microphone whose transcript lands in the composer to be corrected, one utterance per
99
+ press, sentence-at-a-time reading, barge-in on the next press or on Stop, and markdown stripped so `**bold**`
100
+ is not pronounced. **A reply is read aloud only when the ask arrived by voice**, so a typed question never turns
101
+ on the speakers — and it needs no wire field, because how a message was sent is the composer's own business.
102
+ A question or an approval the loop parked on is read aloud under that same condition, because the loop stops
103
+ there: a voice user who is never told about the card is a conversation that simply ends.
104
+ The contract is a subscription rather than `listen(): Promise<string>` on purpose: a promise fits push-to-talk
105
+ and nothing else, so hands-free could then only arrive as a breaking change. `useSpeech` in a util lib's
106
+ `webkit/` is the engine — the browser's own recognition and synthesis on the web, the Capacitor plugins in a
107
+ WebView, **which has neither on Android or iOS**, so a `speech.plugin.ts` declares the permission and
108
+ the packages the native build needs. An engine answering `available()` false renders no microphone at all,
109
+ the same rule as publishing no tool for a control the screen does not draw.
110
+ - **`attach` and `voice` both carry functions, so a server layout cannot pass either.** A closure does not cross
111
+ the RSC boundary — `non-scalar-props-restricted` says so on `page/**` — so an app that wants either mounts the
112
+ chat from a small client component in the app's own `ui/` that calls the hook.
113
+ - **A dialog's close is the dialog's own dismissal, not a state flip.** `closeDialogIn<Ns>` (and `Dialog.Close`)
114
+ run through whatever `Dialog.Modal` registered, so the agent takes the exact path the X button takes —
115
+ `confirmClose` still prompts and `onCancel` still fires. A close that only set `open` to false would skip both,
116
+ which is a different action wearing the same name.
117
+
118
+ ## Zones And Route Guidance
119
+ - **`<Agent.Zone id="comments">` runs a second agent over one section, in parallel with the root.** Everything
120
+ mounted inside — `st.use` subscriptions, hook tools, Guides — belongs to that zone's own conversation *and*
121
+ stays visible to the root agent: **zones are views, never walls**, so wrapping a section costs the root nothing.
122
+ An `Agent.Chat` inside binds to the zone session automatically; a zone's `readScreen` reads only its own
123
+ `data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
124
+ membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
125
+ mounts it in.
126
+ - **Everything a zone publishes is named `<id>.<name>`,** and that name is what instructions must use. A bare
127
+ `createVideoProject` inside a zone naming its tool `videoProjectDraft.createVideoProject` is a tool that does
128
+ not exist: the model calls it, the surface answers `Unknown tool`, and a turn is gone. Build the name from the
129
+ id (``const zoneTool = (name) => `${ZONE}.${name}` ``) rather than writing it in two places, and read
130
+ `Agent.Context`'s **Assemble** to see the published list — the names exist in neither file's source. The dot is
131
+ the surface's own join and never reaches a provider: `AgentService` renames it to `__` on the wire, because
132
+ every OpenAI-compatible and Anthropic function schema allows `[A-Za-z0-9_-]` only, up to 64 characters.
133
+ - **`builtins` decides which of the runtime's own tools a session gets** — all of them by default, `false` none,
134
+ an array exactly the ones it names, on `Agent.Zone` and `Agent.Chat` alike. A zone whose conversation must stay
135
+ on one screen takes `navigate` and `goBack` off it: `<Agent.Zone id="wizard" builtins={["readScreen",
136
+ "readState"]}>`. The tools are **withheld, not discouraged** — a withheld name answers the same "unknown tool"
137
+ a name that was never registered gets, so no prompt can talk the model past it, and the narrowing is the
138
+ session's alone: the surface is shared, and the neighbouring zone still navigates. Shadowing a built-in by
139
+ declaring a hook tool of the same name works at the root and **not inside a zone**, where the hook entry is
140
+ registered under its scope-prefixed name and never collides — `builtins` is the zone's answer. A tool the screen
141
+ declares under a built-in name is the screen's, not the runtime's, so `builtins={false}` leaves it standing.
142
+ - **`<Agent.History load save clear onCompact />` backs a zone's transcript from a leaf inside it**, instead of
143
+ through `persist` on whoever builds the session. A function cannot cross the server/client boundary as a prop,
144
+ so `persist={myStore}` makes every ancestor up to the zone a client component; mounted this way the only client
145
+ module is the leaf, and `Agent.Zone` and the chat inside it stay server-assembled. Same shape as `Agent.Guide`,
146
+ and it renders nothing. Restoring follows the session's one rule — it lands only while nothing has happened to
147
+ the conversation yet — so mounting with the zone restores and mounting later saves from there on, with the
148
+ store never asked for a transcript that would be discarded. `session.setHistory` / `setOnCompact` are the same
149
+ thing for a session an app built itself — and are what a host calls when the store must outlive the view, since
150
+ the component's store lives exactly as long as the component. A zone's own session dies with it either way; one
151
+ the app handed in through `session` does not, and its saving stops when the `Agent.History` unmounts.
152
+ - **A zone can be handed a session instead of building one**: `<Agent.Zone session={mine} onSession={…}>`. The app
153
+ then owns it, so unmounting the zone leaves it running — the opposite of a session the zone made, which it
154
+ aborts. `onSession` fires either way, for a page that wants to send into the conversation from outside it.
155
+ - **`akanjs/ui` exports what a custom transport or transcript needs**, so no app reaches into the package's
156
+ internals: `httpRunner` (the wire the default runner speaks — point it at an endpoint of the app's own and
157
+ streaming is kept), `fetchRunner` (the default, to wrap), `AgentSession`, `AgentProvider`, and the
158
+ `AgentRunner` / `RunnerRequest` / `RunnerEvent` / `ChatMessage` / `SessionHistory` / `PublishedTool` /
159
+ `ContextBlock` types. `onCompact(replaced, summary)` reports a compaction's cut, for a host that keeps a
160
+ server-side summary and has to move its own watermark to the same place.
161
+ - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or a page — the render
162
+ tree is the cascade: nested Guides concatenate outer-to-inner and navigating away withdraws them. It is a
163
+ component, not a pageConfig field. Module `*.abstract.md` files are developer docs and are never served to the
164
+ agent.
165
+
166
+ ## Publishing A Tool
167
+ - **Declare the tool beside the control that already does it.**
168
+ `st.tool("x").desc("…").arg("id", ID).opt("force", Boolean).exec(fn)` publishes one action and returns the
169
+ callable to hand to `onClick` — one handler for the person and the agent, which is the point: a button wired to
170
+ an inline arrow can be clicked by a person and by nobody else. `.desc()` is required and comes first, `.arg()`
171
+ is an argument the caller must pass and `.opt()` one it may leave out (the `exec` parameter is then `| null`),
172
+ and `.exec()` is the only hook, so the chain completes in one unconditional statement and the callable carries
173
+ `data-akan-action` like a store setter does. A `remove*` name defaults to a confirm gate. Reach a store action
174
+ from the body — `.exec((id) => st.do.removeX(id))` — which is how an agent gets CRUD; `st.do` on its own
175
+ reaches nobody.
176
+ - **The second argument is how the call behaves, never what it is.** `{ settle, confirm, guard }`:
177
+ `settle: false` marks a read that returns what is already there and skips the wait; every other tool is waited
178
+ out before its effect on the screen is reported, because a write may still be landing when `exec` resolves. A
179
+ tool that reads and one that writes are otherwise the same declaration — there is no taxonomy field, because
180
+ nothing downstream would read one.
181
+ - **A falsy name declares the tool without publishing it** — the callable still drives the click a person makes,
182
+ and the agent never learns the tool exists. That is the only way a conditional surface stays legal, because
183
+ `.exec()` is a hook and the declaration can never be skipped: withhold the name, not the call. `st.useState`
184
+ and `st.expose` take a falsy name the same way, and an unpublished callable carries no `data-akan-action` —
185
+ that attribute names a tool an agent can reach. **Publish a tool only where the screen already renders the
186
+ control**: a lever no one can pull by hand is not one to hand an agent, and every published tool is paid for in
187
+ every turn's prompt. The mirror of the same rule is why the control gets a tool at all.
188
+ - **An `enumOf` class is a complete argument type on its own**: `.arg("mode", TaskStatus)` publishes the values as
189
+ the argument's `enum`, refuses anything off them by name at call time, and narrows the `.exec` parameter to the
190
+ value union — nothing else to write, and the scalar (`string` / `integer` / `number`) comes from the values.
191
+ **A value set the *render* decides takes `.arg(name, type, { oneOf })`** (or `.opt`) instead, because `enumOf` registers
192
+ globally and a component cannot build one per render: pass the list it has — a slice's sort keys, the options a
193
+ prop carried — and it is published and enforced the same way. Neither reaches a set that fills in *after* the
194
+ first render, since a declaration is mount-static; put that in the tool's `guard`, which is re-read per call and
195
+ can name the current values in its refusal. **An argument type nothing can describe — a model class, `Any`, a
196
+ `Map` — withdraws the whole tool and says so on the console**, naming the tool, the argument and the type; the
197
+ callable still drives the click a person makes. It does not throw: a tool schema is built during render, and an
198
+ agent-tooling mistake that aborted the render would cost the route its server rendering. `st.useState`'s `set`
199
+ degrades the same way, to read-only.
200
+ - **A component that renders once per row never closes over its row's id — it takes the id as an argument.** A
201
+ tool that captured its own row would be fifty registrations of one name, forty-nine of them shadowed, and the
202
+ survivor would remove whichever row happened to mount last. Take the id instead — `removeTask(taskId)`, never
203
+ fifty `removeTask` — and every row's registration is then interchangeable, so a row component may publish after
204
+ all. **Nothing is written to say so: the description says it.** Fifty rows of one component produce one name
205
+ and one `.desc()`, which is the same declaration fifty times over and warns about nothing; a second description
206
+ arriving under a name already taken is two components that happened to pick one word, and that warns. So the
207
+ rule an app follows is about the sentence, not about a flag — two tools that would do different things
208
+ (a different `modal`, a different redirect) cannot honestly share a description, and that pair wants a
209
+ `namespace` or nothing at all. The ids come from the `<slice>.items` resource `Load.Units` and
210
+ `Data.ListContainer` already expose.
211
+
212
+ ## What `akanjs/ui` Publishes For You
213
+ - **`akanjs/ui` publishes its own controls, so an app writes nothing for them.** `Data.ListContainer` (and every
214
+ `Model.AdminPanel`) publishes its toolbar and its row verbs; `Model.NewWrapper` (so `Model.New` too) publishes
215
+ `new<Model>`; `Load.Units`, `Load.Pagination` and `Data.Pagination` publish `setPageOf<Model>`; `Layout.Sider`,
216
+ `System.SelectLanguage`, `Link.Back` and `System.ThemeToggle` publish the shell. **A component that can render
217
+ twice on one screen takes a `namespace` prop and publishes nothing without it** — `Tab`, `Dialog`,
218
+ `ScreenNavigator`, `Dropdown`. Pass one (`<Tab namespace="detail">`) and the tool becomes `switchTabInDetail`;
219
+ leave it off and that tab is invisible to the agent, because two tabs answering to `switchTab` would mean the
220
+ first to mount loses. A named `Dropdown` publishes `openDropdownIn<Ns>` / `closeDropdownIn<Ns>` and the state
221
+ `dropdownIn<Ns>`, and its trigger annotates whichever of the two its next click performs. `Model.NewWrapper`
222
+ takes the same prop but publishes without one, because its slice already names it — a second create trigger for
223
+ the same slice, opening a form seeded differently, is what needs the suffix.
224
+ - **The `Model.*` row wrappers publish their verb, taking the id.** `Model.EditWrapper`, `Model.ViewWrapper`,
225
+ `Model.RemoveWrapper` and `Model.Remove` publish `edit<Model>` / `view<Model>` / `remove<Model>` with a
226
+ `modelId` argument, so a list built from `Load.Units` and an app's own `Unit` reaches the same verbs an
227
+ `AdminPanel` does. `Model.SureToRemove` publishes the same — except under `typeNameToRemove`, where it
228
+ publishes nothing: that gate makes a person retype the model's name, an approval card is one click, and
229
+ offering the lever at a friction the screen does not have is not the same control.
230
+ - **A dropdown's menu is mounted from the first render and hidden while closed** — the deliberate opposite of the
231
+ modal rule below, because a menu is one click away rather than a surface of its own. A tool is declared by a
232
+ mount effect, so an unmounted menu is one whose row verbs and field setters do not exist yet: an agent asked for
233
+ one finds nothing, and no catalogue entry hints that opening the menu would help. `readScreen` still skips
234
+ hidden content, so the items themselves are read only after `openDropdownIn<Ns>` — what a closed menu publishes
235
+ is its tools, not its text. The cost is that `content` renders on page load, so a heavy panel belongs behind a
236
+ `Dialog` instead.
237
+ - **A modal publishes its verbs while it is open, and only then.** `Model.EditModal` publishes `submit<Model>`
238
+ and `cancelEditOf<Model>`, `Model.ViewModal` publishes `closeViewOf<Model>`, and `Model.ViewEditModal`
239
+ publishes `edit<Model>` / `submit<Model>` / `closeViewOf<Model>` — each from a subtree that mounts with the
240
+ open modal, never from the component that merely holds it. That is what makes a list legal: `Data.CardList`
241
+ renders one editor per row, and at most one of them is ever open, so one name is registered rather than fifty.
242
+ It also means the verb is absent from the catalogue while nothing is open, which is honest — and costs the
243
+ agent nothing, because the catalogue is re-read on the turn that follows the tool call that opened the form.
244
+
245
+ ## Forms
246
+ - **A form control publishes its own setter, and reading a form publishes one tool that fills several at once.**
247
+ Both are free: an app writes no `st.tool` for a form. A `Field.*` / `Input.*` / `Select` / `Switch` handed
248
+ `onChange={st.do.setTitleOnTask}` **by reference** publishes `setTitleOnTask` while it is on screen — the same
249
+ reference that earns `data-akan-action`, so the tool and the person press one function and an inline arrow
250
+ still publishes nothing. `st.use.taskForm()` adds `fillTaskForm(patch)`, which takes several fields in one call
251
+ and is the only way to reach a list, a map, or an embedded object, whose rows are written through
252
+ `writeOnTask(path, value)` and can carry no annotation. It is a patch: a field left out keeps its value.
253
+ `fillTaskForm` refuses a plain field whose control is not on screen and names the ones that are; a composite it
254
+ cannot see is let through, which is the one place an agent reaches a field the screen may not draw. Never a
255
+ relation (picked or uploaded, not typed), a base document field, or a `hidden`/`secret` one at any depth —
256
+ their reads are masked and a writer would be the door around that. `st.use.taskForm({ agent: false })`
257
+ withholds the patch tool; an inline arrow withholds a control's own.
258
+ **The patch writes each plain field through that control's own published tool, not the setter underneath it** —
259
+ which is what carries the control's `transform`, so a field cannot normalize one way for `setPhoneOnBizAccount`
260
+ and another way for `fillBizAccountForm`. Only a composite, having no control, dispatches its setter directly.
261
+ The patch tool's entry is a pure function of the model, so a form put on screen by a shell that subscribes it
262
+ (`Model.EditModal`) *and* by the `Template` inside it registers the same name with the same description twice
263
+ over — one declaration, not a clash. Neither has to suppress the other, and neither is asked to.
264
+ - **A `disabled` control publishes nothing, so the agent never gets a lever the person cannot pull.** Every value
265
+ control reads it — `Field.*`, `Input.*`, `Select`, `Switch`, and the four relation pickers — and disabling a
266
+ mounted control withdraws its tool for as long as it stays disabled. One gate covers both writers: with no
267
+ control published, `fill<Model>Form`'s guard refuses that field too. `readScreen` says `(disabled)` beside the
268
+ control, from the native attribute or `aria-disabled`, so a refusal is something the agent could have read first
269
+ rather than a surprise. This is the same rule as publishing only where the screen renders the control, applied to
270
+ a control the screen renders but withholds.
271
+ - **Whatever the wrapper was for, there is a place to put it that is not the wrapper.** An inline arrow is the one
272
+ shape that publishes nothing, so each reason for writing one has its own home, and reaching for that home is what
273
+ keeps the field reachable:
274
+ - *normalize* — `(v) => set(formatPhone(v))` becomes the control's own `transform` prop, which every text and
275
+ number `Field.*` already takes (`Field.Phone` defaults it to `formatPhone`). `onChange` stays a reference, and
276
+ **`transform` runs on the agent's write too**, by both paths — the field's own tool and `fill<Model>Form`,
277
+ which goes through the control to get it — otherwise a person would store `010-1234-5678` and an agent the raw
278
+ digits. It normalizes one scalar, so an array control applies it per element and a cleared nullable field stays
279
+ null. It is the *control's* rule, though: a rule that must hold however the field is written — including a
280
+ composite path or a base-document write — belongs in `_postSet<Field>` below.
281
+ - *multi-write* — `(v) => { set(v); other(v); }` becomes a **`_postSet<Field>` method on the store**, and the
282
+ control keeps handing over the generated setter by reference. It runs right after the field is written, so it
283
+ reads the new value, and it reaches every other generated action with `this.` —
284
+ `_postSetToBiz(toBiz) { if (toBiz) this.addSendEmailsOnEstSheet(toBiz.sendEmails ?? []); }`. Nothing about the
285
+ control changes, so `data-akan-action` **and** `data-akan-state` both survive, and the rule now fires for every
286
+ writer — the person, the agent, `fill<Model>Form` — which is what a rule about a field should do.
287
+ **A generated action cannot be overridden, so do not try.** They all come from mapped types, and a mapped type
288
+ produces *properties*: a subclass method of the same name is `TS2425`, optional or not, and the two shapes
289
+ TypeScript does allow — a class field and a getter — are both skipped by `StoreRegistry.register`, which only
290
+ collects prototype descriptors holding a function. There is no legal middle, which is exactly why the hook
291
+ carries a leading `_` and no model suffix: a name no mapped type can produce is the only name a subclass may
292
+ declare. It cannot be typed either, for the same reason, so a misspelled field is named on the console at
293
+ registration instead. Calling a generated action *from* a custom one is fine and always was — `this.setXOnY(v)`
294
+ typechecks anywhere.
295
+ - *nested path* — `(v) => writeOnTask("payments.3.name", v)` has no home and needs none: an embedded row is
296
+ unannotatable by design, and an agent reaches it through `fillTaskForm`, which waves composites through.
297
+
298
+ `no-unpublished-form-setter.grit` errors on the pure-forwarding shape only, because every other one has a
299
+ legitimate reading. **`akan quality scan` counts them all** (`akan.agent.unpublished-form-setter`, one warning per
300
+ file): the lint rule is the per-line enforcement, the scan is the inventory of fields this screen writes but
301
+ cannot be asked to write.
302
+ - **A relation reaches an agent from its picker, not from the form patch.** `fillTaskForm` publishes no schema for
303
+ one and is right not to: the form holds the whole related document, so an id would need a lookup the store does
304
+ not do. The picker is where that lookup lives, so `Field.Parent` / `Field.Children` publish the pair themselves —
305
+ `load<Field>OptionsOn<Model>`, which loads the slice and returns `[{ id, label }]`, and the field's own
306
+ `set<Field>On<Model>` taking `<field>Id` / `<field>Ids`. Listing is its own tool because loading is its own step
307
+ for a person too: the options arrive when the dropdown opens, and an agent never opens it. `Field.ParentId` /
308
+ `Field.ChildrenId` need none of that — the id *is* the value, so the ordinary setter describes it. All four still
309
+ require the setter **by reference**, and a `disabled` picker publishes nothing.
310
+ - **An array of embedded rows also publishes `add<Field>On<Model>` and `sub<Field>On<Model>`** — append, and
311
+ remove-by-position — beside the whole-array setter. Not new authority: the setter can already produce any array
312
+ those two can, so they are strictly weaker. What they add is that neither can touch a row it was not given, and
313
+ that is the point: writing the whole array means echoing every row the agent is *not* changing, `checked`
314
+ validates types and not values, so one mistyped row nobody asked about is written silently. Both take a list and
315
+ act atomically, because removing positions one call at a time would shift the ones not yet removed. **Only an
316
+ embedded-row array gets them** — an array of primitives or of relation ids has nothing to retype wrong, its
317
+ values *are* the payload, so it keeps one setter and pays for no extra tools. `add` appends and publishes no
318
+ insert position, matching the `+` a person presses; `addOrSub` is never published, since it matches by `indexOf`
319
+ and would compare rows by reference. Editing a row in place stays `fill<Model>Form`'s job.
320
+ - **A list the person can drag also publishes `move<Field>On<Model>(from, to)`**, and `DraggableList` is a form
321
+ control like any other: handed the generated setter by reference it publishes that field, so an app that renders
322
+ its own rows with `DraggableList` writes no `st.tool`. The reorder tool exists for the same reason `add`/`sub` do
323
+ — the drag is the lever the screen offers and it changes no entry's content, so moving one row should not mean
324
+ retyping the nine beside it. No store action answers to it: reordering *is* a whole-array write, so the tool
325
+ splices the live entries and hands them to the setter the drag hands them to, `transform` deliberately not
326
+ applied, since the values are stored already and dragging normalizes nothing. It comes from the control saying
327
+ it sorts, not from the field, so a plain `Field.List` publishes no reorder and a scalar field never gets one.
328
+ **A component that composes `DraggableList` and already published the field hands the inner list a wrapper** —
329
+ the two would otherwise register one name twice, and the outer one is the one holding `transform`. That is what
330
+ `Field.TextList` does, and the only place an inline arrow is the right answer rather than a bug.
331
+
332
+ ## Reading State
333
+ - **Reading is per key, not per store.** `st.expose(name, Type).desc("…").value(v)` publishes a derived value and
334
+ `st.useState(name, Type, { set: true }).desc("…").init(initial)` local state, read-only without `set`. Both
335
+ end in a hook, so a conditional surface withholds the name rather than skipping the chain. A subscribed store key is listed in
336
+ the state context block by name and pulled with `readState(key)`, masked by the model that key declares — while
337
+ a key the screen does not read stays unreadable even when a sibling key of the same store is live. **There is no
338
+ store-level exposure declaration**: a store class says nothing about agents, and `st.use.x({ agent: false })` is
339
+ how the component that subscribes a value keeps it off the surface. Base-store plumbing does the same at the
340
+ call site — `st.use.path({ agent: false })`, `st.use.tryJwt({ agent: false })` — so routing and the caller's
341
+ credential stay off the surface unless a component opts a key in, as ThemeToggle does for `theme`.
342
+ - **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
343
+ strings a *user* reads: Chat's own buttons go through `l("base.*")`, the model's text never does.
344
+ - **The declared type is the mask.** `st.expose`/`st.useState` take a scalar, an enum, a model class, a one-level
345
+ array of any of those, or `Any`. A model class both typechecks what the component hands over — the state object,
346
+ so a hydrated document and a plain copy of one are equally accepted — and strips the model's `hidden`, `secret`,
347
+ and `visual` fields on the way out, by the model that was *named* rather than by whatever class the value still
348
+ happens to carry. A `Date` leaves as an ISO string. `Any` is the escape hatch and passes the value untouched, so
349
+ a payload nobody modeled stays publishable and its owner keeps the tokens it costs. A type nothing can read (a
350
+ `Map`, an `Upload`, a class that is not a model) is reported on the console and left unpublished, never thrown —
351
+ the same degradation an undescribable `.arg` gets. `.value()` also takes a thunk, which is read when the agent
352
+ reads: that is the shape for a value assembled out of a ref the children fill in after the render.
353
+ - **A field the page needs and an agent does not is `field.visual`.** `abstractData: field.visual(String)` — a blur
354
+ placeholder, a rendered HTML body, a serialized geometry: real data the screen renders, and hundreds of tokens per
355
+ record no question is answered from. It is stripped by `mask`, so it leaves every agent-facing read and every MCP
356
+ result, and it is left alone by `resolveReturn`, so the browser still receives it. Unlike `hidden`/`secret` it is
357
+ cost rather than secrecy: nothing is *refused* over one, and it stays an ordinary stored `property`, so
358
+ persistence, search, forms and the page response are untouched. Reach for it whenever a field is bulky and
359
+ useless to a model — that is cheaper than every tool learning to avoid it.
360
+
361
+ ## Slash Commands And The Transcript
362
+ - **`prompt()` endpoints double as the chat's slash commands.** There is no listing endpoint — the client reads
363
+ its own serialized signals — so a prompt's dictionary `.desc()` is what the menu shows, and its guards are
364
+ enforced by the prompt's own GET at call time. Arguments are positional and whitespace-separated, and quoting
365
+ is how a sentence stays one of them (`/reviewTask t1 "look at the totals"`) — a prompt taking a single `String`
366
+ is the common case, and an unquoted sentence would fill its second parameter with the second word.
367
+ - **The chat answers six slash commands of its own**, listed in the same `/` menu ahead of the prompts:
368
+ `/new` (`/clear`), `/retry`, `/compact`, `/copy`, `/help` and `/tools`. An app writes none of them and cannot add
369
+ one — the extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
370
+ **A built-in wins a name collision with a prompt of the same name**, the mirror image of the tool rule: a
371
+ component's `st.tool` shadows a built-in it means to replace, but no library's prompt may take `/new` away from
372
+ the user who typed it — so a shadowed prompt is dropped from the menu rather than listed twice. `/new` and
373
+ `/copy` are also dispatched *before* the is-a-turn-running check **and before the question card takes the
374
+ composer**, because mid-turn is exactly when they are reached for and a question the agent asked is the middle
375
+ of a turn like any other — answered as text, `/new` would have reached the model as the user's decision.
376
+ `/new` therefore aborts the turn it is clearing and waits for it to wind down, since the loop clears its own
377
+ running flag a microtask later and a transcript emptied before that lands is one the dying turn appends onto.
378
+ - **A command's output is a `local` message: rendered in the transcript, withheld from the wire.** The transcript
379
+ *is* the model's history, so `/help` text appended plainly would come back next turn as something the assistant
380
+ believes it said. `session.note(text)` is the only way to write one, `session.report(error)` stays what a
381
+ host-side *failure* lands in, and `local` messages are left out of a `/copy` export too — they are the chat
382
+ talking to itself. Their text is user-facing, so it goes through `l("base.*")` like every other chat string.
383
+ - **`/copy` exists because nothing else keeps the transcript.** The relay is stateless and the conversation lives
384
+ only in that browser, so an export is the one path a wrong answer has to whoever could fix it — which is why it
385
+ carries the route and the timestamp. `/retry` replays only the trailing user message, leaving anything before it
386
+ in place, so a prompt's own preamble is not sent twice.
387
+ - **A long conversation summarizes itself, because nothing else is keeping it inside the model's window.** The
388
+ loop runs in the browser and the relay holds no session, so an uncompacted chat grows until the provider
389
+ refuses the whole request — a refusal, never a shorter answer, which is why compaction runs *before* the turn
390
+ that would have overflowed rather than as a recovery after it. Past `compact.at` estimated tokens (four
391
+ characters to a token, over the JSON the turn posts; 24k by default, well under the smallest window a provider
392
+ is likely to have, since the tools and the screen context ride on top of it and neither compacts) the history above the last `keep` messages
393
+ becomes one message standing in for it, flagged `summary` on the wire — `<Agent.Chat compact={{ at, keep }} />`
394
+ tunes it per provider and `{ at: 0 }` turns it off, and `/compact` does the same on demand keeping nothing.
395
+ **The cut never lands on a `tool` message**: the kept half may not open with a result whose call was summarized
396
+ away, a shape every provider dialect rejects. A user message is preferred where the tail holds one — everything
397
+ above it is settled — but one assistant turn that ran ten tools has none, and that is the transcript that
398
+ outgrows the window, so a cut there opens on the assistant message instead. The summarizing
399
+ turn carries no tools and no screen context — it summarizes the conversation, it does not act on it — and it is
400
+ fed a *bounded* digest rather than the transcript itself, since the transcript being summarized is the one that
401
+ no longer fits. A summary that cannot be produced leaves the transcript alone, the turn goes out as it would
402
+ have, and the next one asks again — a summarizer that was momentarily unreachable says nothing about whether
403
+ this transcript can shrink. One that *landed* and shrank nothing does, so that one is not retried until another
404
+ threshold's worth has been added. On the
405
+ wire a summary wears the user's role because the wire has no other, so a provider mapping frames it as a system
406
+ message and `/retry` steps over it — replaying it would send the notes back as a question. **While a summary is
407
+ being written the transcript says so** (`session.isCompacting`, distinct from `isRunning`, which an
408
+ auto-compaction runs inside): that turn takes as long as any other and answers nothing the user asked, so a chat
409
+ that only shows the running dot reads as a question being ignored.
410
+ - **The transcript shows what each turn costs, because nothing else can.** The header carries the whole
411
+ conversation's estimated tokens (`session.tokens`), and every settled tool row carries its own and **opens onto
412
+ the value the model was handed** — a tool result is the one part of a transcript neither the user nor the app
413
+ author ever sees, being the app's own return value. A window that filled after four messages is explained by
414
+ *which* row cost a million tokens and by nothing else, so that is the question the row answers in one click.
415
+ - **A stopped turn answers the calls it never ran, because an unanswered call ends the conversation.** Every
416
+ provider dialect refuses an assistant message whose `tool_calls` have no results — on that turn and on every
417
+ later one — so Stop landing between a call and its result would leave a transcript nothing can be sent from,
418
+ with no way out but `/new`. `Transcript.sanitize` holds the invariant in one place and runs where a transcript
419
+ is assembled rather than where each hole is made: the turn's own request, a transcript restored from storage,
420
+ and a stored transcript capped to its newest messages, whose window can start mid-pair. A call the loop never
421
+ reached is *answered* rather than erased — a model told the call was stopped asks again, where one shown no
422
+ call at all answers as if it had the result.
423
+ - **A turn that failed says so on the wire.** `error` is a field only this wire has, so a provider mapping reads
424
+ `text` and drops it; `AgentService.explained` folds it into the text before any adaptor sees it, because an
425
+ assistant turn that says nothing is one the model repeats.
426
+ - **↑ and ↓ in the composer walk what was sent**, seeded from the transcript so a persisted chat does not lose
427
+ only what was just typed. A single-line input has nothing of its own on the vertical arrows, and the
428
+ half-written draft they were walked away from comes back at the bottom of the walk. **The `/` menu takes those
429
+ keys while it is open** — it is the thing on screen the arrows point at — with Enter picking the highlighted
430
+ row, Tab completing its name, and Escape closing the menu and then, pressed again, the panel.
431
+
432
+ ## Built-In Tools
433
+ - The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same
434
+ router `Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and
435
+ a page that draws no back link is not one you may not leave — a screen that must not be left withholds them
436
+ with `builtins`, not with prose), `readScreen` (the rendered DOM as compact text —
437
+ headings, links, control values, and `(disabled)` on a control or button that has it; the chat's own UI is
438
+ skipped via `data-agent-ui`, and a password value is never read), `readState(key)` (one masked store key), and
439
+ `highlight(target)`. Declaring a hook tool under one of those names shadows the built-in, so reuse them only to
440
+ mean that. **There is no general-purpose wait**: a built-in one was reachable on every screen and a model spent
441
+ it on whatever key it liked, parking turns nobody asked to park. Waiting belongs to the screen that knows what
442
+ is worth waiting for — publish an `st.tool` beside the control that starts the work, and let it await the work.
443
+ - **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
444
+ payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
445
+ awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
446
+ and the session awaits it after every non-`query` tool before taking the change report. Without it the report
447
+ describes the moment before the change landed and the `readScreen` that follows reads the page the user left.
448
+ New tools and state from a fresh route are still only listed from the next turn: the catalogue is snapshotted
449
+ when the turn starts.
450
+ - **A tool that waits for its own work costs no model turns; one that returns early costs one round trip per
451
+ look.** The session awaits `run`, so a `.exec` that awaits the store action finishing the job simply makes the
452
+ turn take that long — and the change report that follows carries whatever landed, so the model needs no second
453
+ call to read the result. A fire-and-forget tool leaves the agent to poll instead, which burns the whole
454
+ `maxTurns` budget in seconds on a job measured in minutes. Say so in the `desc` ("takes about two minutes; do
455
+ not poll while it runs") and, for a route full of slow work, in an `Agent.Guide`.
456
+ - **A wait is a screen's own tool, declared where the slow work is.** For the job a tool cannot await — one
457
+ started in an earlier turn, or by a person clicking the button — publish an `st.tool` that parks on the thing
458
+ *that* screen knows about, and say in its `desc` what it waits for and roughly how long. A general built-in was
459
+ tried and removed: reachable on every screen with no idea what any key means, a model spent it on whatever key
460
+ looked promising and parked turns nobody asked to park. The screen that starts the work is the only place that
461
+ knows what finishing looks like, and a tool declared there is also one the agent cannot reach on a screen where
462
+ waiting makes no sense. Honour `AgentAbort.current` in the body and report with `AgentProgress.report`, so Stop
463
+ ends the wait and the row says how it is going.
464
+ - **Stop reaches a tool that is still running.** The session races every call against its abort signal, so a
465
+ two-minute tool does not hold the loop for two minutes after the user presses Stop. The signal itself arrives
466
+ through `AgentAbort.current` — the same module slot `AgentProgress` is — and honouring it is optional, since the
467
+ race lands whatever the tool does; what it buys is the tool's own cleanup, a timer or a poll loop that would
468
+ otherwise run out with nobody left to answer. A tool that ignores it is left running rather than cancelled: the
469
+ work is usually a job a server is already doing.
470
+ - **`readScreen` takes a `section`, and `highlight` a `target`.** Both resolve a name the agent has already seen —
471
+ a `data-akan-action` / `data-akan-state` annotation, an `Agent.Zone` or `useScreenScope` container
472
+ (`data-agent-scope`, which `Load.Units` / `Load.View` / `Data.ListContainer` put on the container they render),
473
+ an element id, or **a heading by its own text**, matched on letters and digits so the slug an agent writes for a
474
+ heading it read resolves. That tolerance stops at headings: a heading is a landmark and scrolling to the wrong
475
+ one costs nothing, while two buttons reading "Save" are not the same control. **Nothing hidden ever resolves** —
476
+ a ring nobody can see reads as a broken tool, not as a miss. A section named by a heading is read to the next
477
+ heading of its level or higher.
478
+ - **A screen is only aimable if its names are printed.** `readScreen` writes `(#anchor)` beside a heading that
479
+ opens an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
480
+ everything past the 8000-character limit is unreachable, because nothing names it, and an agent asked to point
481
+ at a section it cannot name guesses a slug and is refused. A refusal lists the sections actually on screen.
482
+ `highlight` scrolls its target into view and flashes it **once the scroll lands**, since a smooth scroll across a
483
+ long page outlasts the flash; it is the one built-in that exists for the *user's* benefit, because showing where
484
+ a control is beats writing directions to it.
485
+ - **`<Agent.Skip label="site footer">` leaves a region out of the default read** — chrome that costs tokens and
486
+ answers nothing: a footer, a cookie banner, a nav that repeats on every route. What stands in its place is
487
+ `[skipped: site footer]`, never nothing, because a deleted region reads as an absent one and an agent asked about
488
+ the footer would answer that the page has none. The marker's name **is** a `section`, so naming it reads the
489
+ region after all: what the marker withholds is the *default* read, not the region. It renders a wrapper div, so
490
+ where that would move a flex or grid layout put `data-agent-skip="<name>"` on the element the page already
491
+ renders — an unlabelled attribute falls back to the tag name, which is why the raw form belongs on a `<footer>`
492
+ or `<nav>` and the component insists on a label. A region that is hidden anyway leaves no marker claiming it is
493
+ there. **It hides text, not behaviour**: tools and state keys are declarations rather than markup, so an
494
+ `st.tool` inside is published exactly as before and `highlight` still reaches a control in there. This is
495
+ `field.visual` one layer up — cost, not secrecy.
496
+ - **Reach for it second.** A read narrows from the other side too: `Agent.Zone` and `readScreen({ section })` scope
497
+ to one container, which beats blocklisting five regions on a screen that is mostly chrome. And a footer is last
498
+ in the document, so on a page long enough to truncate it was already past the cut — the regions worth marking are
499
+ the ones *above* the content.
500
+ - **Every tool's return value is bounded at 20,000 characters before it enters the transcript** (`ToolOutput.limit`,
501
+ clipped with a note that gives the real size and tells the model to read a narrower part). `readScreen` caps
502
+ itself; nothing else did, and a store key is sized for a screen rather than for a model's window — one
503
+ `readState` of a list whose rows carry inlined bytes is megabytes, which the loop then posts on this turn *and on
504
+ every turn after it*, so the window fills after four messages. Compaction cannot save that: it summarizes what is
505
+ above the cut and a result this large arrives below it. Clipped rather than dropped, because a model handed a
506
+ value it cannot see the end of asks a narrower question, where one handed nothing answers from the field names.
507
+ The cap is a safety rail, not the design: a tool should return what answers the question — an id, a count, the
508
+ three fields the agent asked about — and `AgentContext` inlines only small primitives for the same reason.
509
+ - **A slow tool reports its own progress with `AgentProgress.report(message, { done, total })`** from wherever the
510
+ work is — a store action, an upload loop, an adapter — reached through a module slot rather than a parameter, and
511
+ a no-op when nobody is rendering it. The chat shows it on that call's row until the row resolves. It is the
512
+ browser twin of `McpProgress.report`. Import it and `AgentAbort` from `akanjs/store`: an app may not reach
513
+ `use-agentic` directly (`no-import-external-library`), and those two are the channels a long tool body needs.
514
+ - **The turn cap is a question, not a dead end.** At `maxTurns` the session asks whether to keep going through the
515
+ same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
516
+ keep-going choice reaches the model as guidance. A host that renders no `pendingQuestion` passes no
517
+ `continueAsk` and keeps the old failure, because asking with nobody listening would hang.
518
+ - **`askUser` is a fourth built-in the *session* owns, not the surface.** The answer comes from the conversation
519
+ rather than the screen, so it rides on every turn whatever the page declares, and a zone agent asks inside its
520
+ own transcript. `choices` offers a pick (`multiple` for several) and omitting them asks for free text; the card
521
+ keeps a free-text row either way, because the model wrote the options and only the user knows whether the answer
522
+ is among them. The loop parks on the question exactly as it parks on an approval, a dismissal is the tool's
523
+ error result rather than a silent empty answer, and the settled exchange renders as question-and-answer instead
524
+ of a tool row. **Never re-implement it per screen** — a `st.tool("askAboutX")` that opens a modal is the same
525
+ thing with a worse transcript — and a hook tool named `askUser` shadows it like any other built-in.
@@ -0,0 +1,25 @@
1
+ {
2
+ "title": "Workspace Conventions",
3
+ "description": "The tool-neutral Akan convention set rendered into every workspace AGENTS.md managed block",
4
+ "group": "core",
5
+ "codegenPriority": 1,
6
+ "deprecated": false,
7
+ "scans": [],
8
+ "update": {
9
+ "filePath": "./conventions.instruction.md",
10
+ "contents": [
11
+ "change scope",
12
+ "lint-enforced rules",
13
+ "coding style and comments",
14
+ "client/server boundaries and SSR",
15
+ "domain module, service, signal, and scalar conventions",
16
+ "page routing, sync layout, and layer placement"
17
+ ],
18
+ "rules": [
19
+ "Use current Akan APIs only",
20
+ "State the rule and the reason it exists, never restate a type signature",
21
+ "Name the .grit rule that enforces a rule wherever one does",
22
+ "Keep it workspace-neutral — no app or repo names, since every workspace renders this file"
23
+ ]
24
+ }
25
+ }