@alexkroman1/aai-cli 8.2.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/_dev-env.d.ts +15 -2
  2. package/dist/{_dev-server-Dt3TCjhj.mjs → _dev-server-BAR8Ya43.mjs} +182 -55
  3. package/dist/_dev-server.d.ts +5 -30
  4. package/dist/_dev-typecheck.d.ts +38 -0
  5. package/dist/_dev-vite-config.d.ts +74 -0
  6. package/dist/_e2e-test-utils.d.ts +33 -1
  7. package/dist/_init-BNotRgV1.mjs +163 -0
  8. package/dist/_resource-commands.d.ts +0 -1
  9. package/dist/{_slug-api-z-USxPtL.mjs → _slug-api--ZBmIGH-.mjs} +1 -1
  10. package/dist/{_studio-DR5yzZ1T.mjs → _studio-CKrsixd0.mjs} +2 -1
  11. package/dist/_studio.d.ts +3 -1
  12. package/dist/{build-BBxf8vom.mjs → build-DvDdSwAS.mjs} +1 -1
  13. package/dist/cli.mjs +26 -97
  14. package/dist/{delete-Dl_LSfvI.mjs → delete-8pjH8n8n.mjs} +3 -2
  15. package/dist/{deploy-DRC8jWr8.mjs → deploy-CjU3e-3P.mjs} +4 -4
  16. package/dist/{dev-QuuOOUtv.mjs → dev-dFzdvwa7.mjs} +3 -2
  17. package/dist/dev.d.ts +2 -0
  18. package/dist/{eval-DfQ8cm5p.mjs → eval-DwNwdqmS.mjs} +1 -1
  19. package/dist/{init-CUo87gcI.mjs → init-DK7GpIF9.mjs} +7 -6
  20. package/dist/{logs-CQ2T46dr.mjs → logs-D2Yhqe2w.mjs} +2 -2
  21. package/dist/scaffold/.env.example +7 -4
  22. package/dist/scaffold/CLAUDE.md +152 -97
  23. package/dist/scaffold/global.d.ts +12 -0
  24. package/dist/scaffold/package.json +4 -4
  25. package/dist/scaffold/server.mjs +49 -2
  26. package/dist/scaffold/tsconfig.json +1 -0
  27. package/dist/scaffold/vitest.config.ts +24 -0
  28. package/dist/{secret-Ct34kdgU.mjs → secret-CwgDLpSy.mjs} +2 -2
  29. package/dist/{studio-CzTzPqe1.mjs → studio-D3-HWIfk.mjs} +7 -5
  30. package/dist/templates/briefing-desk/agent.test.ts +3 -15
  31. package/dist/templates/briefing-desk/shared.ts +3 -3
  32. package/dist/templates/call-audit/agent.eval.test.ts +8 -1
  33. package/dist/templates/call-audit/agent.test.ts +0 -1
  34. package/dist/templates/call-audit/agent.ts +4 -3
  35. package/dist/templates/call-audit/client.tsx +4 -7
  36. package/dist/templates/call-audit/workflows/audit.ts +10 -1
  37. package/dist/templates/code-interpreter/agent.eval.test.ts +14 -43
  38. package/dist/templates/dispatch-center/agent.eval.test.ts +16 -26
  39. package/dist/templates/embedded-assets/agent.eval.test.ts +6 -16
  40. package/dist/templates/embedded-assets/agent.test.ts +3 -16
  41. package/dist/templates/health-assistant/agent.eval.test.ts +5 -15
  42. package/dist/templates/health-assistant/agent.test.ts +3 -14
  43. package/dist/templates/infocom-adventure/agent.eval.test.ts +6 -16
  44. package/dist/templates/infocom-adventure/agent.test.ts +3 -17
  45. package/dist/templates/link-digest/agent.test.ts +0 -14
  46. package/dist/templates/link-digest/agent.ts +9 -5
  47. package/dist/templates/link-digest/client.tsx +8 -6
  48. package/dist/templates/math-buddy/agent.eval.test.ts +11 -40
  49. package/dist/templates/night-owl/agent.eval.test.ts +15 -44
  50. package/dist/templates/night-owl/agent.test.ts +2 -15
  51. package/dist/templates/personal-finance/agent.eval.test.ts +10 -39
  52. package/dist/templates/pipeline-simple/agent.test.ts +73 -0
  53. package/dist/templates/pizza-ordering/agent.eval.test.ts +2 -17
  54. package/dist/templates/pizza-ordering/agent.test.ts +2 -17
  55. package/dist/templates/plan-and-execute/agent.eval.test.ts +2 -12
  56. package/dist/templates/plan-and-execute/agent.test.ts +3 -23
  57. package/dist/templates/plan-and-execute/shared.ts +1 -1
  58. package/dist/templates/podcast-digest/agent.test.ts +0 -1
  59. package/dist/templates/podcast-digest/client.tsx +3 -6
  60. package/dist/templates/podcast-digest/workflows/digest.ts +16 -8
  61. package/dist/templates/recap-workflow/agent.eval.test.ts +7 -14
  62. package/dist/templates/recap-workflow/agent.test.ts +3 -18
  63. package/dist/templates/recap-workflow/agent.ts +7 -5
  64. package/dist/templates/redline/agent.test.ts +0 -13
  65. package/dist/templates/redline/agent.ts +3 -2
  66. package/dist/templates/redline/client.tsx +9 -4
  67. package/dist/templates/research-workflow/agent.eval.test.ts +7 -14
  68. package/dist/templates/research-workflow/agent.test.ts +3 -15
  69. package/dist/templates/research-workflow/agent.ts +7 -5
  70. package/dist/templates/research-workflow/workflows/research.ts +8 -2
  71. package/dist/templates/retail/agent.eval.test.ts +10 -25
  72. package/dist/templates/retail/registry.test.ts +16 -13
  73. package/dist/templates/solo-rpg/agent.eval.test.ts +5 -15
  74. package/dist/templates/solo-rpg/agent.test.ts +17 -124
  75. package/dist/templates/solo-rpg/shared.ts +13 -61
  76. package/dist/templates/spoken-summary/agent.test.ts +0 -1
  77. package/dist/templates/spoken-summary/agent.ts +5 -4
  78. package/dist/templates/spoken-summary/client.tsx +4 -7
  79. package/dist/templates/support-line/agent.eval.test.ts +2 -12
  80. package/dist/templates/support-line/agent.test.ts +3 -22
  81. package/dist/templates/transcription-workflow/agent.ts +4 -4
  82. package/dist/templates/transcription-workflow/client.tsx +5 -5
  83. package/dist/templates/transcription-workflow/workflows/transcribe.ts +10 -1
  84. package/dist/templates/travel-concierge/agent.eval.test.ts +5 -15
  85. package/dist/templates/travel-concierge/agent.test.ts +3 -23
  86. package/dist/templates/web-researcher/agent.eval.test.ts +2 -5
  87. package/dist/test-CvwgeVSQ.mjs +181 -0
  88. package/dist/test.d.ts +33 -2
  89. package/package.json +4 -4
  90. package/dist/_init-BhEfQ6Yi.mjs +0 -93
  91. package/dist/storage-i_79ZwAR.mjs +0 -66
  92. package/dist/storage.d.ts +0 -22
  93. package/dist/templates/solo-rpg/tools/load_game.ts +0 -37
  94. package/dist/templates/solo-rpg/tools/save_game.ts +0 -32
  95. package/dist/test-BvyT6sAc.mjs +0 -104
