@akanjs/cli 3.0.2 → 3.1.0-rc.1
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.
- package/.build-stamp +1 -1
- package/{CodeAgent-szbjv4gv.js → CodeAgent-kyprmy06.js} +2 -2
- package/{CodeTui-5nw86ab6.js → CodeTui-21mtze3j.js} +1 -1
- package/{TunnelShare-cwh9ezsm.js → TunnelShare-q4jq08qg.js} +2 -2
- package/{agent.command-np1dffkb.js → agent.command-f3gsc1gd.js} +5 -5
- package/akanCode.js +2 -2
- package/{application.command-gk69v1xs.js → application.command-5s780vnz.js} +37 -15
- package/{applicationBuildRunner-wx09n9g6.js → applicationBuildRunner-1yaqsrdq.js} +115 -103
- package/buildBatch.proc.js +16 -5
- package/cloud.command-yj6xb2a7.js +192 -0
- package/cloud.script-h6pvcpa2.js +20 -0
- package/{code.command-hmtx14b2.js → code.command-3wkn4ja5.js} +6 -6
- package/commandManifest.json +1 -1
- package/{context.command-6aqvdx43.js → context.command-9jjn3gcw.js} +13 -13
- package/context.runner-atcrzz5b.js +21 -0
- package/{guideline.command-axvf68gc.js → guideline.command-dtczrqpf.js} +2 -2
- package/guidelines/agentRule/agentRule.instruction.md +10 -2
- package/guidelines/conventions/conventions.instruction.md +70 -25
- package/guidelines/cssRule/cssRule.instruction.md +19 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +72 -12
- package/guidelines/queryRule/queryRule.instruction.md +11 -1
- package/guidelines/runtimeRule/runtimeRule.instruction.md +97 -16
- package/guidelines/ssrRule/ssrRule.instruction.md +10 -7
- package/guidelines/transportRule/transportRule.instruction.md +19 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +6 -6
- package/incrementalBuilder.proc.js +24 -3
- package/{index-h9ht6s60.js → index-05g3jayg.js} +1 -1
- package/{index-jk9xnck6.js → index-1w2qaee2.js} +240 -133
- package/{index-g38nhv87.js → index-2etcv0sc.js} +1 -1
- package/{index-zyw622y1.js → index-2mx692qq.js} +3 -3
- package/{index-50wb2a26.js → index-55mynnqs.js} +354 -129
- package/{index-2wc41k5w.js → index-56d2g8ms.js} +2 -2
- package/{index-q6ft9fkc.js → index-6jjtnhkw.js} +8 -3
- package/index-6w0rbr9d.js +68 -0
- package/{index-hxnnfy57.js → index-8ggztp8k.js} +204 -8
- package/{index-zwd84m0w.js → index-8r4d7cmt.js} +4 -4
- package/{index-j38qkvkk.js → index-ae5pwhgs.js} +12 -10
- package/{index-gtjgnv36.js → index-aprt7pkz.js} +6 -9
- package/{index-1bjkfvm9.js → index-c1x0be5j.js} +2 -2
- package/{index-z750gk1j.js → index-c2yrsxdh.js} +13 -7
- package/{index-cb6yc5mb.js → index-c76k723e.js} +2 -2
- package/{index-6keyf4sg.js → index-ctanya41.js} +4 -4
- package/{index-f4e08ptp.js → index-e0cvze1s.js} +4 -4
- package/{index-hp3stk1m.js → index-fm46xrf1.js} +3 -3
- package/{index-3xmzr1p1.js → index-gnyf1fqy.js} +2 -2
- package/{index-nwd6qzvv.js → index-jx4bzvwg.js} +138 -55
- package/{index-jb0hmt3k.js → index-krq7btbt.js} +3 -3
- package/{index-94s080qm.js → index-pgj2rt78.js} +139 -1
- package/index-q958tqqg.js +77 -0
- package/{index-50jjwg8y.js → index-qt22xstj.js} +1 -1
- package/index-rc5rwyps.js +201 -0
- package/{index-xwprn9nr.js → index-rtct7d43.js} +1 -1
- package/{index-bsv901fd.js → index-rwq3ekqc.js} +7 -7
- package/{index-1gs9g12n.js → index-v29746vb.js} +4 -4
- package/index-ww9bhp67.js +62 -0
- package/index.js +22 -21
- package/{library.command-9m2rdwwz.js → library.command-gaga5bmq.js} +3 -3
- package/{localRegistry.command-kda81jyk.js → localRegistry.command-vsreyy42.js} +9 -9
- package/{module.command-q63dffya.js → module.command-7vpf6db4.js} +5 -5
- package/{package.command-y2jh0m99.js → package.command-twmvvf69.js} +3 -3
- package/package.json +2 -2
- package/{page.command-f3e4cwhh.js → page.command-5asq7fdf.js} +3 -3
- package/pipeline.command-d0v38572.js +1334 -0
- package/{primitive.command-r4kq75qj.js → primitive.command-dyrs2fm8.js} +6 -6
- package/projectTypecheck-cvtkrzp2.js +7 -0
- package/{quality.command-f7gzh26z.js → quality.command-6emsqpbg.js} +5 -4
- package/{repair.command-0qmyr6qa.js → repair.command-8t6aegv5.js} +5 -5
- package/{scalar.command-kddn7av4.js → scalar.command-bnggy1x7.js} +4 -4
- package/{subspace.command-ft65qvc4.js → subspace.command-md2teygq.js} +158 -91
- package/templates/app/env/env.client.type.ts +2 -3
- package/templates/app/page/styles.css.template +8 -0
- package/templates/appSample/page/task/[taskId]/_index.tsx +2 -2
- package/templates/appSample/page/task/[taskId]/edit.tsx +2 -2
- package/templates/appSample/page/task/_index.tsx +2 -2
- package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -2
- package/templates/crudPages/[__model__Id]/page.tsx +3 -3
- package/templates/crudPages/page.tsx +2 -2
- package/templates/crudSinglePage/page.tsx +3 -3
- package/templates/lib/cnst.ts +2 -2
- package/templates/lib/sig.ts +3 -3
- package/{tunnel.command-b3ymdk4f.js → tunnel.command-jx3tdm2d.js} +16 -13
- package/{workflow.command-z1srcdfw.js → workflow.command-jx0wag5r.js} +10 -10
- package/{workspace.command-vp6vmx3z.js → workspace.command-yxd7rr7g.js} +57 -27
- package/cloud.command-tw06613r.js +0 -95
- package/cloud.script-64gzba8r.js +0 -20
- package/context.runner-hnkvv3z3.js +0 -21
- package/index-t91a7339.js +0 -104
|
@@ -67,7 +67,9 @@ back.
|
|
|
67
67
|
- **Never import a third-party package** from `page/**`, from any barrel, or from any
|
|
68
68
|
`*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
|
|
69
69
|
(`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
|
|
70
|
-
`common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
|
|
70
|
+
`common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft. A module's
|
|
71
|
+
`*.signal.test.ts` / `*.service.test.ts` is held to the same rule but may import the runtime's `node:*` and
|
|
72
|
+
`bun:*` modules (a temp dir, a socket, sqlite), which module code leaves to `srvkit/`.
|
|
71
73
|
- **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
|
|
72
74
|
`*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
|
|
73
75
|
`#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
|
|
@@ -202,6 +204,13 @@ back.
|
|
|
202
204
|
- Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
|
|
203
205
|
- 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.
|
|
204
206
|
- 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()`.
|
|
207
|
+
- **One suite per module file, named after it.** A module test is `<model>.<role>.test.ts` beside the file it covers
|
|
208
|
+
(`chatMessage.signal.test.ts`, `chatMessage.service.test.ts`, `ChatMessage.Zone.test.tsx`). Never split one role
|
|
209
|
+
across topic files such as `chatMessage.trim.signal.test.ts` / `chatMessage.security.signal.test.ts` — add a
|
|
210
|
+
`describe` block to the existing suite. A suite that drives several modules (guards across models, an app's MCP
|
|
211
|
+
exposure list) belongs to none of them and goes in `srvkit/`, like `libs/shared/srvkit/guards.signal.test.ts`.
|
|
212
|
+
Never put a test at the `lib/` root. `akan sync` refuses all three shapes. Nothing imports a test, so a build never
|
|
213
|
+
typechecks one; `akan typecheck <app-or-lib>` does, `bun:test` types included.
|
|
205
214
|
- `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.
|
|
206
215
|
- A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
|
|
207
216
|
|
|
@@ -310,11 +319,11 @@ are, because those are the ones the server could have done.
|
|
|
310
319
|
consume them in a server component, never as a `Zone` prop.
|
|
311
320
|
6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
|
|
312
321
|
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
313
|
-
|
|
314
|
-
copy the shape rather than the component.
|
|
322
|
+
is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
|
|
315
323
|
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
316
324
|
lookups belong in a module-scope `as const` map.
|
|
317
|
-
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any
|
|
325
|
+
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any of the page's
|
|
326
|
+
markup renders. After its fetch the redirect is an in-page one (HTTP 200); `.config({ ssr: "block" })` makes it a 3xx.
|
|
318
327
|
9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
|
|
319
328
|
`<details>`/`<summary>`, keeps both branches server-rendered.
|
|
320
329
|
10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
|
|
@@ -379,8 +388,8 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
379
388
|
from `akan start`; `build-ios` / `build-android` / `build-desktop` write
|
|
380
389
|
`dist/native/<app>/<target>/build/<platform>`, a desktop app only for the OS that builds it — `--arch` picks
|
|
381
390
|
a Windows or Linux app's CPU, and a macOS app is Apple silicon only. `--installer true` adds a Windows setup, a
|
|
382
|
-
macOS dmg or a Linux AppImage; `
|
|
383
|
-
`
|
|
391
|
+
macOS dmg or a Linux AppImage; `--sign` signs with the identity `AKAN_NATIVE_MACOS_*` / `AKAN_NATIVE_WINDOWS_*`
|
|
392
|
+
names and `--notarize` uploads the signed macOS app to Apple — the env alone does neither. With `updates: { url, publicKey }` an installed app takes the signed
|
|
384
393
|
releases `akan publish-update` makes; `akan update-keygen` makes the key and prints its `publicKey`.
|
|
385
394
|
- **A desktop app that carries its server (`desktop: { server: true }` in `native`, or in one target) gets nothing
|
|
386
395
|
from `docker`** — and `server: { omit: [...] }` leaves out packages only the image needs. That server is API-only, runs in database mode `single`, and listens on a loopback port any
|
|
@@ -393,7 +402,9 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
393
402
|
`NativeFile.resolve(grant, "read")` in `akanjs/server` asks the shell for it. What it carries is readable on the
|
|
394
403
|
user's computer — `private/` (each lib's too, under `private/libs/<lib>`), the one `env.server.<env>.ts` of its
|
|
395
404
|
`--env` and its libs' server env defaults (`env.server.testing.ts`) — and it has no `public/`: read runtime files
|
|
396
|
-
from `AKAN_APP_DIR`, never `process.cwd()`.
|
|
405
|
+
from `AKAN_APP_DIR`, never `process.cwd()`. **`server: { profile: "edge" }` bundles `env.server.<env>.edge.ts` in
|
|
406
|
+
that file's place**, and the build refuses one holding a secret-named value; `server: { modules: ["@edge"] }`
|
|
407
|
+
mounts only the modules whose `serve()` lists `operationMode: ["edge"]`, and what they reach.
|
|
397
408
|
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
398
409
|
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
399
410
|
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
@@ -481,7 +492,9 @@ count was shortened to `count(query)`, with `countDocuments` kept as `@deprecate
|
|
|
481
492
|
`removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
|
|
482
493
|
so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`. The
|
|
483
494
|
write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write into it
|
|
484
|
-
and save it, so the save hooks do run; reach for those when the write has to be seen by them.
|
|
495
|
+
and save it, so the save hooks do run; reach for those when the write has to be seen by them. `upsertById(data, {
|
|
496
|
+
preserveTimestamps })` writes a record whole under its own id, new or in place, with no hook: the write a sync or a
|
|
497
|
+
backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
|
|
485
498
|
|
|
486
499
|
**`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
|
|
487
500
|
explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
|
|
@@ -517,7 +530,9 @@ workflow changes.
|
|
|
517
530
|
|
|
518
531
|
- Keep domain business operations in `.service.ts` classes built with `serve(...)`.
|
|
519
532
|
- Keep execution contracts and triggers in `.signal.ts` classes built with `internal(...)`, `slice(...)`, and `endpoint(...)`.
|
|
520
|
-
- Use `Internal` for internal triggers such as init, interval, cron, or queue jobs.
|
|
533
|
+
- Use `Internal` for internal triggers such as init, interval, cron, or queue jobs. A `process` job's
|
|
534
|
+
`backoff: { type: "exponential", delay, maxDelay }` caps the wait between attempts, and the internal's
|
|
535
|
+
`onFailed(job, error)` runs once a job has failed its last attempt — the dead letter, on either queue.
|
|
521
536
|
- Use `Slice` for typed data views that feed client stores and zones; keep each slice focused on one purpose.
|
|
522
537
|
- Use `Endpoint` for query and mutation contracts exposed to callers.
|
|
523
538
|
- Connect external APIs or infrastructure through adapters, usually under `srvkit/`, and inject them into services instead of importing vendor clients directly into domain logic.
|
|
@@ -541,6 +556,11 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
541
556
|
inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
|
|
542
557
|
*gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
|
|
543
558
|
recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
|
|
559
|
+
- **A device or another server proves itself with a machine token, never a staff session.**
|
|
560
|
+
`MachineToken.issue({ kind, id, version })` (`akanjs/server`) signs one with `AKAN_MACHINE_PRIVATE_KEY` (made by
|
|
561
|
+
`akan machine-keygen <file>`), with no expiry; a server holding that key or `AKAN_MACHINE_PUBLIC_KEY` sets
|
|
562
|
+
`account.machine` from a verified one on HTTP and websocket alike, and only from one. Read it with `.with(Machine)`
|
|
563
|
+
or in a guard, and revoke by bumping the version the guard compares to the record's own.
|
|
544
564
|
- **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
|
|
545
565
|
(`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
|
|
546
566
|
bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
|
|
@@ -603,7 +623,7 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
603
623
|
|
|
604
624
|
- `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
|
|
605
625
|
- Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
|
|
606
|
-
- `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.
|
|
626
|
+
- `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. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the server is what its name implies, not its bytes — check the bytes when the type matters.
|
|
607
627
|
- Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference. A `Binary` return travels as base64 in an
|
|
608
628
|
HTTP body, and a return declared `Any` that turns out to be bytes fails instead of reaching the caller as `{}`.
|
|
609
629
|
|
|
@@ -669,7 +689,8 @@ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `la
|
|
|
669
689
|
"archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
|
|
670
690
|
touches nothing.
|
|
671
691
|
- `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
|
|
672
|
-
most one of these", not for claiming the next item off a queue.
|
|
692
|
+
most one of these", not for claiming the next item off a queue. A condition in the query is a compare-and-set in
|
|
693
|
+
every mode: of two concurrent calls that both saw the row match, one applies, and `modifiedCount` says which.
|
|
673
694
|
- **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
|
|
674
695
|
single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
|
|
675
696
|
- **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
|
|
@@ -735,7 +756,12 @@ Conventions that hold for both shapes:
|
|
|
735
756
|
- `try/catch` is rare and always converts an exception into a decision, never swallows one. Guards catch → `logger.warn` → `return false`; adapters catch → `logger.error` → `return null`; UI uses `try/finally` to reset a spinner. A bodyless `catch {}` is acceptable only with a one-line reason.
|
|
736
757
|
- Store actions do not `try/catch` — let the framework toast the `Err`. Client-side validation failure is `msg.error("<key>")` plus an early return, never a throw.
|
|
737
758
|
- **A server-side `fetch.clone({ origin })` opens no websocket unless `connect: true`**: it makes one-off calls to
|
|
738
|
-
another server, and a socket it opened would reconnect forever with nobody to close it.
|
|
759
|
+
another server, and a socket it opened would reconnect forever with nobody to close it. It never carries the
|
|
760
|
+
visitor's session either: a call to another origin sends only the `jwt` its caller gave the clone.
|
|
761
|
+
- **Retry a call with `retry`, never with a loop of your own.** `fetch.clone({ origin, retry: {} })` (or a call's
|
|
762
|
+
own `{ retry }`) tries again on an unreachable server, a 502/503/504 and a 429 after its Retry-After, backing off
|
|
763
|
+
with jitter; a mutation retries under one `Idempotency-Key` it mints, or the `idempotencyKey` you name, and the
|
|
764
|
+
server runs a mutation once per key and caller, answering a repeat with the first result. A 4xx is never retried.
|
|
739
765
|
- `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
|
|
740
766
|
so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
|
|
741
767
|
- **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })` restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to `Internal Server Error` on the way out.
|
|
@@ -768,9 +794,10 @@ it.
|
|
|
768
794
|
`Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
|
|
769
795
|
tokens, `outputSchema` for nothing that reaches the model.
|
|
770
796
|
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
771
|
-
allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
797
|
+
allowedOrigins, pageSize, language, outputSchema, rateLimit, apps, modern, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
772
798
|
`main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
773
|
-
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off
|
|
799
|
+
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off, and an edge server
|
|
800
|
+
(`AKAN_PUBLIC_OPERATION_MODE=edge`) starts with it off unless `AKAN_MCP=true` or `setMcp(true)`. Every call that executes an
|
|
774
801
|
endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
|
|
775
802
|
/ `AKAN_MCP_RATE_LIMIT` tunes or disables it.
|
|
776
803
|
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
@@ -811,7 +838,15 @@ it.
|
|
|
811
838
|
from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
|
|
812
839
|
inside the body reads as the screen refusing the account, the same way a redirect does. A
|
|
813
840
|
required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
|
|
814
|
-
only instruction, in English — `<Agent.Guide>` never reaches
|
|
841
|
+
only instruction, in English — `<Agent.Guide>` never reaches a prompt, and the in-page chat lists no page prompts.
|
|
842
|
+
- **The same page is a tool too, and an MCP Apps host renders it.** A `.prompt()` page answers `tools/call` with
|
|
843
|
+
that briefing plus its web URL, and a host that declares the `io.modelcontextprotocol/ui` extension (claude.ai,
|
|
844
|
+
ChatGPT, VS Code) shows the screen itself in the chat — the CSR bundle in a sandboxed iframe whose every call the
|
|
845
|
+
host relays under the agent's token. It shows the page alone: every layout above it still runs (guards, providers,
|
|
846
|
+
`<Agent.Guide>`) and draws nothing, unless it draws no page, as a gate does. Nothing is written for the view. An
|
|
847
|
+
endpoint kept off the shelf only by `mcp: false` stays callable by that view and is listed to no model; a `Person`
|
|
848
|
+
act is still refused there. `option.setMcp({ apps: false })` keeps pages prompts only, and a page named like an
|
|
849
|
+
endpoint stays one (boot warns).
|
|
815
850
|
|
|
816
851
|
Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
|
|
817
852
|
`McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
|
|
@@ -905,7 +940,7 @@ Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH
|
|
|
905
940
|
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
906
941
|
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
907
942
|
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
908
|
-
- Search runs in every database mode — fts5 on SQLite
|
|
943
|
+
- Search runs in every database mode — fts5 on SQLite, a weighted `tsvector` (`pg_trgm` for `trigram`) on
|
|
909
944
|
Postgres. The same text matches the same documents; only the order may differ on Postgres.
|
|
910
945
|
|
|
911
946
|
### Image & File Fields
|
|
@@ -957,9 +992,12 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
957
992
|
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
|
|
958
993
|
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` /
|
|
959
994
|
`.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` /
|
|
960
|
-
`.layoutStyle()` on the root layout; `.prompt()` on a page. A named export beside the chain, or
|
|
961
|
-
`_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function
|
|
962
|
-
route's args returning it
|
|
995
|
+
`.layoutStyle()` / `.viewport()` on the root layout; `.prompt()` on a page. A named export beside the chain, or
|
|
996
|
+
`page()` in a `_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function
|
|
997
|
+
of the route's args returning it, and one that throws renders no head (warned about) so a layout's sign-in redirect
|
|
998
|
+
still answers; there is no metadata object, and analytics is the app's own script, not a stage.
|
|
999
|
+
Heads merge root first: a deeper `.head()` replaces the `<title>`, a `<meta>` with the same `name` / `property`,
|
|
1000
|
+
and a canonical / manifest / icon link, and keeps the rest — so a page states only what it changes.
|
|
963
1001
|
- **A CSR bundle (a native app, `?csr=true`) never shows a root layout's own markup.** Pages render into the
|
|
964
1002
|
frame, and what the root layout draws around `{children}` stays in a hidden container, so its wrappers cannot
|
|
965
1003
|
stack a second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
|
|
@@ -971,7 +1009,9 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
971
1009
|
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
972
1010
|
search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
|
|
973
1011
|
them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
|
|
974
|
-
never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`.
|
|
1012
|
+
never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`. On a web
|
|
1013
|
+
page's first render `st.use.searchParams()` is still the server's empty snapshot, synced right after hydration, so
|
|
1014
|
+
an effect that needs the query on its first run takes the page's `.search()` value as a prop.
|
|
975
1015
|
- `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single `export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework `akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins). See the UI Customization reference for the slot list.
|
|
976
1016
|
- The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
|
|
977
1017
|
loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
|
|
@@ -988,10 +1028,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
988
1028
|
export default page()
|
|
989
1029
|
.param("orgId", ID)
|
|
990
1030
|
.config({ transition: "stack" })
|
|
991
|
-
.render(
|
|
1031
|
+
.render(({ orgId }) => {
|
|
992
1032
|
const { l } = usePage();
|
|
993
1033
|
getSelf({ unauthorize: "/signin" });
|
|
994
|
-
const
|
|
1034
|
+
const { taskInitInOrg } = fetch.initTaskInOrg(orgId);
|
|
995
1035
|
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
996
1036
|
});
|
|
997
1037
|
```
|
|
@@ -999,7 +1039,8 @@ export default page()
|
|
|
999
1039
|
- There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
|
|
1000
1040
|
`.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
|
|
1001
1041
|
- Body order inside `.render()`: `usePage()`, auth, fetch, return.
|
|
1002
|
-
-
|
|
1042
|
+
- Hand each `fetch.init*` / `view*` / `edit*` field to its section unawaited. Await only what the shell itself
|
|
1043
|
+
reads — a title, a redirect, an id for a link — and run those awaits through one `Promise.all`.
|
|
1003
1044
|
- Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
|
|
1004
1045
|
- `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
|
|
1005
1046
|
name and no return type.
|
|
@@ -1020,12 +1061,12 @@ export default page()
|
|
|
1020
1061
|
content outside the markers is yours. `akan lint` fails when the generated section is stale.
|
|
1021
1062
|
- The `plugin/` facet holds Akan plugin declarations; files use the `<name>.plugin.ts` convention (e.g. `pushNotification.plugin.ts`) and are re-exported from the generated `plugin/index.ts` barrel.
|
|
1022
1063
|
- Do not add `apps/*/base` or `libs/*/base`; place shared utilities under that app or lib's own `common/`.
|
|
1023
|
-
- `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts
|
|
1064
|
+
- `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts`. Nothing hand-written sits beside them, tests included: a test goes in the module folder it covers (`lib/<model>/<model>.signal.test.ts`), or in `srvkit/` when it drives several modules.
|
|
1024
1065
|
- Domain module folders are `lib/<model>` for database modules, `lib/_<service>` for service modules, and `lib/__scalar/<scalar>` for scalar modules.
|
|
1025
1066
|
- Database module UI files are limited to `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.Util.tsx`, `<Model>.View.tsx`, and `<Model>.Zone.tsx`.
|
|
1026
1067
|
- Service module UI files are limited to `<Service>.Util.tsx` and `<Service>.Zone.tsx`.
|
|
1027
1068
|
- Scalar module UI files are limited to `<Scalar>.Template.tsx` and `<Scalar>.Unit.tsx`.
|
|
1028
|
-
-
|
|
1069
|
+
- A module test is named after the one module file it covers — `<model>.<role>.test.ts` / `.spec.ts`, `<Model>.<Role>.test.tsx` — so `<model>.<topic>.signal.test.ts` is refused, just like `<model>.<topic>.signal.ts`.
|
|
1029
1070
|
- `ui/index.ts`, `webkit/index.ts`, `srvkit/index.ts`, `common/index.ts`, `plugin/index.ts`, and module `lib/**/index.ts` files are generated by scanSync; do not hand-edit or track them.
|
|
1030
1071
|
- Generated facet indexes export only 1-depth files/folders with `export * from "./name";`. A facet folder with
|
|
1031
1072
|
nothing to export syncs to `export {};` (a missing folder gets no barrel), so a stale barrel heals on the next
|
|
@@ -1131,6 +1172,10 @@ actions and state (see `<model>.store.ts` above).
|
|
|
1131
1172
|
exist yet and the whole `libs/` container is watched, which is the safe direction.
|
|
1132
1173
|
- Test production build generation with `bun run akan build <appName>`.
|
|
1133
1174
|
- To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
|
|
1175
|
+
- A server a test boots in-process takes `start({ listen: false, schedules: false })` to run no internal (init,
|
|
1176
|
+
interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server process a
|
|
1177
|
+
test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so spawn it inside
|
|
1178
|
+
the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs neither.
|
|
1134
1179
|
|
|
1135
1180
|
```bash
|
|
1136
1181
|
cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
|
|
@@ -62,6 +62,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
|
|
|
62
62
|
- Theme-following colors still belong to the app: a lib token is for a color that must **not** change with `data-theme`.
|
|
63
63
|
- An `@import` the CSS pipeline cannot resolve fails the build; it is never dropped. A stylesheet under `page/` that no route imports is compiled by nothing and warns at build time.
|
|
64
64
|
|
|
65
|
+
## Frame Variables (the page's chrome and the device's insets)
|
|
66
|
+
|
|
67
|
+
The frame writes these on `#frameRoot` and `<html>`, so any class can read them, web and native alike:
|
|
68
|
+
|
|
69
|
+
- `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
|
|
70
|
+
`env(safe-area-inset-*)` where the route chain declares `safeArea` (`.config({ safeArea: true })` on the root
|
|
71
|
+
layout) and `0px` where it does not; in a native app they are the shell's inset. Use the variable alone: never
|
|
72
|
+
write `env(safe-area-inset-*)` beside it, and never subtract one from the other. The frame paints the bottom strip
|
|
73
|
+
only under bottom chrome (a `BottomInset` or `BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's
|
|
74
|
+
`fixed bottom-0` bar paints its own safe-area padding.
|
|
75
|
+
- `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
|
|
76
|
+
when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
|
|
77
|
+
from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
|
|
78
|
+
- `--akan-keyboard-inset`, `--akan-viewport-height`, `--akan-viewport-offset-top` — the on-screen keyboard over the
|
|
79
|
+
page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it).
|
|
80
|
+
- `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
|
|
81
|
+
so a column sized by it leaves the panned-to area blank: keep a keyboard-heavy screen's input bar in flow at the end
|
|
82
|
+
of its column, or in the keyboard slot.
|
|
83
|
+
|
|
65
84
|
## Codegen Rules
|
|
66
85
|
- Do not hardcode hex or raw-palette colors (`bg-red-500`, `#ff0000`); use semantic tokens.
|
|
67
86
|
- Do not put inline color literals in `style={{ }}`; use token classes.
|
|
@@ -10,7 +10,8 @@ The always-loaded convention set carries the short version; this is the full con
|
|
|
10
10
|
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
11
11
|
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
|
|
12
12
|
that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
|
|
13
|
-
`Public`. `AKAN_MCP=false` takes the whole surface off
|
|
13
|
+
`Public`. `AKAN_MCP=false` takes the whole surface off, and an edge server (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts
|
|
14
|
+
with it off — `AKAN_MCP=true` or `setMcp(true)` puts it back. The reasoning is that the guards are already the
|
|
14
15
|
authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
|
|
15
16
|
switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
|
|
16
17
|
until somebody remembers it.
|
|
@@ -38,6 +39,9 @@ export class FileSlice extends slice(srv.file, {
|
|
|
38
39
|
`update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
|
|
39
40
|
the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
|
|
40
41
|
their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
|
|
42
|
+
- **A screen keeps reaching it.** To a host that renders MCP Apps views, an endpoint refused for `mcp: false` alone
|
|
43
|
+
is published with `visibility: ["app"]`: the view the page tool opens may call it, the model is never shown it
|
|
44
|
+
(below). Every other refusal still holds there.
|
|
41
45
|
- **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
|
|
42
46
|
the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
|
|
43
47
|
the guard rules keep out.
|
|
@@ -50,13 +54,14 @@ export class FileSlice extends slice(srv.file, {
|
|
|
50
54
|
|
|
51
55
|
## Configuration
|
|
52
56
|
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
53
|
-
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
|
|
57
|
+
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
|
|
58
|
+
`promptBudget`, `apps`, `modern`, and `auth`. **Not `main.ts`**: the gateway
|
|
54
59
|
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
55
60
|
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
56
61
|
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
57
62
|
`AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
|
|
58
63
|
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
|
|
59
|
-
`AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
|
|
64
|
+
`AKAN_MCP_PROMPT_BUDGET`, `AKAN_MCP_APPS`, `AKAN_MCP_MODERN`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
|
|
60
65
|
not, which the option overrides.
|
|
61
66
|
The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
|
|
62
67
|
has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
|
|
@@ -100,8 +105,8 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
|
|
|
100
105
|
export default page()
|
|
101
106
|
.param("taskId", ID, { desc: "The task to review." })
|
|
102
107
|
.prompt("reviewTask", "Review this task and suggest next steps.")
|
|
103
|
-
.render(
|
|
104
|
-
const { taskView } =
|
|
108
|
+
.render(({ taskId }) => {
|
|
109
|
+
const { taskView } = fetch.viewTask(taskId);
|
|
105
110
|
return <Task.Zone.View view={taskView} />;
|
|
106
111
|
});
|
|
107
112
|
```
|
|
@@ -226,7 +231,9 @@ export default page()
|
|
|
226
231
|
wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
|
|
227
232
|
disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
|
|
228
233
|
method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
|
|
229
|
-
gone".
|
|
234
|
+
gone". **While MCP Apps renders views only the legacy pair is spoken**: `server/discover` answers `-32601` and
|
|
235
|
+
`initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached through
|
|
236
|
+
`initialize`. `modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
|
|
230
237
|
- **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
|
|
231
238
|
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
232
239
|
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
@@ -354,6 +361,58 @@ in-page chat lists none of them: its `/` menu is its six built-in commands. Pick
|
|
|
354
361
|
rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
|
|
355
362
|
when the id is already in hand.
|
|
356
363
|
|
|
364
|
+
## MCP Apps — The Page, Rendered In The Chat
|
|
365
|
+
**A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint`. Its result is the
|
|
366
|
+
prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
|
|
367
|
+
`io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
|
|
368
|
+
Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
|
|
369
|
+
`ui://<app>/app/<basePath>`) and renders the screen in place of the result: the app's CSR bundle, one HTML document,
|
|
370
|
+
in a sandboxed iframe started on the page the tool ran. Its first queries are answered from that run — carried in
|
|
371
|
+
the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
|
|
372
|
+
call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
|
|
373
|
+
|
|
374
|
+
- **What a view may call** is every published tool and every endpoint kept off the shelf by `mcp: false` alone,
|
|
375
|
+
listed with `visibility: ["app"]` — which a host offers to the view and never to the model. Visibility is a host's
|
|
376
|
+
UX, not authorization: the guards decide, as everywhere. A client that did not declare the extension is listed
|
|
377
|
+
neither those tools nor the `ui://` resources, a call to one answers `Unknown tool`, and a page tool's result
|
|
378
|
+
carries none of the view's data for it.
|
|
379
|
+
- **The capability is read per request.** A legacy client declares it once at `initialize` and is issued an
|
|
380
|
+
`Mcp-Session-Id` (`ui.<uuid>`) that stores nothing, since its id is the capability; with `modern: true` a modern
|
|
381
|
+
request carries it in `_meta`. A client that declares nothing is issued no session, exactly as before.
|
|
382
|
+
- **The view is the page, not the app around it.** Each layout above it still runs — guards, providers,
|
|
383
|
+
`<Agent.Guide>` — but what it draws around `{children}` is hidden, with its frame chrome (`Layout.Navbar`,
|
|
384
|
+
`TopInset`, `BottomInset`, `BottomTab`), the `topInset` / `bottomInset` it reserves and the tools its controls
|
|
385
|
+
publish. A layout that draws no page, like a gate, is the screen then and is shown. The screen the view opened on
|
|
386
|
+
has no back button: there is nothing behind it.
|
|
387
|
+
- **The view has no network.** `connect-src` stays closed; the app's own origin is opened for what a page loads by
|
|
388
|
+
URL — fonts (served with `Access-Control-Allow-Origin: *`) and stored images — and every root-relative URL the
|
|
389
|
+
page writes is pointed at it, since a host may block the document's `<base>` (Claude does).
|
|
390
|
+
Cookies and Web Storage are in-memory, history is in-memory, and the websocket, live sync, pubsub, the reconnect
|
|
391
|
+
overlay and `<Agent.Chat />` are off. A path link routes inside the view; any other link opens through the host.
|
|
392
|
+
- **The view looks like the host.** Its neutrals (`--background`, `--foreground`, `--muted`, `--border`, `--ring`) and
|
|
393
|
+
`--font-sans` / `--font-mono` follow the host's theme variables, its theme follows the host's, and so does its
|
|
394
|
+
language when the app speaks it; the app's brand colors and radii stay its own.
|
|
395
|
+
- **The host's model drives the screen too.** The view offers the host the screen's own tools — the surface
|
|
396
|
+
`<Agent.Chat />` reads: `st.tool`, the form setters, `fill<Model>Form`, `navigate` / `readScreen` / `readState` /
|
|
397
|
+
`highlight` — and, to a host that takes it, the screen's context (route, live state, `<Agent.Guide>`) through
|
|
398
|
+
`ui/update-model-context` whenever it moves. A tool that declares `confirm` asks the person in the view through
|
|
399
|
+
the `AgentApproval` slot, whatever the host asks on its own; a card tool, which a person answers in a chat, is not
|
|
400
|
+
offered.
|
|
401
|
+
- **A person tapping in the view acts with the agent's token**, so `isAgentCall` is true and a `Person` act is
|
|
402
|
+
refused there. That act belongs on the web page the result links to. The view holds no token: it is handed the
|
|
403
|
+
caller's claims, so `getSelf({ unauthorize })` in a layout renders the screen instead of a sign-in page, and each
|
|
404
|
+
call it makes is relayed and judged by the guards.
|
|
405
|
+
- **A view's call answers what a browser would get**, in `_meta` beside the masked result: the value with its
|
|
406
|
+
`visual` fields, or the domain `Err` with its key and data, toasted in the user's language. A refusal and a crash
|
|
407
|
+
stay generalized.
|
|
408
|
+
- **What a document load would do, the view does in place.** `router.refresh()` renders the route afresh, a
|
|
409
|
+
download (`<a download>`, a data table's export) goes to a host that saves files, and a full navigation off the
|
|
410
|
+
app shows a notice instead of a blank frame.
|
|
411
|
+
- **A page named like an endpoint stays a prompt only**, published or not, and the boot log says so: the endpoint
|
|
412
|
+
keeps its name. A refused screen answers as a tool error (`isError`), which the model reads.
|
|
413
|
+
- `option.setMcp({ apps: false })` / `AKAN_MCP_APPS=false` keeps every page a prompt only. `web: { csr: false }`
|
|
414
|
+
publishes the tool with no view, and an API-only build (`web: false`) has no pages to publish.
|
|
415
|
+
|
|
357
416
|
## Progress Reporting
|
|
358
417
|
**`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
|
|
359
418
|
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
@@ -421,8 +480,9 @@ the same guards.** What stays closed is closed because no agent has a reason to
|
|
|
421
480
|
|
|
422
481
|
## Writing `instructions`
|
|
423
482
|
|
|
424
|
-
`*.abstract.md` and `<Agent.Guide>`
|
|
425
|
-
published entry's `.desc()`, and a
|
|
483
|
+
`*.abstract.md` never reaches an MCP client, and `<Agent.Guide>` only through an MCP Apps view, beside the screen's own
|
|
484
|
+
tools it explains. Exactly three texts reach every client: `instructions`, every published entry's `.desc()`, and a
|
|
485
|
+
page's `.prompt()` description. All three are English, whatever the users speak
|
|
426
486
|
— the reader is a model, and the catalogue is built once in one `language` and cached by clients.
|
|
427
487
|
|
|
428
488
|
- **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
|
|
@@ -453,10 +513,10 @@ published entry's `.desc()`, and a page's `.prompt()` description. All three are
|
|
|
453
513
|
- The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
|
|
454
514
|
redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
|
|
455
515
|
rotation) and the refresh-session and option unit tests.
|
|
456
|
-
- **One exposure test per app**, in
|
|
457
|
-
names with `toEqual`, and assert the curated and admin-only ones with
|
|
458
|
-
the guards, so a guard change fails this test first — which is the point.
|
|
459
|
-
`pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
516
|
+
- **One exposure test per app**, in `srvkit/mcpExposure.signal.test.ts`, since it spans every module: list
|
|
517
|
+
`tools/list` with a user token and pin the names with `toEqual`, and assert the curated and admin-only ones with
|
|
518
|
+
`not.toContain`. Exposure is derived from the guards, so a guard change fails this test first — which is the point.
|
|
519
|
+
The framework's own pattern is `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
460
520
|
- Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
|
|
461
521
|
prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
|
|
462
522
|
both answering 200 is the grace window working, not a fault.
|
|
@@ -65,9 +65,15 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
65
65
|
- `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
|
|
66
66
|
and there is no way to change that. They also report only counts, never which row they touched, so they are for
|
|
67
67
|
"there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
|
|
68
|
+
The query is a compare-and-set in every mode: of two concurrent `updateOne({ id, balance: { $gte: n } }, …)` that
|
|
69
|
+
both read the row as matching, only one applies, so `modifiedCount` is the verdict.
|
|
68
70
|
- **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
|
|
69
71
|
model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
|
|
70
72
|
throws at boot instead (`assertFilterFitsCrud`).
|
|
73
|
+
- **An insight field counts or sums.** `accumulate: {}` counts the rows the query matches and `accumulate: { status:
|
|
74
|
+
"active" }` those that also match the condition; `accumulate: { $sum: "totalValue", status: "active" }` adds the
|
|
75
|
+
field up over the same rows (`field(Float, { default: 0, accumulate: … })`), a stored value that is not a number
|
|
76
|
+
counting as nothing — the same answer on SQLite and Postgres.
|
|
71
77
|
- **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
|
|
72
78
|
key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
|
|
73
79
|
or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
|
|
@@ -119,7 +125,7 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
119
125
|
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
120
126
|
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
121
127
|
path.
|
|
122
|
-
- Search runs in every database mode: fts5 on SQLite
|
|
128
|
+
- Search runs in every database mode: fts5 on SQLite, a weighted `tsvector` under a GIN index on Postgres.
|
|
123
129
|
For the same text the same documents match — punctuation splits words the way unicode61 does, and a phrase never
|
|
124
130
|
runs from one column into the next — but the order may differ, since Postgres ranks without document frequency.
|
|
125
131
|
Assert on the match set, never on a Postgres order the weights alone do not decide.
|
|
@@ -185,6 +191,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
185
191
|
unmounted `refPath` candidate only warns, since that list spans optional modules by design.
|
|
186
192
|
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped
|
|
187
193
|
by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
194
|
+
- **A reference no cascade removes keeps pointing at the removed row, and a response reads it live-only**: a list
|
|
195
|
+
leaves the target out, an optional field reads `null`, and a required one answers 404
|
|
196
|
+
`base.error.relationNotFound` naming the model and the field path. Make a reference whose target can go first
|
|
197
|
+
`.optional()`, or let it go with the target through `removeWith`.
|
|
188
198
|
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
189
199
|
restorable, and reviving the owner does not revive what went with it.
|
|
190
200
|
- A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
|