@akanjs/cli 3.0.0-alpha.46 → 3.0.0-alpha.47

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.
@@ -21,28 +21,31 @@
21
21
 
22
22
  ## Lint-Enforced Rules (These Break The Build)
23
23
 
24
- Enforced by `biome.json` and the grit plugins in `pkgs/@akanjs/devkit/lint/`. Several of them produce output
25
- that looks wrong; do not "fix" it back.
24
+ Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — that file scopes every grit plugin in
25
+ `@akanjs/devkit/lint/` to the paths it applies to. Several rules produce output that looks wrong; do not "fix" it
26
+ back.
26
27
 
27
28
  - **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
28
29
  arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or
29
30
  `border-foreground/5 border-t` is correct. Write the classes in any order, run the formatter, leave the result.
30
31
  - **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as
31
- no CSS and fail lint (`no-raw-palette-class.grit`, `no-arbitrary-color.grit`, `no-daisyui-legacy-class.grit`,
32
- `no-inline-color.grit`): raw palette classes (`bg-blue-500`), arbitrary color values (`bg-[#3b82f6]`), daisyUI
33
- legacy classes (`btn-primary`, `card-body`), and color literals in `style={{...}}`. Use semantic tokens
34
- (`bg-primary`, `text-foreground/70`). A legitimate fixed color (OS-chrome mockups, data-viz) takes a
35
- `// biome-ignore lint/plugin: <reason>` with the reason spelled out. `apps/akan/page/v1/**` is excluded.
32
+ no CSS and fail lint (`no-raw-palette-class.grit`, `no-arbitrary-color.grit`, `no-daisyui-legacy-class.grit`, `no-inline-color.grit`,
33
+ `no-interpolated-arbitrary-class.grit`): raw palette classes (`bg-blue-500`), arbitrary color values
34
+ (`bg-[#3b82f6]`), an arbitrary class built by interpolation, daisyUI legacy classes (`btn-primary`, `card-body`)
35
+ and its dropped color slots (`bg-base-100`, `text-base-content`, `text-primary-content`, `bg-error` — use
36
+ `background`/`muted`/`border`, `foreground`, `<color>-foreground`, `destructive`), and color literals in
37
+ `style={{...}}`. Use semantic tokens (`bg-primary`, `text-foreground/70`). A legitimate
38
+ fixed color (OS-chrome mockups, data-viz) takes a `// biome-ignore lint/plugin: <reason>` with the reason
39
+ spelled out.
36
40
  - **Never `throw new Error`.** Throw `new Err("<module>.error.<key>")` and register the key as `[en, ko]` in that
37
41
  module's dictionary `.error({})`. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
38
- `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts `*.test.ts`, `*.spec.ts`, `*.constant.ts`,
39
- `common/**`, and `apps/akan/env/**` — `common/` and `env/` have no legal `Err` import path, so keep throwing code
40
- out of them.
42
+ `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, and
43
+ `env/**` — `common/` and `env/` have no legal `Err` import path, so keep throwing code out of them.
41
44
  - **Never import a third-party package** from `page/**`, from any barrel, or from any
42
45
  `*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
43
- (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims such as
44
- `libs/<lib>/base/<pkg>.ts` and `libs/<lib>/webkit/<hook>.ts` exist for exactly this reason — they are
45
- load-bearing, not cruft. Do not delete them.
46
+ (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
47
+ `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft. Do not delete
48
+ them.
46
49
  - **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
47
50
  `*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
48
51
  `#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
@@ -59,28 +62,20 @@ that looks wrong; do not "fix" it back.
59
62
  - **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
60
63
  - **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
61
64
  roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server has
62
- to hand across the boundary; take `bannerId: string` and read the model from the store instead. Three shapes are
63
- exempt because none of them is an instance: `cnst.<Enum>["value"]`, whose indexed access resolves to a string
64
- union; a `ClientInit` / `ClientView` / `ClientEdit` type argument, which the framework maps to `GetStateObject<…>`
65
- plain data; and a `ModelsProps<cnst.Setting>` type argument, whose only use of the model is the `onClickItem`
66
- callback, so nothing model-shaped ever lands on a prop. `ModelProps<"setting", cnst.LightSetting>` is *not* exempt
67
- — that one spreads the model onto the props themselves, and `Unit` / `View`, which take it, are server components
68
- this rule does not cover. Any *other* indexed access is still flagged — `cnst.Banner["image"]` is a `File`.
69
- **Only prop positions are read** — a `*Props` interface or type alias, and the inline object type on the
70
- component's own parameter. A `cnst` type that never leaves the file is not a boundary crossing and stays legal: a
71
- local annotation, a callback parameter the framework itself types with the model
72
- (`renderItem={(ticket: cnst.LightTicket) => …}`), a module-scope helper, a non-`Props` local shape, and the props
73
- of a component nested inside another one. A function-typed prop (`onPick?: (t: cnst.LightTicket) => void`) is
74
- exempt for the same reason — a closure cannot cross the RSC boundary at all, so whoever passes it is a client
75
- component already holding the value.
65
+ to hand across the boundary; take `bannerId: string` and read the model from the store instead. **Only prop
66
+ positions are read** — a `*Props` interface or type alias, and the inline object type on the component's own
67
+ parameter — and only shapes that are actually an instance are flagged. Exempt: an indexed enum access
68
+ (`cnst.<Enum>["value"]`, a string union), a `ClientInit` / `ClientView` / `ClientEdit` type argument (plain
69
+ `GetStateObject<…>` data), a `ModelsProps<…>` type argument, a function-typed prop, and any `cnst` type that
70
+ never leaves the file. `ModelProps<"setting", cnst.LightSetting>` and any other indexed access
71
+ (`cnst.Banner["image"]` is a `File`) stay flagged.
76
72
  - **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
77
- `onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}` —
78
- generated field setters take exactly one value — but the arrow is a fresh anonymous closure, so the control
79
- emits no `data-akan-action` and publishes no agent tool for the field. The failure is silent and the two lines
80
- read the same, which is why it is a lint error. A wrapper that transforms the value
81
- (`st.do.setNameOnX(toCamelCase(name))`), adds a statement, or writes a nested path with `writeOnX` is doing
82
- something a reference cannot and stays legal — publish that one with an explicit `st.tool`. Scoped to
83
- `{apps,libs}/**/*.tsx`; a typed parameter is not matched, so it under-reports rather than misfiring.
73
+ `onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
74
+ the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
75
+ for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
76
+ statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
77
+ Scoped to `{apps,libs}/**/*.tsx`, and a typed parameter is not matched, so it under-reports rather than
78
+ misfiring.
84
79
  - **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
85
80
  `../map/map.constant` are the sanctioned exception.
86
81
  - **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
@@ -101,10 +96,10 @@ that looks wrong; do not "fix" it back.
101
96
  own category name suggests does nothing. Suppress a plugin only where the rule is genuinely wrong for the file, and
102
97
  say why: the module-convention plugins (`no-import-external-library`, `no-deep-internal-import`, the store/signal
103
98
  ones) apply to `apps/**` and `libs/**` only, so a plain package under `pkgs/` never needs the escape hatch.
104
- - **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome 2.5.8 does not report the parse
105
- error; it falls back to discovery and aborts on whatever nested config the walk finds, typically inside a directory
106
- `files.includes` excludes. Rename the file to `biome.jsonc` to document a disabled rule; `akan lint` pins the config
107
- path either way, so it reports the parse error on the offending line.
99
+ - **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
100
+ error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
101
+ `biome.jsonc` to document a disabled rule; `akan lint` pins the config path either way, so it reports the parse
102
+ error on the offending line.
108
103
  - **`akan lint` prints up to 200 diagnostics** (`--maxDiagnostics <n>`, `0` for no limit). Biome's own default is 20
109
104
  with no count, which reads as progress when the mix of findings merely changed.
110
105
 
@@ -145,7 +140,7 @@ that looks wrong; do not "fix" it back.
145
140
  - Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
146
141
  - Run package suites with `bun run akan test <pkg>` from the repo root, or `cd <pkg> && bun test --isolate`. Plain `bun test` without `--isolate` shares one global object across test files and fails dozens of tests from cross-file state pollution (`bunfig.toml` `[test] isolate` is not honored as of Bun 1.3), and running `bun test` from the repo root breaks subprocess stdio pipes.
147
142
  - Split signal tests in two. `<model>.signal.spec.ts` holds reusable fixtures built on `sampleOf(cnst.XInput)` with explicit `Promise<cnst.X>` return types and **no assertions**. `<model>.signal.test.ts` holds the assertions: `describe("<Model> Signal")`, `let` fixtures at describe scope, one `beforeAll`, story-ordered `it`s, and negatives via `await expect(p).rejects.toThrow()`.
148
- - `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the shared lib directly.
143
+ - `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the owning lib directly.
149
144
  - A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
150
145
 
151
146
  ## Comments
@@ -176,7 +171,7 @@ Do not narrate code. Do document the thing the code cannot say. Both halves are
176
171
  - Use Bun and ESM assumptions from the root `tsconfig.json`.
