@alexkroman1/aai-cli 6.10.0 → 6.11.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 (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -0,0 +1,343 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Specs for the spoken-summary app's declaration and its four legs.
4
+ *
5
+ * **The body itself is not driven here**, and that is a property of what a
6
+ * workflow template demonstrates rather than a gap: imported through vitest
7
+ * with no bundler in the path, a `"use step"` function is an ordinary async
8
+ * function — so its HTTP handling, its fatal/retryable classification and what
9
+ * it returns are all testable, while durability, suspension and replay are not.
10
+ * A body test that looked like a durability test would be the worse failure;
11
+ * the real thing is exercised end to end by `aai-cli`'s
12
+ * `dev-workflow.scenario.test.ts`.
13
+ *
14
+ * The two legs worth their own sections are the ones the SDK grew for this
15
+ * template. `speak` is where a step SPEAKS and STORES, and the assertion that
16
+ * matters is that it returns an id rather than bytes — a step is journaled by
17
+ * its return value, and audio in one is megabytes replayed on every resume.
18
+ */
19
+
20
+ import { stubReporter, stubSpeech, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
+ import { installStubGateway } from "@alexkroman1/aai/testing/vitest";
22
+ import { readUpload, uploadInfo } from "@alexkroman1/aai/utils";
23
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
24
+ import { FatalError, RetryableError } from "workflow";
25
+ import agentDef, { spokenSummary } from "./agent.ts";
26
+ import { speak, spokenSummaryFlow, summarize } from "./workflows/summarize.ts";
27
+ import { countWords, createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
28
+
29
+ /** The id every spec below uploads under. */
30
+ const UPLOAD_ID = "upl_test";
31
+
32
+ /** Slots left published reach the next file, so every one is released here. */
33
+ const restores: (() => void)[] = [];
34
+ afterEach(() => {
35
+ while (restores.length > 0) restores.pop()?.();
36
+ });
37
+
38
+ beforeEach(() => {
39
+ // WRITABLE, because this app's whole second half stores a file — and it is
40
+ // opt-in precisely so a step that wrote one nobody meant it to would fail.
41
+ restores.push(
42
+ stubUploads(
43
+ { [UPLOAD_ID]: { bytes: new Uint8Array(64), name: "standup.wav", type: "audio/wav" } },
44
+ { writable: true },
45
+ ),
46
+ );
47
+ // The step env, which is where `requireStepEnv` and `stepSpeak` read the key.
48
+ // `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it before every
49
+ // test, so nothing here has to remember to put it back.
50
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
51
+ });
52
+
53
+ describe("the declaration", () => {
54
+ test("is a workflow app with the one workflow the page starts by name", () => {
55
+ // The page calls `api.start("spokenSummary", …)`, so a rename here is a
56
+ // runtime 400 rather than a compile error. This is what pins it.
57
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["spokenSummary"]);
58
+ expect(agentDef.page).toBe("static");
59
+ });
60
+
61
+ test("declares no providers and exactly the one credential its steps read", () => {
62
+ // A workflow app has no session, so nothing else in its config could name
63
+ // one — and one AssemblyAI key covers transcription, the model and the voice.
64
+ expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
65
+ });
66
+
67
+ test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
68
+ expect(spokenSummary.uploads).toEqual(["recording"]);
69
+ });
70
+
71
+ test("offers real voice ids, so the synthesis cannot fail silently in band", async () => {
72
+ const parsed = spokenSummary.input?.["~standard"].validate({
73
+ recording: UPLOAD_ID,
74
+ voice: "not-a-voice",
75
+ });
76
+
77
+ expect((await parsed)?.issues).toBeTruthy();
78
+ expect(
79
+ (await spokenSummary.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "jane" }))
80
+ ?.issues,
81
+ ).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe("transcribing", () => {
86
+ test("streams the stored recording to the provider and keeps the URL it answered", async () => {
87
+ const fetches = stubStepFetch(() => ({ body: { upload_url: "https://cdn/aai/1" } }));
88
+ restores.push(fetches.restore, stubReporter().restore);
89
+
90
+ await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
91
+ audioUrl: "https://cdn/aai/1",
92
+ });
93
+ expect(fetches.calls[0]?.url).toBe("https://api.assemblyai.com/v2/upload");
94
+ expect(fetches.calls[0]?.headers.Authorization).toBe("test-key");
95
+ // The bytes really went, and they went as the file rather than as JSON.
96
+ expect(fetches.calls[0]?.body?.length).toBe(64);
97
+ });
98
+
99
+ test("a 429 from the provider is RETRYABLE and a 400 is not", async () => {
100
+ const first = stubStepFetch(() => ({ status: 429, body: { error: "slow down" } }));
101
+ restores.push(first.restore, stubReporter().restore);
102
+ await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(RetryableError);
103
+ first.restore();
104
+
105
+ const second = stubStepFetch(() => ({ status: 400, body: { error: "bad model" } }));
106
+ restores.push(second.restore);
107
+ await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(FatalError);
108
+ });
109
+
110
+ test("a job the provider gave up on is FATAL — no number of polls changes it", async () => {
111
+ restores.push(
112
+ stubStepFetch(() => ({ body: { status: "error", error: "corrupt audio" } })).restore,
113
+ );
114
+
115
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("corrupt audio");
116
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
117
+ });
118
+
119
+ test("`done` is decided here, so the body never reads a provider's vocabulary", async () => {
120
+ restores.push(stubStepFetch(() => ({ body: { status: "queued" } })).restore);
121
+
122
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({ done: false });
123
+ });
124
+
125
+ test("a finished poll carries the transcript, named by the FILENAME", async () => {
126
+ // ONE request, not two: this used to poll for a status and then fetch the
127
+ // identical URL again for the text the poll already had in its hand.
128
+ const fetches = stubStepFetch(() => ({
129
+ body: { status: "completed", text: " we shipped it ", audio_duration: 12.4 },
130
+ }));
131
+ restores.push(fetches.restore, stubReporter().restore);
132
+
133
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({
134
+ done: true,
135
+ transcript: { source: "standup.wav", durationMs: 12_400, text: "we shipped it" },
136
+ });
137
+ expect(fetches.calls).toHaveLength(1);
138
+ });
139
+
140
+ test("a recording of silence is FATAL rather than an empty summary", async () => {
141
+ // The failure this template is most likely to meet: silence transcribes
142
+ // successfully to nothing, and everything downstream would then be asked to
143
+ // summarize and speak no words at all.
144
+ restores.push(
145
+ stubStepFetch(() => ({ body: { status: "completed", text: " ", audio_duration: 3 } }))
146
+ .restore,
147
+ );
148
+
149
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("no speech in that recording");
150
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
151
+ });
152
+ });
153
+
154
+ describe("summarizing", () => {
155
+ test("asks the model for a spoken script as well as points, and keeps both", async () => {
156
+ const calls = installStubGateway(
157
+ JSON.stringify({
158
+ headline: "Launch is on",
159
+ points: ["Ship Tuesday", "Two bugs left"],
160
+ spoken: "The launch is on for Tuesday, with two bugs still open.",
161
+ }),
162
+ );
163
+ restores.push(stubReporter().restore);
164
+
165
+ const summary = await summarize("we ship tuesday");
166
+
167
+ expect(summary.headline).toBe("Launch is on");
168
+ expect(summary.points).toEqual(["Ship Tuesday", "Two bugs left"]);
169
+ expect(summary.spoken).toBe("The launch is on for Tuesday, with two bugs still open.");
170
+ // The prompt really carries the transcript, and really asks for the two
171
+ // shapes — a page that got bullets read aloud is the failure this prevents.
172
+ expect(calls[0]?.prompt).toContain("we ship tuesday");
173
+ expect(calls[0]?.prompt).toContain("READ ALOUD");
174
+ });
175
+
176
+ test("caps the points at what the schema promises the page", async () => {
177
+ installStubGateway(
178
+ JSON.stringify({
179
+ headline: "Many things",
180
+ points: ["a", "b", "c", "d", "e", "f"],
181
+ spoken: "Several things happened.",
182
+ }),
183
+ );
184
+ restores.push(stubReporter().restore);
185
+
186
+ expect((await summarize("…")).points).toHaveLength(4);
187
+ });
188
+
189
+ test("a reply with no spoken script FAILS rather than defaulting to silence", async () => {
190
+ installStubGateway(JSON.stringify({ headline: "Launch is on", points: ["Ship Tuesday"] }));
191
+ restores.push(stubReporter().restore);
192
+
193
+ await expect(summarize("…")).rejects.toThrow(/did not match the shape/);
194
+ });
195
+ });
196
+
197
+ describe("speaking", () => {
198
+ test("stores a WAV and returns its ID — never the bytes", async () => {
199
+ const speech = stubSpeech({ pcmBytes: 48_000 });
200
+ restores.push(speech.restore, stubReporter().restore);
201
+
202
+ const spoken = await speak("The launch is on for Tuesday.");
203
+
204
+ // An id, because a step is journaled by its return value: audio in one is
205
+ // megabytes replayed on every resume.
206
+ expect(spoken).toEqual({ audio: "upl_stub_1", durationMs: 1000 });
207
+ expect(speech.calls[0]?.text).toBe("The launch is on for Tuesday.");
208
+ });
209
+
210
+ test("what it stored is a real WAV, named and typed for the browser", async () => {
211
+ restores.push(stubSpeech({ pcmBytes: 4000 }).restore, stubReporter().restore);
212
+
213
+ const { audio } = await speak("Hello.");
214
+
215
+ await expect(uploadInfo(audio)).resolves.toMatchObject({
216
+ name: "summary.wav",
217
+ // The byte route serves this as `Content-Type`, and a browser will not
218
+ // play inline a file it was handed as octet-stream.
219
+ type: "audio/wav",
220
+ size: 44 + 4000,
221
+ });
222
+ const { bytes } = await readUpload(audio, { end: 12 });
223
+ expect(String.fromCharCode(...bytes.subarray(0, 4))).toBe("RIFF");
224
+ expect(String.fromCharCode(...bytes.subarray(8, 12))).toBe("WAVE");
225
+ });
226
+
227
+ test("passes a chosen voice through, and omits it entirely when none was chosen", async () => {
228
+ const speech = stubSpeech();
229
+ restores.push(speech.restore, stubReporter().restore);
230
+
231
+ await speak("Hello.", "michael");
232
+ await speak("Hello.");
233
+
234
+ expect(speech.calls[0]?.voice).toBe("michael");
235
+ // The SDK's own default, not one this template restates.
236
+ expect(speech.calls[1]?.voice).toBe("jane");
237
+ });
238
+ });
239
+
240
+ describe("countWords", () => {
241
+ test("counts words rather than characters, and answers 0 for nothing", () => {
242
+ expect(countWords("we shipped it on tuesday")).toBe(5);
243
+ expect(countWords(" ")).toBe(0);
244
+ });
245
+ });
246
+
247
+ describe("the whole run", () => {
248
+ /**
249
+ * Answer every leg's HTTP, so the BODY can be driven end to end.
250
+ *
251
+ * Imported through vitest with no bundler in the path, a `"use workflow"`
252
+ * function is an ordinary async function — so what this exercises is the
253
+ * ORDER the legs are wired in and the shape they hand each other, which is
254
+ * the one thing the per-leg specs above cannot see. Durability, suspension
255
+ * and replay are not testable here and are not what this claims.
256
+ *
257
+ * The job answers `completed` on its FIRST poll, deliberately: a second poll
258
+ * would reach the durable `sleep`, which outside a real run is not a wait
259
+ * this spec should be taking.
260
+ *
261
+ * **The model call goes through this too, not `installStubGateway`.** A
262
+ * published `stepFetch` is what `stepGenerate` makes its request with, so a
263
+ * global-fetch stub is never reached once one exists — which is exactly the
264
+ * point `stubStepFetch` exists to make.
265
+ */
266
+ function stubProvider(reply: { headline: string; points: string[]; spoken: string }) {
267
+ return stubStepFetch((request) => {
268
+ if (request.url.includes("llm-gateway")) {
269
+ return { body: { choices: [{ message: { content: JSON.stringify(reply) } }] } };
270
+ }
271
+ if (request.url.endsWith("/v2/upload")) return { body: { upload_url: "https://cdn/aai/1" } };
272
+ if (request.method === "POST") return { body: { id: "t_1" } };
273
+ // One GET, carrying both the status and the text. It used to take two —
274
+ // a poll that read a status and threw the transcript away, then a fetch
275
+ // of the identical URL for the text it had just discarded.
276
+ return {
277
+ body: {
278
+ status: "completed",
279
+ text: "we ship tuesday and two bugs are left",
280
+ audio_duration: 42,
281
+ },
282
+ };
283
+ });
284
+ }
285
+
286
+ test("transcribes, summarizes, speaks, and reports the file it made", async () => {
287
+ restores.push(
288
+ stubProvider({
289
+ headline: "Launch is on",
290
+ points: ["Ship Tuesday"],
291
+ spoken: "The launch is on for Tuesday.",
292
+ }).restore,
293
+ stubReporter().restore,
294
+ stubSpeech().restore,
295
+ );
296
+
297
+ const summary = await spokenSummaryFlow({ recording: UPLOAD_ID });
298
+
299
+ expect(summary).toEqual({
300
+ source: "standup.wav",
301
+ durationMs: 42_000,
302
+ words: 8,
303
+ headline: "Launch is on",
304
+ points: ["Ship Tuesday"],
305
+ spoken: "The launch is on for Tuesday.",
306
+ transcript: "we ship tuesday and two bugs are left",
307
+ // The output carries an ID, never the audio — the rule the whole
308
+ // template exists to demonstrate.
309
+ audio: "upl_stub_1",
310
+ audioDurationMs: 250,
311
+ });
312
+ });
313
+
314
+ test("the voice the form chose reaches the synthesizer", async () => {
315
+ const speech = stubSpeech();
316
+ restores.push(
317
+ stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." })
318
+ .restore,
319
+ stubReporter().restore,
320
+ speech.restore,
321
+ );
322
+
323
+ await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" });
324
+
325
+ expect(speech.calls[0]).toMatchObject({ text: "Spoken.", voice: "michael" });
326
+ });
327
+
328
+ test("a recording the provider gave up on fails the run rather than half-summarizing", async () => {
329
+ restores.push(
330
+ stubStepFetch((request) =>
331
+ request.url.endsWith("/v2/upload")
332
+ ? { body: { upload_url: "https://cdn/aai/1" } }
333
+ : request.method === "POST"
334
+ ? { body: { id: "t_1" } }
335
+ : { body: { status: "error", error: "corrupt audio" } },
336
+ ).restore,
337
+ stubReporter().restore,
338
+ stubSpeech().restore,
339
+ );
340
+
341
+ await expect(spokenSummaryFlow({ recording: UPLOAD_ID })).rejects.toThrow("corrupt audio");
342
+ });
343
+ });
@@ -0,0 +1,142 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * A WORKFLOW APP that goes audio in, audio out: upload a recording and it comes
4
+ * back with a summary you can read AND one you can listen to.
5
+ *
6
+ * `link-digest` is the template to read first: it owns the shape —
7
+ * `workflowApp()`, no session, no tools, a form that starts a run and a page
8
+ * that watches it — and none of that is restated here. `transcription-workflow`
9
+ * owns the other half of the background: uploads, and what it costs to cut a
10
+ * long recording up. What THIS one adds is the return trip.
11
+ *
12
+ * ```text
13
+ * a WAV → transcript → summary → a WAV of the summary
14
+ * async STT LLM Gateway streaming TTS
15
+ * ```
16
+ *
17
+ * ## The last arrow is the one that needed the SDK to grow
18
+ *
19
+ * The first three are ordinary step work. The fourth was impossible until two
20
+ * things existed, and they are what this template is the reference use of:
21
+ *
22
+ * - **`stepSpeak`** (`@alexkroman1/aai/utils`) synthesizes from inside a step.
23
+ * The session TTS surface cannot be used here at all: a `TtsSession` is an
24
+ * event stream wired into a live pipeline's playback, with a turn tracker and
25
+ * barge-in behind it, and a step has no turn to be part of and has to return
26
+ * a VALUE.
27
+ * - **`writeUpload`** (same subpath) puts that value where a browser can reach
28
+ * it. A run's OUTPUT is read back as JSON, so audio cannot travel in one —
29
+ * the same rule that keeps a recording's bytes out of a run's INPUT, arriving
30
+ * at the other end of the run.
31
+ *
32
+ * And **`api.download(id)`** is the browser half: the run's output names an
33
+ * upload id, and the page turns it into a `Blob` it can play and offer as a
34
+ * file. `workflows/summarize.ts` carries the rest, including why the model is
35
+ * asked for a spoken script as well as a bullet list.
36
+ *
37
+ * ## What it needs
38
+ *
39
+ * - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
40
+ * `aai secret put ASSEMBLYAI_API_KEY` once deployed. One key covers all
41
+ * three services this uses: transcription, the LLM Gateway, and the voice.
42
+ * `requiredEnv` below is what makes a deploy check for it rather than letting
43
+ * the first run find out.
44
+ * - **Storage** (`aai storage enable`, Settings → Database in the studio, or
45
+ * `DATABASE_URL` under `aai dev`). REQUIRED here, and more so than for most
46
+ * workflow apps: an upload's record is a row, and this app uses uploads at
47
+ * BOTH ends — the recording coming in and the summary going out.
48
+ *
49
+ * ## The recording is UPLOADED, and the run carries its id
50
+ *
51
+ * A workflow's input is journaled and replayed on every resume, so a
52
+ * recording's BYTES cannot live in it. So the file goes to
53
+ * `POST /workflows/uploads` (the browser does this for you: `uploads` below is
54
+ * what makes `<WorkflowFields>` render a file picker, and `useWorkflowSubmit`
55
+ * stores the file before starting the run), the input carries the returned id,
56
+ * and the step that needs the bytes streams them out with `readUpload`.
57
+ *
58
+ * ## It is scriptable, which is the other half of having an API
59
+ *
60
+ * The page is one caller. Three requests do the whole thing from a shell —
61
+ * upload, start a run, then fetch the summary's audio by the id the run
62
+ * reported:
63
+ *
64
+ * ```sh
65
+ * ID=$(curl -s -X POST "https://<your-agent>/workflows/uploads?name=standup.wav" \
66
+ * -H 'content-type: audio/wav' --data-binary @standup.wav | jq -r .id)
67
+ *
68
+ * OUT=$(curl -s -X POST https://<your-agent>/workflows/runs \
69
+ * -H 'content-type: application/json' \
70
+ * -d "{\"workflow\":\"spokenSummary\",\"wait\":30000,\"input\":{\"recording\":\"$ID\"}}")
71
+ *
72
+ * curl -s "https://<your-agent>/workflows/uploads/$(echo "$OUT" | jq -r .run.output.audio)" \
73
+ * -o summary.wav
74
+ * ```
75
+ */
76
+
77
+ import { workflow, workflowApp } from "@alexkroman1/aai";
78
+ import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
79
+ import { z } from "zod";
80
+ import { spokenSummaryFlow } from "./workflows/summarize.ts";
81
+
82
+ /**
83
+ * The voices the form offers.
84
+ *
85
+ * READ from the SDK's catalog rather than listed, because a wrong voice id is a
86
+ * SILENT failure — it is a free-form string the service rejects in band after
87
+ * the socket is open, so the synthesis simply produces nothing. Narrowed to the
88
+ * English ones because the summary is written in the transcript's language and
89
+ * the prompt does not translate; every voice in the catalog speaks exactly one.
90
+ */
91
+ const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
92
+ .filter(([, spec]) => spec.language === "en")
93
+ .map(([id]) => id);
94
+
95
+ /**
96
+ * The same list as a TUPLE, which is what `z.enum` takes.
97
+ *
98
+ * Destructured rather than cast: a `.map` produces an array, and
99
+ * `as [string, ...string[]]` would be a template teaching a cast. The default
100
+ * covers the empty case honestly — a catalog with no English voice falls back
101
+ * to the SDK's own default rather than rendering a picker with no options.
102
+ */
103
+ const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
104
+
105
+ /**
106
+ * The declaration: schema, description, and the directive body.
107
+ *
108
+ * Exported so `WorkflowOutputOf<typeof spokenSummary>` names the output type in
109
+ * one place — including from `client.tsx`, where `import type` is erased and so
110
+ * bundles nothing server-side.
111
+ */
112
+ export const spokenSummary = workflow({
113
+ description: "Transcribe a recording, summarize it, and read the summary back as audio",
114
+ input: z.object({
115
+ // A plain string, because an upload id is what the run really receives.
116
+ // What makes it a file picker rather than a text box is the `uploads` line
117
+ // below.
118
+ recording: z.string().describe("A recording to summarize — WAV, MP3 or M4A"),
119
+ // An enum, so the form renders a SELECT rather than a text box — which is
120
+ // the whole reason the list is derived above rather than left free-form.
121
+ // Optional, so the SDK's own default voice applies when nobody chooses.
122
+ voice: z
123
+ .enum([FIRST_VOICE, ...OTHER_VOICES])
124
+ .optional()
125
+ .describe("Voice to read the summary in"),
126
+ }),
127
+ // The one line that makes the form take a file: `<WorkflowFields>` renders a
128
+ // picker for this property, `useWorkflowSubmit` stores the chosen file, and
129
+ // the step that transcribes it reads it back with `readUpload`.
130
+ uploads: ["recording"],
131
+ run: spokenSummaryFlow,
132
+ });
133
+
134
+ export default workflowApp({
135
+ name: "Spoken Summary",
136
+ workflows: { spokenSummary },
137
+ // Checked at deploy time, so a missing key is a warning naming it rather than
138
+ // a run that fails on its second step. A workflow app declares no providers,
139
+ // so this is the only thing that can name the credential its steps read — and
140
+ // this one key covers transcription, the model and the voice alike.
141
+ requiredEnv: ["ASSEMBLYAI_API_KEY"],
142
+ });