@@ -7,8 +7,12 @@ You are helping build a voice agent using the **aai** framework.
7
7
  The fast loop: edit → `pnpm dev` (browser, talk to it) →
8
8
  `pnpm test` (logic) → `pnpm build` (validate bundle).
9
9
 
10
- 1. **Iterate in `pnpm dev`** — hot reload + browser UI. Speak to the
11
- agent to verify behavior end-to-end. This is the primary feedback loop.
10
+ 1. **Iterate in `pnpm dev`** — browser UI, and `pnpm dev -- --watch` to
11
+ rebuild and restart on every save. Speak to the agent to verify behavior
12
+ end-to-end. This is the primary feedback loop. Watching is OPT-IN because a
13
+ restart ends in-flight voice sessions, which is right while you are editing
14
+ and wrong while something is driving the agent for twenty minutes;
15
+ `AAI_DEV_WATCH=1` is the same switch for a process supervisor.
12
16
  2. **Run `pnpm test` after logic changes** — vitest. Co-locate tests as
13
17
  `agent.test.ts` (see `pipeline-simple` template for a reference).
14
18
  **When the project has an `agent.test.ts` (the default `simple`
@@ -18,6 +22,20 @@ The fast loop: edit → `pnpm dev` (browser, talk to it) →
18
22
  agent that no longer exists. When a test fails after your change, decide
19
23
  which side is stale: updating the test to match the new agent is a normal
20
24
  fix, not a workaround. Do not delete a test to make it pass.
25
+
26
+ **A spec that needs the agent as DEPLOYED imports one module:**
27
+
28
+ ```ts no-check
29
+ import agentDef from "virtual:aai/agent";
30
+ ```
31
+
32
+ That is `agent.ts` with its `tools/` directory discovered and its
33
+ `system-prompt.md` applied — the same lowering `aai build` does, so a spec
34
+ measures the agent that ships rather than the raw default export (which has
35
+ no tools and the framework's default prompt). `vitest.config.ts` registers
36
+ the plugin that serves it; a scaffolded project already has it. For a runner
37
+ that is not vitest, `deployedAgent` on `@alexkroman1/aai/testing` is the same
38
+ thing written out.
21
39
  3. **Run `pnpm eval` when you change what the agent DOES** — a test asserts
22
40
  the agent's shape; an eval drives a real session and asserts what it did.
23
41
  Cases live in `agent.eval.test.ts` (the `simple` template ships one):
@@ -163,8 +181,8 @@ import { agent } from "@alexkroman1/aai";
163
181
  export default agent({
164
182
  name: string; // required — display name
165
183
  systemPrompt?: string; // usually ABSENT — write system-prompt.md
166
- // instead; declare it only to COMPOSE
167
- // one (`system` is an accepted alias)
184
+ // instead; declare it only to COMPOSE one.
185
+ // There is no `system` alias one name.
168
186
  greeting?: string; // default: "Hey there..."
169
187
  voice?: string; // TTS voice for the default pipeline, e.g. "michael"
170
188
  // (shorthand for tts: assemblyAITts({ voice });
@@ -180,6 +198,10 @@ export default agent({
180
198
  // (there is no `tools` field — a tool is a FILE;
181
199
  // see "A file in tools/ IS a tool")
182
200
  maxSteps?: number; // default: 10 — max tool calls per turn
201
+ temperature?: number; // sampling temperature for the agent's OWN model calls
202
+ // (pipeline and text). Unset = the model's default; some
203
+ // models ignore it and warn. S2S REFUSES it — the model
204
+ // runs in the provider's service and never sees this.
183
205
  toolChoice?: ToolChoice; // "auto" (default) | "required" | "none"
184
206
  // | { type: "tool", toolName }
185
207
  idleTimeoutMs?: number; // disconnect after inactivity (ms)
@@ -192,6 +214,18 @@ export default agent({
192
214
  preemptiveGeneration?: boolean; // pipeline only — start the reply from a high-confidence interim (default false; true opts in)
193
215
  syncState?: StateProjection; // show a slot to the client: slot.projection(view)
194
216
  // (read it with useAgentState; see UI hooks)
217
+ minTurnSilenceMs?: number; // pipeline only — pause (ms) that ENDS a user turn once the
218
+ // text reads complete (default 560)
219
+ maxTurnSilenceMs?: number; // pipeline only — pause (ms) that ends a turn REGARDLESS of
220
+ // content (default 1600). The endpointing knob to reach for:
221
+ // it bounds the utterances that never read as finished.
222
+ // Both are shorthand for the same options on the default
223
+ // assemblyAIStt() stage — invalid with an explicit `stt`.
224
+ requiredEnv?: string[]; // env vars this agent reads. A deploy CHECKS them, so a
225
+ // missing key fails at `aai push` instead of mid-call.
226
+ // Declare every key any tool or step reads.
227
+ text?: true; // text-only agent: no STT, no TTS, `llm` is the one stage
228
+ events?: SessionEventHandlers; // observe the session (see "Watching the session")
195
229
  });
196
230
  ```
197
231
 
@@ -361,17 +395,20 @@ callback. Reach for `agent()` when someone is on the line — a voice agent can
361
395
  also START a workflow from a tool (`ctx.workflows.start(def, input)`) and
362
396
  answer the turn, which is the other shape.
363
397
 
364
- **Storage is OPTIONAL, and what it buys is DURABILITY.** With a database
365
- (`aai storage enable`, Settings Database in the studio, or `DATABASE_URL`
366
- under `aai dev`) runs live in it and survive a restart, a redeploy and an idle
367
- sandbox. Without one they live in the process that started them — you can
368
- submit the form, watch the run and read its result, and everything in flight is
369
- lost when that process goes away. That is the honest tradeoff, and it is what
398
+ **Runs are DURABLE on the platform with no setup.** A deployed app's runs live
399
+ on the platform's own database, so they survive a restart, a redeploy and an
400
+ idle sandbox. There is nothing to enable.
401
+
402
+ Under `aai dev` without a `DATABASE_URL` they live in the process that started
403
+ them you can submit the form, watch the run and read its result, and
404
+ everything in flight is lost when that process goes away. That is the honest
405
+ tradeoff, and it is what
370
406
  lets you build a workflow app before provisioning anything.
371
407
 
372
- Two things do need the database whatever the run does: `ctx.db`, and a workflow
373
- **upload** — an upload's record is a row, so `api.upload` and the file-taking
374
- form hooks refuse by name until storage is on (see "Database API" below).
408
+ One thing does need a database whatever the run does: a workflow **upload**. An
409
+ upload's record is a row, so `api.upload` and the file-taking form hooks refuse
410
+ by name without a `DATABASE_URL`. (`ctx.db` was the other; there is no `ctx.db`
411
+ — see "Persisting data" below.)
375
412
 
376
413
  ### Workflow bodies live in `workflows/`
377
414
 
@@ -418,7 +455,8 @@ warned about by `aai build` and `aai dev`, naming the file and the call:
418
455
  JSON-shaped and small. Put bytes in storage and pass the key.
419
456
  - **A step gets no tool context.** It is bundled and dispatched separately from
420
457
  the agent, so there is no `ctx` in one — see below for how it reaches the
421
- agent's env and a model anyway. `ctx.db` has no step-side equivalent yet.
458
+ agent's env and a model anyway. A step reaches a database the same way a tool
459
+ does now — its own client, its own credential from `requireStepEnv`.
422
460
 
423
461
  ### A step's env, and calling a model from one
424
462
 
@@ -468,6 +506,11 @@ is the one to use**:
468
506
  | `stepTranscribeUpload` / `Submit` / `Poll` | the matching `*Classified` |
469
507
  | `sendToChannel` (`/channels`) | `sendToChannelClassified` |
470
508
 
509
+ `stepFetchOk` is the one that is not spelled `*Classified`, and the name is the
510
+ difference: the others turn an already-thrown failure into a classified one,
511
+ while this also turns a NON-2XX RESPONSE into a throw — `stepFetch` resolves
512
+ with a `404` rather than raising it. Two changes, so two names.
513
+
471
514
  The whole of what a wrapper adds is `throwStepError`, and that is worth having
472
515
  because the DevKit's retry policy is decided by WHICH error a step throws. Raw,
473
516
  every failure looks the same to it: a bad API key is retried until the attempts
@@ -497,11 +540,13 @@ every one of those bundles. A step pays nothing for the extra import line.
497
540
  Three more subpaths a `workflows/*.ts` module can reach, all with the same
498
541
  bundling rule as `/step` — import them there, never through the root barrel:
499
542
 
500
- - **`@alexkroman1/aai/transcribe`** — `stepTranscribeSync(bytes)` for a short
543
+ - **`@alexkroman1/aai/step`** — `stepTranscribeSync(bytes)` for a short
501
544
  recording, or `stepTranscribeUpload` → `stepTranscribeSubmit` →
502
545
  `stepTranscribePoll` for a long one, plus `Transcript`, `TranscribeError` and
503
- the `TRANSCRIBE_*` limits. Use the `Classified` wrappers above: a provider
504
- refusal a container it will not read, a recording with no speech — arrives
546
+ the `TRANSCRIBE_*` limits. (There is no `/transcribe` subpath; transcription
547
+ lives on `/step` with the other step primitives.) Use the `Classified`
548
+ wrappers above: a provider refusal — a container it will not read, a
549
+ recording with no speech — arrives
505
550
  with `retryable: false`, and unclassified a step re-uploads the same bytes
506
551
  until its attempts run out.
507
552
  - **`@alexkroman1/aai/ffmpeg`** — `transcodeToWav(bytes, { sampleRate })`,
@@ -705,7 +750,7 @@ rather than fail the turn:
705
750
  import { webSearch } from "@alexkroman1/aai/tools";
706
751
  import { isToolFailure } from "@alexkroman1/aai/utils";
707
752
 
708
- const found = await webSearch<{ results?: { url?: string }[] }>({ query, max_results: 4 });
753
+ const found = await webSearch<{ results?: { url?: string }[] }>({ query, maxResults: 4 });
709
754
  // NOT `(found.results ?? [])` — a REFUSED search would then read as an empty web.
710
755
  if (isToolFailure(found)) return `That search failed: ${found.error}`;
711
756
  return (found.results ?? []).map((one) => one.url);
@@ -862,8 +907,39 @@ export default agent({
862
907
  Tools, the database, `ctx`, and the UI all behave identically across modes.
863
908
  Only the audio + LLM transport differs.
864
909
 
865
- **There is no text-only agent mode.** An agent is a voice conversation
866
- every pipeline agent must declare a real TTS provider.
910
+ **Four front doors, each one field on `agent()`.** Omit them all for PIPELINE
911
+ (voice, cascaded STT LLM TTS) — the default, and the mode this guide
912
+ assumes. `s2s:` selects speech-to-speech. **`text: true` selects a text-only
913
+ agent**: no STT, no TTS, `llm` is the one stage, and the host runs it with
914
+ `createTextAgent` from `@alexkroman1/aai-runtime`. `workflowApp()` (see
915
+ "Workflow apps") builds a form with no session at all. Setting a field from the
916
+ wrong arm is a compile error naming the rule, so the modes cannot be mixed by
917
+ accident. Every pipeline agent must declare a real TTS provider — that is a
918
+ statement about pipeline mode, not about the SDK.
919
+
920
+ ### Answering a phone call
921
+
922
+ A deployed voice agent already serves carrier media streams — there is nothing
923
+ to switch on. `createServer` mounts `WS /phone` whenever the agent is a voice
924
+ agent (`telephony` defaults to `true`, and to `false` for a `page: "static"`
925
+ workflow app, which has no stages to put on a call). Point the carrier at it
926
+ with a `carrier` query parameter naming who is dialling:
927
+
928
+ ```text
929
+ wss://<your-agent-url>/phone?carrier=twilio
930
+ wss://<your-agent-url>/phone?carrier=telnyx
931
+ ```
932
+
933
+ Twilio and Telnyx are the two carriers this build decodes (`CARRIER_CODECS`);
934
+ an unknown `carrier` is declined at the upgrade. Both speak 8 kHz mu-law, which
935
+ the bridge transcodes in both directions, so the agent, its tools and its slots
936
+ behave exactly as they do in the browser — a phone call is a transport, not a
937
+ mode. Nothing about `agent.ts` changes to support one.
938
+
939
+ Turn the route off with `telephony: false` on `createServer`. If you are
940
+ embedding the runtime yourself rather than deploying, the pieces are
941
+ `createTelephonyBridge`, `startTelephonySession`, `TELEPHONY_PATH` and
942
+ `carrierByName`, all on `@alexkroman1/aai-runtime`.
867
943
 
868
944
  **Silence nudge (pipeline only):** set `silenceTimeoutMs` to make the
869
945
  assistant proactively take a turn after that much user silence (e.g.
@@ -1062,10 +1138,14 @@ same way in `aai dev` and deployed.
1062
1138
  ### `ctx` (ToolContext)
1063
1139
 
1064
1140
  ```ts no-check
1065
- ctx.env: Readonly<Record<string, string>> // secrets from .env / aai secret put
1141
+ ctx.env: Readonly<Partial<Record<string, string>>> // secrets from .env / aai secret put.
1142
+ // Partial: every read is `string | undefined`.
1143
+ // Use requireEnv(ctx, "KEY") to fail by NAME
1144
+ // instead of throwing a TypeError at the model.
1145
+ ctx.workflows: WorkflowClient // start / signal / wake / find / stream a durable run
1146
+ // from a tool (see "Workflows")
1066
1147
  ctx.slots: SlotStore // where sessionSlot() keeps this session's state —
1067
1148
  // reach for the slot, never this (see "Session state")
1068
- ctx.db: Db // SQL database, needs storage enabled (see Database section)
1069
1149
  ctx.messages: readonly Message[] // conversation history [{role, content}]
1070
1150
  ctx.sessionId: string // unique session ID
1071
1151
  ctx.send(event: string, data: unknown): void // push custom event to browser client (silently dropped over 64 KB JSON)
@@ -1181,9 +1261,10 @@ Four rules, and each is an error rather than advice if you get it wrong:
1181
1261
  - **Hold plain data.** Objects, arrays, strings, numbers, booleans and null. A
1182
1262
  `Map`, a `Set`, a `Date` or a class instance is refused with the field named,
1183
1263
  because none of them survives being stored.
1184
- - **State is STORED when your app has a database** (`aai storage enable`, or a
1185
- `DATABASE_URL` in `.env`), so a crash or a redeploy no longer loses it. Without
1186
- one it lives in memory for the life of the process. You write the same code
1264
+ - **State is STORED on the platform**, so a crash or a redeploy no longer loses
1265
+ it the platform keeps a session's slots on its own database and there is
1266
+ nothing to enable. Under `aai dev` it lives in memory for the life of the
1267
+ process unless you set a `DATABASE_URL` in `.env`. You write the same code
1187
1268
  either way; that is the reason for the rules above.
1188
1269
 
1189
1270
  There is nothing to declare on `agent()` — the slot owns its own default. Use
@@ -1267,8 +1348,8 @@ an SDK dependency. `support-line` is the worked example.
1267
1348
 
1268
1349
  `ctx.generate` is ONE prompt. When answering takes an unknown number of tool
1269
1350
  calls whose intermediate results the conversation has no reason to carry,
1270
- delegate to a **subagent** instead: a second tool loop with its own
1271
- instructions, model, tools and — the whole point — its own context window.
1351
+ delegate to a **subagent** instead: a second tool loop with its own system
1352
+ prompt, model, tools and — the whole point — its own context window.
1272
1353
 
1273
1354
  ```ts
1274
1355
  import { subagent, tool } from "@alexkroman1/aai";
@@ -1276,7 +1357,9 @@ import { z } from "zod";
1276
1357
 
1277
1358
  const researcher = subagent({
1278
1359
  name: "researcher",
1279
- instructions:
1360
+ // `systemPrompt`, the same field name `agent()` uses — a subagent is a
1361
+ // field-for-field smaller agent, so nothing about it is spelled differently.
1362
+ systemPrompt:
1280
1363
  "Research the task with the tools you have. IMPORTANT: your final message " +
1281
1364
  "is the only thing the caller sees — end with a self-contained summary.",
1282
1365
  builtinTools: ["web_search", "visit_webpage"],
@@ -1470,7 +1553,7 @@ something it has to notice and switch off.
1470
1553
 
1471
1554
  | Tool | Description | Params |
1472
1555
  | --- | --- | --- |
1473
- | `web_search` | Search the web (DuckDuckGo) — no API key required | `query`, `max_results?` (default 5) |
1556
+ | `web_search` | Search the web (DuckDuckGo) — no API key required | `query`, `maxResults?` (default 5) |
1474
1557
  | `visit_webpage` | Fetch URL to plain text | `url` |
1475
1558
  | `get_page_design` | Fetch URL's raw HTML + CSS (style blocks and linked stylesheets) to study/mimic a site's design | `url` |
1476
1559
  | `fetch_json` | HTTP GET a JSON API | `url`, `headers?` |
@@ -1533,75 +1616,49 @@ that case — `slot.update`'s window is synchronous — so reach for the lock wh
1533
1616
  the thing being mutated is outside the session. `withLock` takes an optional
1534
1617
  acquire deadline and throws `KeyedLockTimeoutError` when it runs out.
1535
1618
 
1536
- ## Database API`ctx.db`
1619
+ ## Persisting databring your own client
1537
1620
 
1538
- Persistent SQL storage scoped per app, backed by the app's own Postgres
1539
- schema. Access via `ctx.db`:
1621
+ **There is no `ctx.db`.** It was a SQL handle on the tool context, backed first
1622
+ by a Postgres the platform provisioned per app and later by a `DATABASE_URL` an
1623
+ author set. The platform provisions no database, and no longer hands tool code
1624
+ one either.
1540
1625
 
1541
- ```ts no-check
1542
- ctx.db.query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
1543
- ```
1626
+ So a tool that needs to persist anything uses a client of its own:
1544
1627
 
1545
- One parameterized statement per call, `$1, $2…` placeholders — never
1546
- interpolate values into the SQL string. The rows come back as plain objects;
1547
- `jsonb` columns are returned already parsed (no `JSON.parse` needed).
1548
- A query returning more than 1000 rows throws — always bound reads with
1549
- `LIMIT` (paginate with `LIMIT`/`OFFSET`).
1550
-
1551
- **The database must be enabled** or accessing `ctx.db` throws:
1552
-
1553
- - CLI: `aai storage enable`
1554
- - Studio: Settings pane → Database → Enable database (covers both the
1555
- preview and published agents, each with its own schema)
1556
- - `aai dev`: set `DATABASE_URL` in the project `.env`
1628
+ ```ts no-check
1629
+ // tools/save_note.ts a driver you added, a credential you set.
1630
+ import { tool } from "@alexkroman1/aai";
1631
+ import postgres from "postgres";
1632
+ import { z } from "zod";
1557
1633
 
1558
- **A workflow UPLOAD needs one more thing locally: somewhere to put the bytes.**
1559
- A deployed agent gets it from the platform; under `aai dev` the bytes go to a
1560
- bucket you point it at, and the Supabase CLI prints the two values
1561
- (`supabase start`, then `supabase status -o env`):
1634
+ // Module scope, so one pool serves every call in this sandbox.
1635
+ const sql = postgres(process.env.DATABASE_URL ?? "");
1562
1636
 
1563
- ```sh
1564
- DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
1565
- AAI_UPLOAD_STORAGE_URL=http://127.0.0.1:54321
1566
- AAI_UPLOAD_STORAGE_KEY=<SERVICE_ROLE_KEY>
1567
- AAI_UPLOAD_STORAGE_BUCKET=blobs
1637
+ export default tool({
1638
+ description: "Save a note.",
1639
+ inputSchema: z.object({ body: z.string() }),
1640
+ execute: async ({ body }) => {
1641
+ await sql`insert into notes (body) values (${body})`;
1642
+ return "saved";
1643
+ },
1644
+ });
1568
1645
  ```
1569
1646
 
1570
- `blobs`, not `uploads`: that is the one bucket the local stack declares
1571
- (`supabase/config.toml`, applied by `supabase start`), and uploads land under an
1572
- `uploads/` PREFIX inside it the same layout production uses beside its
1573
- `blobs/<sha256>` deploy artifacts. Nothing creates a bucket for you.
1574
-
1575
- `.env.example` in a scaffolded project carries this block commented out.
1647
+ Add the driver to your project's `package.json` and the URL with `aai secret put
1648
+ DATABASE_URL …` (or in `.env` under `aai dev`). Nothing here is privileged an
1649
+ HTTP API, a provider SDK or a hosted KV works the same way.
1576
1650
 
1577
- Without both halves `api.upload(file)` fails naming the one that is missing —
1578
- never quietly into a directory, which is what it used to do and then lose by the
1579
- time a resumed run read it.
1651
+ **What the platform DOES persist for you**, with no setup:
1580
1652
 
1581
- Create tables lazily from tool code and upsert with `on conflict`:
1582
-
1583
- ```ts no-check
1584
- await ctx.db.query(`create table if not exists app_state (
1585
- key text primary key,
1586
- value jsonb not null,
1587
- updated_at timestamptz not null default now()
1588
- )`);
1589
- await ctx.db.query(
1590
- "insert into app_state (key, value, updated_at) values ($1, $2::jsonb, now()) " +
1591
- "on conflict (key) do update set value = excluded.value, updated_at = now()",
1592
- ["user:123", JSON.stringify({ name: "Alex" })],
1593
- );
1594
- const rows = await ctx.db.query<{ value: { name: string } }>(
1595
- "select value from app_state where key = $1",
1596
- ["user:123"],
1597
- );
1598
- ```
1653
+ - **`sessionSlot`** this session's state, durable across a crash or a
1654
+ redeploy. Reach for it before reaching for a database; most agents need
1655
+ nothing else.
1656
+ - **Durable workflow runs** a run survives the sandbox recycling, every
1657
+ redeploy, and a multi-day `sleep()`.
1599
1658
 
1600
- Use `ctx.db` for data that must outlive the session (saves, filed records,
1601
- user profiles). For scratch that only the current session needs, prefer
1602
- a `sessionSlot` (per-session state, stored when the app has a database); the
1603
- `remember`/`recall` builtins likewise remain for session-scoped notes the
1604
- LLM manages itself.
1659
+ Those two cover almost everything an agent wants. A database is for data that
1660
+ must outlive a session AND be queryable: a ledger, filed records, cross-session
1661
+ saves.
1605
1662
 
1606
1663
  ## Custom UI — `client()`
1607
1664
 
@@ -1947,15 +2004,13 @@ Common mistakes when working in aai projects:
1947
2004
  platform's Modal/Deno sandbox; the self-hosted `aai dev` server has no
1948
2005
  sandbox, so there `run_code` refuses with an error result. Deploy to test
1949
2006
  it end-to-end, or use the `calculate` builtin for simple arithmetic in dev.
1950
- - **`ctx.db` throws until the database is enabled.** Enable it with
1951
- `aai storage enable` (CLI), Settings Database (studio), or
1952
- `DATABASE_URL` in `.env` (`aai dev`) before shipping tools that persist
1953
- data. A deployed agent reads its connection string when its sandbox is
1954
- built, so enabling rebuilds it the change reaches a running agent
1955
- without a redeploy.
1956
- - **The database is per-app.** Rows are shared by every session of one
1957
- deployment — key them yourself if sessions must not see each other's data
1958
- (or keep session-scoped data in a `sessionSlot`).
2007
+ - **There is no `ctx.db`.** The platform provisions no database and hands tool
2008
+ code none, so a tool that persists brings its own client — see "Persisting
2009
+ data". Two consequences worth knowing before you do: a deployed agent reads a
2010
+ secret when its sandbox is BUILT, so a newly set `DATABASE_URL` reaches it on
2011
+ the next deploy rather than immediately; and a database you bring is shared by
2012
+ every session of the deployment, so key rows yourself if sessions must not see
2013
+ each other's data (or keep session-scoped data in a `sessionSlot`).
1959
2014
  - **Rime language codes are ISO 639-3** (3-letter, e.g. `"eng"`), not
1960
2015
  ISO 639-1 (`"en"`).
1961
2016
 
@@ -1 +1,13 @@
1
1
  /// <reference types="vite/client" />
2
+
3
+ /**
4
+ * `virtual:aai/agent` — the agent as `aai build` lowers it: `agent.ts` with its
5
+ * `tools/` directory discovered and its `system-prompt.md` applied.
6
+ *
7
+ * Served by `aaiAgentPlugin()` in `vitest.config.ts`, which resolves it against
8
+ * the importing spec's own directory. See `@alexkroman1/aai/testing/vite`.
9
+ */
10
+ declare module "virtual:aai/agent" {
11
+ const agentDef: import("@alexkroman1/aai").AgentDef;
12
+ export default agentDef;
13
+ }
@@ -14,9 +14,9 @@
14
14
  "publish:agent": "aai publish"
15
15
  },
16
16
  "dependencies": {
17
- "@alexkroman1/aai": "^8.2.0",
18
- "@alexkroman1/aai-runtime": "^8.2.0",
19
- "@alexkroman1/aai-ui": "^8.2.0",
17
+ "@alexkroman1/aai": "^9.0.0",
18
+ "@alexkroman1/aai-runtime": "^9.0.0",
19
+ "@alexkroman1/aai-ui": "^9.0.0",
20
20
  "@workflow/world-postgres": "4.3.3",
21
21
  "react": "^19.2.8",
22
22
  "react-dom": "^19.2.8",
@@ -26,7 +26,7 @@
26
26
  "zod": "^4.4.3"
27
27
  },
28
28
  "devDependencies": {
29
- "@alexkroman1/aai-cli": "^8.2.0",
29
+ "@alexkroman1/aai-cli": "^9.0.0",
30
30
  "@tailwindcss/vite": "^4.3.3",
31
31
  "@types/node": "^26.2.0",
32
32
  "@types/react": "^19.2.18",
@@ -17,7 +17,11 @@ import { readFile } from "node:fs/promises";
17
17
  import path from "node:path";
18
18
  import { fileURLToPath, pathToFileURL } from "node:url";
19
19
  import { parseEnv } from "node:util";
20
- import { createAgentServer, withHostCredentialFallback } from "@alexkroman1/aai-runtime";
20
+ import {
21
+ createAgentServer,
22
+ ensureSessionStateSchema,
23
+ withHostCredentialFallback,
24
+ } from "@alexkroman1/aai-runtime";
21
25
  import { defaultClientDir } from "@alexkroman1/aai-ui/client-dir";
22
26
 
23
27
  const root = path.dirname(fileURLToPath(import.meta.url));
@@ -54,7 +58,22 @@ if (!existsSync(workerPath)) {
54
58
  }
55
59
  // A file: URL rather than a relative specifier, so the path is correct on
56
60
  // Windows, where a bare POSIX-looking path is not a valid module specifier.
57
- const { default: agent } = await import(pathToFileURL(workerPath).href);
61
+ const worker = await import(pathToFileURL(workerPath).href);
62
+ const agent = worker.default;
63
+
64
+ /**
65
+ * The compiled workflow surface, carried on the bundle as two string exports.
66
+ *
67
+ * A `"use workflow"` body has to go through the DevKit's compiler, which happens
68
+ * at BUILD time — a deployed agent is handed one ESM string and has no bundler —
69
+ * so `aai build` leaves the result here as data. Passing them is what makes
70
+ * durable workflows actually run: without them the server accepts a run and no
71
+ * world is ever started to execute it, so it sits `pending` with no error
72
+ * anywhere. Both are absent for a project with no `workflows/` directory.
73
+ */
74
+ const workflowCode =
75
+ typeof worker.__aaiWorkflowCode === "string" ? worker.__aaiWorkflowCode : undefined;
76
+ const stepCode = typeof worker.__aaiStepCode === "string" ? worker.__aaiStepCode : undefined;
58
77
 
59
78
  /**
60
79
  * Parse a dotenv-syntax file into a record; `{}` when it does not exist.
@@ -132,6 +151,24 @@ const env = await resolveAgentEnv();
132
151
  */
133
152
  const publicUrl = process.env.PUBLIC_URL?.trim();
134
153
 
154
+ /**
155
+ * Create the session-state tables, when this agent has a database.
156
+ *
157
+ * A `DATABASE_URL` puts session state in Postgres, and those tables come with
158
+ * whoever OWNS the database — which for a self-hosted agent is you, with no
159
+ * migration step anywhere to hang them off. Without this the server starts,
160
+ * reports `sessionState: postgres, durable: true`, and then every session dies
161
+ * at start with a fatal error the browser shows as "Session failed to start",
162
+ * the real reason (`relation "aai_session_events" does not exist`) appearing
163
+ * only in this process's log.
164
+ *
165
+ * Best-effort: if a real migration already created them and this role may not
166
+ * CREATE, it warns and the server starts anyway.
167
+ */
168
+ if (env.DATABASE_URL) {
169
+ await ensureSessionStateSchema({ url: env.DATABASE_URL, logger: console });
170
+ }
171
+
135
172
  const server = createAgentServer({
136
173
  agent,
137
174
  env,
@@ -141,6 +178,16 @@ const server = createAgentServer({
141
178
  providerEnv: withHostCredentialFallback(env),
142
179
  clientDir: resolveClientDir(),
143
180
  ...(publicUrl ? { publicUrl } : {}),
181
+ // Durable workflows. A `DATABASE_URL` in `env` puts the runs in Postgres and
182
+ // they survive a restart; without one they live in a per-process directory and
183
+ // do not, which is the same trade `aai dev` makes.
184
+ //
185
+ // Passed straight through rather than conditionally spread: both fields accept
186
+ // `undefined` explicitly, so a project with no `workflows/` directory needs no
187
+ // guard here — and a truthiness-guarded spread is what `guard-invariants`
188
+ // rule 22 counts.
189
+ workflowCode,
190
+ stepCode,
144
191
  });
145
192
 
146
193
  // Loopback by default: this server has no request authentication of its own,
@@ -4,6 +4,7 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
6
  "strict": true,
7
+ "noUncheckedIndexedAccess": true,
7
8
  "useUnknownInCatchVariables": false,
8
9
  "verbatimModuleSyntax": true,
9
10
  "allowImportingTsExtensions": true,
@@ -1,3 +1,4 @@
1
+ import { aaiAgentPlugin } from "@alexkroman1/aai/testing/vite";
1
2
  import { defineConfig } from "vitest/config";
2
3
 
3
4
  /**
@@ -16,9 +17,32 @@ import { defineConfig } from "vitest/config";
16
17
  * explicit `import { test } from "vitest"`. Both spellings are common, both
17
18
  * are correct, and tsconfig's `types: ["vitest/globals"]` already promises
18
19
  * the un-imported one — this makes the runtime match the types.
20
+ *
21
+ * `reporters` is PINNED, and left unset it is not merely a default — it is a
22
+ * different reporter depending on who is running. Vitest 4 resolves an unset
23
+ * value to `std-env`'s `isAgent ? "agent" : "default"`, and the agent reporter
24
+ * prints a passing file's captured console output nowhere. So `aai test` run by
25
+ * a coding agent — this project's own studio agent, or a CLI agent in your
26
+ * terminal — swallowed every `console.log` from a test that passed, which is
27
+ * exactly where you put one while working out what an agent said or which tool
28
+ * it reached for. Measured on a scaffolded project, vitest 4.1.10: with the
29
+ * agent markers in the environment a module-scope `console.warn` printed
30
+ * nothing; with them stripped it printed; pinning this restored it either way.
31
+ *
32
+ * That reporter exists to keep an agent's output small, so this is a real
33
+ * trade — it is pinned because a debugging line you cannot see costs more than
34
+ * the tokens it saves, and because a test run that behaves differently
35
+ * depending on who typed the command is the harder thing to reason about. Drop
36
+ * the line if you would rather have the terser output.
37
+ *
38
+ * `aaiAgentPlugin` serves `virtual:aai/agent` — the agent lowered the way
39
+ * `aai build` lowers it, so a spec imports one module instead of rebuilding it
40
+ * out of a glob, a `?raw` read and `deployedAgent`.
19
41
  */
20
42
  export default defineConfig({
43
+ plugins: [aaiAgentPlugin()],
21
44
  test: {
22
45
  globals: true,
46
+ reporters: ["default"],
23
47
  },
24
48
  });
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log, s as unwrapCancel } from "./_ui-u7T4YooX.mjs";
4
- import { f as checkedResponse, p as isStringArray } from "./_studio-DR5yzZ1T.mjs";
5
- import { t as secretRequest } from "./_slug-api-z-USxPtL.mjs";
4
+ import { f as checkedResponse, p as isStringArray } from "./_studio-CKrsixd0.mjs";
5
+ import { t as secretRequest } from "./_slug-api--ZBmIGH-.mjs";
6
6
  import { isRecord } from "@alexkroman1/aai/utils";
7
7
  import * as p from "@clack/prompts";
8
8
  import { text } from "node:stream/consumers";
@@ -5,9 +5,10 @@ import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
5
5
  import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
6
6
  import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
7
7
  import { layerScaffold } from "./_templates-4WcKOjS5.mjs";
8
- import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-DR5yzZ1T.mjs";
8
+ import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-CKrsixd0.mjs";
9
+ import { existsSync } from "node:fs";
9
10
  import path from "node:path";
10
- import { isRecord } from "@alexkroman1/aai/utils";
11
+ import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
11
12
  import { mkdir, readdir, writeFile } from "node:fs/promises";
12
13
  //#region studio.ts
13
14
  /**
@@ -74,7 +75,7 @@ async function executePull(opts) {
74
75
  serverUrl,
75
76
  studioProject: opts.project,
76
77
  studioSourceHash: remote.sourceHash,
77
- ...remote.deployedSlug ? { slug: remote.deployedSlug } : {}
78
+ ...omitUndefined({ slug: remote.deployedSlug })
78
79
  });
79
80
  const count = Object.keys(remote.files).length;
80
81
  log.success(`Pulled ${opts.project} (${count} files) into ${target}`);
@@ -95,6 +96,7 @@ async function pushProject(opts) {
95
96
  const { files, warnings } = await collectSourceFiles(opts.cwd);
96
97
  for (const warning of warnings) log.warn(warning);
97
98
  if (Object.keys(files).length === 0) throw new Error("Nothing to push — this directory has no project files.");
99
+ if (!files["agent.ts"] && existsSync(path.join(opts.cwd, "agent.ts"))) throw new CliError("entry_not_synced", warnings.find((w) => w.startsWith("agent.ts ")) ?? "agent.ts exists locally but was not synced.", "The entry file must sync to deploy — reduce its size or fix its encoding.");
98
100
  let project = config?.studioProject;
99
101
  let baseHash = config?.studioSourceHash;
100
102
  let slug = config?.slug;
@@ -114,7 +116,7 @@ async function pushProject(opts) {
114
116
  serverUrl,
115
117
  studioProject: project,
116
118
  studioSourceHash: result.sourceHash,
117
- ...slug ? { slug } : {}
119
+ ...omitUndefined({ slug })
118
120
  });
119
121
  return {
120
122
  project,
@@ -166,7 +168,7 @@ async function executePublish(opts) {
166
168
  const hadSlug = pushed.slug !== void 0;
167
169
  if (pushed.slug) await syncEnvSecrets(opts.cwd, serverUrl, apiKey, project);
168
170
  log.step(`Publishing ${project} (builds in the project's sandbox)…`);
169
- const result = checkedResponse(await publishStudioProject(serverUrl, apiKey, project), (value) => isRecord(value) && typeof value.slug === "string" && typeof value.output === "string", `the publish route at ${serverUrl}`);
171
+ const result = checkedResponse(await publishStudioProject(serverUrl, apiKey, project, { skipTypecheck: opts.skipTypecheck }), (value) => isRecord(value) && typeof value.slug === "string" && typeof value.output === "string", `the publish route at ${serverUrl}`);
170
172
  if (result.output.trim()) log.message(result.output.trim());
171
173
  await updateProjectConfig(opts.cwd, {
172
174
  serverUrl,