@alexkroman1/aai-cli 6.8.0 → 6.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli.mjs +84 -56
  2. package/dist/logs-BWVGSjLR.mjs +74 -0
  3. package/dist/logs.d.ts +63 -0
  4. package/dist/scaffold/CLAUDE.md +30 -10
  5. package/dist/scaffold/package.json +3 -3
  6. package/dist/templates/dispatch-center/agent.ts +2 -2
  7. package/dist/templates/dispatch-center/client.tsx +3 -7
  8. package/dist/templates/dispatch-center/shared.ts +3 -0
  9. package/dist/templates/link-digest/agent.ts +5 -2
  10. package/dist/templates/pizza-ordering/agent.test.ts +10 -3
  11. package/dist/templates/pizza-ordering/agent.ts +2 -2
  12. package/dist/templates/pizza-ordering/client.tsx +2 -7
  13. package/dist/templates/pizza-ordering/shared.ts +3 -0
  14. package/dist/templates/plan-and-execute/agent.test.ts +4 -3
  15. package/dist/templates/plan-and-execute/agent.ts +2 -2
  16. package/dist/templates/plan-and-execute/client.tsx +2 -5
  17. package/dist/templates/plan-and-execute/shared.ts +3 -0
  18. package/dist/templates/recap-workflow/agent.ts +5 -2
  19. package/dist/templates/research-workflow/agent.ts +5 -2
  20. package/dist/templates/retail/client.tsx +10 -4
  21. package/dist/templates/solo-rpg/agent.ts +2 -2
  22. package/dist/templates/solo-rpg/client.tsx +5 -14
  23. package/dist/templates/solo-rpg/shared.ts +7 -0
  24. package/dist/templates/support-line/agent.test.ts +4 -3
  25. package/dist/templates/support-line/agent.ts +2 -2
  26. package/dist/templates/support-line/client.tsx +2 -5
  27. package/dist/templates/support-line/shared.ts +3 -0
  28. package/dist/templates/transcription-workflow/agent.ts +4 -2
  29. package/dist/templates/transcription-workflow/client.tsx +0 -5
  30. package/dist/templates/travel-concierge/agent.test.ts +4 -3
  31. package/dist/templates/travel-concierge/agent.ts +2 -2
  32. package/dist/templates/travel-concierge/client.tsx +2 -6
  33. package/dist/templates/travel-concierge/shared.ts +3 -0
  34. package/package.json +3 -3
  35. package/dist/templates/transcription-workflow/api-help.tsx +0 -214
package/dist/cli.mjs CHANGED
@@ -760,68 +760,95 @@ const storageDir = {
760
760
  description: "Project directory",
761
761
  required: false
762
762
  };
763
+ const storageStatus = defineExec({
764
+ meta: {
765
+ name: "status",
766
+ description: "Show whether storage is enabled"
767
+ },
768
+ args: {
769
+ dir: storageDir,
770
+ server: sharedArgs.server,
771
+ json: sharedArgs.json
772
+ },
773
+ cwd: "any",
774
+ async run({ args, cwd }) {
775
+ const { executeStorageStatus } = await import("./storage-DzRZ-eCw.mjs");
776
+ return executeStorageStatus(resolveStorageCwd(cwd, args.dir), args.server);
777
+ }
778
+ });
779
+ const storageEnable = defineExec({
780
+ meta: {
781
+ name: "enable",
782
+ description: "Enable the agent's app database"
783
+ },
784
+ args: {
785
+ dir: storageDir,
786
+ server: sharedArgs.server,
787
+ json: sharedArgs.json
788
+ },
789
+ cwd: "any",
790
+ async run({ args, cwd }) {
791
+ const { executeStorageEnable } = await import("./storage-DzRZ-eCw.mjs");
792
+ return executeStorageEnable(resolveStorageCwd(cwd, args.dir), args.server);
793
+ }
794
+ });
795
+ const storageDisable = defineExec({
796
+ meta: {
797
+ name: "disable",
798
+ description: "Disable storage and DROP the database schema with all its data"
799
+ },
800
+ args: {
801
+ dir: storageDir,
802
+ force: {
803
+ type: "boolean",
804
+ alias: "f",
805
+ description: "Skip confirmation prompt"
806
+ },
807
+ server: sharedArgs.server,
808
+ json: sharedArgs.json
809
+ },
810
+ cwd: "any",
811
+ async run({ args, cwd }) {
812
+ const { executeStorageDisable } = await import("./storage-DzRZ-eCw.mjs");
813
+ return executeStorageDisable(resolveStorageCwd(cwd, args.dir), {
814
+ server: args.server,
815
+ force: args.force
816
+ });
817
+ }
818
+ });
819
+ const logs = defineExec({
820
+ meta: {
821
+ name: "logs",
822
+ description: "Show what the deployed agent has printed"
823
+ },
824
+ args: {
825
+ dir: storageDir,
826
+ follow: {
827
+ type: "boolean",
828
+ alias: "f",
829
+ description: "Keep printing new output"
830
+ },
831
+ server: sharedArgs.server,
832
+ json: sharedArgs.json
833
+ },
834
+ cwd: "any",
835
+ async run({ args, cwd }) {
836
+ const { executeLogs } = await import("./logs-BWVGSjLR.mjs");
837
+ return executeLogs(resolveStorageCwd(cwd, args.dir), {
838
+ server: args.server,
839
+ follow: args.follow
840
+ });
841
+ }
842
+ });
763
843
  const storage = defineCommand({
764
844
  meta: {
765
845
  name: "storage",
766
846
  description: "Manage the agent's app database"
767
847
  },
768
848
  subCommands: {
769
- status: defineExec({
770
- meta: {
771
- name: "status",
772
- description: "Show whether storage is enabled"
773
- },
774
- args: {
775
- dir: storageDir,
776
- server: sharedArgs.server,
777
- json: sharedArgs.json
778
- },
779
- cwd: "any",
780
- async run({ args, cwd }) {
781
- const { executeStorageStatus } = await import("./storage-DzRZ-eCw.mjs");
782
- return executeStorageStatus(resolveStorageCwd(cwd, args.dir), args.server);
783
- }
784
- }),
785
- enable: defineExec({
786
- meta: {
787
- name: "enable",
788
- description: "Enable the agent's app database"
789
- },
790
- args: {
791
- dir: storageDir,
792
- server: sharedArgs.server,
793
- json: sharedArgs.json
794
- },
795
- cwd: "any",
796
- async run({ args, cwd }) {
797
- const { executeStorageEnable } = await import("./storage-DzRZ-eCw.mjs");
798
- return executeStorageEnable(resolveStorageCwd(cwd, args.dir), args.server);
799
- }
800
- }),
801
- disable: defineExec({
802
- meta: {
803
- name: "disable",
804
- description: "Disable storage and DROP the database schema with all its data"
805
- },
806
- args: {
807
- dir: storageDir,
808
- force: {
809
- type: "boolean",
810
- alias: "f",
811
- description: "Skip confirmation prompt"
812
- },
813
- server: sharedArgs.server,
814
- json: sharedArgs.json
815
- },
816
- cwd: "any",
817
- async run({ args, cwd }) {
818
- const { executeStorageDisable } = await import("./storage-DzRZ-eCw.mjs");
819
- return executeStorageDisable(resolveStorageCwd(cwd, args.dir), {
820
- server: args.server,
821
- force: args.force
822
- });
823
- }
824
- })
849
+ status: storageStatus,
850
+ enable: storageEnable,
851
+ disable: storageDisable
825
852
  }
826
853
  });