177
172
  - Prefer path aliases over deep relative imports when crossing package boundaries.
178
173
  - Use `akanjs/*` for framework facets, `@apps/*` for apps, `@libs/*` for shared libs, and `@contract/*` for contract code.
179
- - Respect existing client/server entrypoints such as `@libs/shared/client`, `@libs/shared/server`, `@apps/akasys/client`, and `@apps/akasys/server`.
174
+ - Respect existing client/server entrypoints — `@libs/<lib>/client` / `@libs/<lib>/server` and `@apps/<app>/client` / `@apps/<app>/server`.
180
175
  - Let Biome organize imports instead of manually reshuffling unrelated imports.
181
176
  - Namespace the generated barrels in backend `.ts` files: `import * as cnst from "../cnst"`, `* as db`, `* as srv`. Use `import type * as srv` in services so the runtime graph stays lazy, and a value import in signals.
182
177
  - In `.tsx` files use one flat named import from the package client path (`import { cnst, fetch, st, Ticket, usePage } from "@apps/<app>/client"`) — never a relative `../` import.
@@ -231,59 +226,29 @@ are, because those are the ones the server could have done.
231
226
 
232
227
  ### Server-Side Implementation Playbook
233
228
 
234
- **① Wrap the interaction, not the UI.** The smallest useful client component is a shell that adds one behaviour and
235
- renders `children` untouched. The children stay server components, so the markup inside them never reaches the
236
- bundle. `libs/shared/ui/Only/User.tsx` is the shape: it reads auth state on the client and returns `{children}`.
237
-
238
- ```tsx
239
- "use client";
240
- export const ClickWrapper = ({ children, onPick }: ClickWrapperProps) => (
241
- <div onClick={onPick}>{children}</div>
242
- );
243
- ```
244
-
245
- **② Split compound components so panels stay on the server.** A tab, accordion, or disclosure needs client state
246
- only for *which* part is visible — never for what the parts contain. Split into a context provider plus menu and
247
- panel pieces, and take panel content as `children`. `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in `akanjs/ui`
248
- is exactly this: only the provider and the menu hold state, and `<Tab.Panel>` renders its children as-is, so a
249
- server `Unit`/`View` passed in stays server-rendered. Never reach for one `"use client"` file with a mode
250
- `useState` and every panel body inlined.
251
-
252
- **③ Sync state instead of fetching it.** A server component cannot hold state, so render the initial data on the
253
- server and hand it across the boundary as a serializable object. That is what `init` / `view` props are: the route
254
- calls `fetch.initXInY(...)` / `fetch.viewX(...)`, passes the result into a `Zone`, and `Load.Units` / `Load.View`
255
- hydrate the store from it. Never replace that with a `useEffect(…, [])` that fetches on mount — it renders an empty
256
- shell, hydrates, then round-trips for data the server already had.
257
-
258
- **④ Push the boundary down to the leaf that needs it.** When a `Zone` reads the store, it should hold *zero*
259
- markup and delegate to a server `View`. `User.Zone.Self` is one line — `st.use.self()` into
260
- `<User.View.General user={self} />` — so the whole detail surface renders server-side wherever a route uses the
261
- `View` directly.
262
-
263
- **⑤ Hand the promise across, not the awaited value.** `ClientInit` / `ClientView` are `PromiseOrObject<T>`, so a
264
- route may pass an unawaited `fetch.initX(...)`; `Load.*` renders a skeleton and resolves it. `await` in the route
265
- blocks the shell for data the page needs immediately; passing the promise streams the rest. Independent fetches
266
- still go through one `Promise.all`.
267
-
268
- **⑥ Use named `ReactNode` slots, not just `children`.** A client shell can take several server-rendered subtrees:
269
- `Layout.Navbar` accepts `title`, `back`, `left`, `right`, and `children`, so a client navbar composes server
270
- content in five places instead of absorbing it.
271
-
272
- **⑦ Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>` (`isNew()`,
273
- `canWrite(user?)`, `formatTimes()`), and enum→class lookups belong in a module-scope `as const` map. Both sides
274
- call the same method, so the server can render the result — a client component that exists only to compute a label
275
- is markup in the wrong place.
276
-
277
- **⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any HTML is
278
- sent. A client-side auth check costs a hydration round-trip and flashes the wrong UI first.
279
-
280
- **⑨ Prefer CSS over client state for pure visibility.** Toggling with a `data-*` attribute plus `group-data-[…]`
281
- variants (see `libs/util/ui/Grid/*`) or with `<details>`/`<summary>` keeps both branches server-rendered. Reach for
282
- `useState` when the state is real, not when a variant would do.
283
-
284
- **⑩ Keep the heavy island out of the first load.** A large client-only widget goes behind the
285
- `ui/<Folder>/index_.tsx` + `lazy()` pair so the server renders the page around it. `usePage()` and `l()` work in
286
- server components, so translation never forces a boundary.
229
+ 1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
230
+ `children` untouched, so the markup inside never reaches the bundle.
231
+ 2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
232
+ `akanjs/ui` is the shape: only the provider and menu hold state, and `<Tab.Panel>` renders children as-is.
233
+ Never one `"use client"` file with a mode `useState` and every panel body inlined.
234
+ 3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes
235
+ the result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it.
236
+ Never a `useEffect(…, [])` that fetches on mount.
237
+ 4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
238
+ delegate to a server `View`.
239
+ 5. **Hand the promise across, not the awaited value.** `ClientInit` / `ClientView` are `PromiseOrObject<T>`, so a
240
+ route may pass an unawaited `fetch.initX(...)` and `Load.*` resolves it behind a skeleton.
241
+ 6. **Use named `ReactNode` slots, not just `children`.** `Layout.Navbar` takes `title`, `back`, `left`, `right`,
242
+ and `children`, so a client shell composes server content in five places instead of absorbing it.
243
+ 7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
244
+ lookups belong in a module-scope `as const` map.
245
+ 8. **Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any HTML
246
+ is sent.
247
+ 9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
248
+ `<details>`/`<summary>`, keeps both branches server-rendered.
249
+ 10. **Keep the heavy island out of the first load.** A large client-only widget goes behind the
250
+ `ui/<Folder>/index_.tsx` + `lazy()` pair. `usePage()` and `l()` work in server components, so translation
251
+ never forces a boundary.
287
252
 
288
253
  Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`, or
289
254
  `akan guideline show ssrRule`.
@@ -327,23 +292,22 @@ Full version with code, the `Tab` composition example, and a review checklist: `
327
292
 
328
293
  **`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
329
294
  classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
330
- predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`, `isCancellable()`) — the Light class is the
331
- one both server and client hold, so shared logic belongs there instead of in a util module. This is the most commonly
332
- missed rule in the codebase. Collection-level helpers go `static` on the full model. Give any field whose business
333
- meaning is not obvious a short trailing comment.
295
+ predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`) — the Light class is the one both server
296
+ and client hold, so shared logic belongs there instead of in a util module. This is the most commonly missed rule in
297
+ the codebase. Collection-level helpers go `static` on the full model. Give any field whose business meaning is not
298
+ obvious a short trailing comment.
334
299
 
335
300
  **`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
336
301
  with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
337
302
  so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line comment above each stating the
338
303
  transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
339
304
  Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
340
- model facade's `removeMany(query)` and the store's `removeManyByQuery` stamp `removedAt` like `remove(id)` does; the
341
305
  framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
342
- The facade keeps `Many`/`One` spelled out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`):
343
- a bare `update`/`remove` would read like the document-path `update(id)` / `doc.remove()` while hitting every match.
344
- Only the count was shortened — `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)`
345
- and `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no
346
- hooks either, so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
306
+ The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`), because a
307
+ bare `update`/`remove` would read like the document-path `update(id)` / `doc.remove()` while hitting every match; only
308
+ the count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
309
+ `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
310
+ so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
347
311
 
348
312
  **`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
349
313
  explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
@@ -359,9 +323,8 @@ stores need none, because state and CRUD actions are generated. The body is thre
359
323
  `this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
360
324
  `this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
361
325
  through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
362
- nothing** — `st.do.<action>()` is typed `void` / `Promise<void>`, so hand the result to `this.set({...})` rather
363
- than returning it (`no-return-in-store-action.grit`); a bare `return;` guard stays fine. **Never
364
- `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
326
+ nothing** (`no-return-in-store-action.grit`) — hand the result to `this.set({...})`; a bare `return;` guard stays
327
+ fine. **Never `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
365
328
 
366
329
  **`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
367
330
  `.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
@@ -388,6 +351,7 @@ workflow changes.
388
351
  - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
389
352
  - A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run.
390
353
  - A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
354
+ - **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
391
355
 
392
356
  ### Authorization Defaults
393
357
 
@@ -406,7 +370,7 @@ workflow changes.
406
370
 
407
371
  - `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
408
372
  - Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
