@alexkroman1/aai-cli 5.1.1 → 5.3.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 (99) hide show
  1. package/dist/_agent.d.ts +39 -0
  2. package/dist/_api-client.d.ts +37 -0
  3. package/dist/_bundler.d.ts +46 -0
  4. package/dist/_config.d.ts +57 -0
  5. package/dist/_default-html.d.ts +12 -0
  6. package/dist/_deploy.d.ts +28 -0
  7. package/dist/_dev-server.d.ts +74 -0
  8. package/dist/{_init-B-LEVTHx.mjs → _init-DU-sXH6S.mjs} +44 -65
  9. package/dist/_init.d.ts +7 -0
  10. package/dist/_mock-api.d.ts +22 -0
  11. package/dist/_mock-registry.d.ts +17 -0
  12. package/dist/_output.d.ts +50 -0
  13. package/dist/_server-common.d.ts +17 -0
  14. package/dist/_slug-api.d.ts +15 -0
  15. package/dist/_templates.d.ts +21 -0
  16. package/dist/_typecheck-gate.d.ts +7 -0
  17. package/dist/_ui.d.ts +22 -0
  18. package/dist/_utils.d.ts +48 -0
  19. package/dist/_vite-env.d.ts +8 -0
  20. package/dist/build.d.ts +16 -0
  21. package/dist/cli.d.ts +1 -0
  22. package/dist/cli.mjs +1 -1
  23. package/dist/client-bundler.d.ts +21 -0
  24. package/dist/delete.d.ts +18 -0
  25. package/dist/deploy.d.ts +15 -0
  26. package/dist/dev.d.ts +13 -0
  27. package/dist/{init-tc3Rbgrf.mjs → init-BjeK8crW.mjs} +1 -1
  28. package/dist/init.d.ts +24 -0
  29. package/dist/scaffold/.env.example +11 -0
  30. package/dist/scaffold/CLAUDE.md +938 -0
  31. package/dist/scaffold/global.d.ts +1 -0
  32. package/dist/scaffold/package.json +32 -0
  33. package/dist/scaffold/pnpm-workspace.yaml +13 -0
  34. package/dist/scaffold/tsconfig.json +19 -0
  35. package/dist/scaffold/vite.config.ts +14 -0
  36. package/dist/scaffold/vitest.config.ts +24 -0
  37. package/dist/secret.d.ts +25 -0
  38. package/dist/storage.d.ts +22 -0
  39. package/dist/templates/code-interpreter/agent.ts +11 -0
  40. package/dist/templates/code-interpreter/system-prompt.md +18 -0
  41. package/dist/templates/dispatch-center/agent.test.ts +153 -0
  42. package/dist/templates/dispatch-center/agent.ts +50 -0
  43. package/dist/templates/dispatch-center/client.tsx +378 -0
  44. package/dist/templates/dispatch-center/shared.ts +631 -0
  45. package/dist/templates/dispatch-center/system-prompt.md +39 -0
  46. package/dist/templates/dispatch-center/tools/incident_add_note.ts +27 -0
  47. package/dist/templates/dispatch-center/tools/incident_create.ts +79 -0
  48. package/dist/templates/dispatch-center/tools/incident_escalate.ts +88 -0
  49. package/dist/templates/dispatch-center/tools/incident_get.ts +36 -0
  50. package/dist/templates/dispatch-center/tools/incident_triage.ts +78 -0
  51. package/dist/templates/dispatch-center/tools/incident_update_status.ts +68 -0
  52. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +52 -0
  53. package/dist/templates/dispatch-center/tools/ops_protocols.ts +27 -0
  54. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +149 -0
  55. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +97 -0
  56. package/dist/templates/dispatch-center/tools/resources_get_available.ts +36 -0
  57. package/dist/templates/dispatch-center/tools/resources_update_status.ts +62 -0
  58. package/dist/templates/embedded-assets/agent.ts +55 -0
  59. package/dist/templates/embedded-assets/knowledge.json +20 -0
  60. package/dist/templates/health-assistant/agent.ts +174 -0
  61. package/dist/templates/health-assistant/system-prompt.md +17 -0
  62. package/dist/templates/infocom-adventure/agent.ts +119 -0
  63. package/dist/templates/infocom-adventure/client.tsx +279 -0
  64. package/dist/templates/infocom-adventure/shared.ts +38 -0
  65. package/dist/templates/infocom-adventure/system-prompt.md +43 -0
  66. package/dist/templates/math-buddy/agent.ts +16 -0
  67. package/dist/templates/math-buddy/system-prompt.md +12 -0
  68. package/dist/templates/night-owl/agent.ts +76 -0
  69. package/dist/templates/night-owl/client.tsx +130 -0
  70. package/dist/templates/night-owl/shared.ts +5 -0
  71. package/dist/templates/personal-finance/agent.ts +11 -0
  72. package/dist/templates/personal-finance/system-prompt.md +17 -0
  73. package/dist/templates/pipeline-simple/agent.test.ts +32 -0
  74. package/dist/templates/pipeline-simple/agent.ts +13 -0
  75. package/dist/templates/pizza-ordering/agent.test.ts +235 -0
  76. package/dist/templates/pizza-ordering/agent.ts +174 -0
  77. package/dist/templates/pizza-ordering/client.tsx +120 -0
  78. package/dist/templates/pizza-ordering/shared.ts +142 -0
  79. package/dist/templates/pizza-ordering/system-prompt.md +17 -0
  80. package/dist/templates/simple/agent.ts +5 -0
  81. package/dist/templates/solo-rpg/agent.test.ts +477 -0
  82. package/dist/templates/solo-rpg/agent.ts +37 -0
  83. package/dist/templates/solo-rpg/client.tsx +809 -0
  84. package/dist/templates/solo-rpg/shared.ts +747 -0
  85. package/dist/templates/solo-rpg/system-prompt.md +115 -0
  86. package/dist/templates/solo-rpg/tools/action_roll.ts +98 -0
  87. package/dist/templates/solo-rpg/tools/burn_momentum.ts +69 -0
  88. package/dist/templates/solo-rpg/tools/check_state.ts +11 -0
  89. package/dist/templates/solo-rpg/tools/load_game.ts +25 -0
  90. package/dist/templates/solo-rpg/tools/oracle.ts +248 -0
  91. package/dist/templates/solo-rpg/tools/save_game.ts +20 -0
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +208 -0
  93. package/dist/templates/solo-rpg/tools/update_state.ts +170 -0
  94. package/dist/templates/web-researcher/agent.ts +11 -0
  95. package/dist/test.d.ts +39 -0
  96. package/dist/tsdown.config.d.ts +2 -0
  97. package/dist/typecheck.d.ts +26 -0
  98. package/dist/worker-bundler.d.ts +29 -0
  99. package/package.json +9 -6
