@alexkroman1/aai-cli 7.0.0 → 8.1.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -18,11 +18,51 @@ The fast loop: edit → `pnpm dev` (browser, talk to it) →
18
18
  agent that no longer exists. When a test fails after your change, decide
19
19
  which side is stale: updating the test to match the new agent is a normal
20
20
  fix, not a workaround. Do not delete a test to make it pass.
21
- 3. **Run `pnpm build` before declaring done** — bundles `agent.ts`,
21
+ 3. **Run `pnpm eval` when you change what the agent DOES** — a test asserts
22
+ the agent's shape; an eval drives a real session and asserts what it did.
23
+ Cases live in `agent.eval.test.ts` (the `simple` template ships one):
24
+
25
+ ```ts no-check
26
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
27
+ import { expect } from "vitest";
28
+ import agentDef from "./agent.ts";
29
+
30
+ describeEval(agentDef, (test) => {
31
+ test(
32
+ "looks the order up before answering",
33
+ async ({ session }) => {
34
+ // `say()` returns THAT turn — the reply, its tool calls, its events.
35
+ const turn = await session.say("where is order W1234?");
36
+ expect(turn.toolCalls.map((c) => c.name)).toContain("look_up");
37
+ expect(turn.text).toMatch(/shipped/i);
38
+ },
39
+ // What a SCRIPTED model answers with when there is no key (below).
40
+ { stubReply: "Order W1234 shipped yesterday." },
41
+ );
42
+ });
43
+ ```
44
+
45
+ Everything is real except the microphone and the speaker: your tools run,
46
+ your prompt runs, the session's own event stream is what you assert over.
47
+ Two things to know before reading a green run:
48
+
49
+ - **With a provider key it uses a LIVE model** — it spends tokens, and it is
50
+ a noisy instrument. One failure is a question, not a verdict; re-run before
51
+ believing either answer.
52
+ - **Without one it uses a SCRIPTED model** answering each case's `stubReply`,
53
+ and says so. That still proves the agent boots, the tools resolve and the
54
+ session reaches a reply — it proves nothing about what the agent SAYS. Give
55
+ a case `{ live: true }` instead when no script could honestly stand in
56
+ (a tool the model has to choose for itself, a refusal, a judgement).
57
+
58
+ No eval can see anything below the audio boundary — when the agent decides
59
+ you stopped talking, barge-in, two sentences merging into one turn. Those
60
+ need `pnpm dev` and your own voice.
61
+ 4. **Run `pnpm build` before declaring done** — bundles `agent.ts`,
22
62
  type-checks, and validates the manifest. Catches issues `dev` won't.
23
- 4. **Make small, focused changes** — verify each one before stacking the
63
+ 5. **Make small, focused changes** — verify each one before stacking the
24
64
  next.
25
- 5. **Look at templates before writing custom code** — the CLI ships working
65
+ 6. **Look at templates before writing custom code** — the CLI ships working
26
66
  examples inside its own package, at
27
67
  `node_modules/@alexkroman1/aai-cli/dist/templates/`. Read them directly;
28
68
  `aai init --template <name>` scaffolds a fresh project from one. Closest
@@ -51,6 +91,7 @@ npx @alexkroman1/aai-cli init # Scaffold a new agent
51
91
  npx @alexkroman1/aai-cli templates # List available templates
52
92
  npx @alexkroman1/aai-cli dev # Start local dev server
53
93
  npx @alexkroman1/aai-cli test # Run agent.test.ts via vitest
94
+ npx @alexkroman1/aai-cli eval # Run agent.eval.test.ts against a model
54
95
  npx @alexkroman1/aai-cli build # Bundle and validate
55
96
  npx @alexkroman1/aai-cli deploy # Deploy to production
56
97
  npx @alexkroman1/aai-cli delete # Remove deployed agent
@@ -59,9 +100,9 @@ npx @alexkroman1/aai-cli secret delete NAME
59
100
  npx @alexkroman1/aai-cli secret list