409
- - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])` (see `libs/shared/lib/file/file.signal.ts`). It is not a model field type.
373
+ - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a model field type.
410
374
 
411
375
  ### Mutation HTTP Verb
412
376
 
@@ -510,532 +474,74 @@ Conventions that hold for both shapes:
510
474
  Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
511
475
  follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
512
476
  that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
513
- `Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
514
- authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
515
- switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
516
- until somebody remembers it.
517
-
518
- Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
519
- `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, and `auth`. **Not `main.ts`**: the gateway
520
- there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
521
- mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
522
- declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
523
- `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
524
- `AKAN_MCP_LANGUAGE`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that
525
- configures what the source does not, which the option overrides.
526
- The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
527
- has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
528
- `AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
529
- concatenation.
530
-
531
- ```typescript
532
- // apps/<app>/lib/option.ts
533
- export const option = new AkanOption<ModulesOptions>().setMcp({
534
- instructions: "Domain tools for the akan app. Start from taskInTodo.",
535
- language: "en",
536
- });
537
- ```
538
-
539
- ```typescript
540
- // <model>.signal.ts — every one of these is an MCP tool or prompt, with no `mcp:` option anywhere
541
- export class TaskSlice extends slice(
542
- srv.task,
543
- { guards: { root: Admin, get: SignedIn, cru: SignedIn } },
544
- (init) => ({
545
- // its own guards: the map above reaches base CRUD and the root slice, never a named slice — so a named slice
546
- // that names none is refused rather than published, which is the one shape to watch for.
547
- inTodo: init({ guards: [SignedIn] }).exec(function () {
548
- return this.taskService.queryByStatuses(["todo"]);
549
- }),
550
- }),
551
- ) {}
552
-
553
- export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => ({
554
- startTask: mutation(cnst.Task, { guards: [SignedIn] })
555
- .param("taskId", ID)
556
- .exec(async function (taskId) {
557
- return await this.taskService.startTask(taskId);
558
- }),
559
- reviewTask: prompt({ guards: [SignedIn] })
560
- .param("taskId", ID)
561
- .exec(async function (taskId) {
562
- const task = await this.taskService.getTask(taskId);
563
- return [Msg.user(`Review this task and suggest next steps.`), Msg.resource(`akan://task/${taskId}`, task)];
564
- }),
565
- })) {}
566
- ```
567
-
568
- - **The refusals are fail-closed**: **an endpoint that declares no `guards` at all** (nobody decided who may reach
569
- it), **a mutation with no real `guards`** (`[Public]` is having none, spelled out — it answers true
570
- unconditionally), `pubsub` and `message` (their internal args read a socket an MCP request does not have), an
571
- `Any` or `Upload` return, a file upload, and **an argument typed `Any` that must be filled**.
572
- A `prompt` refuses two more, because its `arguments` is one string per name with no schema beside it: a **list
573
- argument**, which could never carry a second value, and **any `Any` argument** — a tool leaves that out of its
574
- schema, and a prompt has no schema to leave it out of.
575
- - **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=… prompts=…`
576
- count. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
577
- because there is no absent opt-in to notice. The API explorer badges the same rule per endpoint (`MCP` /
578
- `MCP refused`), from the same shared implementation the catalogue runs.
579
- - **An `Any` argument is left out of the published schema** rather than described as `{}` — it tells a model
580
- nothing — and a value sent for one is refused by name, so the endpoint reads it as omitted. That is what happens
581
- to the root list's raw `query` descriptor: read as sent, it would be an arbitrary filter over every model you
582
- publish. Declare a named filter slice when an agent should narrow a list.
583
- - **A nullable model return publishes no `outputSchema`**, and its empty answer ships as the text `null` with no
584
- `structuredContent`. That field is an object by definition, so `null` cannot ride in it any more than an array
585
- can — a list is wrapped as `{ items: … }` for the same reason — and a declared schema obliges every result to
586
- match it, so a client SDK throws on the first call that finds nothing. A nullable *list* keeps its schema, and a
587
- scalar return has no structured half at all: it ships as the value itself, not as JSON.
588
- - **An `outputSchema` names no `hidden` or `secret` field.** Every response has both stripped, so publishing them
589
- promises a property no answer can carry — and on a model like `user` the names are the leak. Your *input* schema
590
- keeps them: they are legal to send, and the same model describes a request body.
591
- - A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
592
- message more helpful — the difference is what enumerates your private surface. A guard's refusal is generalized
593
- the same way: the caller reads `You are not permitted to perform this action.`, never `Access denied by guard:
594
- Admin`, which names your authorization structure to the one caller barred from it. A domain `Err` resolves
595
- through the dictionary first and keeps its own words.
596
- - The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
597
- and are not configurable. Clients are told to distrust hints; they are never a gate.
598
- - **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
599
- whatever it declared, and reports each one in the boot log like any other refusal.
600
- - OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
601
- path, the spelling most clients try first). `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, and `AKAN_MCP_RESOURCE`
602
- configure it; `insufficient_scope` is enforced only once `AKAN_MCP_SCOPES` is set. A token carrying no `aud` at
603
- all is refused once `AKAN_MCP_AUTH_SERVERS` names an issuer — that issuer mints tokens for its other resources
604
- too — and accepted while none is named, because a first-party Akan token is bound by app and environment.
605
- - **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
606
- description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
607
- the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
608
- their generated `Get X`. Write that model `.desc()` — it is the only text those entries can carry. There is no
609
- `akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
610
- with exposure derived from the guards the resolved catalogue is the only place that can answer.
611
- - A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
612
- Every other MCP client sends no `Origin` at all, and the one that does is matched against the forwarded host so
613
- a proxy does not turn each call into a 403 — which is only as trustworthy as an edge that *overwrites* that
614
- header. `AKAN_MCP_RESOURCE` pins the resource identifier where you cannot guarantee it.
615
- - **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
616
- - **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
617
- unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
618
- published schema and nothing on the wire enforces it — and so does a document that is not there, as
619
- `No <model> found for the arguments given.` A `prompt`, having no `isError` to carry a refusal, answers `-32602`.
620
- Only a real failure logs a stack; an agent can drive the rest at will.
621
- - **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
622
- wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
623
- disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
624
- method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
625
- gone".
626
- - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
627
- body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
628
- rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
629
- are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
630
- - **An expired or wrongly-audienced bearer token is refused up front**, so an agent is told to authenticate rather
631
- than that the tool does not exist. Its **signature is not checked** — that needs your app's own secret — so a
632
- token signed wrong, like an opaque one, still degrades to an anonymous caller.
633
- - **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/light/{id}`, `akan://<model>/list` for the model's own
634
- list, and `akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any
635
- token there is one a slice could also be named. **Those four are the whole set**, so only the generated reads are
636
- addressable: a custom endpoint keeps its tool and gets no resource template.
637
- - **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
638
- clients, so there is no `Accept-Language` negotiation.
639
-
640
- **`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
641
- returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
642
- `Msg.assistant` / `Msg.link` / `Msg.resource` / `Msg.image` / `Msg.imageOf`. It takes `.param()` and `.search()`
643
- only, because `prompts/get` sends a flat string map. **An embedded payload is masked by the model you name** —
644
- `Msg.resource(uri, task, { model: cnst.LightTask })`, or `Msg.mask(cnst.LightTask, task)` for one piece of an
645
- assembly. Taking the model as an argument is what makes a `{ ...doc }` spread maskable, since that and `toJSON()`
646
- arrive with the class already gone; a value with no model named whose `hidden`/`secret` fields are populated is
647
- **refused**, one level into a plain object too. **A `prompt` is also mounted as a
648
- plain HTTP `GET` whether or not you enabled MCP**, because that route is what lets a web UI preview one — and it
649
- is in your OpenAPI document like any other `GET`, answering the one fixed `PromptMessage[]` shape. MCP exposure
650
- gates the catalogue, not the surface, so guard it
651
- like any other read — and a prompt declaring no
652
- `guards` at all is named in the boot log, while an explicit `[Public]` is a decision and stays quiet. Every `Msg` builder takes
653
- optional `annotations` last (`audience`, `priority` 0..1, `lastModified`) — give the instruction a high `priority`
654
- and its attachments a low one, or a client with a full window drops blocks by position and keeps the attachment
655
- over the ask.
656
-
657
- **`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
658
- frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
659
- websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
660
- costs something to assemble.
477
+ `Public`. The guards are already the authorization decision, so a second switch would say nothing they do not —
478
+ while guaranteeing that every endpoint added later is invisible to agents until somebody remembers it.
479
+
480
+ - Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
481
+ allowedOrigins, pageSize, language, auth })`, **not `main.ts`**. Every lib's option is read in mount order with
482
+ the app's last, and each field has an `AKAN_MCP_*` env spelling the option overrides.
483
+ - **The refusals are fail-closed**: an endpoint with no `guards`, a mutation with no real guard (`[Public]` is
484
+ having none), `pubsub` and `message`, an `Any` or `Upload` return, a file upload, and a required `Any` argument.
485
+ A `prompt` also refuses a list argument and any `Any` argument.
486
+ - **Every refusal is named in the boot log**, with a `MCP catalogue: tools=… prompts=…` count. Read that line
487
+ first when a tool you expected is missing — with no opt-in to notice, it is the only place the answer exists.
488
+ The boot log also names every published entry with no dictionary `.desc()`: an agent picks a tool by its
489
+ description, so write the model `.desc()` the generated entries borrow.
490
+ - A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
491
+ generalized to `You are not permitted to perform this action.` Never make either message more helpful — the
492
+ difference is what enumerates your private surface.
493
+ - `AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch; `AKAN_MCP=false` takes the
494
+ whole surface off.
495
+ - **`prompt()`** is invoked by the *user* as a slash command, never chosen by the model. `exec` returns
496
+ `PromptMessage[]` built with `Msg.user` / `Msg.assistant` / `Msg.resource` / `Msg.image`; an embedded payload is
497
+ masked by the model you name (`Msg.resource(uri, task, { model: cnst.LightTask })`), and one whose
498
+ `hidden`/`secret` fields are populated with no model named is refused. It is also mounted as a plain HTTP `GET`
499
+ whether or not MCP is enabled, so guard it like any other read.
500
+
501
+ Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
502
+ `McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
661
503
 