@@ -0,0 +1,938 @@
1
+ # aai Voice Agent
2
+
3
+ You are helping build a voice agent using the **aai** framework.
4
+
5
+ ## Workflow
6
+
7
+ The fast loop: edit → `pnpm dev` (browser, talk to it) →
8
+ `pnpm test` (logic) → `pnpm build` (validate bundle).
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.
12
+ 2. **Run `pnpm test` after logic changes** — vitest. Co-locate tests as
13
+ `agent.test.ts` (see `pipeline-simple` template for a reference).
14
+ **The project starts with an `agent.test.ts`, and it is yours to
15
+ maintain.** It asserts the agent's shape — name, providers, tool names —
16
+ so rewriting the agent without updating it leaves a test asserting an
17
+ agent that no longer exists. When a test fails after your change, decide
18
+ which side is stale: updating the test to match the new agent is a normal
19
+ fix, not a workaround. Do not delete a test to make it pass.
20
+ 3. **Run `pnpm build` before declaring done** — bundles `agent.ts`,
21
+ type-checks, and validates the manifest. Catches issues `dev` won't.
22
+ 4. **Make small, focused changes** — verify each one before stacking the
23
+ next.
24
+ 5. **Look at templates before writing custom code** — the CLI ships working
25
+ examples inside its own package, at
26
+ `node_modules/@alexkroman1/aai-cli/dist/templates/`. Read them directly;
27
+ `aai init --template <name>` scaffolds a fresh project from one. Closest
28
+ matches: `simple`, `pipeline-simple`, `web-researcher`, `solo-rpg`,
29
+ `pizza-ordering`.
30
+
31
+ ## CLI
32
+
33
+ ```sh
34
+ npx @alexkroman1/aai-cli init # Scaffold a new agent
35
+ npx @alexkroman1/aai-cli dev # Start local dev server
36
+ npx @alexkroman1/aai-cli test # Run agent.test.ts via vitest
37
+ npx @alexkroman1/aai-cli build # Bundle and validate
38
+ npx @alexkroman1/aai-cli deploy # Deploy to production
39
+ npx @alexkroman1/aai-cli deploy -y # Deploy without prompts
40
+ npx @alexkroman1/aai-cli delete # Remove deployed agent
41
+ npx @alexkroman1/aai-cli secret put NAME # Set a secret
42
+ npx @alexkroman1/aai-cli secret delete NAME
43
+ npx @alexkroman1/aai-cli secret list
44
+ ```
45
+
46
+ The scaffold's `package.json` exposes `dev`, `build`, `test`, and `deploy`
47
+ as `pnpm <name>` shortcuts. Other commands (`init`, `delete`, `secret`)
48
+ are CLI-only.
49
+
50
+ ## Project structure
51
+
52
+ ```text
53
+ my-agent/
54
+ agent.ts # Agent definition (required)
55
+ agent.test.ts # Unit tests (optional)
56
+ client.tsx # Custom UI (optional, React)
57
+ shared.ts # Types shared between agent.ts and client.tsx
58
+ system-prompt.md # Long system prompts (optional, imported)
59
+ tools/ # Tool files when too large for inline (optional)
60
+ package.json
61
+ tsconfig.json
62
+ .env # Local dev secrets (gitignored)
63
+ ```
64
+
65
+ ## `agent()` API
66
+
67
+ ```ts
68
+ import { agent } from "@alexkroman1/aai";
69
+
70
+ export default agent({
71
+ name: string; // required — display name
72
+ systemPrompt?: string; // default: general voice assistant
73
+ greeting?: string; // default: "Hey there..."
74
+ sttPrompt?: string; // STT guidance for jargon/acronyms
75
+ builtinTools?: BuiltinTool[]; // see built-in tools table
76
+ tools?: Record<string, ToolDef>;
77
+ maxSteps?: number; // default: 10 — max tool calls per turn
78
+ toolChoice?: "auto" | "required"; // default: "auto"
79
+ idleTimeoutMs?: number; // disconnect after inactivity (ms)
80
+ silenceTimeoutMs?: number; // pipeline only — assistant speaks up after this much user silence (ms)
81
+ silencePrompt?: string; // instruction injected on silence timeout (requires silenceTimeoutMs)
82
+ minBargeInWords?: number; // pipeline only — words before user speech interrupts the reply (default 2)
83
+ interruptionMinDurationMs?: number; // pipeline only — sustained speech (ms) before an interim barge-in interrupts (default 0 = off)
84
+ holdPhrase?: string; // pipeline only — spoken before a silent tool-call turn (default "One moment."; "" disables)
85
+ falseInterruptionTimeoutMs?: number; // pipeline only — resume an interrupted reply if no user turn commits (default 2000; 0 disables)
86
+ state?: () => S; // per-session mutable state, exposed as ctx.state
87
+ // (S is inferred; see "Typing ctx.state")
88
+ syncState?: (state: S) => unknown; // push a projection of state to the client
89
+ // (read it with useAgentState; see UI hooks)
90
+ });
91
+ ```
92
+
93
+ > When `stt`, `llm`, and `tts` are all provided, the agent runs in
94
+ > **Pipeline mode** — see the section below.
95
+
96
+ Minimal agent — a cascaded pipeline, which is what you should build unless
97
+ the user asks for the speech-to-speech API:
98
+
99
+ ```ts
100
+ import { agent, assemblyAIPipeline } from "@alexkroman1/aai";
101
+
102
+ export default agent({
103
+ name: "My Agent",
104
+ ...assemblyAIPipeline(),
105
+ });
106
+ ```
107
+
108
+ `assemblyAIPipeline()` sets all three stages to AssemblyAI, which bill to
109
+ the one key a published agent is guaranteed to have. Override a single stage
110
+ by setting it after the spread — everything else stays as the preset put it:
111
+
112
+ ```ts
113
+ import { agent, assemblyAIPipeline } from "@alexkroman1/aai";
114
+ import { assemblyAI as assemblyAITts } from "@alexkroman1/aai/tts";
115
+
116
+ export default agent({
117
+ name: "My Agent",
118
+ ...assemblyAIPipeline(),
119
+ tts: assemblyAITts({ voice: "paul" }),
120
+ });
121
+ ```
122
+
123
+ `agent({ name })` alone is legal and gives you S2S mode instead — see below.
124
+
125
+ System prompt from file:
126
+
127
+ ```ts
128
+ import { agent } from "@alexkroman1/aai";
129
+ import systemPrompt from "./system-prompt.md?raw";
130
+ export default agent({ name: "My Agent", systemPrompt });
131
+ ```
132
+
133
+ **JSON imports need no attribute.** `resolveJsonModule` is on, so
134
+ `import data from "./knowledge.json"` is all it takes. Do NOT write
135
+ `assert { type: "json" }` — import assertions were replaced by import
136
+ attributes and TypeScript rejects them (`TS2880`). If you want to be
137
+ explicit the modern spelling is `with { type: "json" }`, but plain is fine.
138
+
139
+ ## Pipeline mode
140
+
141
+ Omitting `stt`/`llm`/`tts` gives **S2S mode**: AssemblyAI's speech-to-speech
142
+ service handles STT, the LLM loop, and TTS in one socket. Fewer moving
143
+ parts, but you cannot choose the model, swap a provider, or tune a stage.
144
+
145
+ **Prefer pipeline mode** — declare all three — unless the user specifically
146
+ asks for the speech-to-speech API. Nearly every template ships this way, and
147
+ it is what the App Builder defaults to. The host runs the LLM loop locally
148
+ (Vercel AI SDK) with your chosen STT, LLM, and TTS. You need it when:
149
+
150
+ - you want a specific LLM (Anthropic, OpenAI, Gemini, Mistral, xAI, Groq,
151
+ hundreds of models via OpenRouter, or 25+ models via the AssemblyAI
152
+ LLM Gateway)
153
+ - you want a specific STT model or TTS voice
154
+ - you need to swap providers without changing agent code
155
+
156
+ **The rule:** set all three of `stt`, `llm`, `tts` together, or none. A
157
+ partial config is rejected at parse time.
158
+
159
+ ```ts
160
+ import { agent } from "@alexkroman1/aai";
161
+ import { assemblyAI } from "@alexkroman1/aai/stt";
162
+ import { anthropic } from "@alexkroman1/aai/llm";
163
+ import { cartesia } from "@alexkroman1/aai/tts";
164
+
165
+ export default agent({
166
+ name: "My Agent",
167
+ stt: assemblyAI({ model: "universal-3-5-pro" }),
168
+ llm: anthropic({ model: "claude-haiku-4-5" }),
169
+ tts: cartesia(),
170
+ });
171
+ ```
172
+
173
+ Tools, the database, `ctx`, and the UI all behave identically across modes.
174
+ Only the audio + LLM transport differs.
175
+
176
+ **There is no text-only agent mode.** An agent is a voice conversation —
177
+ every pipeline agent must declare a real TTS provider.
178
+
179
+ **Silence nudge (pipeline only):** set `silenceTimeoutMs` to make the
180
+ assistant proactively take a turn after that much user silence (e.g.
181
+ "Are you still there?"). Customize the injected instruction with
182
+ `silencePrompt`. The nudge never appears as a user transcript, and the
183
+ assistant stops nudging after 3 consecutive unanswered nudges until the
184
+ user speaks again.
185
+
186
+ **Voice-UX tuning (pipeline only):** `minBargeInWords` controls how many
187
+ words of user speech interrupt the assistant mid-reply (default 2, so
188
+ one-word backchannels like "yeah" don't cut it off);
189
+ `interruptionMinDurationMs` adds an optional sustained-speech gate on top
190
+ (interim transcripts only — committed turns always land). End-of-turn
191
+ detection (how long a pause ends the user's turn) belongs to the STT
192
+ provider: `assemblyAI({ minTurnSilenceMs })` / `deepgram({ endpointing })`,
193
+ both defaulting to 1500 ms so mid-utterance pauses don't split a request.
194
+ `holdPhrase` is spoken when a turn opens with a tool call and no speech.
195
+ `falseInterruptionTimeoutMs` resumes an interrupted reply when a barge-in
196
+ turns out to be noise (no user turn commits within the window).
197
+
198
+ ## Providers
199
+
200
+ Provider SDKs are **optional peer dependencies**. Install only the SDKs
201
+ for the providers you actually use.
202
+
203
+ ### STT — `@alexkroman1/aai/stt`
204
+
205
+ | Factory | Default model | Env var |
206
+ | ------------- | ------------------------ | -------------------- |
207
+ | `assemblyAI` | `"universal-3-5-pro"` | `ASSEMBLYAI_API_KEY` |
208
+ | `deepgram` | `"nova-3"` | `DEEPGRAM_API_KEY` |
209
+ | `elevenlabs` | `"scribe_v2_realtime"` | `ELEVENLABS_API_KEY` |
210
+ | `soniox` | `"stt-rt-v3"` | `SONIOX_API_KEY` |
211
+
212
+ All STT factories accept `{ model?: string, ... }`. Bare calls
213
+ (`deepgram()`, `soniox()`, etc.) use the default model.
214
+
215
+ `assemblyAI` accepts an optional `region: "eu"` for EU data residency —
216
+ it routes streaming transcription to AssemblyAI's EU endpoints. EU-region
217
+ API keys require it; the US endpoints reject them. Example:
218
+ `assemblyAI({ model: "universal-3-5-pro", region: "eu" })`.
219
+
220
+ ### LLM — `@alexkroman1/aai/llm`
221
+
222
+ | Factory | SDK package | Env var |
223
+ | ----------- | --------------------- | -------------------------------- |
224
+ | `anthropic` | `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` |
225
+ | `openai` | `@ai-sdk/openai` | `OPENAI_API_KEY` |
226
+ | `google` | `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
227
+ | `mistral` | `@ai-sdk/mistral` | `MISTRAL_API_KEY` |
228
+ | `xai` | `@ai-sdk/xai` | `XAI_API_KEY` |
229
+ | `groq` | `@ai-sdk/groq` | `GROQ_API_KEY` |
230
+ | `openrouter`| `@ai-sdk/openai` | `OPENROUTER_API_KEY` |
231
+ | `gateway` | `ai` (built in) | `AI_GATEWAY_API_KEY` |
232
+ | `assemblyAI`| `@ai-sdk/openai` | `ASSEMBLYAI_API_KEY` |
233
+
234
+ LLM factories require `{ model: string }`. Example:
235
+ `anthropic({ model: "claude-haiku-4-5" })`.
236
+
237
+ `openrouter` routes through [OpenRouter](https://openrouter.ai) — an
238
+ OpenAI-compatible endpoint fronting hundreds of models addressed as
239
+ `"creator/model"`, e.g.
240
+ `openrouter({ model: "meta-llama/llama-3.3-70b-instruct" })`. It needs
241
+ no extra SDK install (it reuses the `@ai-sdk/openai` client).
242
+
243
+ `gateway` routes through the [Vercel AI
244
+ Gateway](https://vercel.com/docs/ai-gateway) — one endpoint fronting
245
+ hundreds of models addressed as `"creator/model"`, e.g.
246
+ `gateway({ model: "zai/glm-4.6" })`. It needs no extra SDK install
247
+ (the gateway client ships inside the `ai` package).
248
+
249
+ `assemblyAI` routes through the [AssemblyAI LLM
250
+ Gateway](https://www.assemblyai.com/docs/llm-gateway) — an
251
+ OpenAI-compatible endpoint fronting 25+ models (Claude, GPT, Gemini,
252
+ etc.) with the same API key as AssemblyAI STT. It accepts an optional
253
+ `region: "eu"` for EU data residency. It shares its name with the STT
254
+ factory, so alias one when using both:
255
+
256
+ ```ts
257
+ import { assemblyAI } from "@alexkroman1/aai/stt";
258
+ import { assemblyAI as assemblyAILlm } from "@alexkroman1/aai/llm";
259
+
260
+ export default agent({
261
+ name: "My Agent",
262
+ stt: assemblyAI({ model: "universal-3-5-pro" }),
263
+ llm: assemblyAILlm({ model: "claude-sonnet-4-6" }),
264
+ tts: cartesia(),
265
+ });
266
+ ```
267
+
268
+ An all-AssemblyAI pipeline — one provider, one key:
269
+
270
+ ```ts
271
+ import { agent } from "@alexkroman1/aai";
272
+ import { assemblyAI } from "@alexkroman1/aai/stt";
273
+ import { assemblyAI as assemblyAILlm } from "@alexkroman1/aai/llm";
274
+ import { assemblyAI as assemblyAITts } from "@alexkroman1/aai/tts";
275
+
276
+ export default agent({
277
+ name: "My Agent",
278
+ stt: assemblyAI({ model: "universal-3-5-pro" }),
279
+ llm: assemblyAILlm({ model: "gemini-2.5-flash-lite" }),
280
+ tts: assemblyAITts({ voice: "vera" }),
281
+ });
282
+ ```
283
+
284
+ ### TTS — `@alexkroman1/aai/tts`
285
+
286
+ | Factory | Default voice | Env var |
287
+ | ------------ | ---------------------------------------- | -------------------- |
288
+ | `assemblyAI` | `"vera"` | `ASSEMBLYAI_API_KEY` |
289
+ | `cartesia` | `"f786b574-daa5-4673-aa0c-cbe3e8534c02"` | `CARTESIA_API_KEY` |
290
+ | `rime` | `"cove"` (model `mistv2`) | `RIME_API_KEY` |
291
+
292
+ Bare calls (`assemblyAI()`, `cartesia()`, `rime()`) use the defaults.
293
+ Override with `{ voice, model, language }`.
294
+
295
+ **AssemblyAI TTS** shares `ASSEMBLYAI_API_KEY` with AssemblyAI STT and the
296
+ LLM Gateway, so an all-AssemblyAI pipeline needs exactly one secret. Each
297
+ voice speaks one language, and this is the whole catalog — **a voice not on
298
+ this list is rejected after the socket opens, which leaves the agent
299
+ connected, "ready", and permanently silent**, so pick one from here rather
300
+ than guessing a plausible name:
301
+
302
+ - **English, US accent**: `alba`, `anna`, `charles`, `eve`, `george`,
303
+ `jane`, `jean`, `mary`, `michael`
304
+ - **English, UK accent**: `paul`, `vera` (the default)
305
+ - **Native accent, code-switches with English**: `estelle` (fr),
306
+ `giovanni` (it), `juergen` (de), `lola` (es), `rafael` (pt)
307
+
308
+ There is no separate age/gender/style axis — match the persona by picking a
309
+ name and accent, and put the delivery in the system prompt instead.
310
+
311
+ Set
312
+ `language` only alongside a voice that speaks it, as an ISO 639-1 code —
313
+ `"en"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"es"` are the six the catalog
314
+ covers, and the SDK translates each to the full name the service wants.
315
+ Anything else fails at session start. Because the factory is named
316
+ `assemblyAI` in `/stt`, `/llm`, and `/tts`, alias on import.
317
+
318
+ **Rime quirk:** language uses ISO 639-3 three-letter codes (e.g. `"eng"`
319
+ not `"en"`).
320
+
321
+ Set provider keys the same way as any secret: `.env` for local dev,
322
+ `aai secret put` for production.
323
+
324
+ ## `tool()` API
325
+
326
+ ```ts
327
+ import { tool } from "@alexkroman1/aai";
328
+ import { z } from "zod";
329
+
330
+ const myTool = tool({
331
+ description: string; // shown to LLM — decides when to call
332
+ parameters?: z.ZodObject; // Zod schema (omit for no-arg tools)
333
+ execute(args, ctx): unknown; // sync or async
334
+ });
335
+ ```
336
+
337
+ `execute` may call `fetch` directly — tool code reaches external APIs the
338
+ same way in `aai dev` and deployed.
339
+
340
+ ### `ctx` (ToolContext)
341
+
342
+ ```ts
343
+ ctx.env: Readonly<Record<string, string>> // secrets from .env / aai secret put
344
+ ctx.state: S // per-session mutable state (agent's `state` factory)
345
+ ctx.db: Db // SQL database, needs storage enabled (see Database section)
346
+ ctx.messages: readonly Message[] // conversation history [{role, content}]
347
+ ctx.sessionId: string // unique session ID
348
+ ctx.send(event: string, data: unknown): void // push custom event to browser client
349
+ ctx.generate(opts): Promise<{ text, object? }> // one-shot LLM call (host-side)
350
+ ```
351
+
352
+ **Typing `ctx.state` is optional.** `ctx.state` is untyped by default, and
353
+ the project's tsconfig turns off `noImplicitAny`, so both of these compile
354
+ with no annotations and no errors:
355
+
356
+ ```ts
357
+ ctx.state.count++;
358
+ ctx.state.incidents.filter((i) => i.status === "open");
359
+ ```
360
+
361
+ Write the code first. Do NOT add type annotations defensively — almost
362
+ nothing requires them, and time spent on them is time not spent on the agent.
363
+
364
+ **The one exception, and it is not optional: annotate any variable you
365
+ declare empty.** With `noImplicitAny` off, TypeScript does not widen an empty
366
+ initializer from what you later assign, so `[]` stays `never[]` and `null`
367
+ stays `null` — forever, whether or not a callback is involved:
368
+
369
+ ```ts
370
+ const items = []; // never[] → items.push(x) is an error
371
+ let best = null; // null → best = {...} is an error
372
+ const [picks, set] = useState([]); // never[] in a client, same thing
373
+
374
+ const items: Pick[] = []; // ✅ annotate the DECLARATION
375
+ let best: Pick | null = null; // ✅
376
+ const [picks, set] = useState<Pick[]>([]); // ✅
377
+ ```
378
+
379
+ Annotating the *use* instead does not help — the declaration is still wrong,
380
+ so the next push reports the next line, and you can burn a whole session
381
+ fixing one call site at a time. This is the single most common way a
382
+ generated agent fails to build.
383
+
384
+ Declaring a state type is still worth it once the shape is settled, because
385
+ it turns a misspelled field into a compile error instead of `undefined` at
386
+ runtime:
387
+
388
+ ```ts
389
+ import { agent, tool } from "@alexkroman1/aai";
390
+ import type { ToolContext } from "@alexkroman1/aai"; // types need `import type`
391
+ import { z } from "zod";
392
+
393
+ type Incident = { id: string; status: "open" | "closed" };
394
+ type State = { incidents: Incident[] };
395
+
396
+ const listOpen = tool({
397
+ description: "List open incidents",
398
+ execute: (_args, ctx: ToolContext<State>) => {
399
+ // `i` infers as Incident, and `i.staus` would now be an error.
400
+ return ctx.state.incidents.filter((i) => i.status === "open");
401
+ },
402
+ });
403
+
404
+ export default agent({
405
+ name: "Dispatch",
406
+ state: (): State => ({ incidents: [] }),
407
+ tools: { listOpen },
408
+ });
409
+ ```
410
+
411
+ A tool annotated with a state shape the agent's factory doesn't produce is a
412
+ compile error, which is the point.
413
+
414
+ **`verbatimModuleSyntax` applies to every type you import** — `ToolContext`,
415
+ `ToolDef`, `Message`, provider types. A plain
416
+ `import { ToolContext }` fails; use `import type { ToolContext }`, or
417
+ `import { agent, type ToolContext }` to combine with value imports.
418
+
419
+ `ctx.generate({ prompt, system?, llm?, schema?, temperature?, maxOutputTokens? })`
420
+ runs one LLM generation on the host. It defaults to the agent's pipeline
421
+ `llm`; pass an `llm` descriptor (from `@alexkroman1/aai/llm`) to use another
422
+ provider whose API key is in the agent's secrets — that's also how S2S
423
+ agents use it. `schema` must be a **plain JSON Schema object** (use
424
+ `z.toJSONSchema(...)`), never a Zod schema.
425
+
426
+ ### Inline tool example
427
+
428
+ ```ts
429
+ import { agent, tool } from "@alexkroman1/aai";
430
+ import { z } from "zod";
431
+
432
+ export default agent({
433
+ name: "Weather Agent",
434
+ tools: {
435
+ get_weather: tool({
436
+ description: "Get current weather for a city",
437
+ parameters: z.object({
438
+ city: z.string().describe("City name"),
439
+ }),
440
+ async execute({ city }, ctx) {
441
+ const resp = await fetch(
442
+ `https://api.example.com/weather?q=${city}&key=${ctx.env.WEATHER_KEY}`,
443
+ );
444
+ return resp.json();
445
+ },
446
+ }),
447
+ },
448
+ });
449
+ ```
450
+
451
+ **Calling the network builtins from your own tool code.** `web_search`,
452
+ `visit_webpage` and `fetch_json` are declared to the MODEL — the LLM calls
453
+ them, and they are not on `ctx`. When your own `execute` needs one, import
454
+ it:
455
+
456
+ ```ts
457
+ import { fetchJson, visitWebpage, webSearch } from "@alexkroman1/aai/tools";
458
+
459
+ execute: async ({ city }) => await fetchJson(`https://api.example.com/${city}`),
460
+ // Reading fields off the result needs no cast. Pass a shape when you want
461
+ // it checked: `await fetchJson<Forecast>(url)`.
462
+ ```
463
+
464
+ Same implementations the builtins use, so you get URL screening, credential-
465
+ header stripping, size caps and timeouts rather than a bare `fetch`. Plain
466
+ `fetch` still works when you want none of that. There is no callable
467
+ `run_code`: it exists to run code the model wrote, and tool code that wants
468
+ to compute something can just compute it.
469
+
470
+ **But prefer the BUILTIN when the model should decide.** These two are not
471
+ interchangeable:
472
+
473
+ - If the agent's job is to search or browse — a research assistant, anything
474
+ that follows a link the user mentions — declare
475
+ `builtinTools: ["web_search", "visit_webpage"]` and let the model call
476
+ them. It can then search several times with different queries, or read one
477
+ specific page, as the conversation needs.
478
+ - Import from `/tools` when YOUR tool's own logic needs a fetch: a currency
479
+ tool hitting one known API, a price checker with a fixed endpoint.
480
+
481
+ Wrapping `webSearch` in a single custom tool is the mistake to avoid — it
482
+ replaces "the model searches as needed" with one fixed query-and-summarize
483
+ pipeline, and no amount of prompting gets the flexibility back.
484
+
485
+ **`parameters` is a Zod object, or absent.** The field itself is optional,
486
+ but its VALUE must be a plain `z.object(...)` — so all of these are type
487
+ errors:
488
+
489
+ ```ts
490
+ parameters: z.undefined(), // ✗ ZodUndefined
491
+ parameters: z.void(), // ✗
492
+ parameters: z.object({ q: z.string() }).optional(), // ✗ ZodOptional
493
+ ```
494
+
495
+ For a tool with no arguments write `tool({ description, execute })`, or
496
+ `parameters: z.object({})` if you prefer it explicit. To make an individual
497
+ argument optional, put `.optional()` on the FIELD, never on the object:
498
+ `z.object({ notes: z.string().optional() })`.
499
+
500
+ **Do not annotate `execute`'s return type.** Nothing needs it — the result
501
+ is serialized to the model either way — and it reliably breaks the moment
502
+ the tool also returns an error, because `Promise<DrugInfo>` does not accept
503
+ `{ error: "not found" }`. Every such annotation eventually costs a build
504
+ round to widen into a union. Let it infer.
505
+
506
+ ### Separate file pattern
507
+
508
+ For complex tools — `tools/` is a convention, any import path works:
509
+
510
+ ```ts
511
+ // tools/roll_dice.ts
512
+ import { tool } from "@alexkroman1/aai";
513
+ import { z } from "zod";
514
+
515
+ export const rollDice = tool({
516
+ description: "Roll dice",
517
+ parameters: z.object({ sides: z.number() }),
518
+ execute({ sides }) {
519
+ return Math.floor(Math.random() * sides) + 1;
520
+ },
521
+ });
522
+ ```
523
+
524
+ ```ts
525
+ // agent.ts
526
+ import { agent } from "@alexkroman1/aai";
527
+ import { rollDice } from "./tools/roll_dice.ts";
528
+
529
+ export default agent({
530
+ name: "Dice Agent",
531
+ tools: { roll_dice: rollDice },
532
+ });
533
+ ```
534
+
535
+ ## Built-in tools
536
+
537
+ Enable via `builtinTools` in `agent()`. When `builtinTools` is omitted, the
538
+ cognitive defaults (`think`, `remember`, `recall`, `calculate`) are enabled;
539
+ set `builtinTools` explicitly (including `[]`) to override.
540
+
541
+ | Tool | Description | Params |
542
+ | --- | --- | --- |
543
+ | `web_search` | Search the web (DuckDuckGo) — no API key required | `query`, `max_results?` (default 5) |
544
+ | `visit_webpage` | Fetch URL to plain text | `url` |
545
+ | `get_page_design` | Fetch URL's raw HTML + CSS (style blocks and linked stylesheets) to study/mimic a site's design | `url` |
546
+ | `fetch_json` | HTTP GET a JSON API | `url`, `headers?` |
547
+ | `run_code` | Execute JS in the agent's sandbox — same authority as the agent's own tool code, output is what it logs (5s timeout) | `code` |
548
+ | `think` | Private reasoning scratchpad, no side effects (on by default) | `thought` |
549
+ | `remember` | Save a confirmed fact to session notes (on by default) | `key`, `value` |
550
+ | `recall` | Read session notes saved with `remember` (on by default) | `key?` |
551
+ | `calculate` | Safe arithmetic evaluator, no code execution (on by default) | `expression` |
552
+
553
+ **Every builtin in this table is a tool the MODEL calls — not a function
554
+ your code can call.** Listing one in `builtinTools` adds it to the model's
555
+ tool set; it does not import anything into `agent.ts`. There is no
556
+ `fetch_json()` you can call from a tool's `execute`.
557
+
558
+ So the two ways to reach an API are genuinely different designs, and both
559
+ are valid:
560
+
561
+ - **Declare the builtin** (`builtinTools: ["fetch_json"]`) when the MODEL
562
+ should decide the URL and read the JSON — general lookups you cannot
563
+ enumerate ahead of time.
564
+ - **Write your own tool** whose `execute` calls `fetch` when YOU own the
565
+ URL and the shape — a specific endpoint, auth, or a response you want to
566
+ reshape before the model sees it.
567
+
568
+ The network builtins take model-controlled URLs, so they are SSRF-screened
569
+ when the runtime is not inside a container (private/loopback blocked). Your
570
+ own tool code has open egress either way.
571
+
572
+ ## Calling an external API from your own tool code
573
+
574
+ `fetch` inside a tool's `execute` works directly — no declaration needed,
575
+ identical under `aai dev` and deployed. This is the right choice when your
576
+ code owns the URL.
577
+
578
+ Reaching for the `fetch_json` builtin instead is a different design, not a
579
+ shortcut for the same one: it hands URL choice to the model. You cannot
580
+ call it from `execute` — see the builtin table above.
581
+
582
+ ## Database API — `ctx.db`
583
+
584
+ Persistent SQL storage scoped per app, backed by the app's own Postgres
585
+ schema. Access via `ctx.db`:
586
+
587
+ ```ts
588
+ ctx.db.query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
589
+ ```
590
+
591
+ One parameterized statement per call, `$1, $2…` placeholders — never
592
+ interpolate values into the SQL string. The rows come back as plain objects;
593
+ `jsonb` columns are returned already parsed (no `JSON.parse` needed).
594
+ A query returning more than 1000 rows throws — always bound reads with
595
+ `LIMIT` (paginate with `LIMIT`/`OFFSET`).
596
+
597
+ **Storage must be enabled** or accessing `ctx.db` throws:
598
+
599
+ - CLI: `aai storage enable`
600
+ - Studio: the Storage toggle
601
+ - `aai dev`: set `DATABASE_URL` in the project `.env`
602
+
603
+ Create tables lazily from tool code and upsert with `on conflict`:
604
+
605
+ ```ts
606
+ await ctx.db.query(`create table if not exists app_state (
607
+ key text primary key,
608
+ value jsonb not null,
609
+ updated_at timestamptz not null default now()
610
+ )`);
611
+ await ctx.db.query(
612
+ "insert into app_state (key, value, updated_at) values ($1, $2::jsonb, now()) " +
613
+ "on conflict (key) do update set value = excluded.value, updated_at = now()",
614
+ ["user:123", JSON.stringify({ name: "Alex" })],
615
+ );
616
+ const rows = await ctx.db.query<{ value: { name: string } }>(
617
+ "select value from app_state where key = $1",
618
+ ["user:123"],
619
+ );
620
+ ```
621
+
622
+ Use `ctx.db` for data that must outlive the session (saves, filed records,
623
+ user profiles). For scratch that only the current session needs, prefer
624
+ `ctx.state` (per-session mutable state — no storage required); the
625
+ `remember`/`recall` builtins likewise remain for session-scoped notes the
626
+ LLM manages itself.
627
+
628
+ ## Custom UI — `client()`
629
+
630
+ File: `client.tsx` alongside `agent.ts`. Uses **React** (not Preact).
631
+ Always import `"@alexkroman1/aai-ui/styles.css"` first.
632
+
633
+ ### Tier 1 — config only (default UI)
634
+
635
+ ```tsx
636
+ import "@alexkroman1/aai-ui/styles.css";
637
+ import { client } from "@alexkroman1/aai-ui";
638
+
639
+ client({ name: "My Agent" });
640
+ ```
641
+
642
+ ### Tier 1 with sidebar
643
+
644
+ ```tsx
645
+ import "@alexkroman1/aai-ui/styles.css";
646
+ import { client, useEvent } from "@alexkroman1/aai-ui";
647
+ import { useState } from "react";
648
+
649
+ function Sidebar() {
650
+ const [items, setItems] = useState<string[]>([]);
651
+ useEvent<{ item: string }>("new_item", (data) => {
652
+ setItems((prev) => [...prev, data.item]);
653
+ });
654
+ return (
655
+ <div className="p-4">
656
+ {items.map((it, i) => <p key={i}>{it}</p>)}
657
+ </div>
658
+ );
659
+ }
660
+
661
+ client({ name: "My Agent", sidebar: Sidebar });
662
+ ```
663
+
664
+ ### Tier 2 — full custom component
665
+
666
+ ```tsx
667
+ import "@alexkroman1/aai-ui/styles.css";
668
+ import { client, useSession } from "@alexkroman1/aai-ui";
669
+
670
+ function MyApp() {
671
+ const { messages, userTranscript, started, running, start, toggle, reset } =
672
+ useSession();
673
+ return (
674
+ <div>
675
+ {messages.map((m, i) => <p key={i}>{m.content}</p>)}
676
+ {userTranscript != null && <p>{userTranscript || "..."}</p>}
677
+ {!started ? (
678
+ <button onClick={start}>Start</button>
679
+ ) : (
680
+ <>
681
+ <button onClick={toggle}>{running ? "Stop" : "Resume"}</button>
682
+ <button onClick={reset}>Reset</button>
683
+ </>
684
+ )}
685
+ </div>
686
+ );
687
+ }
688
+
689
+ client({ component: MyApp });
690
+ ```
691
+
692
+ ### `client()` config
693
+
694
+ | Field | Type | Default | Description |
695
+ | --- | --- | --- | --- |
696
+ | `name` | `string` | — | Header/start screen title (tier 1) |
697
+ | `component` | `ComponentType` | — | Custom root component (tier 2) |
698
+ | `sidebar` | `ComponentType` | — | Sidebar alongside default chat (tier 1) |
699
+ | `sidebarWidth` | `string` | `"18rem"` | CSS width of sidebar |
700
+ | `theme` | `ClientTheme` | — | `{ bg, primary, text, surface, border }` |
701
+ | `target` | `string \| HTMLElement` | `"#app"` | Mount target |
702
+ | `tools` | `ToolDisplayConfig` | — | Icon/label overrides per tool name |
703
+
704
+ **The two tiers are mostly exclusive.** `sidebar`, `sidebarWidth`, and
705
+ `tools` configure the default shell, so passing any of them alongside
706
+ `component` is a type error. `name` is the exception — it is allowed with a
707
+ custom component and becomes the page title, since there is no shell header
708
+ to put it in.
709
+
710
+ ### `useSession()` return type
711
+
712
+ | Field | Type | Description |
713
+ | --- | --- | --- |
714
+ | `state` | `AgentState` | `"disconnected"` `"connecting"` `"ready"` `"listening"` `"thinking"` `"speaking"` `"error"` |
715
+ | `messages` | `ChatMessage[]` | `{ role, content }` |
716
+ | `toolCalls` | `ToolCallInfo[]` | `{ callId, name, args, status, result? }` |
717
+ | `customEvents` | `CustomEvent[]` | `{ id, event, data }` from `ctx.send()` |
718
+ | `userTranscript` | `string \| null` | `null` = not speaking, `""` = speech detected, string = text |
719
+ | `agentTranscript` | `string \| null` | `null` = not speaking, string = streaming response |
720
+ | `error` | `SessionError \| null` | `{ code, message }` |
721
+ | `started` | `boolean` | Whether session started |
722
+ | `running` | `boolean` | Whether session active |
723
+
724
+ Methods: `start()`, `toggle()`, `reset()`, `cancel()`, `disconnect()`,
725
+ `resetState()`.
726
+
727
+ ## UI hooks
728
+
729
+ **`useToolResult`** — fires once per completed tool call (deduplicates by
730
+ callId):
731
+
732
+ ```ts
733
+ useToolResult("tool_name", (result, toolCall) => { ... }) // one tool
734
+ useToolResult((toolName, result, toolCall) => { ... }) // all tools
735
+ useToolResult<ResultType>("tool_name", (result) => { ... }) // typed (optional)
736
+ ```
737
+
738
+ `result` is the tool's return value, already JSON-parsed and untyped — read
739
+ fields off it directly (`result.price`). The type parameter is optional; add
740
+ it only when you want the shape checked.
741
+
742
+ **There is no global `JSX` namespace.** React 19 removed it, so
743
+ `JSX.Element` is `Cannot find namespace 'JSX'` (`TS2503`). Type a component's
744
+ return as `ReactNode` — `import type { ReactNode } from "react"` — which is
745
+ also what you want for anything that can be a string, an array, or null.
746
+
747
+ **`useAgentState`** — the agent's session state, pushed automatically:
748
+
749
+ ```ts
750
+ // agent.ts
751
+ export default agent({
752
+ state: () => ({ cart: [] as Item[], staffPin: "" }),
753
+ syncState: (s) => ({ cart: s.cart }), // staffPin never leaves the server
754
+ tools: { ... },
755
+ });
756
+
757
+ // client.tsx
758
+ const view = useAgentState<{ cart: Item[] }>(); // null until the first push
759
+ return <Cart items={view?.cart ?? []} />;
760
+ ```
761
+
762
+ **Reach for this before wiring `useToolResult` into `useState`.** Without
763
+ it the pattern is: return a cart snapshot from every tool, declare a type
764
+ describing what those tools return, and mirror it into `useState` — three
765
+ things to keep in step, and the usual source of drift when you add a tool
766
+ and forget to return the snapshot from it.
767
+
768
+ `syncState` is a projection, not a flag, because state often holds things
769
+ that should not reach a browser (keys, PINs, scratch) or cannot be
770
+ serialized. Whatever it returns is exactly what the client receives. It runs
771
+ after every tool call and is sent only when the result changed.
772
+
773
+ **`useEvent`** — fires for custom events from `ctx.send()`:
774
+
775
+ ```ts
776
+ useEvent<DataType>("event_name", (data) => { ... })
777
+ ```
778
+
779
+ Server: `ctx.send("order", { total: "$14.99" })` —
780
+ Client: `useEvent("order", (data) => ...)`.
781
+
782
+ **`useTheme`** — returns `{ bg, primary, text, surface, border }`.
783
+
784
+ **`useToolCallStart`** — fires when a tool call begins (status `"pending"`).
785
+
786
+ **Anti-pattern:** Do NOT use `useEffect` + `toolCalls` to build derived
787
+ state. Use `useToolResult` — it deduplicates. The `useEffect` pattern
788
+ re-processes every tool call on every render, causing duplicates.
789
+
790
+ ## Components
791
+
792
+ Available from `@alexkroman1/aai-ui`:
793
+
794
+ | Component | Props | Description |
795
+ | --- | --- | --- |
796
+ | `StartScreen` | `children` (**required**)`, icon?, title?, subtitle?, buttonText?` | **Wrapper, never self-closing.** Shows the start card, then renders `children` — your whole app — once the session starts |
797
+ | `ChatView` | `icon?, title?` | Chat interface (header + messages + controls) |
798
+ | `SidebarLayout` | `sidebar, children, sidebarWidth?, sidebarPosition?` | Two-column layout |
799
+ | `MessageList` | — | Messages with auto-scroll, tool calls, transcript |
800
+ | `Controls` | — | Stop/Resume + New Conversation buttons |
801
+ | `Button` | — | Styled button |
802
+
803
+ The usual shape — note `StartScreen` **wraps** the app rather than sitting
804
+ beside it; writing `<StartScreen ... />` self-closing is a `TS2741:
805
+ Property 'children' is missing` build error:
806
+
807
+ ```tsx
808
+ import "@alexkroman1/aai-ui/styles.css";
809
+ import { ChatView, client, StartScreen } from "@alexkroman1/aai-ui";
810
+
811
+ function PizzaApp() {
812
+ return (
813
+ <StartScreen title="Pizza Palace" subtitle="Voice-powered ordering">
814
+ <ChatView />
815
+ </StartScreen>
816
+ );
817
+ }
818
+
819
+ client({ component: PizzaApp });
820
+ ```
821
+
822
+ ## Styling
823
+
824
+ - **Tailwind CSS v4** — compiled at bundle time, configured via CSS.
825
+ Do NOT create `tailwind.config.js` — it will be ignored.
826
+ - Use Tailwind classes for layout, `useTheme()` for dynamic colors.
827
+ - Set theme: `client({ theme: { bg, primary, text, surface, border } })`.
828
+ - Override CSS custom properties for extra tokens:
829
+ `--color-aai-*`, `--radius-aai`, `--font-aai`.
830
+ - Always import `"@alexkroman1/aai-ui/styles.css"` at the top of `client.tsx`.
831
+
832
+ ### Design guidelines
833
+
834
+ A custom UI should look deliberate, not like boilerplate. When building or
835
+ restyling a `client.tsx`:
836
+
837
+ - **Color:** pick one primary brand color, 2-3 neutrals (white/grays/black
838
+ variants), and at most 1-2 accents — 3-5 colors total. Avoid gradients
839
+ unless asked. If you override an element's background color, also set its
840
+ text color so contrast holds.
841
+ - **Typography:** at most 2 font families — one for headings, one for body.
842
+ Body text 14px or larger with a relaxed line height (`leading-relaxed`).
843
+ - **Layout:** design mobile-first, then enhance with responsive prefixes
844
+ (`md:`, `lg:`). Prefer flexbox (`flex items-center justify-between`);
845
+ use grid only for genuinely two-dimensional layouts; avoid absolute
846
+ positioning unless nothing else works.
847
+ - **Tailwind:** stay on the spacing scale (`p-4`, never `p-[16px]`), use
848
+ `gap-*` between siblings rather than per-child margins, and wrap headings
849
+ and key copy in `text-balance` or `text-pretty`.
850
+ - **Accessibility:** semantic elements (`main`, `header`, `button`), alt
851
+ text on meaningful images, `sr-only` labels on icon-only buttons.
852
+ - **No filler:** no emojis as icons, no decorative gradient blobs or
853
+ abstract placeholder shapes, no lorem-ipsum-looking content.
854
+
855
+ ## Secrets
856
+
857
+ Never hardcode secrets in agent code.
858
+
859
+ - **Local dev:** `.env` in project root. Only declared keys available via
860
+ `ctx.env`.
861
+ - **Production:** `npx @alexkroman1/aai-cli secret put NAME`
862
+ - **Access:** `ctx.env.MY_KEY` in tool execute functions.
863
+ - **AssemblyAI key:** CLI prompts on first use, stores globally. No `.env`
864
+ entry needed. For CI, set `ASSEMBLYAI_API_KEY` env var.
865
+
866
+ ## Voice rules for systemPrompt
867
+
868
+ - Short, punchy sentences — optimize for speech, not text
869
+ - Never mention "search results" or "sources" — speak as if knowledge is
870
+ your own
871
+ - No visual formatting (bullets, bold) — use "First", "Next", "Finally"
872
+ - Lead with the most important information
873
+ - Keep answers to 1-3 sentences
874
+ - No exclamation points — calm, conversational tone
875
+ - No hedging ("It seems that", "I believe")
876
+ - Define personality, tone, and specialty
877
+ - Include when and how to use each tool
878
+
879
+ Patterns by agent type:
880
+
881
+ - **Code execution:** "You MUST use run_code for ANY math, counting, or
882
+ data processing. NEVER do mental math."
883
+ - **Research:** "Search first. Never guess or rely on memory for factual
884
+ questions."
885
+ - **FAQ/support:** "Base answers strictly on your knowledge — don't guess."
886
+ - **Game/interactive:** "You ARE the game. Keep descriptions to 2-4
887
+ sentences. No visual formatting."
888
+
889
+ ## Gotchas
890
+
891
+ Common mistakes when working in aai projects:
892
+
893
+ - **Tool execute must return a value.** A missing return = `undefined` in
894
+ LLM context = the model thinks the tool failed.
895
+ - **Filter large API responses before returning them from tools.** Return
896
+ values are injected into LLM context. Truncate, summarize, or extract
897
+ only what the model needs.
898
+ - **Pipeline mode requires all three of `stt` / `llm` / `tts`.** Partial
899
+ configs are rejected at parse time. Use S2S (omit all three) if you
900
+ don't need provider control.
901
+ - **Never hardcode secrets.** Use `ctx.env.MY_KEY`. `.env` for local dev,
902
+ `aai secret put` for production.
903
+ - **Don't use `useEffect` + `toolCalls` to derive state.** Use
904
+ `useToolResult` — it deduplicates by callId. The useEffect pattern
905
+ re-fires on every render and produces duplicates.
906
+ - **Always import `"@alexkroman1/aai-ui/styles.css"` first** in
907
+ `client.tsx`. Missing this = unstyled UI.
908
+ - **Don't create `tailwind.config.js`.** Tailwind v4 is configured via
909
+ CSS; the config file is ignored.
910
+ - **Voice prompts ≠ chat prompts.** No bullets, no bold, no exclamation
911
+ points. See "Voice rules" above.
912
+ - **`fetch` to private IPs is blocked** (SSRF protection). Use public URLs.
913
+ - **`run_code` only executes on the deployed platform.** It runs inside the
914
+ platform's Modal/Deno sandbox; the self-hosted `aai dev` server has no
915
+ sandbox, so there `run_code` refuses with an error result. Deploy to test
916
+ it end-to-end, or use the `calculate` builtin for simple arithmetic in dev.
917
+ - **`ctx.db` throws until storage is enabled.** Enable it with
918
+ `aai storage enable` (CLI), the Storage toggle (studio), or `DATABASE_URL`
919
+ in `.env` (`aai dev`) before shipping tools that persist data.
920
+ - **The database is per-app.** Rows are shared by every session of one
921
+ deployment — key them yourself if sessions must not see each other's data
922
+ (or keep session-scoped data in `ctx.state`).
923
+ - **Rime language codes are ISO 639-3** (3-letter, e.g. `"eng"`), not
924
+ ISO 639-1 (`"en"`).
925
+
926
+ ## Constraints
927
+
928
+ - Tool `execute` return values go into LLM context — filter and truncate
929
+ large API responses
930
+ - `fetch` is proxied through the host; private/internal IPs are blocked
931
+ (SSRF protection)
932
+ - Agent code runs in a sandboxed worker — use `fetch` for HTTP, `ctx.env`
933
+ for secrets
934
+ - Tool execution timeout: 30 seconds
935
+ - `maxSteps` limits tool calls per turn (default 10) — increase for
936
+ multi-tool workflows
937
+ - Tool returns `undefined` if execute function has no return statement —
938
+ always return a value