60
101
  ```
61
102
 
62
- The scaffold's `package.json` exposes `dev`, `build`, `test`, and `deploy`
63
- as `pnpm <name>` shortcuts. Other commands (`init`, `templates`, `delete`, `secret`)
64
- are CLI-only.
103
+ The scaffold's `package.json` exposes `dev`, `build`, `test`, `eval` and
104
+ `deploy` as `pnpm <name>` shortcuts. Other commands (`init`, `templates`,
105
+ `delete`, `secret`) are CLI-only.
65
106
 
66
107
  ## Running it yourself (`npm start`)
67
108
 
@@ -75,11 +116,15 @@ PORT=8080 HOST=0.0.0.0 npm start # bind every interface, e.g. in a container
75
116
 
76
117
  `npm start` **builds first** (that is the `prestart` script) and then serves
77
118
  the result: `server.mjs` boots `.aai/worker.mjs`, the same artifact
78
- `aai publish` uploads. The build is what makes `tools/` work a tool is
79
- registered by existing, and the enumeration happens where the bundle is
80
- assembled, so a server that loaded `agent.ts` directly would run an agent with
81
- none of its tools. The same build produces your `client.tsx`, so a custom UI is
82
- served with no extra step.
119
+ `aai publish` uploads. It serves your own `client.tsx` build when there is one
120
+ and falls back to `defaultClientDir()` (`@alexkroman1/aai-ui/client-dir`), the
121
+ prebuilt default UI shipped inside the package the only export of `aai-ui`
122
+ that runs on Node rather than in the browser.
123
+
124
+ The build is what makes `tools/` work — a tool is registered by existing, and
125
+ the enumeration happens where the bundle is assembled, so a server that loaded
126
+ `agent.ts` directly would run an agent with none of its tools. The same build
127
+ produces your `client.tsx`, so a custom UI is served with no extra step.
83
128
 
84
129
  Secrets work the same as everywhere else: `ctx.env` holds the keys declared
85
130
  in `.env` (or `.env.example`), and a real environment variable of that name
@@ -99,6 +144,7 @@ it needs the platform's sandbox and refuses outside one.
99
144
  my-agent/
100
145
  agent.ts # Agent definition (required)
101
146
  agent.test.ts # Unit tests (optional)
147
+ agent.eval.test.ts # Behaviour evals, run by `pnpm eval` (optional)
102
148
  client.tsx # Custom UI (optional, React)
103
149
  shared.ts # Types shared between agent.ts and client.tsx
104
150
  system-prompt.md # The system prompt — discovered, not imported
@@ -213,6 +259,19 @@ import { agent } from "@alexkroman1/aai";
213
259
  export default agent({ name: "My Agent" });
214
260
  ```
215
261
 
262
+ Declare neither and the agent runs on `DEFAULT_SYSTEM_PROMPT`, exported from
263
+ `@alexkroman1/aai` so you can read what you are replacing — or compose against
264
+ it, rather than restating the voice rules at the bottom of this guide:
265
+
266
+ ```ts
267
+ import { DEFAULT_SYSTEM_PROMPT, agent } from "@alexkroman1/aai";
268
+
269
+ export default agent({
270
+ name: "My Agent",
271
+ systemPrompt: `${DEFAULT_SYSTEM_PROMPT}\n\nYou only ever discuss pizza.`,
272
+ });
273
+ ```
274
+
216
275
  ```markdown
217
276
  <!-- system-prompt.md -->
218
277
  You are a concise, friendly assistant.
@@ -369,10 +428,10 @@ A step has no `ctx`, so the two things tool code takes for granted come from
369
428
  root barrel would drag the whole SDK into that bundle.
370
429
 
371
430
  ```ts no-check
372
- import { requireStepEnv, stepEnv, StepGenerateError, stepGenerate } from "@alexkroman1/aai/step";
373
- import { FatalError } from "workflow";
431
+ import { stepEnv } from "@alexkroman1/aai/step";
432
+ import { stepGenerateClassified } from "@alexkroman1/aai/step-errors";
374
433
 