662
504
  ## In-Page Agent
663
505
 
664
506
  Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
665
507
  declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
666
- store key readable while the reading component is mounted. Nothing is derived from a store class — declaring a
667
- method on a `store(...)` gives an agent nothing at all, because a lever the screen does not offer the user is not
668
- one an agent may pull in their place, and a module's whole vocabulary published at once was noise the model paid
669
- for. `Load` scopes, the route, and the live keys complete the context. The React core is the `use-agentic` package;
670
- apps and libs never import it directly (`no-import-external-library`) — everything reaches them through `st.*` and
671
- `akanjs/ui`.
672
-
673
- - **Mount `<Agent.Chat />` once in a layout.** That is the floating chat, the approval card, and the client-side
674
- loop. The default runner drives `runAgentTurn`, which the **framework serves on every app** — no lib to mount,
675
- `AKAN_AGENT=false` takes it off — and negotiates streaming via `accept`, so assistant text arrives as it is
676
- generated with zero app code. The endpoint is a stateless relay and **never executes tools**: every tool runs in
677
- the caller's own browser session, gated by guards and the approval card. Its guard is `AgentRelayAccess`, which
678
- **refuses every call until the app names a guard of its own** — the same answer `None` gives, with no boot
679
- warning. A product with accounts names it in its `option.ts`, `option.setAgentAccess(SignedIn)`, taking the same
680
- guard classes every endpoint takes (an array is ANDed, `null` clears what a library set); without one the chat
681
- cannot spend the LLM key.
682
- `persist` keeps the transcript across reloads (sessionStorage; `{ storage: "local" }` to outlive the tab),
683
- default off, and `shortcut={false}` gives the browser back the Cmd/Ctrl+L the launcher otherwise captures.
684
- **A session the chat made ends when the chat unmounts** — nothing renders its approvals once it is gone, so a
685
- turn left running would drive a screen the user has navigated away from; a session handed down by an
686
- `AgentProvider` or an `Agent.Zone` belongs to whoever provided it. Re-skin through the `AgentChat` slot in
687
- `_overrides.tsx`.
508
+ store key readable while the reading component is mounted. Nothing is derived from a store class — a lever the
509
+ screen does not offer the user is not one an agent may pull in their place. The React core is the `use-agentic`
510
+ package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
511
+ `st.*` and `akanjs/ui`.
512
+
513
+ - **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
514
+ every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
515
+ `AgentRelayAccess`, which refuses every call until the app names a guard of its own —
516
+ `option.setAgentAccess(SignedIn)`.
688
517
  - **The LLM is configured in `option.ts`, never through the environment.** `option.setLlm({ apiKey, model, host })`