827
854
  const mainCommand = defineCommand({
@@ -854,6 +881,7 @@ const mainCommand = defineCommand({
854
881
  }
855
882
  }),
856
883
  secret,
884
+ logs,
857
885
  storage,
858
886
  workflow,
859
887
  templates: defineExec({
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { a as ok } from "./_output-CKkmWs7i.mjs";
3
+ import { n as log } from "./_ui-u7T4YooX.mjs";
4
+ import { f as checkedResponse } from "./_studio-U3jhKlA3.mjs";
5
+ import { n as slugRequest } from "./_slug-api-BxrsJXVI.mjs";
6
+ import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
7
+ import { sleep } from "@alexkroman1/aai/internal";
8
+ function isLogsPage(value) {
9
+ return isRecord(value) && Array.isArray(value.lines) && typeof value.cursor === "number" && typeof value.running === "boolean";
10
+ }
11
+ /** `HH:MM:SS.mmm`, local — the same shape the studio pane prints. */
12
+ function formatTime(at) {
13
+ const d = new Date(at);
14
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
15
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
16
+ }
17
+ /** One rendered line: time, then the text. `stderr` is called out by name. */
18
+ function formatLine(line) {
19
+ const mark = line.stream === "stderr" ? " ERR" : "";
20
+ return `${formatTime(line.at)}${mark} ${line.text}`;
21
+ }
22
+ async function readPage(cwd, after, server) {
23
+ const { data, slug } = await slugRequest(cwd, `/logs?after=${after}`, { action: "logs" }, server);
24
+ return {
25
+ page: checkedResponse(data, isLogsPage, `the logs route for ${slug}`),
26
+ slug
27
+ };
28
+ }
29
+ /**
30
+ * Print the agent's buffered output, once or continuously.
31
+ *
32
+ * The `--json` shape reports the LINE COUNT rather than the lines: a follow
33
+ * loop has no final count to report, and a one-shot read that dumped every line
34
+ * into a result object would duplicate what it just printed.
35
+ */
36
+ async function executeLogs(cwd, opts = {}) {
37
+ const { page, slug } = await readPage(cwd, -1, opts.server);
38
+ let cursor = page.cursor;
39
+ let printed = printPage(page);
40
+ if (!opts.follow) {
41
+ if (printed === 0) log.info(page.running ? `${slug} is running and has printed nothing yet.` : `${slug} isn't running. Start a session, or send it a request, and its output shows up here.`);
42
+ log.info("Recent output only — an agent's log lives in its sandbox and goes when it does.");
43
+ return ok({
44
+ slug,
45
+ lines: printed,
46
+ running: page.running
47
+ });
48
+ }
49
+ log.info(`Following ${slug}. Ctrl-C to stop.`);
50
+ const pollMs = opts.pollMs ?? 1e3;
51
+ let running = page.running;
52
+ while (!opts.signal?.aborted) {
53
+ await sleep(pollMs, omitUndefined({ signal: opts.signal }));
54
+ if (opts.signal?.aborted) break;
55
+ const next = await readPage(cwd, cursor, opts.server).catch(() => void 0);
56
+ if (!next) continue;
57
+ cursor = next.page.cursor;
58
+ running = next.page.running;
59
+ printed += printPage(next.page);
60
+ }
61
+ return ok({
62
+ slug,
63
+ lines: printed,
64
+ running
65
+ });
66
+ }
67
+ /** Write a page's lines (and any gap) to the log. Returns how many lines. */
68
+ function printPage(page) {
69
+ if (page.dropped > 0) log.warn(`… ${page.dropped} earlier line(s) dropped — the agent printed faster than this read`);
70
+ for (const line of page.lines) log.message(formatLine(line));
71
+ return page.lines.length;
72
+ }
73
+ //#endregion
74
+ export { executeLogs };
package/dist/logs.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `aai logs` — what the deployed agent has printed.
3
+ *
4
+ * The terminal half of the same read the studio's Logs pane makes
5
+ * (`GET /:slug/logs`, aai-server/agent-logs.ts): a bounded ring the GUEST holds,
6
+ * paged by cursor. Two things follow from that and are what this command has to
7
+ * be honest about, because neither is visible in a wall of lines:
8
+ *
9
+ * - **The ring dies with the sandbox.** This is "what my agent printed
10
+ * recently", never "what it printed last Tuesday", and the footer says so on
11
+ * a one-shot read rather than leaving the absence to be inferred.
12
+ * - **`running` is not `lines.length`.** An agent that is up and quiet and one
13
+ * that is not up at all both print nothing, and they want opposite things
14
+ * from the reader. The one-shot read reports which it was.
15
+ *
16
+ * `--follow` polls. A stream would be the nicer shape and the source is not one:
17
+ * the guest holds a RING with a cursor, and a reconnecting stream would have to
18
+ * re-derive that cursor anyway.
19
+ */
20
+ import { type CommandResult } from "./_output.ts";
21
+ /** One captured line, as the platform reports it. */
22
+ export type LogLine = {
23
+ seq: number;
24
+ at: number;
25
+ stream: "stdout" | "stderr";
26
+ text: string;
27
+ };
28
+ /** One read of the ring. Mirrors `AgentLogsResponse` on the platform. */
29
+ export type LogsPage = {
30
+ lines: LogLine[];
31
+ cursor: number;
32
+ dropped: number;
33
+ running: boolean;
34
+ };
35
+ /** How often `--follow` asks for what is new. */
36
+ export declare const FOLLOW_POLL_MS = 1000;
37
+ export type LogsOpts = {
38
+ server?: string | undefined;
39
+ /** Keep polling until interrupted. */
40
+ follow?: boolean | undefined;
41
+ /** Poll cadence override, for tests. */
42
+ pollMs?: number | undefined;
43
+ /** Stops a `--follow` loop. Tests pass one; the CLI leaves it to Ctrl-C. */
44
+ signal?: AbortSignal | undefined;
45
+ };
46
+ /** `HH:MM:SS.mmm`, local — the same shape the studio pane prints. */
47
+ export declare function formatTime(at: number): string;
48
+ /** One rendered line: time, then the text. `stderr` is called out by name. */
49
+ export declare function formatLine(line: LogLine): string;
50
+ type LogsData = {
51
+ slug: string;
52
+ lines: number;
53
+ running: boolean;
54
+ };
55
+ /**
56
+ * Print the agent's buffered output, once or continuously.
57
+ *
58
+ * The `--json` shape reports the LINE COUNT rather than the lines: a follow
59
+ * loop has no final count to report, and a one-shot read that dumped every line
60
+ * into a result object would duplicate what it just printed.
61
+ */
62
+ export declare function executeLogs(cwd: string, opts?: LogsOpts): Promise<CommandResult<LogsData>>;
63
+ export {};
@@ -300,8 +300,17 @@ callback. Reach for `agent()` when someone is on the line — a voice agent can
300
300
  also START a workflow from a tool (`ctx.workflows.start(def, input)`) and
301
301
  answer the turn, which is the other shape.
302
302
 
303
- **Requires storage** (`aai storage enable`, or `DATABASE_URL` under
304
- `aai dev`): runs live in the database.
303
+ **Storage is OPTIONAL, and what it buys is DURABILITY.** With a database
304
+ (`aai storage enable`, Settings → Database in the studio, or `DATABASE_URL`
305
+ under `aai dev`) runs live in it and survive a restart, a redeploy and an idle
306
+ sandbox. Without one they live in the process that started them — you can
307
+ submit the form, watch the run and read its result, and everything in flight is
308
+ lost when that process goes away. That is the honest tradeoff, and it is what
309
+ lets you build a workflow app before provisioning anything.
310
+
311
+ Two things do need the database whatever the run does: `ctx.db`, and a workflow
312
+ **upload** — an upload's record is a row, so `api.upload` and the file-taking
313
+ form hooks refuse by name until storage is on (see "Database API" below).
305
314
 
306
315
  ### Workflow bodies live in `workflows/`
307
316
 
@@ -1339,16 +1348,26 @@ also what you want for anything that can be a string, an array, or null.
1339
1348
  // shared.ts — the slot owns the shape; `agent()` has no `state` field.
1340
1349
  export const cartSlot = sessionSlot("cart", () => ({ cart: [] as Item[], staffPin: "" }));
1341
1350
 
1351
+ // Compose the projection HERE, once, and import it at both ends: staffPin stays
1352
+ // server-side, and the agent and the client cannot name different views of it.
1353
+ export const cartProjection = cartSlot.projection((s) => ({ cart: s.cart }));
1354
+
1342
1355
  // agent.ts
1343
- export default agent({
1344
- syncState: cartSlot.projection((s) => ({ cart: s.cart })), // staffPin stays server-side
1345
- });
1356
+ export default agent({ syncState: cartProjection });
1346
1357
 
1347
- // client.tsx
1348
- const view = useAgentState<{ cart: Item[] }>(); // null until the first push
1349
- return <Cart items={view?.cart ?? []} />;
1358
+ // client.tsx — the projection types the state AND supplies the frame the client
1359
+ // renders before the first push, so there is no type argument and no `?? EMPTY`.
1360
+ const view = useAgentState(cartProjection);
1361
+ return <Cart items={view.cart} />;
1350
1362
  ```
1351
1363
 
1364
+ Passing the projection is the shape to copy. The other two overloads still
1365
+ exist: `useAgentState<S>()` returns `S | null` (nullable — nothing is pushed
1366
+ before the first tool call), and `useAgentState<S>(fallback)` returns `S` for a
1367
+ frame you build yourself. Reach for `fallback` only when the slot's factory is
1368
+ expensive to import into the browser — the projection overload calls it to build
1369
+ the empty frame.
1370
+
1352
1371
  **Reach for this before wiring `useToolResult` into `useState`.** Without
1353
1372
  it the pattern is: return a cart snapshot from every tool, declare a type
1354
1373
  describing what those tools return, and mirror it into `useState` — three
@@ -1511,8 +1530,9 @@ Common mistakes when working in aai projects:
1511
1530
  - **`ctx.db` throws until the database is enabled.** Enable it with
1512
1531
  `aai storage enable` (CLI), Settings → Database (studio), or
1513
1532
  `DATABASE_URL` in `.env` (`aai dev`) before shipping tools that persist
1514
- data. In the studio it takes effect when each agent next deploys — the
1515
- preview redeploys itself, production on the next publish.
1533
+ data. A deployed agent reads its connection string when its sandbox is
1534
+ built, so enabling rebuilds it the change reaches a running agent
1535
+ without a redeploy.
1516
1536
  - **The database is per-app.** Rows are shared by every session of one
1517
1537
  deployment — key them yourself if sessions must not see each other's data
1518
1538
  (or keep session-scoped data in a `sessionSlot`).
@@ -13,8 +13,8 @@
13
13
  "publish:agent": "aai publish"
14
14
  },
15
15
  "dependencies": {
16
- "@alexkroman1/aai": "^6.8.0",
17
- "@alexkroman1/aai-ui": "^6.8.0",
16
+ "@alexkroman1/aai": "^6.9.1",
17
+ "@alexkroman1/aai-ui": "^6.9.1",
18
18
  "@workflow/world-postgres": "4.3.3",
19
19
  "react": "^19.2.8",
20
20
  "react-dom": "^19.2.8",
@@ -23,7 +23,7 @@
23
23
  "zod": "^4.4.3"
24
24
  },
25
25
  "devDependencies": {
26
- "@alexkroman1/aai-cli": "^6.8.0",
26
+ "@alexkroman1/aai-cli": "^6.9.1",
27
27
  "@tailwindcss/vite": "^4.3.3",
28
28
  "@types/node": "^26.2.0",
29
29
  "@types/react": "^19.2.18",
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { dashboardView, dispatchSlot } from "./shared.ts";
2
+ import { dashboardProjection } from "./shared.ts";
3
3
 
4
4
  export default agent({
5
5
  name: "Dispatch Command Center",
@@ -7,7 +7,7 @@ export default agent({
7
7
  // something to project rather than an empty state object.
8
8
  // One projection replaces eleven `ctx.send("incidents", ...)` calls, and
9
9
  // is the single place that decides caller PII stays server-side.
10
- syncState: dispatchSlot.projection(dashboardView),
10
+ syncState: dashboardProjection,
11
11
  // A dispatcher reads addresses and unit numbers in bursts with pauses inside
12
12
  // one message ("unit twelve … respond to"). The default pipeline's
13
13
  // `max_turn_silence` already tolerates that; reach for
@@ -7,8 +7,8 @@ import {
7
7
  useSession,
8
8
  useUserTranscript,
9
9
  } from "@alexkroman1/aai-ui";
10
- import type { DashboardView, DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
11
- import { dashboardView, dispatchSlot } from "./shared.ts";
10
+ import type { DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
11
+ import { dashboardProjection } from "./shared.ts";
12
12
 
13
13
  const CSS = `
14
14
  @keyframes dc-pulse {
@@ -57,10 +57,6 @@ const statusColors: Record<string, string> = {
57
57
  escalated: "#ef4444",
58
58
  } satisfies Record<Status, string>;
59
59
 
60
- // The board before the first tool call — derived from the projection so a
61
- // new DashboardView field can't silently miss the pre-first-tool-call render.
62
- const EMPTY_DASH: DashboardView = dispatchSlot.projection(dashboardView)(undefined);
63
-
64
60
  // The dot's colour per session state, as an EXHAUSTIVE map rather than an
65
61
  // if-chain with a grey default.
66
62
  //
@@ -152,7 +148,7 @@ function App() {
152
148
  // The agent's own board, projected by `syncState`. This replaced a
153
149
  // useState mirror that merged incident deltas out of tool events — the
154
150
  // projection is already the complete list, so there is nothing to merge.
155
- const dash = useAgentState<DashboardView>(EMPTY_DASH);
151
+ const dash = useAgentState(dashboardProjection);
156
152
 
157
153
  const incidentList = [...dash.incidents].reverse();
158
154
  const activeIncidents = incidentList.filter((i) => i.status !== "resolved");
@@ -215,6 +215,9 @@ export const dispatchSlot = sessionSlot("dispatch", createDefaultState, {
215
215
  },
216
216
  });
217
217
 
218
+ /** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
219
+ export const dashboardProjection = dispatchSlot.projection(dashboardView);
220
+
218
221
  /**
219
222
  * The board as a READ hands it out: deep-frozen, and typed to say so.
220
223
  *
@@ -44,8 +44,11 @@
44
44
  * doc for the one thing that changes under `aai dev` (the key has to be in
45
45
  * `.env`, not just your shell).
46
46
  *
47
- * Requires storage (`aai storage enable`, or `DATABASE_URL` under `aai dev`) —
48
- * runs and the correlation-key index both live there.
47
+ * Storage is what makes it DURABLE (`aai storage enable`, Settings Database
48
+ * in the studio, or `DATABASE_URL` under `aai dev`) — runs and the
49
+ * correlation-key index both live there. Without it both live in the process,
50
+ * so a run in flight is lost when the agent restarts, redeploys or goes idle —
51
+ * which is fine while you are building one.
49
52
  */
50
53
 
51
54
  import { workflow, workflowApp } from "@alexkroman1/aai";
@@ -18,7 +18,14 @@ const agentDef = withDiscoveredTools(
18
18
  import.meta.glob("./tools/*.ts", { eager: true }),
19
19
  );
20
20
 
21
- import { calculateTotal, orderSlot, orderView, type Pizza, pizzaPrice } from "./shared.ts";
21
+ import {
22
+ calculateTotal,
23
+ orderProjection,
24
+ orderSlot,
25
+ orderView,
26
+ type Pizza,
27
+ pizzaPrice,
28
+ } from "./shared.ts";
22
29
 
23
30
  // ─── Test doubles ────────────────────────────────────────────────────────────
24
31
 
@@ -231,8 +238,8 @@ describe("orderView projection", () => {
231
238
 
232
239
  test("an untouched session projects an empty cart, not undefined", () => {
233
240
  // The client renders before any tool has run, so `state.order` is absent —
234
- // this is exactly the value `client.tsx` hoists as its fallback.
235
- expect(orderSlot.projection(orderView)(undefined)).toMatchObject({
241
+ // this is exactly the frame `client.tsx` gets from the same projection.
242
+ expect(orderProjection()).toMatchObject({
236
243
  pizzas: [],
237
244
  total: "$0.00",
238
245
  orderPlaced: false,
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { menuText, orderSlot, orderView } from "./shared.ts";
2
+ import { menuText, orderProjection } from "./shared.ts";
3
3
  import systemPrompt from "./system-prompt.md?raw";
4
4
 
5
5
  // The in-progress order lives in one `sessionSlot` (see shared.ts) —
@@ -15,7 +15,7 @@ export default agent({
15
15
  // The cart, pushed to the client after every tool call. Replaces a
16
16
  // `ctx.send("order", ...)` in each of the five order tools, and the
17
17
  // event-diffing the client had to do to rebuild the cart from them.
18
- syncState: orderSlot.projection(orderView),
18
+ syncState: orderProjection,
19
19
  // The menu section is generated from MENU so the prompt can never quote a
20
20
  // price the pricing code doesn't charge.
21
21
  systemPrompt: `${systemPrompt}\n${menuText()}`,
@@ -1,11 +1,6 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
- import type { OrderView } from "./shared.ts";
4
- import { formatPrice, orderSlot, orderView, pizzaPrice } from "./shared.ts";
5
-
6
- // Derived from the projection so a new OrderView field can't silently miss
7
- // the pre-first-tool-call render — `projection` runs it over an empty cart.
8
- const EMPTY_ORDER: OrderView = orderSlot.projection(orderView)(undefined);
3
+ import { formatPrice, orderProjection, pizzaPrice } from "./shared.ts";
9
4
 
10
5
  function PizzaIcon({ size }: { size: string }) {
11
6
  const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
@@ -30,7 +25,7 @@ function OrderSidebar() {
30
25
  // tool call. This replaced ~45 lines that rebuilt the cart by diffing
31
26
  // added/removed/updated events — where one missed event desynced the view
32
27
  // for the rest of the session.
33
- const order = useAgentState<OrderView>(EMPTY_ORDER);
28
+ const order = useAgentState(orderProjection);
34
29
 
35
30
  if (order.orderPlaced) {
36
31
  return (
@@ -157,3 +157,6 @@ export function orderView(order: FrozenOrderState): OrderView {
157
157
  : {}),
158
158
  };
159
159
  }
160
+
161
+ /** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
162
+ export const orderProjection = orderSlot.projection(orderView);
@@ -26,7 +26,7 @@ const agentDef = withDiscoveredTools(
26
26
  import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./graph.ts";
27
27
  import { EXECUTOR_SYSTEM, PLANNER_SYSTEM, REPLANNER_SYSTEM, REVISE_SYSTEM } from "./prompts.ts";
28
28
  import type { SearchFn } from "./shared.ts";
29
- import { MAX_PAST_STEPS, planSlot, planView } from "./shared.ts";
29
+ import { MAX_PAST_STEPS, planProjection, planSlot, planView } from "./shared.ts";
30
30
 
31
31
  // ─── A scripted model ────────────────────────────────────────────────────────
32
32
  //
@@ -381,8 +381,9 @@ describe("plan_status", () => {
381
381
 
382
382
  describe("planView projection", () => {
383
383
  test("an untouched call projects an empty plan, not undefined", () => {
384
- // Exactly the value client.tsx hoists as its fallback.
385
- expect(planSlot.projection(planView)(undefined)).toEqual({
384
+ // Exactly the frame `client.tsx` renders before the first push — it passes
385
+ // this same projection to `useAgentState`.
386
+ expect(planProjection()).toEqual({
386
387
  objective: null,
387
388
  plan: [],
388
389
  done: [],
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { planSlot, planView } from "./shared.ts";
2
+ import { planProjection } from "./shared.ts";
3
3
 
4
4
  /**
5
5
  * A planning desk you can phone: plan-and-execute, with the caller in the loop.
@@ -18,6 +18,6 @@ export default agent({
18
18
  // The plan exists before the first tool call, so a resumed connection has
19
19
  // something to project.
20
20
  // The plan, its progress and its revision trail, pushed after every tool call.
21
- syncState: planSlot.projection(planView),
21
+ syncState: planProjection,
22
22
  greeting: "Planning desk. Tell me what you're trying to get done and I'll work out the steps.",
23
23
  });
@@ -1,9 +1,6 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
- import type { PlanView } from "./shared.ts";
4
- import { planSlot, planView } from "./shared.ts";
5
-
6
- const EMPTY_PLAN: PlanView = planSlot.projection(planView)(undefined);
3
+ import { planProjection } from "./shared.ts";
7
4
 
8
5
  /**
9
6
  * The plan, ticking off.
@@ -14,7 +11,7 @@ const EMPTY_PLAN: PlanView = planSlot.projection(planView)(undefined);
14
11
  */
15
12
  function PlanSidebar() {
16
13
  const theme = useTheme();
17
- const plan = useAgentState<PlanView>(EMPTY_PLAN);
14
+ const plan = useAgentState(planProjection);
18
15
 
19
16
  if (!plan.objective) {
20
17
  return (
@@ -159,3 +159,6 @@ export function planView(state: FrozenPlanState): PlanView {
159
159
  progress: total === 0 ? 0 : done / total,
160
160
  };
161
161
  }
162
+
163
+ /** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
164
+ export const planProjection = planSlot.projection(planView);
@@ -65,8 +65,11 @@
65
65
  * the run really submits the recording, really polls it, and really deletes it
66
66
  * when it has to.
67
67
  *
68
- * Requires storage (`aai storage enable`, or `DATABASE_URL` under `aai dev`) —
69
- * runs and the correlation-key index both live there.
68
+ * Storage is what makes it DURABLE (`aai storage enable`, Settings Database
69
+ * in the studio, or `DATABASE_URL` under `aai dev`) — runs and the
70
+ * correlation-key index both live there. Without it both live in the process:
71
+ * the flow still runs, and a recap parked on a callback does not survive the
72
+ * agent restarting, redeploying or going idle.
70
73
  */
71
74
 
72
75
  import { agent } from "@alexkroman1/aai";
@@ -69,8 +69,11 @@
69
69
  * thing that changes under `aai dev` (the key has to be in `.env`, not just your
70
70
  * shell).
71
71
  *
72
- * Requires storage (`aai storage enable`, or `DATABASE_URL` under `aai dev`) —
73
- * runs and the key index both live there.
72
+ * Storage is what makes it DURABLE (`aai storage enable`, Settings Database
73
+ * in the studio, or `DATABASE_URL` under `aai dev`) — runs and the key index
74
+ * both live there. Without it both live in the process, so a run in flight is
75
+ * lost when the agent restarts, redeploys or goes idle; everything below still
76
+ * works, which is what lets you try it before provisioning anything.
74
77
  */
75
78
 
76
79
  import { agent } from "@alexkroman1/aai";
@@ -43,10 +43,16 @@ const statusColors: Record<string, string> = {
43
43
  } satisfies Record<OrderStatus, string>;
44
44
 
45
45
  // The sidebar before the first tool call, derived from the projection itself so
46
- // a new StoreView field can't miss the pre-first-call render. Built from
47
- // `emptyRetailState` rather than `retailSlot.projection(...)(undefined)`,
48
- // because the slot's factory lives in `store.ts` and pulls the 107 KB seed —
49
- // importing it here would ship the whole catalog to the browser.
46
+ // a new StoreView field can't miss the pre-first-call render.
47
+ //
48
+ // **This is the one template that does NOT pass its projection to
49
+ // `useAgentState`**, and the reason is the browser bundle rather than style:
50
+ // that overload derives the empty frame by calling the projection, which calls
51
+ // the slot's `create()` — and this slot's factory lives in `store.ts` and pulls
52
+ // the 107 KB seed, so importing it here would ship the whole catalog to the
53
+ // browser. `emptyRetailState()` is the same shape without the seed. Reach for
54
+ // the projection overload everywhere the factory is cheap, which is every
55
+ // other stateful template.
50
56
  const EMPTY_VIEW: StoreView = storeView(emptyRetailState());
51
57
 
52
58
  // The dot's colour per session state, as an EXHAUSTIVE map rather than an
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { gameSlot } from "./shared.ts";
2
+ import { gameProjection } from "./shared.ts";
3
3
 
4
4
  export default agent({
5
5
  name: "Solo RPG",
@@ -14,5 +14,5 @@ export default agent({
14
14
  // remembering to push or watching the UI quietly fall out of sync.
15
15
  // The identity projection: this campaign IS what the client renders, and the
16
16
  // slot's own default is what a session that has run no tool projects.
17
- syncState: gameSlot.projection((game) => game),
17
+ syncState: gameProjection,
18
18
  });
@@ -7,7 +7,7 @@ import {
7
7
  type Disposition,
8
8
  type GameState,
9
9
  GENRES,
10
- gameSlot,
10
+ gameProjection,
11
11
  MAX_RESOURCE,
12
12
  MIN_MOMENTUM,
13
13
  type NPC,
@@ -15,18 +15,6 @@ import {
15
15
  type TIME_PHASES,
16
16
  } from "./shared.ts";
17
17
 
18
- // The campaign before the first tool call, derived from the SAME projection the
19
- // server pushes rather than from the module default beside it — so a session
20
- // that has run no tool renders exactly what its first push will replace. Every
21
- // sibling template derives its fallback this way; this one read `DEFAULT_STATE`
22
- // directly, which is a second copy of "what an untouched session looks like".
23
- //
24
- // It is typed READONLY because that is what the projection produces, and a
25
- // client only ever renders what the server pushed — so the four components
26
- // below take a readonly campaign too, which is the deep-readonly slot
27
- // propagating exactly as far as it should.
28
- const EMPTY_GAME = gameSlot.projection((game) => game)(undefined);
29
-
30
18
  // ── Color Palette ────────────────────────────────────────────────────────────
31
19
  const C = {
32
20
  bg: "#0a0a0c",
@@ -784,7 +772,10 @@ function SoloRPGApp() {
784
772
  // The agent's own session state, projected by `syncState` and pushed after
785
773
  // every tool call — no per-tool `ctx.send`, and nothing to keep in step
786
774
  // when a new tool starts mutating the game.
787
- const game = useAgentState<DeepReadonly<GameState>>(EMPTY_GAME);
775
+ // `gameProjection` carries both halves: the campaign's readonly type — which
776
+ // is what the four components below take — and the frame a session that has
777
+ // run no tool renders.
778
+ const game = useAgentState(gameProjection);
788
779
 
789
780
  return (
790
781
  <StartScreen
@@ -328,6 +328,13 @@ export const DEFAULT_STATE: GameState = {
328
328
  // another's.
329
329
  export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_STATE));
330
330
 
331
+ /**
332
+ * The IDENTITY projection, used by `syncState` and by `useAgentState`: this
333
+ * campaign IS what the client renders, so there is nothing to trim — and it
334
+ * carries the deep-readonly type the client's components take.
335
+ */
336
+ export const gameProjection = gameSlot.projection((game) => game);
337
+
331
338
  /**
332
339
  * The game as a READ hands it out: deep-frozen, and typed to say so.
333
340
  *
@@ -31,7 +31,7 @@ import {
31
31
  GROUNDED_SYSTEM,
32
32
  REWRITE_SYSTEM,
33
33
  } from "./prompts.ts";
34
- import { retrieve, supportSlot, supportView } from "./shared.ts";
34
+ import { retrieve, supportProjection, supportSlot, supportView } from "./shared.ts";
35
35
 
36
36
  // ─── A scripted model ────────────────────────────────────────────────────────
37
37
  //
@@ -332,8 +332,9 @@ describe("log_ticket", () => {
332
332
 
333
333
  describe("supportView projection", () => {
334
334
  test("an untouched call projects an empty trace, not undefined", () => {
335
- // Exactly the value client.tsx hoists as its fallback.
336
- expect(supportSlot.projection(supportView)(undefined)).toMatchObject({
335
+ // Exactly the frame `client.tsx` renders before the first push — it passes
336
+ // this same projection to `useAgentState`.
337
+ expect(supportProjection()).toMatchObject({
337
338
  product: "Meridian Fibre",
338
339
  trace: null,
339
340
  asked: [],
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { PRODUCT, supportSlot, supportView } from "./shared.ts";
2
+ import { PRODUCT, supportProjection } from "./shared.ts";
3
3
 
4
4
  /**
5
5
  * A support line that grades its own retrieval before it speaks — the
@@ -22,6 +22,6 @@ export default agent({
22
22
  // something to project.
23
23
  // The projection is also the privacy boundary: a logged ticket carries the
24
24
  // caller's callback number, and only its reference crosses to the browser.
25
- syncState: supportSlot.projection(supportView),
25
+ syncState: supportProjection,
26
26
  greeting: `${PRODUCT} support, you're through to the automated line. What's happened?`,
27
27
  });
@@ -1,9 +1,6 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
- import type { SupportView } from "./shared.ts";
4
- import { PRODUCT, supportSlot, supportView } from "./shared.ts";
5
-
6
- const EMPTY_SUPPORT: SupportView = supportSlot.projection(supportView)(undefined);
3
+ import { PRODUCT, supportProjection } from "./shared.ts";
7
4
 
8
5
  /**
9
6
  * The graph, as it ran for the last question.
@@ -15,7 +12,7 @@ const EMPTY_SUPPORT: SupportView = supportSlot.projection(supportView)(undefined
15
12
  */
16
13
  function TraceSidebar() {
17
14
  const theme = useTheme();
18
- const support = useAgentState<SupportView>(EMPTY_SUPPORT);
15
+ const support = useAgentState(supportProjection);
19
16
  const trace = support.trace;
20
17
 
21
18
  return (
@@ -238,3 +238,6 @@ export function supportView(state: FrozenSupportState): SupportView {
238
238
  ticket: state.ticket?.reference ?? null,
239
239
  };
240
240
  }
241
+
242
+ /** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
243
+ export const supportProjection = supportSlot.projection(supportView);
@@ -17,8 +17,10 @@
17
17
  * `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
18
18
  * what makes a deploy check for it rather than letting the first run find out.
19
19
  * A step reads it with `requireStepEnv`; see `@alexkroman1/aai/utils`.
20
- * - **Storage** (`aai storage enable`, or `DATABASE_URL` under `aai dev`) runs
21
- * live there.
20
+ * - **Storage** (`aai storage enable`, Settings Database in the studio, or
21
+ * `DATABASE_URL` under `aai dev`). REQUIRED here, unlike most workflow apps:
22
+ * a run survives without it, but an UPLOAD's record is a row, so the form
23
+ * below refuses by name until storage is on.
22
24
  * - **A linear-PCM WAV.** The cutting is arithmetic over byte offsets, which is
23
25
  * only possible on uncompressed audio; `workflows/wav.ts` says so in more
24
26
  * detail, and an unsupported file fails the run by name with the `ffmpeg`
@@ -168,7 +168,6 @@ import {
168
168
  } from "@alexkroman1/aai-ui";
169
169
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
170
170
  import type { transcribe } from "./agent.ts";
171
- import { ApiHelp } from "./api-help.tsx";
172
171
  import {
173
172
  clock,
174
173
  countWords,
@@ -463,10 +462,6 @@ function TranscriptionDesk() {
463
462
  openId={openId}
464
463
  onOpen={(runId) => setOpenId((current) => (current === runId ? undefined : runId))}
465
464
  />
466
-
467
- {/* The most useful thing about a workflow app is the least discoverable:
468
- this page is one caller of an ordinary HTTP API. See `api-help.tsx`. */}
469
- <ApiHelp />
470
465
  </main>
471
466
  );
472
467
  }
@@ -18,7 +18,7 @@ const agentDef = withDiscoveredTools(
18
18
  import.meta.glob("./tools/*.ts", { eager: true }),
19
19
  );
20
20
 
21
- import { activeAssistant, FLIGHTS, tripSlot, tripView } from "./shared.ts";
21
+ import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./shared.ts";
22
22
 
23
23
  // ─── Harness ─────────────────────────────────────────────────────────────────
24
24
 
@@ -298,8 +298,9 @@ describe("search tools", () => {
298
298
 
299
299
  describe("tripView projection", () => {
300
300
  test("an untouched call projects the seeded booking at the concierge desk", () => {
301
- // Exactly the value client.tsx hoists as its fallback.
302
- const view = tripSlot.projection(tripView)(undefined);
301
+ // Exactly the frame `client.tsx` renders before the first push — it passes
302
+ // this same projection to `useAgentState`.
303
+ const view = tripProjection();
303
304
  expect(view).toMatchObject({
304
305
  assistant: "primary",
305
306
  assistantTitle: "concierge",
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { tripSlot, tripView } from "./shared.ts";
2
+ import { tripProjection } from "./shared.ts";
3
3
 
4
4
  /**
5
5
  * A phone travel concierge, adapted from LangGraph's customer-support tutorial.
@@ -23,7 +23,7 @@ export default agent({
23
23
  // so a resumed connection has something to project.
24
24
  // One projection replaces a `ctx.send` in each of eleven tools — and is the
25
25
  // single place that decides the caller's record leaves the server trimmed.
26
- syncState: tripSlot.projection(tripView),
26
+ syncState: tripProjection,
27
27
  greeting:
28
28
  "Swiss Air Travel, this is the concierge desk. I can see your booking — what can I do for you today?",
29
29
  });
@@ -1,11 +1,7 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
3
  import type { TripView } from "./shared.ts";
4
- import { SPECIALIST_IDS, SPECIALISTS, tripSlot, tripView } from "./shared.ts";
5
-
6
- // Derived from the projection so a new TripView field cannot silently miss the
7
- // pre-first-tool-call render.
8
- const EMPTY_TRIP: TripView = tripSlot.projection(tripView)(undefined);
4
+ import { SPECIALIST_IDS, SPECIALISTS, tripProjection } from "./shared.ts";
9
5
 
10
6
  const DESKS = ["primary", ...SPECIALIST_IDS] as const;
11
7
 
@@ -44,7 +40,7 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
44
40
 
45
41
  function ItinerarySidebar() {
46
42
  const theme = useTheme();
47
- const trip = useAgentState<TripView>(EMPTY_TRIP);
43
+ const trip = useAgentState(tripProjection);
48
44
 
49
45
  return (
50
46
  <div className="flex flex-col gap-4 p-4 h-full min-h-0" style={{ color: theme.text }}>
@@ -545,3 +545,6 @@ export function tripView(state: FrozenTripState): TripView {
545
545
  log: state.log,
546
546
  };
547
547
  }
548
+
549
+ /** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
550
+ export const tripProjection = tripSlot.projection(tripView);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.8.0",
3
+ "version": "6.9.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,8 +44,8 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai-ui": "6.8.0",
48
- "@alexkroman1/aai": "6.8.0"
47
+ "@alexkroman1/aai": "6.9.1",
48
+ "@alexkroman1/aai-ui": "6.9.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",
@@ -1,214 +0,0 @@
1
- // Copyright 2026 the AAI authors. MIT license.
2
- /**
3
- * "You do not need this page" — the HTTP API, on the page.
4
- *
5
- * A workflow app's whole surface is `GET|POST /workflows/*`, and the page is one
6
- * caller of it. That is the most useful thing about the shape and the least
7
- * discoverable: nothing in a form suggests that the same work is three `curl`
8
- * calls, that a run id is the entire handle (no session, no cookie), or that a
9
- * transcript can be collected days later from another machine. So the recipes are
10
- * rendered where somebody is already looking, rather than left in a README they
11
- * would have to know exists.
12
- *
13
- * ## It links to the LIVE listing
14
- *
15
- * `GET /workflows` serves each workflow's name, description and input schema — the
16
- * same JSON `<WorkflowFields>` renders this form from. So the link is not
17
- * documentation about the API, it is the API answering for itself, on this
18
- * deployment, at this version. A reader who wants the schema gets the real one;
19
- * a reader whose agent is behind `AAI_WORKFLOW_API_TOKEN` gets a 401, which is
20
- * also the truth.
21
- *
22
- * ## The three recipes are the three flows
23
- *
24
- * The first two differ only in who names the upload — which is the whole of what
25
- * lets one of them start before the bytes are in — and the third does none of that
26
- * work at all. Showing them side by side is the clearest statement of the trade
27
- * available, and cheaper than the prose that would otherwise have to make it.
28
- *
29
- * They are also KEPT HONEST by being runnable: every one of these was executed
30
- * against a real dev server, which is how two bugs in the streaming path were
31
- * found (a missing wake after the upload, and a poll that slept on a stale view).
32
- */
33
-
34
- /** @jsxImportSource react */
35
-
36
- import type { ReactNode } from "react";
37
-
38
- /** The API root, relative to wherever this page is served from. */
39
- const API = "workflows";
40
-
41
- /**
42
- * One shell recipe, with a heading and a note.
43
- *
44
- * Rendered in a `<pre>` rather than assembled from styled spans: it exists to be
45
- * SELECTED and pasted, and any markup inside the block is markup a copy picks up.
46
- */
47
- function Recipe({
48
- title,
49
- note,
50
- script,
51
- }: {
52
- title: string;
53
- note: string;
54
- script: string;
55
- }): ReactNode {
56
- return (
57
- <section className="flex flex-col gap-2">
58
- <h4 className="text-xs font-medium uppercase tracking-[1.2px]">{title}</h4>
59
- <p className="text-xs opacity-70">{note}</p>
60
- <pre className="overflow-x-auto rounded-md border p-3 text-xs leading-relaxed">{script}</pre>
61
- </section>
62
- );
63
- }
64
-
65
- /** Store the whole file, then start a run on its id. */
66
- const CLASSIC = `# 1. store the recording. Answers 201 once the LAST byte is in, which is why
67
- # this shape cannot start the run any earlier.
68
- ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.wav" \
69
- -H 'content-type: audio/wav' --data-binary @standup.wav | jq -r .id)
70
-
71
- # 2. run it. \`wait\` holds the request open for up to 60s; drop it to get
72
- # { runId } straight back and poll instead.
73
- curl -s -X POST "$AGENT/workflows/runs" \
74
- -H 'content-type: application/json' \
75
- -d "{\\"workflow\\":\\"transcribe\\",\\"wait\\":60000,
76
- \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r '.run.output.transcript'`;
77
-
78
- /** Start the run first, then stream the file to the id it is watching. */
79
- const STREAMING = `# No splitting, no ffmpeg — the file goes in ONE request. The only difference
80
- # from the recipe above is that YOU pick the upload id, so it is already valid
81
- # when the run starts and the run reads the bytes as they land.
82
-
83
- # 1. pick an id and start the run on it. Nothing has been uploaded yet.
84
- ID=$(openssl rand -hex 16)
85
- RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
86
- -H 'content-type: application/json' \
87
- -d "{\\"workflow\\":\\"transcribeStream\\",
88
- \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
89
-
90
- # 2. PUT the whole file. The upload record exists from the first byte with
91
- # complete:false, and its size grows — which is what the run polls.
92
- curl -s -X PUT "$AGENT/workflows/uploads/$ID?name=standup.wav" \
93
- -H 'content-type: audio/wav' --data-binary @standup.wav | jq -c '{size, complete}'
94
-
95
- # 3. wake it. The run sleeps between polls, so without this it notices the file
96
- # is finished up to one poll interval late — every time.
97
- curl -s -X POST "$AGENT/workflows/runs/$RUN/wake" > /dev/null
98
-
99
- # 4. collect it whenever — a run id is the whole handle.
100
- curl -s "$AGENT/workflows/runs/$RUN?wait=60000" | jq -r '.run.output.transcript'
101
-
102
- # While it runs, from any other shell:
103
- # curl -s "$AGENT/workflows/uploads/$ID/info" # how much has arrived
104
- # curl -sN "$AGENT/workflows/runs/$RUN/stream" # what the run is saying`;
105
-
106
- /** Hand the whole thing to the async API. */
107
- const BATCH = `# The same two requests as the first recipe — only the workflow name differs.
108
- # No cutting happens anywhere: the run uploads your file to the async API,
109
- # polls the job, and reads the text. It also accepts mp3 and m4a, which the
110
- # two sync flows refuse.
111
- ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.m4a" \
112
- -H 'content-type: audio/mp4' --data-binary @standup.m4a | jq -r .id)
113
-
114
- # No \`wait\` here: an async job takes minutes, well past the 60s ceiling a
115
- # synchronous read can hold. Start it, then follow the run.
116
- RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
117
- -H 'content-type: application/json' \
118
- -d "{\\"workflow\\":\\"transcribeBatch\\",
119
- \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
120
-
121
- curl -sN "$AGENT/workflows/runs/$RUN/events" # status, as it changes
122
- curl -s "$AGENT/workflows/runs/$RUN" | jq -r '.output.transcript // .status'`;
123
-
124
- /** Every route this app answers, and what each is for. */
125
- const ROUTES: readonly { route: string; does: string }[] = [
126
- { route: "GET /workflows", does: "the three workflows and their input schemas" },
127
- { route: "POST /workflows/runs", does: "start a run · body names workflow and input" },
128
- { route: "GET /workflows/runs", does: "runs so far · filter by workflow, key, limit" },
129
- { route: "GET /workflows/runs/:id", does: "one run · add wait=<ms> to block on it" },
130
- { route: "GET /workflows/runs/:id/events", does: "SSE, status transitions" },
131
- { route: "GET /workflows/runs/:id/stream", does: "SSE, what the run has written" },
132
- { route: "POST /workflows/runs/:id/wake", does: "end a pending sleep early" },
133
- { route: "DELETE /workflows/runs/:id", does: "cancel it" },
134
- { route: "POST /workflows/uploads", does: "store a file, id minted by the store" },
135
- {
136
- route: "PUT /workflows/uploads/:id",
137
- does: "store a file under YOUR id, readable as it arrives",
138
- },
139
- {
140
- route: "POST /workflows/uploads/:id/parts",
141
- does: "declare an upload its parts fill in · ?total=<bytes>",
142
- },
143
- {
144
- route: "PUT /workflows/uploads/:id/parts",
145
- does: "one window of it · ?offset=<byte>, sent concurrently",
146
- },
147
- { route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
148
- { route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
149
- ];
150
-
151
- /**
152
- * The whole API, collapsed by default.
153
- *
154
- * `<details>` rather than a state hook: the browser owns disclosure, it is
155
- * keyboard-accessible and findable by in-page search without anyone wiring either,
156
- * and a page that renders a transcript should not re-render because somebody
157
- * expanded a help panel.
158
- */
159
- export function ApiHelp(): ReactNode {
160
- return (
161
- <details className="rounded-md border">
162
- <summary className="cursor-pointer p-4 text-sm font-medium">
163
- Use the API without this page
164
- </summary>
165
- <div className="flex flex-col gap-6 border-t p-4">
166
- <p className="text-sm opacity-70">
167
- This page is one caller. Everything it does is plain HTTP on this agent's own origin,
168
- unauthenticated unless the deployment sets{" "}
169
- <code className="text-xs">AAI_WORKFLOW_API_TOKEN</code> (then every route wants{" "}
170
- <code className="text-xs">Authorization: Bearer …</code>). A run id is the whole handle —
171
- no session, no cookie — so a transcript can be collected from another machine, days later.
172
- </p>
173
- <p className="text-sm">
174
- {/* The API answering for itself: same JSON this form was rendered from. */}
175
- <a className="underline" href={API} target="_blank" rel="noreferrer">
176
- {`GET ${API}`}
177
- </a>{" "}
178
- <span className="opacity-70">
179
- — the live listing, including each workflow's input schema. Set{" "}
180
- <code className="text-xs">AGENT</code> to this page's origin for the recipes below.
181
- </span>
182
- </p>
183
-
184
- <Recipe
185
- title="Store it, then transcribe"
186
- note="Sync API. One request per phase, and the upload has to finish before there is a run."
187
- script={CLASSIC}
188
- />
189
- <Recipe
190
- title="Transcribe while it uploads"
191
- note="Sync API, one upload request, no splitting. The run starts on an id you chose and reads the bytes as they land."
192
- script={STREAMING}
193
- />
194
- <Recipe
195
- title="Let the provider do it"
196
- note="Async API. No cutting and no seams, it accepts compressed audio, and the wait belongs to the provider's queue."
197
- script={BATCH}
198
- />
199
-
200
- <section className="flex flex-col gap-2">
201
- <h4 className="text-xs font-medium uppercase tracking-[1.2px]">Every route</h4>
202
- <ul className="flex flex-col gap-1">
203
- {ROUTES.map((entry) => (
204
- <li key={entry.route} className="flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-3">
205
- <code className="shrink-0 sm:w-72">{entry.route}</code>
206
- <span className="opacity-70">{entry.does}</span>
207
- </li>
208
- ))}
209
- </ul>
210
- </section>
211
- </div>
212
- </details>
213
- );
214
- }