375
- async function summarize(url: string, text: string) {
434
+ async function summarize(text: string) {
376
435
  "use step";
377
436
 
378
437
  // The agent's env by name — the same values a tool reads from `ctx.env`.
@@ -380,16 +439,9 @@ async function summarize(url: string, text: string) {
380
439
  const style = stepEnv("DIGEST_STYLE") ?? "plain";
381
440
 
382
441
  // One model call, on the agent's own ASSEMBLYAI_API_KEY and default model.
383
- return await stepGenerate(`${style} summary of:\n\n${text}`, {
442
+ return await stepGenerateClassified(`${style} summary of:\n\n${text}`, {
384
443
  system: "Reply with two sentences and nothing else.",
385
- }).catch(stopOrRetry);
386
- }
387
-
388
- // The DevKit retries a step that throws, so decide which failures deserve it.
389
- // A rate limit does; a bad key does not.
390
- function stopOrRetry(err: unknown): never {
391
- if (err instanceof StepGenerateError && !err.retryable) throw new FatalError(err.message);
392
- throw err;
444
+ });
393
445
  }
394
446
  ```
395
447
 
@@ -399,7 +451,118 @@ before and after a deploy. List what you read in `requiredEnv` and a deploy
399
451
  checks it for you. And **`stepGenerate` is not `ctx.generate`**: it is one
400
452
  request to the AssemblyAI LLM Gateway, with no tools and no structured output,
401
453
  because bundling the AI SDK into a step artifact costs megabytes on every
402
- deploy. Ask it for JSON and parse the reply if you need a shape.
454
+ deploy. Use `stepGenerateJsonClassified` with a Zod `schema` if you need a shape.
455
+
456
+ ### From a step, reach for the `Classified` call
457
+
458
+ `@alexkroman1/aai/step-errors` publishes a wrapper for every `/step` call that
459
+ can fail against a remote service, and **inside a `"use step"` body the wrapper
460
+ is the one to use**:
461
+
462
+ | Raw, on `@alexkroman1/aai/step` | Use this instead, on `@alexkroman1/aai/step-errors` |
463
+ | --- | --- |
464
+ | `stepGenerate` | `stepGenerateClassified` |
465
+ | `stepGenerateJson` | `stepGenerateJsonClassified` |
466
+ | `stepFetch` | `stepFetchOk` |
467
+ | `stepTranscribeSync` | `stepTranscribeSyncClassified` |
468
+ | `stepTranscribeUpload` / `Submit` / `Poll` | the matching `*Classified` |
469
+ | `sendToChannel` (`/channels`) | `sendToChannelClassified` |
470
+
471
+ The whole of what a wrapper adds is `throwStepError`, and that is worth having
472
+ because the DevKit's retry policy is decided by WHICH error a step throws. Raw,
473
+ every failure looks the same to it: a bad API key is retried until the attempts
474
+ run out, and a rate limit backs off for the DevKit's default one second while
475
+ the delay the gateway itself named sits unread on the error. Classified, a
476
+ terminal failure raises `FatalError` and stops, and a transient one raises
477
+ `RetryableError` carrying the far side's own `Retry-After`. That matters most
478
+ where this SDK encourages a fan-out, because N steps hit a rate limit together
479
+ and a second later all N ask again.
480
+
481
+ **Reach for the raw call when the failure is not simply a failure** — a `404`
482
+ that means "already deleted", a `4xx` whose body decides which advice to print.
483
+ Then classify it yourself: `throwStepError(err)`, `throwFatalStepError(err)` to
484
+ stop outright, `toStepError(cause, message)` to build the error without throwing,
485
+ or `throwFfmpegStepError(err)` for a media failure, whose default runs the other
486
+ way (only a `timeout` or an `aborted` is worth another attempt).
487
+
488
+ **Why the split exists, since the wrapper is what you usually want:** this is
489
+ the one authoring module allowed to import the DevKit's `workflow` package, and
490
+ `/step` is not written only for a step — `mapConcurrent` bounds a rate-limited
491
+ call anywhere, `stepFetch` is an ordinary HTTP client, and your specs drive
492
+ exported steps directly. Putting `workflow` in `/step`'s graph would put it in
493
+ every one of those bundles. A step pays nothing for the extra import line.
494
+
495
+ ### Media, big files, and transcription from a step
496
+
497
+ Three more subpaths a `workflows/*.ts` module can reach, all with the same
498
+ bundling rule as `/step` — import them there, never through the root barrel:
499
+
500
+ - **`@alexkroman1/aai/transcribe`** — `stepTranscribeSync(bytes)` for a short
501
+ recording, or `stepTranscribeUpload` → `stepTranscribeSubmit` →
502
+ `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
505
+ with `retryable: false`, and unclassified a step re-uploads the same bytes
506
+ until its attempts run out.
507
+ - **`@alexkroman1/aai/ffmpeg`** — `transcodeToWav(bytes, { sampleRate })`,
508
+ `runFfmpeg(args)`, `probeMedia(source)` for duration and stream info, and
509
+ `FfmpegError`/`isFfmpegError`. Under `aai dev` it needs ffmpeg on your PATH;
510
+ a `missing-binary` failure says so and carries the install line.
511
+ - **`@alexkroman1/aai/step-files`** — for a recording too big to hold in memory.
512
+ `readUploadToFile(uploadId, path)` streams an upload to disk,
513
+ `writeUploadFromFile(path)` streams one back, and `withTempDir(work)` gives
514
+ both a directory that is cleaned up even when the step throws.
515
+
516
+ ```ts no-check
517
+ import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
518
+ import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
519
+ import { readUploadToFile, withTempDir } from "@alexkroman1/aai/step-files";
520
+
521
+ export async function measure(uploadId: string) {
522
+ "use step";
523
+
524
+ return await withTempDir(async (dir) => {
525
+ const path = `${dir}/input`;
526
+ // Read the upload ONCE. A five-step version reads it five times, and on a
527
+ // 700 MB recording that is the expensive part by an order of magnitude.
528
+ await readUploadToFile(uploadId, path);
529
+ const media = await probeMedia(path).catch(throwFfmpegStepError);
530
+ return { durationMs: media.durationMs };
531
+ });
532
+ }
533
+ ```
534
+
535
+ `call-audit` is the worked example for all three at once.
536
+
537
+ ### Posting somewhere — `@alexkroman1/aai/channels`
538
+
539
+ A run that finishes while nobody is on the line needs somewhere to put the
540
+ result. `slackChannel({ webhookUrl })` names a destination and
541
+ `sendToChannelClassified(channel, message)` posts to it:
542
+
543
+ ```ts no-check
544
+ import { type ChannelMessage, slackChannel } from "@alexkroman1/aai/channels";
545
+ import { requireStepEnv } from "@alexkroman1/aai/step";
546
+ import { sendToChannelClassified } from "@alexkroman1/aai/step-errors";
547
+
548
+ export async function announce(headline: string, points: string[]) {
549
+ "use step";
550
+
551
+ const message: ChannelMessage = {
552
+ text: headline,
553
+ sections: points.map((point) => ({ text: point })),
554
+ };
555
+ return await sendToChannelClassified(slackChannel({ webhookUrl: requireStepEnv("SLACK_WEBHOOK_URL") }), message);
556
+ }
557
+ ```
558
+
559
+ The webhook URL is a secret like any other — declare it in `requiredEnv` and set
560
+ it with `aai secret put`. A channel's credential is its DESTINATION and is
561
+ passed in, which is why no channel reads an env var of its own. `ChannelMessage`
562
+ is rendered per platform, so the same message is legal on a channel kind added
563
+ later; `isSlackWebhookUrl` / `isSlackWorkflowTriggerUrl` validate a pasted URL
564
+ before a run depends on it, and `channelAdvice` turns a refusal into a sentence
565
+ a person can act on. `podcast-digest` is the worked example.
403
566
 
404
567
  ### A step's HTTP: use `stepFetch`, not `fetch`
405
568
 
@@ -685,14 +848,14 @@ descriptor (the descriptor owns its own voice). A raw config that skips
685
848
  ```ts
686
849
  import { agent } from "@alexkroman1/aai";
687
850
  import { assemblyAIStt } from "@alexkroman1/aai/stt";
688
- import { anthropic } from "@alexkroman1/aai/llm";
689
- import { cartesia } from "@alexkroman1/aai/tts";
851
+ import { anthropicLlm } from "@alexkroman1/aai/llm";
852
+ import { cartesiaTts } from "@alexkroman1/aai/tts";
690
853
 
691
854
  export default agent({
692
855
  name: "My Agent",
693
856
  stt: assemblyAIStt({ model: "universal-3-5-pro" }),
694
- llm: anthropic({ model: "claude-haiku-4-5" }),
695
- tts: cartesia(),
857
+ llm: anthropicLlm({ model: "claude-haiku-4-5" }),
858
+ tts: cartesiaTts(),
696
859
  });
697
860
  ```
698
861
 
@@ -716,7 +879,7 @@ one-word backchannels like "yeah" don't cut it off);
716
879
  500 ms; `0` disables; interim transcripts only — committed turns always
717
880
  land). End-of-turn detection (how long a pause ends the user's turn)
718
881
  belongs to the STT provider: `assemblyAIStt({ minTurnSilenceMs })` (default
719
- 1600 ms) / `deepgram({ endpointing })` (default 1500 ms), so mid-utterance
882
+ 1600 ms) / `deepgramStt({ endpointing })` (default 1500 ms), so mid-utterance
720
883
  pauses don't split a request.
721
884
  `deadAirCoverMs` is how long a turn may go silent before the transport speaks
722
885
  a short filler, so a long tool chain doesn't sound like a dropped call. It is
@@ -753,15 +916,15 @@ for the providers you actually use.
753
916
  | Factory | Default model | Env var |
754
917
  | --------------- | ---------------------- | -------------------- |
755
918
  | `assemblyAIStt` | `"universal-3-5-pro"` | `ASSEMBLYAI_API_KEY` |
756
- | `deepgram` | `"nova-3"` | `DEEPGRAM_API_KEY` |
919
+ | `deepgramStt` | `"nova-3"` | `DEEPGRAM_API_KEY` |
757
920
  | `elevenLabsStt` | `"scribe_v2_realtime"` | `ELEVENLABS_API_KEY` |
758
- | `soniox` | `"stt-rt-v3"` | `SONIOX_API_KEY` |
921
+ | `sonioxStt` | `"stt-rt-v3"` | `SONIOX_API_KEY` |
759
922
 
760
923
  All STT factories accept `{ model?: string, ... }`. Bare calls
761
- (`deepgram()`, `soniox()`, etc.) use the default model. Language is spelled
762
- `language` where the vendor takes one code (`deepgram`, `elevenLabsStt`) and
763
- `languages` where it takes a list (`assemblyAIStt`, `soniox`) — and only
764
- `deepgram`'s unset value means English; the other three auto-detect.
924
+ (`deepgramStt()`, `sonioxStt()`, etc.) use the default model. Language is spelled
925
+ `language` where the vendor takes one code (`deepgramStt`, `elevenLabsStt`) and
926
+ `languages` where it takes a list (`assemblyAIStt`, `sonioxStt`) — and only
927
+ `deepgramStt`'s unset value means English; the other three auto-detect.
765
928
 
766
929
  `elevenLabsStt` carries the stage in its name because ElevenLabs is
767
930
  better known for TTS: when that stage arrives, `elevenLabs` is the name it
@@ -776,32 +939,32 @@ API keys require it; the US endpoints reject them. Example:
776
939
 
777
940
  | Factory | SDK package | Env var |
778
941
  | --------------- | ------------------- | ------------------------------ |
779
- | `anthropic` | `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` |
780
- | `openai` | `@ai-sdk/openai` | `OPENAI_API_KEY` |
781
- | `google` | `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
782
- | `mistral` | `@ai-sdk/mistral` | `MISTRAL_API_KEY` |
783
- | `xai` | `@ai-sdk/xai` | `XAI_API_KEY` |
784
- | `groq` | `@ai-sdk/groq` | `GROQ_API_KEY` |
785
- | `openrouter` | `@ai-sdk/openai` | `OPENROUTER_API_KEY` |
786
- | `gateway` | `ai` (built in) | `AI_GATEWAY_API_KEY` |
942
+ | `anthropicLlm` | `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` |
943
+ | `openaiLlm` | `@ai-sdk/openai` | `OPENAI_API_KEY` |
944
+ | `googleLlm` | `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
945
+ | `mistralLlm` | `@ai-sdk/mistral` | `MISTRAL_API_KEY` |
946
+ | `xaiLlm` | `@ai-sdk/xai` | `XAI_API_KEY` |
947
+ | `groqLlm` | `@ai-sdk/groq` | `GROQ_API_KEY` |
948
+ | `openrouterLlm` | `@ai-sdk/openai` | `OPENROUTER_API_KEY` |
949
+ | `gatewayLlm` | `ai` (built in) | `AI_GATEWAY_API_KEY` |
787
950
  | `assemblyAILlm` | `@ai-sdk/openai` | `ASSEMBLYAI_API_KEY` |
788
951
 
789
952
  LLM factories require `{ model: string }` — the `ModelOptions` interface,
790
953
  shared by all of them except `assemblyAILlm`. Example:
791
- `anthropic({ model: "claude-haiku-4-5" })`. The argument is required because a
792
- third-party vendor's catalog is not this SDK's to default from;
954
+ `anthropicLlm({ model: "claude-haiku-4-5" })`. The argument is required
955
+ because a third-party vendor's catalog is not this SDK's to default from;
793
956
  `assemblyAILlm()` is the one bare call, since it has a default model.
794
957
 
795
- `openrouter` routes through [OpenRouter](https://openrouter.ai) — an
958
+ `openrouterLlm` routes through [OpenRouter](https://openrouter.ai) — an
796
959
  OpenAI-compatible endpoint fronting hundreds of models addressed as
797
960
  `"creator/model"`, e.g.
798
- `openrouter({ model: "meta-llama/llama-3.3-70b-instruct" })`. It needs
961
+ `openrouterLlm({ model: "meta-llama/llama-3.3-70b-instruct" })`. It needs
799
962
  no extra SDK install (it reuses the `@ai-sdk/openai` client).
800
963
 
801
- `gateway` routes through the [Vercel AI
964
+ `gatewayLlm` routes through the [Vercel AI
802
965
  Gateway](https://vercel.com/docs/ai-gateway) — one endpoint fronting
803
966
  hundreds of models addressed as `"creator/model"`, e.g.
804
- `gateway({ model: "zai/glm-4.6" })`. It needs no extra SDK install
967
+ `gatewayLlm({ model: "zai/glm-4.6" })`. It needs no extra SDK install
805
968
  (the gateway client ships inside the `ai` package).
806
969
 
807
970
  `assemblyAILlm` routes through the [AssemblyAI LLM
@@ -826,12 +989,12 @@ Mixing providers works the same way — declare the stages you're changing:
826
989
 
827
990
  ```ts
828
991
  import { agent } from "@alexkroman1/aai";
829
- import { cartesia } from "@alexkroman1/aai/tts";
992
+ import { cartesiaTts } from "@alexkroman1/aai/tts";
830
993
 
831
994
  export default agent({
832
995
  name: "My Agent",
833
996
  llm: "claude-sonnet-4-6",
834
- tts: cartesia(),
997
+ tts: cartesiaTts(),
835
998
  });
836
999
  ```
837
1000
 
@@ -840,10 +1003,10 @@ export default agent({
840
1003
  | Factory | Default voice | Env var |
841
1004
  | --------------- | ---------------------------------------- | -------------------- |
842
1005
  | `assemblyAITts` | `"jane"` | `ASSEMBLYAI_API_KEY` |
843
- | `cartesia` | `"f786b574-daa5-4673-aa0c-cbe3e8534c02"` | `CARTESIA_API_KEY` |
844
- | `rime` | `"cove"` (model `mistv2`) | `RIME_API_KEY` |
1006
+ | `cartesiaTts` | `"f786b574-daa5-4673-aa0c-cbe3e8534c02"` | `CARTESIA_API_KEY` |
1007
+ | `rimeTts` | `"cove"` (model `mistv2`) | `RIME_API_KEY` |
845
1008
 
846
- Bare calls (`assemblyAITts()`, `cartesia()`, `rime()`) use the defaults.
1009
+ Bare calls (`assemblyAITts()`, `cartesiaTts()`, `rimeTts()`) use the defaults.
847
1010
  Override with `{ voice, model, language }`.
848
1011
 
849
1012
  **AssemblyAI TTS** shares `ASSEMBLYAI_API_KEY` with AssemblyAI STT and the
@@ -908,6 +1071,8 @@ ctx.sessionId: string // unique session ID
908
1071
  ctx.send(event: string, data: unknown): void // push custom event to browser client (silently dropped over 64 KB JSON)
909
1072
  ctx.generate(opts): Promise<{ text, object? }> // one-shot LLM call (host-side)
910
1073
  // with a `schema`, `object` is REQUIRED and typed by it
1074
+ ctx.delegate(sub, opts): Promise<DelegateResult> // run a subagent — a whole tool loop with its own
1075
+ // context window (see "Subagents")
911
1076
  ctx.signal: AbortSignal // aborts on barge-in, reset, session stop, or this call's timeout
912
1077
  ```
913
1078
 
@@ -931,29 +1096,29 @@ export const lookup = tool({
931
1096
  });
932
1097
  ```
933
1098
 
934
- **The project's tsconfig turns off `noImplicitAny`, so write the code first.**
935
- Do NOT add type annotations defensively almost nothing requires them, and time
936
- spent on them is time not spent on the agent.
1099
+ **Write the code first; let inference do the work.** The project runs `strict`,
1100
+ so a variable declared empty and filled in the same scope widens from what you
1101
+ put in it `const items = []` followed by `items.push(pick)` infers `Pick[]`
1102
+ with no annotation. Do NOT add type annotations defensively.
937
1103
 
938
- **The one exception, and it is not optional: annotate any variable you
939
- declare empty.** With `noImplicitAny` off, TypeScript does not widen an empty
940
- initializer from what you later assign, so `[]` stays `never[]` and `null`
941
- stays `null` forever, whether or not a callback is involved:
1104
+ **Annotate the DECLARATION when the first write is somewhere the compiler
1105
+ cannot follow** inside a callback, or after the value has already been read.
1106
+ The widening only tracks straight-line code in one scope, so in those cases the
1107
+ declaration keeps its starting type:
942
1108
 
943
1109
  ```ts no-check
944
- const items = []; // never[] items.push(x) is an error
945
- let best = null; // null best = {...} is an error
946
- const [picks, set] = useState([]); // never[] in a client, same thing
1110
+ const items = []; // stays never[] if the only push is in a callback
1111
+ let best = null; // stays null if the only assignment is in a callback
1112
+ const [picks, set] = useState([]); // never[] useState's argument is read immediately
947
1113
 
948
- const items: Pick[] = []; // ✅ annotate the DECLARATION
949
- let best: Pick | null = null; // ✅
1114
+ const items: Pick[] = []; // ✅ annotate the DECLARATION
1115
+ let best: Pick | null = null; // ✅
950
1116
  const [picks, set] = useState<Pick[]>([]); // ✅
951
1117
  ```
952
1118
 
953
1119
  Annotating the *use* instead does not help — the declaration is still wrong,
954
1120
  so the next push reports the next line, and you can burn a whole session
955
- fixing one call site at a time. This is the single most common way a
956
- generated agent fails to build.
1121
+ fixing one call site at a time.
957
1122
 
958
1123
  ### Session state
959
1124
 
@@ -1037,6 +1202,129 @@ that's also how S2S agents use it. Pass a Zod schema as `schema` for typed
1037
1202
  structured output (`generateObject`-style): the result's `object` carries
1038
1203
  the parsed, typed value. A plain JSON Schema object also works.
1039
1204
 
1205
+ The option bag is `GenerateOptions` and the answer is `GenerateResult`
1206
+ (`GenerateObjectResult<T>` with a `schema`), both exported from
1207
+ `@alexkroman1/aai` — annotate a helper that wraps the call rather than
1208
+ re-describing the shape. `GenerateFn` is the type of `ctx.generate` itself,
1209
+ which is what a spec passes to `createToolContext({ generate })`.
1210
+
1211
+ ### When the NEXT step is the hard part — `dialog()` and `procedure()`
1212
+
1213
+ Two declarations for flows, and the difference is who is driving.
1214
+
1215
+ **`dialog()` gates what the MODEL may do next.** A prompt asking the agent to
1216
+ collect an address before taking payment is a suggestion; a dialog is a rule.
1217
+ `dialog(key, spec)` takes `{ initial, states }`, each state carrying an
1218
+ `instruction` the agent is given while it is there and an `on` map of the events
1219
+ that leave it. It is a slot underneath, so the position is persisted with the
1220
+ rest of the session and survives a reconnect.
1221
+
1222
+ ```ts no-check
1223
+ import { dialog } from "@alexkroman1/aai";
1224
+
1225
+ export const checkout = dialog("checkout", {
1226
+ initial: "collecting",
1227
+ states: {
1228
+ collecting: {
1229
+ instruction: "Take the order. Confirm it back before charging anything.",
1230
+ on: { CONFIRMED: "paying" },
1231
+ },
1232
+ paying: {
1233
+ instruction: "Take payment with charge_card. Do not add items now.",
1234
+ on: { PAID: "done" },
1235
+ },
1236
+ done: { instruction: "Read back the order number and say goodbye." },
1237
+ },
1238
+ });
1239
+ ```
1240
+
1241
+ A tool declared with `checkout.tool({...})` is REFUSED unless the dialog is in a
1242
+ state that allows it, and the refusal reaches the model as a `ToolFailure` it
1243
+ can recover from — the point being that the gate is enforced at EXECUTION
1244
+ rather than hoped for in a prompt. The states and events are inferred from the
1245
+ spec, so a misspelled `send` is a compile error. `dispatch-center` and
1246
+ `solo-rpg` are the worked examples.
1247
+
1248
+ **`procedure()` runs a flow YOU drive, with no model in the loop.** Where a
1249
+ dialog constrains a conversation, a procedure is an algorithm with branches,
1250
+ retries and a bounded budget — a grading loop, a retrieval-and-check cycle —
1251
+ expressed as a statechart rather than as a `while` with four early returns:
1252
+
1253
+ ```ts no-check
1254
+ import { procedure } from "@alexkroman1/aai";
1255
+
1256
+ const answer = procedure(ragMachine);
1257
+ const result = await answer.run({ question }, { signal: ctx.signal });
1258
+ ```
1259
+
1260
+ `run` resolves with the machine's output, or throws `ProcedureNotFinishedError`
1261
+ if it stops without reaching a final state — which is what makes "we ran out of
1262
+ attempts" a state you declare and handle rather than an error. Options are
1263
+ `ProcedureRunOptions`; the machine is an XState machine, and `xstate` is already
1264
+ an SDK dependency. `support-line` is the worked example.
1265
+
1266
+ ### Subagents (`ctx.delegate`)
1267
+
1268
+ `ctx.generate` is ONE prompt. When answering takes an unknown number of tool
1269
+ 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.
1272
+
1273
+ ```ts
1274
+ import { subagent, tool } from "@alexkroman1/aai";
1275
+ import { z } from "zod";
1276
+
1277
+ const researcher = subagent({
1278
+ name: "researcher",
1279
+ instructions:
1280
+ "Research the task with the tools you have. IMPORTANT: your final message " +
1281
+ "is the only thing the caller sees — end with a self-contained summary.",
1282
+ builtinTools: ["web_search", "visit_webpage"],
1283
+ maxSteps: 6,
1284
+ });
1285
+
1286
+ export default tool({
1287
+ description: "Research a question in depth",
1288
+ inputSchema: z.object({ question: z.string() }),
1289
+ execute: async ({ question }, ctx) => {
1290
+ const { text, toolCalls } = await ctx.delegate(researcher, { task: question });
1291
+ return { answer: text, lookups: toolCalls.length };
1292
+ },
1293
+ });
1294
+ ```
1295
+
1296
+ Four rules, each of which is how a subagent disappoints when you skip it:
1297
+
1298
+ - **Tell it to summarize.** You receive its FINAL message. A subagent that
1299
+ signs off with "Done." has thrown away everything it read.
1300
+ - **Write the task as a complete brief.** Its context is isolated — it has not
1301
+ heard the conversation. Anything it needs from the call goes in `task`, or in
1302
+ the optional `context` string.
1303
+ - **Give it a budget.** `maxSteps` (default: the framework's) bounds the loop;
1304
+ past it the subagent is asked for its answer with its tools withheld, so a
1305
+ capped run still answers. In a voice session the tool timeout bounds the
1306
+ whole thing, so keep it small.
1307
+ - **Say you are looking it up before you call.** A delegated run takes a
1308
+ moment, and a silent line is the worst thing on a phone call.
1309
+
1310
+ Runs are ordinary promises, so several fan out at once — this is the other
1311
+ reason to reach for a subagent:
1312
+
1313
+ ```ts no-check
1314
+ const runs = await Promise.allSettled(
1315
+ angles.map((angle) => ctx.delegate(researcher, { task: angle })),
1316
+ );
1317
+ ```
1318
+
1319
+ A subagent may name its own `llm` (a cheaper model for a narrower job) and its
1320
+ own `tools` — an explicit map of `tool()` values, which is how you give one
1321
+ run a strictly smaller surface than the agent has. **Delegation is one level
1322
+ deep**: a subagent's own tools get a `ctx.delegate` that refuses.
1323
+
1324
+ In tests, `stubDelegate` from `@alexkroman1/aai/testing` fakes the capability,
1325
+ routed by subagent name; `createToolContext()` defaults `delegate` to a
1326
+ rejection so an unstubbed run cannot reach a real model.
1327
+
1040
1328
  ### A tool that calls an API
1041
1329
 
1042
1330
  ```ts
@@ -1221,6 +1509,30 @@ Reaching for the `fetch_json` builtin instead is a different design, not a
1221
1509
  shortcut for the same one: it hands URL choice to the model. You cannot
1222
1510
  call it from `execute` — see the builtin table above.
1223
1511
 
1512
+ ## Small helpers — `@alexkroman1/aai/utils`
1513
+
1514
+ Zero-dependency helpers a tool body, a step or a client may reach for, so the
1515
+ same three lines are not rewritten per template. Import from `/utils`, which is
1516
+ safe from a `workflows/*.ts` module and from a browser bundle:
1517
+
1518
+ | Helper | For |
1519
+ | --- | --- |
1520
+ | `errorMessage(err)`, `errorDetail(err)` | Turning an unknown `catch` value into a sentence for the model or the log |
1521
+ | `responseErrorMessage(res, label)` | The same for a non-2xx `Response`, preferring a JSON `error` field over the bare status |
1522
+ | `safeJsonParse(text)` | A parse that answers `undefined` instead of throwing |
1523
+ | `formatBytes`, `formatDuration`, `countWords`, `plural` | Narration. Each returns ONE fixed shape, so a step's progress line and the page rendering the same run cannot disagree — they did, one template printing `1:04:09` from its workflow and `64:09` from its page |
1524
+ | `pushCapped(list, item, max)` | An append that keeps the last N, for a log a session accumulates |
1525
+ | `isRecord(x)`, `omitUndefined(obj)` | The object guard and the spread-free way to drop undefined fields |
1526
+ | `decodeHtmlEntities(text)` | Scraped text on its way to a model |
1527
+ | `createKeyedLock()` / `withLock(lock, key, work)` | Serializing async work per key |
1528
+
1529
+ **`createKeyedLock` is the one an agent most needs and least expects to.** The
1530
+ LLM loop runs a step's tool calls CONCURRENTLY, so two tools mutating the same
1531
+ external resource interleave at every `await`. A session-state mutation is NOT
1532
+ that case — `slot.update`'s window is synchronous — so reach for the lock when
1533
+ the thing being mutated is outside the session. `withLock` takes an optional
1534
+ acquire deadline and throws `KeyedLockTimeoutError` when it runs out.
1535
+
1224
1536
  ## Database API — `ctx.db`
1225
1537
 
1226
1538
  Persistent SQL storage scoped per app, backed by the app's own Postgres
@@ -1487,6 +1799,27 @@ Available from `@alexkroman1/aai-ui`:
1487
1799
  | `MessageList` | — | Messages with auto-scroll, tool calls, transcript |
1488
1800
  | `Controls` | — | Stop/Resume + New Conversation buttons |
1489
1801
  | `Button` | — | Styled button |
1802
+ | `UploadProgressBar` | `upload, onPause?, onResume?` | Bytes in flight, with pause/resume |
1803
+
1804
+ **Forms are declared, not written.** `<Form onSubmit>` collects typed values off
1805
+ the DOM and hands them over once the browser's own validation passes; the field
1806
+ components — `TextField`, `TextAreaField`, `NumberField`, `SelectField`,
1807
+ `CheckboxField`, `FileField` and `SubmitButton` — are plain named inputs, and
1808
+ `Field`/`FieldShell` are what a custom control wraps itself in to match them.
1809
+ For a workflow app there is usually no field markup at all: `<WorkflowFields
1810
+ workflow="name" />` fetches that workflow's input schema and renders a control
1811
+ per field, so a page written against one workflow serves another.
1812
+
1813
+ ```tsx no-check
1814
+ import { Form, WorkflowFields } from "@alexkroman1/aai-ui";
1815
+
1816
+ <Form onSubmit={(values) => submit(values)} error={error}>
1817
+ <WorkflowFields workflow="digest" />
1818
+ </Form>;
1819
+ ```
1820
+
1821
+ `transcription-workflow` is the all-declared version; `link-digest` writes its
1822
+ form by hand, which is what the two are for.
1490
1823
 
1491
1824
  The usual shape — note `StartScreen` **wraps** the app rather than sitting
1492
1825
  beside it; writing `<StartScreen ... />` self-closing is a `TS2741:
@@ -1567,6 +1900,14 @@ Never hardcode secrets in agent code.
1567
1900
  - Define personality, tone, and specialty
1568
1901
  - Include when and how to use each tool
1569
1902
 
1903
+ **Three helpers for the other direction — what the caller SAID.** Speech
1904
+ arrives as words, so `@alexkroman1/aai` publishes the conversions a tool
1905
+ otherwise re-derives: `spokenDigits("four one five")` gives `"415"` for an
1906
+ order number or a phone number, `spokenOrdinal("the third one")` gives `3`, and
1907
+ `resolveOne(candidates, spoken, opts)` picks the one item a phrase meant —
1908
+ answering a `ToolFailure` the model can act on when nothing matches or several
1909
+ do, which is the case a hand-written `.find()` gets wrong.
1910
+
1570
1911
  Patterns by agent type:
1571
1912
 
1572
1913
  - **Code execution:** "You MUST use run_code for ANY math, counting, or