689
- — or `setLlm((options) => …)` to read the key out of the app's own env object, which is where a secret belongs —
690
- fills whichever adaptor holds `LlmAdaptorRole`, reaching it as the `llmOption` use. The settings are the role's
691
- rather than one provider's, so they survive a swap. **DeepSeek is the built-in default** (`deepseek-v4-flash` at
692
- `https://api.deepseek.com`); with no `apiKey` the app still boots and the chat answers `llmUnavailable`. Swap
693
- providers the way middleware is applied: `option.applyAdaptor(LlmAdaptorRole, ClaudeLlm)`, where the
694
- implementation is an `adapt()` class in a `srvkit/` implementing `LlmAdaptor.chat(request, onDelta?)` — ignore
695
- `onDelta` and the chat still answers whole.
696
- - **An adaptor answers `null` for "not configured" and *throws* for a refusal it can explain.** The two are
697
- different things to be told: collapsing both into `null`, the way the adapter convention otherwise reads, left a
698
- user reading `llmUnavailable` — "no model is configured" — about a conversation that had merely outgrown the
699
- context window. A thrown `Err` reaches the chat as its own text, so the reason the provider gave is what the
700
- user sees. It travels as the dictionary key plus the values that key interpolates, on both the JSON and the SSE
701
- path, and **`fetchRunner` resolves it against the dictionary one step before the transcript** — the endpoint has
702
- no language to resolve it in and the browser does, which is why the key was reaching the screen raw.
703
- - **A file the user attaches rides the message, and nothing is stored.** The composer takes a paperclip, a drop and
704
- a paste; an image rides as bytes and a text file as text, which is all a browser reads with no dependency.
705
- Everything else is the app's own reader — `<Agent.Chat attach={…} />`, one `File` in, a `MessageAttachment` or
706
- `null` out — because extracting a PDF needs a parser and the framework carries attachments without depending on
707
- one. It runs ahead of the built-in, so it is also where an image is downscaled before it costs a megabyte of
708
- prompt. **What the provider cannot read is replaced by a note naming the file**, never dropped: an attachment the
709
- model never saw is one it answers about from the filename. An adaptor declares `accepts: { image, document }` and
710
- `AgentService.readable` degrades the rest, so a text-only provider needs no attachment code at all — DeepSeek
711
- declares none, which is why an image against the default provider is refused out loud while an extracted PDF
712
- works, `text` being readable by every model there is. **A `prompt()`'s `Msg.image` is the same wire shape** and
713
- reaches the chat as an attachment rather than the literal `[image]` it used to become. Persisting keeps each
714
- attachment's name and drops its content: web storage is a few megabytes, one screenshot fills a chunk of it, and
715
- a save that fails is silent — so keeping the bytes would quietly stop keeping the transcript.
716
- **The ceilings are the message's, not the file's**: 4 MB per file, 8 MB and five files per message, and the same
717
- file twice is refused by name. The bytes ride inside one turn's JSON, so what a provider refuses is the sum —
718
- and a request that cannot be sent is one the user has to empty the composer to escape, which is why the refusal
719
- happens at the paperclip and names the file it dropped.
720
- - **Speech is one engine contract and the framework's own policy.** `<Agent.Chat voice={engine} />` takes a
721
- `VoiceEngine` — `listen(handlers)` and `speak(sentence)`, both cancellable — and the chat decides everything
722
- else: a press-to-talk microphone whose transcript lands in the composer to be corrected, one utterance per
723
- press, sentence-at-a-time reading, barge-in on the next press or on Stop, and markdown stripped so `**bold**`
724
- is not pronounced. **A reply is read aloud only when the ask arrived by voice**, so a typed question never turns
725
- on the speakers — and it needs no wire field, because how a message was sent is the composer's own business.
726
- A question or an approval the loop parked on is read aloud under that same condition, because the loop stops
727
- there: a voice user who is never told about the card is a conversation that simply ends.
728
- The contract is a subscription rather than `listen(): Promise<string>` on purpose: a promise fits push-to-talk
729
- and nothing else, so hands-free could then only arrive as a breaking change. `useSpeech` in
730
- `libs/util/webkit` is the engine — the browser's own recognition and synthesis on the web, the Capacitor
731
- plugins in a WebView, **which has neither on Android or iOS**, so `speech.plugin.ts` declares the permission and
732
- the packages the native build needs. An engine answering `available()` false renders no microphone at all,
733
- the same rule as publishing no tool for a control the screen does not draw.
734
- - **`attach` and `voice` both carry functions, so a server layout cannot pass either.** A closure does not cross
735
- the RSC boundary — `non-scalar-props-restricted` says so on `page/**` — so an app that wants either mounts the
736
- chat from a small client component in `ui/` that calls the hook. `apps/akan/ui/DocsAgentChat.tsx` is the shape.
737
- - **A dialog's close is the dialog's own dismissal, not a state flip.** `closeDialogIn<Ns>` (and `Dialog.Close`)
738
- run through whatever `Dialog.Modal` registered, so the agent takes the exact path the X button takes —
739
- `confirmClose` still prompts and `onCancel` still fires. A close that only set `open` to false would skip both,
740
- which is a different action wearing the same name.
741
- - **`<Agent.Zone id="comments">` runs a second agent over one section, in parallel with the root.** Everything
742
- mounted inside — `st.use` subscriptions, hook tools, Guides — belongs to that zone's own conversation *and*
743
- stays visible to the root agent: **zones are views, never walls**, so wrapping a section costs the root nothing.
744
- An `Agent.Chat` inside binds to the zone session automatically; a zone's `readScreen` reads only its own
745
- `data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
746
- membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
747
- mounts it in.
748
- - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or a page — the render
749
- tree is the cascade: nested Guides concatenate outer-to-inner and navigating away withdraws them. It is a
750
- component, not a pageConfig field. Module `*.abstract.md` files are developer docs and are never served to the
751
- agent.
752
- - **Declare the tool beside the control that already does it.**
753
- `st.tool("x", { desc }).arg("id", ID).exec(fn)` publishes one action and returns the callable to hand to
754
- `onClick` — one handler for the person and the agent, which is the point: a button wired to an inline arrow can
755
- be clicked by a person and by nobody else. `.exec()` is the only hook, so the chain completes in one
756
- unconditional statement, and the callable carries `data-akan-action` like a store setter does. A `remove*` name
757
- defaults to a confirm gate. Reach a store action from the body — `.exec((id) => st.do.removeX(id))` — which is
758
- how an agent gets CRUD; `st.do` on its own reaches nobody.
759
- - **A falsy name declares the tool without publishing it** — the callable still drives the click a person makes,
760
- and the agent never learns the tool exists. That is the only way a conditional surface stays legal, because
761
- `.exec()` is a hook and the declaration can never be skipped: withhold the name, not the call. `st.useState`
762
- and `st.expose` take a falsy name the same way, and an unpublished callable carries no `data-akan-action` —
763
- that attribute names a tool an agent can reach. **Publish a tool only where the screen already renders the
764
- control**: a lever no one can pull by hand is not one to hand an agent, and every published tool is paid for in
765
- every turn's prompt. The mirror of the same rule is why the control gets a tool at all.
766
- - **An `enumOf` class is a complete argument type on its own**: `.arg("mode", TaskStatus)` publishes the values as
767
- the argument's `enum`, refuses anything off them by name at call time, and narrows the `.exec` parameter to the
768
- value union — nothing else to write, and the scalar (`string` / `integer` / `number`) comes from the values.
769
- **A value set the *render* decides takes `.arg(name, type, { oneOf })`** instead, because `enumOf` registers
770
- globally and a component cannot build one per render: pass the list it has — a slice's sort keys, the options a
771
- prop carried — and it is published and enforced the same way. Neither reaches a set that fills in *after* the
772
- first render, since a declaration is mount-static; put that in the tool's `guard`, which is re-read per call and
773
- can name the current values in its refusal. **An argument type nothing can describe — a model class, `Any`, a
774
- `Map` — withdraws the whole tool and says so on the console**, naming the tool, the argument and the type; the
775
- callable still drives the click a person makes. It does not throw: a tool schema is built during render, and an
776
- agent-tooling mistake that aborted the render would cost the route its server rendering. `st.useState`'s `set`
777
- degrades the same way, to read-only.
778
- - **A component that renders once per row never closes over its row's id — it takes the id as an argument.** A
779
- tool that captured its own row would be fifty registrations of one name, forty-nine of them shadowed, and the
780
- survivor would remove whichever row happened to mount last. Take the id instead — `removeTask(taskId)`, never
781
- fifty `removeTask` — and every row's registration is then interchangeable, so a row component may publish after
782
- all: say so with `shared: true` and the repeats are one declaration rather than a warned-about clash. The ids
783
- come from the `<slice>.items` resource `Load.Units` and `Data.ListContainer` already expose. `shared` is a claim
784
- about the *tool*, not a way to quiet a console: two rows whose tools would do different things (a different
785
- `modal`, a different redirect) are not interchangeable, and that one wants a `namespace` or nothing at all.
786
- - **`akanjs/ui` publishes its own controls, so an app writes nothing for them.** `Data.ListContainer` (and every
787
- `Model.AdminPanel`) publishes its toolbar and its row verbs; `Model.NewWrapper` (so `Model.New` too) publishes
788
- `new<Model>`; `Load.Units`, `Load.Pagination` and `Data.Pagination` publish `setPageOf<Model>`; `Layout.Sider`,
789
- `System.SelectLanguage`, `Link.Back` and `System.ThemeToggle` publish the shell. **A component that can render
790
- twice on one screen takes a `namespace` prop and publishes nothing without it** — `Tab`, `Dialog`,
791
- `ScreenNavigator`, `Dropdown`. Pass one (`<Tab namespace="detail">`) and the tool becomes `switchTabInDetail`;
792
- leave it off and that tab is invisible to the agent, because two tabs answering to `switchTab` would mean the
793
- first to mount loses. A named `Dropdown` publishes `openDropdownIn<Ns>` / `closeDropdownIn<Ns>` and the state
794
- `dropdownIn<Ns>`, and its trigger annotates whichever of the two its next click performs. `Model.NewWrapper`
795
- takes the same prop but publishes without one, because its slice already names it — a second create trigger for
796
- the same slice, opening a form seeded differently, is what needs the suffix.
797
- - **The `Model.*` row wrappers publish their verb, taking the id.** `Model.EditWrapper`, `Model.ViewWrapper`,
798
- `Model.RemoveWrapper` and `Model.Remove` publish `edit<Model>` / `view<Model>` / `remove<Model>` with a
799
- `modelId` argument, so a list built from `Load.Units` and an app's own `Unit` reaches the same verbs an
800
- `AdminPanel` does. `Model.SureToRemove` publishes the same — except under `typeNameToRemove`, where it
801
- publishes nothing: that gate makes a person retype the model's name, an approval card is one click, and
802
- offering the lever at a friction the screen does not have is not the same control.
803
- - **A dropdown's menu is mounted from the first render and hidden while closed** — the deliberate opposite of the
804
- modal rule below, because a menu is one click away rather than a surface of its own. A tool is declared by a
805
- mount effect, so an unmounted menu is one whose row verbs and field setters do not exist yet: an agent asked for
806
- one finds nothing, and no catalogue entry hints that opening the menu would help. `readScreen` still skips
807
- hidden content, so the items themselves are read only after `openDropdownIn<Ns>` — what a closed menu publishes
808
- is its tools, not its text. The cost is that `content` renders on page load, so a heavy panel belongs behind a
809
- `Dialog` instead.
810
- - **A modal publishes its verbs while it is open, and only then.** `Model.EditModal` publishes `submit<Model>`
811
- and `cancelEditOf<Model>`, `Model.ViewModal` publishes `closeViewOf<Model>`, and `Model.ViewEditModal`
812
- publishes `edit<Model>` / `submit<Model>` / `closeViewOf<Model>` — each from a subtree that mounts with the
813
- open modal, never from the component that merely holds it. That is what makes a list legal: `Data.CardList`
814
- renders one editor per row, and at most one of them is ever open, so one name is registered rather than fifty.
815
- It also means the verb is absent from the catalogue while nothing is open, which is honest — and costs the
816
- agent nothing, because the catalogue is re-read on the turn that follows the tool call that opened the form.
817
- - **A form control publishes its own setter, and reading a form publishes one tool that fills several at once.**
818
- Both are free: an app writes no `st.tool` for a form. A `Field.*` / `Input.*` / `Select` / `Switch` handed
819
- `onChange={st.do.setTitleOnTask}` **by reference** publishes `setTitleOnTask` while it is on screen — the same
820
- reference that earns `data-akan-action`, so the tool and the person press one function and an inline arrow
821
- still publishes nothing. `st.use.taskForm()` adds `fillTaskForm(patch)`, which takes several fields in one call
822
- and is the only way to reach a list, a map, or an embedded object, whose rows are written through
823
- `writeOnTask(path, value)` and can carry no annotation. It is a patch: a field left out keeps its value.
824
- `fillTaskForm` refuses a plain field whose control is not on screen and names the ones that are; a composite it
825
- cannot see is let through, which is the one place an agent reaches a field the screen may not draw. Never a
826
- relation (picked or uploaded, not typed), a base document field, or a `hidden`/`secret` one at any depth —
827
- their reads are masked and a writer would be the door around that. `st.use.taskForm({ agent: false })`
828
- withholds the patch tool; an inline arrow withholds a control's own.
829
- **The patch writes each plain field through that control's own published tool, not the setter underneath it** —
830
- which is what carries the control's `transform`, so a field cannot normalize one way for `setPhoneOnBizAccount`
831
- and another way for `fillBizAccountForm`. Only a composite, having no control, dispatches its setter directly.
832
- The patch tool is registered `shared`, because the entry is a pure function of the model: a form put on screen by
833
- a shell that subscribes it (`Model.EditModal`) *and* by the `Template` inside it is one declaration twice over,
834
- not a clash — so neither has to suppress the other, and neither is asked to.
835
- - **A `disabled` control publishes nothing, so the agent never gets a lever the person cannot pull.** Every value
836
- control reads it — `Field.*`, `Input.*`, `Select`, `Switch`, and the four relation pickers — and disabling a
837
- mounted control withdraws its tool for as long as it stays disabled. One gate covers both writers: with no
838
- control published, `fill<Model>Form`'s guard refuses that field too. `readScreen` says `(disabled)` beside the
839
- control, from the native attribute or `aria-disabled`, so a refusal is something the agent could have read first
840
- rather than a surprise. This is the same rule as publishing only where the screen renders the control, applied to
841
- a control the screen renders but withholds.
842
- - **Whatever the wrapper was for, there is a place to put it that is not the wrapper.** An inline arrow is the one
843
- shape that publishes nothing, so each reason for writing one has its own home, and reaching for that home is what
844
- keeps the field reachable:
845
- - *normalize* — `(v) => set(formatPhone(v))` becomes the control's own `transform` prop, which every text and
846
- number `Field.*` already takes (`Field.Phone` defaults it to `formatPhone`). `onChange` stays a reference, and
847
- **`transform` runs on the agent's write too**, by both paths — the field's own tool and `fill<Model>Form`,
848
- which goes through the control to get it — otherwise a person would store `010-1234-5678` and an agent the raw
849
- digits. It normalizes one scalar, so an array control applies it per element and a cleared nullable field stays
850
- null. It is the *control's* rule, though: a rule that must hold however the field is written — including a
851
- composite path or a base-document write — belongs in `_postSet<Field>` below.
852
- - *multi-write* — `(v) => { set(v); other(v); }` becomes a **`_postSet<Field>` method on the store**, and the
853
- control keeps handing over the generated setter by reference. It runs right after the field is written, so it
854
- reads the new value, and it reaches every other generated action with `this.` —
855
- `_postSetToBiz(toBiz) { if (toBiz) this.addSendEmailsOnEstSheet(toBiz.sendEmails ?? []); }`. Nothing about the
856
- control changes, so `data-akan-action` **and** `data-akan-state` both survive, and the rule now fires for every
857
- writer — the person, the agent, `fill<Model>Form` — which is what a rule about a field should do.
858
- **A generated action cannot be overridden, so do not try.** They all come from mapped types, and a mapped type
859
- produces *properties*: a subclass method of the same name is `TS2425`, optional or not, and the two shapes
860
- TypeScript does allow — a class field and a getter — are both skipped by `StoreRegistry.register`, which only
861
- collects prototype descriptors holding a function. There is no legal middle, which is exactly why the hook
862
- carries a leading `_` and no model suffix: a name no mapped type can produce is the only name a subclass may
863
- declare. It cannot be typed either, for the same reason, so a misspelled field is named on the console at
864
- registration instead. Calling a generated action *from* a custom one is fine and always was — `this.setXOnY(v)`
865
- typechecks anywhere.
866
- - *nested path* — `(v) => writeOnTask("payments.3.name", v)` has no home and needs none: an embedded row is
867
- unannotatable by design, and an agent reaches it through `fillTaskForm`, which waves composites through.
868
-
869
- `no-unpublished-form-setter.grit` errors on the pure-forwarding shape only, because every other one has a
870
- legitimate reading. **`akan quality scan` counts them all** (`akan.agent.unpublished-form-setter`, one warning per
871
- file): the lint rule is the per-line enforcement, the scan is the inventory of fields this screen writes but
872
- cannot be asked to write.
873
- - **A relation reaches an agent from its picker, not from the form patch.** `fillTaskForm` publishes no schema for
874
- one and is right not to: the form holds the whole related document, so an id would need a lookup the store does
875
- not do. The picker is where that lookup lives, so `Field.Parent` / `Field.Children` publish the pair themselves —
876
- `load<Field>OptionsOn<Model>`, which loads the slice and returns `[{ id, label }]`, and the field's own
877
- `set<Field>On<Model>` taking `<field>Id` / `<field>Ids`. Listing is its own tool because loading is its own step
878
- for a person too: the options arrive when the dropdown opens, and an agent never opens it. `Field.ParentId` /
879
- `Field.ChildrenId` need none of that — the id *is* the value, so the ordinary setter describes it. All four still
880
- require the setter **by reference**, and a `disabled` picker publishes nothing.
881
- - **An array of embedded rows also publishes `add<Field>On<Model>` and `sub<Field>On<Model>`** — append, and
882
- remove-by-position — beside the whole-array setter. Not new authority: the setter can already produce any array
883
- those two can, so they are strictly weaker. What they add is that neither can touch a row it was not given, and
884
- that is the point: writing the whole array means echoing every row the agent is *not* changing, `checked`
885
- validates types and not values, so one mistyped row nobody asked about is written silently. Both take a list and
886
- act atomically, because removing positions one call at a time would shift the ones not yet removed. **Only an
887
- embedded-row array gets them** — an array of primitives or of relation ids has nothing to retype wrong, its
888
- values *are* the payload, so it keeps one setter and pays for no extra tools. `add` appends and publishes no
889
- insert position, matching the `+` a person presses; `addOrSub` is never published, since it matches by `indexOf`
890
- and would compare rows by reference. Editing a row in place stays `fill<Model>Form`'s job.
891
- - **A list the person can drag also publishes `move<Field>On<Model>(from, to)`**, and `DraggableList` is a form
892
- control like any other: handed the generated setter by reference it publishes that field, so an app that renders
893
- its own rows with `DraggableList` writes no `st.tool`. The reorder tool exists for the same reason `add`/`sub` do
894
- — the drag is the lever the screen offers and it changes no entry's content, so moving one row should not mean
895
- retyping the nine beside it. No store action answers to it: reordering *is* a whole-array write, so the tool
896
- splices the live entries and hands them to the setter the drag hands them to, `transform` deliberately not
897
- applied, since the values are stored already and dragging normalizes nothing. It comes from the control saying
898
- it sorts, not from the field, so a plain `Field.List` publishes no reorder and a scalar field never gets one.
899
- **A component that composes `DraggableList` and already published the field hands the inner list a wrapper** —
900
- the two would otherwise register one name twice, and the outer one is the one holding `transform`. That is what
901
- `Field.TextList` does, and the only place an inline arrow is the right answer rather than a bug.
902
- - **Reading is per key, not per store.** `st.useState(name, initial, meta)` publishes local state (read-only
903
- unless `set:` names a type) and `st.expose(name, value)` a derived value. A subscribed store key is listed in
904
- the state context block by name and pulled with `readState(key)`, masked by the model that key declares — while
905
- a key the screen does not read stays unreadable even when a sibling key of the same store is live. **There is no
906
- store-level exposure declaration**: a store class says nothing about agents, and `st.use.x({ agent: false })` is
907
- how the component that subscribes a value keeps it off the surface. Base-store plumbing does the same at the
908
- call site — `st.use.path({ agent: false })`, `st.use.tryJwt({ agent: false })` — so routing and the caller's
909
- credential stay off the surface unless a component opts a key in, as ThemeToggle does for `theme`.
518
+ fills whichever adaptor holds `LlmAdaptorRole`; swap providers with `option.applyAdaptor(LlmAdaptorRole, X)`.
519
+ - **Declare the tool beside the control that already does it.** `st.tool("x", { desc }).arg("id", ID).exec(fn)`
520
+ publishes one action and returns the callable to hand to `onClick` — one handler for the person and the agent.
521
+ Reach a store action from the body (`.exec((id) => st.do.removeX(id))`); `st.do` on its own reaches nobody.
522
+ - **Publish a tool only where the screen already renders the control.** A falsy name declares the tool without
523
+ publishing it, which is how a conditional surface stays legal — `.exec()` is a hook, so withhold the name, not
524
+ the call. A `disabled` control publishes nothing.
525
+ - **A component that renders once per row takes the row id as an argument**, never a closure over it — fifty
526
+ registrations of one name leave forty-nine shadowed. Interchangeable repeats say so with `shared: true`.
527
+ - **A component that can render twice on one screen takes a `namespace` prop** (`Tab`, `Dialog`, `Dropdown`,
528
+ `ScreenNavigator`) and publishes nothing without one.
529
+ - **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
530
+ **by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
531
+ nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
532
+ - **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface;
533
+ a key no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
534
+ - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
535
+ tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
536
+ - The framework publishes five built-ins on every store surface — `navigate`, `goBack`, `readScreen`,
537
+ `readState(key)`, `highlight(target)` — plus `askUser`, which the session owns. **There is no general-purpose
538
+ wait**: publish an `st.tool` beside the control that starts the work and let it await the work.
910
539
  - **Model-facing text is English, always** — tool `desc`, `instructions`, Guide text. The `l()` rule covers
911
- strings a *user* reads: Chat's own buttons go through `l("base.*")`, the model's text never does.
912
- - A masked model never crosses the boundary: a value whose `hidden`/`secret` fields are populated is refused at
913
- read unless a `mask:` model is named — the same rule and wording as `AgentBridge.read`.
914
- - **`prompt()` endpoints double as the chat's slash commands.** There is no listing endpoint — the client reads
915
- its own serialized signals — so a prompt's dictionary `.desc()` is what the menu shows, and its guards are
916
- enforced by the prompt's own GET at call time. Arguments are positional and whitespace-separated, and quoting
917
- is how a sentence stays one of them (`/reviewTask t1 "look at the totals"`) — a prompt taking a single `String`
918
- is the common case, and an unquoted sentence would fill its second parameter with the second word.
919
- - **The chat answers six slash commands of its own**, listed in the same `/` menu ahead of the prompts:
920
- `/new` (`/clear`), `/retry`, `/compact`, `/copy`, `/help` and `/tools`. An app writes none of them and cannot add
921
- one — the extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
922
- **A built-in wins a name collision with a prompt of the same name**, the mirror image of the tool rule: a
923
- component's `st.tool` shadows a built-in it means to replace, but no library's prompt may take `/new` away from
924
- the user who typed it — so a shadowed prompt is dropped from the menu rather than listed twice. `/new` and
925
- `/copy` are also dispatched *before* the is-a-turn-running check **and before the question card takes the
926
- composer**, because mid-turn is exactly when they are reached for and a question the agent asked is the middle
927
- of a turn like any other — answered as text, `/new` would have reached the model as the user's decision.
928
- `/new` therefore aborts the turn it is clearing and waits for it to wind down, since the loop clears its own
929
- running flag a microtask later and a transcript emptied before that lands is one the dying turn appends onto.
930
- - **A command's output is a `local` message: rendered in the transcript, withheld from the wire.** The transcript
931
- *is* the model's history, so `/help` text appended plainly would come back next turn as something the assistant
932
- believes it said. `session.note(text)` is the only way to write one, `session.report(error)` stays what a
933
- host-side *failure* lands in, and `local` messages are left out of a `/copy` export too — they are the chat
934
- talking to itself. Their text is user-facing, so it goes through `l("base.*")` like every other chat string.
935
- - **`/copy` exists because nothing else keeps the transcript.** The relay is stateless and the conversation lives
936
- only in that browser, so an export is the one path a wrong answer has to whoever could fix it — which is why it
937
- carries the route and the timestamp. `/retry` replays only the trailing user message, leaving anything before it
938
- in place, so a prompt's own preamble is not sent twice.
939
- - **A long conversation summarizes itself, because nothing else is keeping it inside the model's window.** The
940
- loop runs in the browser and the relay holds no session, so an uncompacted chat grows until the provider
941
- refuses the whole request — a refusal, never a shorter answer, which is why compaction runs *before* the turn
942
- that would have overflowed rather than as a recovery after it. Past `compact.at` estimated tokens (four
943
- characters to a token, over the JSON the turn posts; 24k by default, well under the smallest window a provider
944
- 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
945
- becomes one message standing in for it, flagged `summary` on the wire — `<Agent.Chat compact={{ at, keep }} />`
946
- tunes it per provider and `{ at: 0 }` turns it off, and `/compact` does the same on demand keeping nothing.
947
- **The cut only ever lands on a user message**: everything above one is settled, so the kept half can never open
948
- with a `tool` result whose call was summarized away, a shape every provider dialect rejects. The summarizing
949
- turn carries no tools and no screen context — it summarizes the conversation, it does not act on it — and it is
950
- fed a *bounded* digest rather than the transcript itself, since the transcript being summarized is the one that
951
- no longer fits. A summary that cannot be produced leaves the transcript alone and the turn goes out as it would
952
- have; one that fails to shrink anything is not retried until another threshold's worth has been added. On the
953
- wire a summary wears the user's role because the wire has no other, so a provider mapping frames it as a system
954
- message and `/retry` steps over it — replaying it would send the notes back as a question.
955
- - **A stopped turn answers the calls it never ran, because an unanswered call ends the conversation.** Every
956
- provider dialect refuses an assistant message whose `tool_calls` have no results — on that turn and on every
957
- later one — so Stop landing between a call and its result would leave a transcript nothing can be sent from,
958
- with no way out but `/new`. `Transcript.sanitize` holds the invariant in one place and runs where a transcript
959
- is assembled rather than where each hole is made: the turn's own request, a transcript restored from storage,
960
- and a stored transcript capped to its newest messages, whose window can start mid-pair. A call the loop never
961
- reached is *answered* rather than erased — a model told the call was stopped asks again, where one shown no
962
- call at all answers as if it had the result.
963
- - **A turn that failed says so on the wire.** `error` is a field only this wire has, so a provider mapping reads
964
- `text` and drops it; `AgentService.explained` folds it into the text before any adaptor sees it, because an
965
- assistant turn that says nothing is one the model repeats.
966
- - **↑ and ↓ in the composer walk what was sent**, seeded from the transcript so a persisted chat does not lose
967
- only what was just typed. A single-line input has nothing of its own on the vertical arrows, and the
968
- half-written draft they were walked away from comes back at the bottom of the walk. **The `/` menu takes those
969
- keys while it is open** — it is the thing on screen the arrows point at — with Enter picking the highlighted
970
- row, Tab completing its name, and Escape closing the menu and then, pressed again, the panel.
971
- - The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same
972
- router `Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and
973
- a page that draws no back link is not one you may not leave), `readScreen` (the rendered DOM as compact text —
974
- headings, links, control values, and `(disabled)` on a control or button that has it; the chat's own UI is
975
- skipped via `data-agent-ui`, and a password value is never read), `readState(key)` (one masked store key), and
976
- `highlight(target)`. Declaring a hook tool under one of those names shadows the built-in, so reuse them only to
977
- mean that. **There is no general-purpose wait**: a built-in one was reachable on every screen and a model spent
978
- it on whatever key it liked, parking turns nobody asked to park. Waiting belongs to the screen that knows what
979
- is worth waiting for — publish an `st.tool` beside the control that starts the work, and let it await the work.
980
- - **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
981
- payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
982
- awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
983
- and the session awaits it after every non-`query` tool before taking the change report. Without it the report
984
- describes the moment before the change landed and the `readScreen` that follows reads the page the user left.
985
- New tools and state from a fresh route are still only listed from the next turn: the catalogue is snapshotted
986
- when the turn starts.
987
- - **A tool that waits for its own work costs no model turns; one that returns early costs one round trip per
988
- look.** The session awaits `run`, so a `.exec` that awaits the store action finishing the job simply makes the
989
- turn take that long — and the change report that follows carries whatever landed, so the model needs no second
990
- call to read the result. A fire-and-forget tool leaves the agent to poll instead, which burns the whole
991
- `maxTurns` budget in seconds on a job measured in minutes. Say so in the `desc` ("takes about two minutes; do
992
- not poll while it runs") and, for a route full of slow work, in an `Agent.Guide`.
993
- - **A wait is a screen's own tool, declared where the slow work is.** For the job a tool cannot await — one
994
- started in an earlier turn, or by a person clicking the button — publish an `st.tool` that parks on the thing
995
- *that* screen knows about, and say in its `desc` what it waits for and roughly how long. A general built-in was
996
- tried and removed: reachable on every screen with no idea what any key means, a model spent it on whatever key
997
- looked promising and parked turns nobody asked to park. The screen that starts the work is the only place that
998
- knows what finishing looks like, and a tool declared there is also one the agent cannot reach on a screen where
999
- waiting makes no sense. Honour `AgentAbort.current` in the body and report with `AgentProgress.report`, so Stop
1000
- ends the wait and the row says how it is going.
1001
- - **Stop reaches a tool that is still running.** The session races every call against its abort signal, so a
1002
- two-minute tool does not hold the loop for two minutes after the user presses Stop. The signal itself arrives
1003
- through `AgentAbort.current` — the same module slot `AgentProgress` is — and honouring it is optional, since the
1004
- race lands whatever the tool does; what it buys is the tool's own cleanup, a timer or a poll loop that would
1005
- otherwise run out with nobody left to answer. A tool that ignores it is left running rather than cancelled: the
1006
- work is usually a job a server is already doing.
1007
- - **`readScreen` takes a `section`, and `highlight` a `target`.** Both resolve a name the agent has already seen —
1008
- a `data-akan-action` / `data-akan-state` annotation, an `Agent.Zone` or `useScreenScope` container
1009
- (`data-agent-scope`, which `Load.Units` / `Load.View` / `Data.ListContainer` put on the container they render),
1010
- an element id, or **a heading by its own text**, matched on letters and digits so the slug an agent writes for a
1011
- heading it read resolves. That tolerance stops at headings: a heading is a landmark and scrolling to the wrong
1012
- one costs nothing, while two buttons reading "Save" are not the same control. **Nothing hidden ever resolves** —
1013
- 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
1014
- heading of its level or higher.
1015
- - **A screen is only aimable if its names are printed.** `readScreen` writes `(#anchor)` beside a heading that
1016
- opens an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
1017
- everything past the 8000-character limit is unreachable, because nothing names it, and an agent asked to point
1018
- at a section it cannot name guesses a slug and is refused. A refusal lists the sections actually on screen.
1019
- `highlight` scrolls its target into view and flashes it **once the scroll lands**, since a smooth scroll across a
1020
- long page outlasts the flash; it is the one built-in that exists for the *user's* benefit, because showing where
1021
- a control is beats writing directions to it.
1022
- - **A slow tool reports its own progress with `AgentProgress.report(message, { done, total })`** from wherever the
1023
- work is — a store action, an upload loop, an adapter — reached through a module slot rather than a parameter, and
1024
- a no-op when nobody is rendering it. The chat shows it on that call's row until the row resolves. It is the
1025
- browser twin of `McpProgress.report`. Import it and `AgentAbort` from `akanjs/store`: an app may not reach
1026
- `use-agentic` directly (`no-import-external-library`), and those two are the channels a long tool body needs.
1027
- - **The turn cap is a question, not a dead end.** At `maxTurns` the session asks whether to keep going through the
1028
- same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
1029
- keep-going choice reaches the model as guidance. A host that renders no `pendingQuestion` passes no
1030
- `continueAsk` and keeps the old failure, because asking with nobody listening would hang.
1031
- - **`askUser` is a fourth built-in the *session* owns, not the surface.** The answer comes from the conversation
1032
- rather than the screen, so it rides on every turn whatever the page declares, and a zone agent asks inside its
1033
- own transcript. `choices` offers a pick (`multiple` for several) and omitting them asks for free text; the card
1034
- keeps a free-text row either way, because the model wrote the options and only the user knows whether the answer
1035
- is among them. The loop parks on the question exactly as it parks on an approval, a dismissal is the tool's
1036
- error result rather than a silent empty answer, and the settled exchange renders as question-and-answer instead
1037
- of a tool row. **Never re-implement it per screen** — a `st.tool("askAboutX")` that opens a modal is the same
1038
- thing with a worse transcript — and a hook tool named `askUser` shadows it like any other built-in.
540
+ strings a *user* reads.
541
+
542
+ Full contract — the chat's own options, attachments and speech, zones, what `akanjs/ui` publishes for you, slash
543
+ commands, transcript compaction, and the built-in tool semantics: `get_guideline` with `agentRule`, or
544
+ `akan guideline show agentRule`.
1039
545
 
1040
546
  ## Scalar Modeling (`**/*.constant.ts`)
1041
547
 
@@ -1085,9 +591,9 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
1085
591
  ### Image & File Fields
1086
592
 
1087
593
  - **Do not declare `Upload` as a model field.** `Upload` is a signal-body-only primitive (see Service And Signal Conventions). Models reference the `File` model instead.
1088
- - Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many (see `libs/shared/lib/user/user.constant.ts`, `libs/shared/lib/banner/banner.constant.ts`).
594
+ - Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
1089
595
  - The store then auto-generates an `upload<Field>On<Model>(fileList)` action that calls the framework upload mutation and polls file status until it leaves `"uploading"` (`pkgs/akanjs/store/action.ts`).
1090
- - Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` lib (`libs/shared/lib/file/*`). Do not hand-roll data-URL fallbacks.
596
+ - Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` module. Do not hand-roll data-URL fallbacks.
1091
597
 
1092
598
  ### Cascade Remove — the `cascade` option
1093
599
 
@@ -1096,46 +602,38 @@ shape, so `cascade` never means "related" — it means one of exactly these:
1096
602
 
1097
603
  - `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
1098
604
  `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it; a primitive, a bare
1099
- `ID`, and a scalar each fail the class build, because none of them names a document to remove.
605
+ `ID`, and a scalar each fail the class build.
1100
606
  - `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
1101
607
  its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three forms:
1102
- a relation (`agentSession: field(AgentSession, { cascade: "removeWith" })`), an id with `ref`
1103
- (`field(ID, { ref: "agentSession", cascade: "removeWith" })`), or a polymorphic id with `refPath`
1104
- (`field(ID, { refPath: "parentType", cascade: "removeWith" })`). An array, a Map, `ref` together with `refPath`,
1105
- and a field naming no owner each fail the class build.
1106
- - **A `refPath` must name an `enumOf` field.** A free-form owner type is unknowable at build time, so every model's
1107
- removal would have to sweep the polymorphic table on the chance it is the owner. The enum names the candidates and
1108
- the reverse index reaches only them.
1109
- - **A cascade goes through the target's service, never its model** — unless it provably makes no difference. The
1110
- service path is what runs the target's `_postRemove`, which is where a module puts the side effect the removal has
1111
- to carry (`FileService._postRemove` deletes the stored blob there).
1112
- - **Bulk is decided at boot, per target model, for both directions.** When the target has no `remove` schema hook, no
1113
- `_pre`/`_postRemove` (its own or a lib's), no cascade of its own, and no children, one `removeManyByQuery` leaves
1114
- exactly the rows the loop would, so the framework takes it. Adding a `_postRemove` to that model silently flips it
1115
- back to one document at a time — the boot log (`info` summary, `verbose` per edge) is the only place that shows.
1116
- - **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` still counts
1117
- and a `removeRef` target the app never mounted fails the boot rather than the first removal. An unmounted
1118
- `removeWith` owner fails the boot too; an unmounted `refPath` candidate only warns, since that list spans optional
1119
- modules by design.
1120
- - **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
1121
- `origin`, so two parents can share one row; declaring `removeRef` says the field owns its target exclusively, and
1122
- that judgement is the declaring model's to make.
1123
- - Removal is soft (`removedAt`) but the storage delete a `_postRemove` performs is not — a cascade is not
608
+ a relation (`field(AgentSession, { cascade: "removeWith" })`), an id with `ref`, or a polymorphic id with
609
+ `refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
610
+ - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
611
+ model's removal would have to sweep the polymorphic table on the chance it is the owner.
612
+ - **A cascade goes through the target's service, never its model** — that path is what runs the target's
613
+ `_postRemove`, where a module puts the side effect the removal has to carry.
614
+ - **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
615
+ `_pre`/`_postRemove`, no cascade of its own, and no children takes one `removeManyByQuery`. Adding a
616
+ `_postRemove` silently flips it back to one document at a time — the boot log is the only place that shows.
617
+ - **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` counts,
618
+ and an unmounted `removeRef` target or `removeWith` owner fails the boot rather than the first removal. An
619
+ unmounted `refPath` candidate only warns, since that list spans optional modules by design.
620
+ - **Nothing checks whether another document still references the same target.** `File` in particular is deduped
621
+ by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
622
+ - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
1124
623
  restorable, and reviving the owner does not revive what went with it.
1125
624
  - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
1126
- polymorphic). Every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table
1127
- on every owner removal.
1128
- - **Query-level removes fire no hooks and therefore no cascade.** `removeManyByQuery` / `updateManyByQuery`, the
1129
- generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById` stamp
1130
- `removedAt` in one atomic UPDATE, so nothing downstream runs. Remove one document at a time when it cascades.
1131
- - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write, so a retry after a partial
1132
- failure re-stamps nothing. Cycles are cut by a visited set carried down the whole chain, with a depth cap of 16.
625
+ polymorphic); every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table.
626
+ - **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
627
+ generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
628
+ at a time when it cascades.
629
+ - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write. Cycles are cut by a
630
+ visited set carried down the whole chain, with a depth cap of 16.
1133
631
 
1134
632
  ## Akan Page Routing (`apps/**/page/**`)
1135
633
 
1136
634
  - `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
1137
635
  - Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
1138
- - Route pages use `_index.tsx`; layouts use `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
636
+ - A route page is either `<routeName>.tsx` (serving `/routeName`) or a directory's `_index.tsx`; layouts use `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
1139
637
  - Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add files like `_Component.tsx` or `_helper.tsx`.
1140
638
  - Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`, `common`, or `lib` instead.
1141
639
  - Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
@@ -1144,9 +642,9 @@ shape, so `cascade` never means "related" — it means one of exactly these:
1144
642
  - Prefer `export default function Page` or `export default async function Page` for page components.
1145
643
  - `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in `akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the default) syncs nothing.
1146
644
  - `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares subRoutes. The folder is generated and gitignored; edit the lib source, never the link.
1147
- - Both path segments are route groups, so a lib route mounts at its own path (`libs/shared/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
645
+ - Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
1148
646
  - `export const pageConfig = { devOnly: true }` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal `true`/`false` — the build reads it off the source without evaluating the module.
1149
- - Before changing route behavior, check `pkgs/akanjs/server/src/routeTree.tsx` and nearby routes for the expected pattern.
647
+ - Before changing route behavior, check `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
1150
648
 
1151
649
  ### Page Body Shape
1152
650
 
@@ -1225,7 +723,7 @@ when two shapes disagree.
1225
723
  - Stale `// TODO: Implement …` comments above implemented methods.
1226
724
  - `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
1227
725
  - Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
1228
- - Raw palette grays such as `text-gray-400` instead of daisyUI semantic tokens.
726
+ - Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
1229
727
 
1230
728
  ## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
1231
729
 
@@ -1242,7 +740,7 @@ when two shapes disagree.
1242
740
  - To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
1243
741
 
1244
742
  ```bash
1245
- cd dist/apps/akan && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=akanjs AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com" AKAN_PUBLIC_APP_NAME=akan AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation AKAN_PUBLIC_BASE_PATHS=akanjs,soft,office bun main.js
743
+ cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
744
+ AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
745
+ AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
1246
746
  ```
1247
-
1248
- - Adjust `<appName>`, `AKAN_PUBLIC_APP_NAME`, and `AKAN_PUBLIC_BASE_PATHS` to match the app being tested.