@alexkroman1/aai-cli 6.3.0 → 6.4.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.
@@ -13,8 +13,8 @@
13
13
  "publish:agent": "aai publish"
14
14
  },
15
15
  "dependencies": {
16
- "@alexkroman1/aai": "^6.3.0",
17
- "@alexkroman1/aai-ui": "^6.3.0",
16
+ "@alexkroman1/aai": "^6.4.0",
17
+ "@alexkroman1/aai-ui": "^6.4.0",
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.3.0",
26
+ "@alexkroman1/aai-cli": "^6.4.0",
27
27
  "@tailwindcss/vite": "^4.3.3",
28
28
  "@types/node": "^26.2.0",
29
29
  "@types/react": "^19.2.18",
@@ -18,10 +18,14 @@
18
18
  */
19
19
 
20
20
  import { stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
+ import { readUpload } from "@alexkroman1/aai/utils";
21
22
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
22
- import { RetryableError } from "workflow";
23
+ import { FatalError, RetryableError } from "workflow";
23
24
  import { z } from "zod";
24
- import agentDef, { transcribe } from "./agent.ts";
25
+ import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
26
+ import { createJob, pollTranscript, readTranscript, uploadToProvider } from "./workflows/batch.ts";
27
+ import { planStreamed, probeUpload } from "./workflows/stream.ts";
28
+
25
29
  import {
26
30
  clock,
27
31
  mergeTranscript,
@@ -49,6 +53,16 @@ const SYNC_ORIGIN = "https://sync.assemblyai.com";
49
53
  /** The id every spec below uploads under. */
50
54
  const UPLOAD_ID = "upl_test";
51
55
 
56
+ /**
57
+ * A fixed run-start epoch, so `elapsedMs` is assertable at all.
58
+ *
59
+ * `startClock` is a step in production; a spec supplies the value directly, which is
60
+ * the point of threading it as an argument rather than reading a clock inside the
61
+ * merge — the duration is then a function of journaled values and not of how long the
62
+ * test took.
63
+ */
64
+ const STARTED_AT = 1_000_000;
65
+
52
66
  /**
53
67
  * Publish one in-memory upload, the way `createServer` publishes a real store.
54
68
  *
@@ -109,10 +123,28 @@ function wavFile(
109
123
  return head;
110
124
  }
111
125
 
112
- describe("the agent declares its workflow and nothing else", () => {
113
- test("under the name the REST route resolves it by", () => {
114
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["transcribe"]);
126
+ describe("the agent declares its three workflows and nothing else", () => {
127
+ test("under the names the REST route resolves them by", () => {
128
+ // The page starts a run by these strings, so a rename is a runtime 400 rather
129
+ // than a compile error — which is what makes pinning them worth a test.
130
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual([
131
+ "transcribe",
132
+ "transcribeStream",
133
+ "transcribeBatch",
134
+ ]);
115
135
  expect(agentDef.workflows?.transcribe).toBe(transcribe);
136
+ expect(agentDef.workflows?.transcribeStream).toBe(transcribeStream);
137
+ expect(agentDef.workflows?.transcribeBatch).toBe(transcribeBatch);
138
+ });
139
+
140
+ test("all three take `recording` as an UPLOAD, which is what makes one picker serve them", () => {
141
+ // There is no second kind of declaration: `recording` carries an upload id in
142
+ // every flow, and the streaming one differs only in that the CLIENT chose the id
143
+ // and PUT the file to it. A divergence here would mean the form had to ask a
144
+ // person how the bytes should travel.
145
+ for (const flow of [transcribe, transcribeStream, transcribeBatch]) {
146
+ expect(flow.uploads).toEqual(["recording"]);
147
+ }
116
148
  });
117
149
 
118
150
  test("with no tools, because the interface is the page and the API", () => {
@@ -538,10 +570,15 @@ describe("transcribeSegment", () => {
538
570
  describe("mergeTranscript", () => {
539
571
  test("stitches the segments in index order, whatever order they arrive in", async () => {
540
572
  publishRecording(new Uint8Array(1));
541
- const merged = await mergeTranscript(UPLOAD_ID, 12_000, [
542
- { index: 1, text: "on Friday if the tests pass" },
543
- { index: 0, text: "we ship on Friday" },
544
- ]);
573
+ const merged = await mergeTranscript(
574
+ UPLOAD_ID,
575
+ 12_000,
576
+ [
577
+ { index: 1, text: "on Friday if the tests pass" },
578
+ { index: 0, text: "we ship on Friday" },
579
+ ],
580
+ STARTED_AT,
581
+ );
545
582
  expect(merged.transcript).toBe("we ship on Friday if the tests pass");
546
583
  expect(merged.words).toBe(8);
547
584
  expect(merged).toMatchObject({ segments: 2, durationMs: 12_000 });
@@ -549,7 +586,7 @@ describe("mergeTranscript", () => {
549
586
 
550
587
  test("names the FILE it transcribed, not the id the run carried", async () => {
551
588
  publishRecording(new Uint8Array(1), "standup.wav");
552
- const merged = await mergeTranscript(UPLOAD_ID, 1000, [{ index: 0, text: "hi" }]);
589
+ const merged = await mergeTranscript(UPLOAD_ID, 1000, [{ index: 0, text: "hi" }], STARTED_AT);
553
590
  expect(merged.source).toBe("standup.wav");
554
591
  });
555
592
  });
@@ -561,3 +598,207 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
561
598
  out.set(b, a.length);
562
599
  return out;
563
600
  }
601
+
602
+ /**
603
+ * The STREAMING flow's own steps.
604
+ *
605
+ * Same honest line as the classic half above: the steps are driven directly and the
606
+ * body is not, because imported through vitest a `"use step"` function is an ordinary
607
+ * async function. Almost nothing here is new — the transcribing and the merging are
608
+ * `transcribe.ts`'s own steps, called unchanged — so what is worth asserting is the
609
+ * two things this flow adds: reading how far the upload has got, and planning from a
610
+ * header while most of the file is still missing.
611
+ */
612
+ describe("the streaming flow", () => {
613
+ const FORMAT: WavFormat = { ...MONO_16K, dataStart: 44, dataEnd: 44 + 320_000 };
614
+
615
+ /** Publish a partially-arrived upload: `stored` bytes of a `declared`-byte file. */
616
+ function publishPartial(stored: number, declared: number, complete = false) {
617
+ const bytes = new Uint8Array(44 + stored);
618
+ bytes.set(wavFile(MONO_16K, declared), 0);
619
+ restore = stubUploads({
620
+ [UPLOAD_ID]: { bytes, name: "standup.wav", type: "audio/wav", complete },
621
+ });
622
+ }
623
+
624
+ test("probeUpload reports what has ARRIVED and whether that is all", async () => {
625
+ publishPartial(1000, 320_000);
626
+ // The poll the body runs. `complete` is separate from `size` because a size that
627
+ // stopped growing is not a claim that the file is finished.
628
+ await expect(probeUpload(UPLOAD_ID)).resolves.toEqual({ size: 44 + 1000, complete: false });
629
+ });
630
+
631
+ test("probeUpload reports complete once it is", async () => {
632
+ publishPartial(320_000, 320_000, true);
633
+ await expect(probeUpload(UPLOAD_ID)).resolves.toMatchObject({ complete: true });
634
+ });
635
+
636
+ test("planStreamed plans the WHOLE recording from a header that arrived alone", async () => {
637
+ // The one real difference from `splitRecording`: only 1000 bytes of audio are
638
+ // stored, and the plan still covers the 320,000 the header declares. Planning
639
+ // from what has arrived would fan out over a fraction of the recording and report
640
+ // success — which is the failure this argument exists to prevent.
641
+ publishPartial(1000, 320_000);
642
+ const plan = await planStreamed(UPLOAD_ID);
643
+ expect(plan.format.dataEnd).toBe(44 + 320_000);
644
+ expect(plan.segments.at(-1)?.end).toBe(44 + 320_000);
645
+ expect(plan.segments.length).toBe(planSegments(FORMAT).length);
646
+ });
647
+
648
+ test("planStreamed refuses a WAV that declares no length, naming the other flow", async () => {
649
+ // `0` means "unknown", and there is nothing to compute a segment list from until
650
+ // the file has finished — which is exactly what `transcribe` is for.
651
+ const bytes = new Uint8Array(44 + 100);
652
+ bytes.set(wavFile(MONO_16K, 100, { declaredDataSize: 0 }), 0);
653
+ restore = stubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
654
+ await expect(planStreamed(UPLOAD_ID)).rejects.toThrow(/declares no data length/);
655
+ });
656
+
657
+ test("planStreamed refuses a file that is not a WAV, terminally", async () => {
658
+ restore = stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
659
+ // Fatal, not retryable: three more attempts read the same bytes.
660
+ await expect(planStreamed(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
661
+ });
662
+
663
+ test("a segment reads SHORT rather than failing when its bytes have not landed", async () => {
664
+ // The property the whole flow rests on, and it predates streaming: `readUpload`
665
+ // clamps its window to what is stored. So a body that asks slightly early gets
666
+ // what exists — which is why the body checks `end <= size` and can trust the
667
+ // clamp for the final segment of a file that came up short.
668
+ publishPartial(1000, 320_000);
669
+ const slice = await readUpload(UPLOAD_ID, { start: 44, end: 44 + 320_000 });
670
+ expect(slice.bytes.length).toBe(1000);
671
+ expect(slice.end).toBe(44 + 1000);
672
+ });
673
+ });
674
+
675
+ /**
676
+ * The ASYNC flow's steps.
677
+ *
678
+ * Driven against a stubbed `stepFetch`, like the sync flow's — and note what that
679
+ * makes assertable: the three calls this flow makes are the whole of it, so the
680
+ * assertions are about the CONTRACT with the provider (an id survives, a failed job
681
+ * is terminal, the file is streamed rather than buffered) rather than about
682
+ * arithmetic this flow does not do.
683
+ */
684
+ describe("the async flow", () => {
685
+ beforeEach(() => {
686
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
687
+ });
688
+
689
+ const batchStubs: (() => void)[] = [];
690
+ afterEach(() => {
691
+ for (const undo of batchStubs.splice(0)) undo();
692
+ });
693
+
694
+ /** Answer the async API, recording what was sent. */
695
+ function stubBatch(answer: (url: string) => { status?: number; body?: unknown }) {
696
+ const stub = stubStepFetch((req) => answer(req.url));
697
+ batchStubs.push(stub.restore);
698
+ return stub.calls;
699
+ }
700
+
701
+ test("uploadToProvider streams the file and answers with the provider's URL", async () => {
702
+ publishRecording(new Uint8Array(5000), "standup.wav");
703
+ const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
704
+ await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
705
+ audioUrl: "https://cdn.example/abc",
706
+ });
707
+ expect(calls.map((one) => one.url)).toEqual(["https://api.assemblyai.com/v2/upload"]);
708
+ });
709
+
710
+ test("the file is STREAMED, so a step never holds a whole recording", async () => {
711
+ publishRecording(new Uint8Array(5000), "standup.wav");
712
+ const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
713
+ await uploadToProvider(UPLOAD_ID);
714
+ // `stubStepFetch` drains a streaming body into bytes, so what this asserts is that
715
+ // every byte went out — the streaming is what keeps a gigabyte off the heap, and
716
+ // the bytes arriving intact is what says the windowing is right.
717
+ const sent = calls[0]?.body;
718
+ expect(sent).toBeInstanceOf(Uint8Array);
719
+ expect(sent instanceof Uint8Array ? sent.length : -1).toBe(5000);
720
+ });
721
+
722
+ test("the upload is a SEPARATE step, so a failed submit does not re-send the file", async () => {
723
+ // Found by running it: as one step, a 400 on the create call retried the whole
724
+ // thing five times and re-uploaded 24 MB on each attempt. The split is what makes
725
+ // a retry of the cheap half cost the cheap half.
726
+ publishRecording(new Uint8Array(5000));
727
+ const calls = stubBatch(() => ({ status: 400, body: { error: "bad field" } }));
728
+ await expect(createJob("https://cdn.example/abc")).rejects.toBeInstanceOf(FatalError);
729
+ // One call, and it is not the upload.
730
+ expect(calls).toHaveLength(1);
731
+ expect(calls[0]?.url).toBe("https://api.assemblyai.com/v2/transcript");
732
+ });
733
+
734
+ test("createJob asks for `speech_models`, plural — the singular field is a 400", async () => {
735
+ publishRecording(new Uint8Array(10));
736
+ const calls = stubBatch(() => ({ body: { id: "tr_1" } }));
737
+ await expect(createJob("https://cdn.example/abc")).resolves.toEqual({ id: "tr_1" });
738
+ const sent = JSON.parse(String(calls[0]?.body)) as Record<string, unknown>;
739
+ // The async API deprecated `speech_model` and answers 400 for any current model
740
+ // name passed to it — which is how the first live run of this flow failed. The
741
+ // STREAMING API still uses the singular field, so neither is "the" spelling.
742
+ expect(sent).toMatchObject({ speech_models: ["universal-3-5-pro"] });
743
+ expect(sent.speech_model).toBeUndefined();
744
+ });
745
+
746
+ test("a job the provider gave up on is TERMINAL, not polled forever", async () => {
747
+ publishRecording(new Uint8Array(10));
748
+ stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
749
+ // The provider has decided; no number of polls changes it, so this must not come
750
+ // back as "not done yet".
751
+ await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
752
+ });
753
+
754
+ test("pollTranscript answers `done` on completed and not before", async () => {
755
+ publishRecording(new Uint8Array(10));
756
+ stubBatch(() => ({ body: { status: "processing" } }));
757
+ await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false, status: "processing" });
758
+ });
759
+
760
+ test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
761
+ publishRecording(new Uint8Array(10));
762
+ stubBatch(() => ({ body: {} }));
763
+ await expect(pollTranscript("tr_1")).resolves.toMatchObject({ done: false });
764
+ });
765
+
766
+ test("readTranscript reports the provider's own duration and ONE segment", async () => {
767
+ publishRecording(new Uint8Array(10), "standup.wav");
768
+ stubBatch(() => ({ body: { text: " hello there ", audio_duration: 12.5 } }));
769
+ await expect(readTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
770
+ source: "standup.wav",
771
+ // Not a fudge: the async API transcribed the recording in one piece, which is
772
+ // the difference this flow is here to show.
773
+ segments: 1,
774
+ durationMs: 12_500,
775
+ words: 2,
776
+ transcript: "hello there",
777
+ });
778
+ });
779
+
780
+ test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
781
+ publishRecording(new Uint8Array(10));
782
+ stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
783
+ await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(RetryableError);
784
+ });
785
+
786
+ test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
787
+ publishRecording(new Uint8Array(10), "standup.wav");
788
+ stubBatch(() => ({ body: { text: "hi", audio_duration: 1 } }));
789
+ const batched = await readTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
790
+ // One key set, so the page's summary line renders every flow's output. A field on
791
+ // one flow and not the others is a panel that shows it for some runs and not
792
+ // others, with nothing saying why.
793
+ expect(Object.keys(batched).sort()).toEqual([
794
+ "durationMs",
795
+ "elapsedMs",
796
+ "segments",
797
+ "source",
798
+ "transcript",
799
+ "words",
800
+ ]);
801
+ // And the wall clock really is measured from what it was handed.
802
+ expect(batched.elapsedMs).toBeGreaterThan(0);
803
+ });
804
+ });
@@ -38,6 +38,25 @@
38
38
  * None of that is this template's code. Uploads are the SDK's, for the reason
39
39
  * every workflow app hits this wall on its first form.
40
40
  *
41
+ * ## Three flows over one job, so they can be compared
42
+ *
43
+ * | | `transcribe` | `transcribeStream` | `transcribeBatch` |
44
+ * | --- | --- | --- | --- |
45
+ * | provider API | sync | sync | **async** |
46
+ * | run starts | after the upload | **before** it | after the upload |
47
+ * | client sends | `POST /uploads` | `PUT /uploads/<id>` | `POST /uploads` |
48
+ * | shape | plan, fan out, merge | poll, fan out, merge | submit, poll, read |
49
+ * | client hook | `useWorkflowSubmit` | `useWorkflowStream` | `useWorkflowSubmit` |
50
+ * | accepts | linear-PCM WAV | linear-PCM WAV | **any audio** |
51
+ * | segments | 7 for 10 minutes | 7 | **1** |
52
+ *
53
+ * The first two are the same fan-out arranged two ways, and the difference between
54
+ * them is measured rather than claimed — see `workflows/stream.ts`, which records
55
+ * what overlapping the upload actually saves (bounded by the transcription, not
56
+ * proportional to the file). The third does none of that work and is what a real
57
+ * product would probably ship; it is here because a template that only showed the
58
+ * clever option would be hiding the simple one.
59
+ *
41
60
  * ## It is scriptable, which is the other half of having an API
42
61
  *
43
62
  * The page is one caller. Two requests do the same thing from a shell — upload,
@@ -52,10 +71,17 @@
52
71
  * -d "{\"workflow\":\"transcribe\",\"wait\":30000,\"input\":{
53
72
  * \"recording\":\"$ID\"}}"
54
73
  * ```
74
+ *
75
+ * The streaming flow is the same three verbs in a different order — start, then
76
+ * upload parts, then seal — and the page renders the whole recipe under "Use the
77
+ * API without this page". `AGENT.md` in this template is the copy a script author
78
+ * reads.
55
79
  */
56
80
 
57
81
  import { workflow, workflowApp } from "@alexkroman1/aai";
58
82
  import { z } from "zod";
83
+ import { transcribeBatchFlow } from "./workflows/batch.ts";
84
+ import { transcribeStreamFlow } from "./workflows/stream.ts";
59
85
  import { transcribeFlow } from "./workflows/transcribe.ts";
60
86
 
61
87
  /**
@@ -83,9 +109,58 @@ export const transcribe = workflow({
83
109
  run: transcribeFlow,
84
110
  });
85
111
 
112
+ /**
113
+ * The same work, started BEFORE the recording has finished uploading.
114
+ *
115
+ * Declared beside `transcribe` rather than replacing it, because the two are worth
116
+ * reading against each other: this one shows the transcript growing while the bytes
117
+ * are still moving, and the other is the shape to understand first — three steps in a
118
+ * straight line, with no polling.
119
+ *
120
+ * **The declaration is IDENTICAL, including `uploads`.** That is the point of the
121
+ * mechanism: `recording` carries an upload id either way, and what differs is only
122
+ * that the client CHOSE the id and `PUT` the file to it, so the id was valid before
123
+ * the bytes were. `useWorkflowStream` is the client half; `workflows/stream.ts` is
124
+ * the body, and it reuses this file's own `transcribeSegment` unchanged.
125
+ */
126
+ export const transcribeStream = workflow({
127
+ description: "Transcribe a recording while it is still uploading",
128
+ input: z.object({
129
+ recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
130
+ }),
131
+ uploads: ["recording"],
132
+ run: transcribeStreamFlow,
133
+ });
134
+
135
+ /**
136
+ * The same work again, handed to AssemblyAI's ASYNC API instead of cut up.
137
+ *
138
+ * The third desk, and the one a real product would probably ship. Both flows above
139
+ * exist because the SYNC endpoint answers inside the request and pays for it with a
140
+ * 120-second, 40 MB cap — so a long recording has to be planned, fanned out and
141
+ * stitched. The async API has no cap: submit a job, poll, read the text.
142
+ *
143
+ * It is declared here so the page can run all three over the same file and show what
144
+ * each costs. What this one gives up is the inside of the work — the latency is the
145
+ * provider's queue rather than a fan-out you can tune — and what it gains is
146
+ * everything the other two spend code on, plus formats they refuse: the async API
147
+ * takes compressed audio, so an m4a off a phone works where a WAV-only cut does not.
148
+ *
149
+ * `workflows/batch.ts` carries the rest, including why the wait is what makes this a
150
+ * workflow rather than a request.
151
+ */
152
+ export const transcribeBatch = workflow({
153
+ description: "Transcribe a recording through the async API, polling until it is done",
154
+ input: z.object({
155
+ recording: z.string().describe("Any recording the async API accepts — WAV, MP3, M4A"),
156
+ }),
157
+ uploads: ["recording"],
158
+ run: transcribeBatchFlow,
159
+ });
160
+
86
161
  export default workflowApp({
87
162
  name: "Transcription Desk",
88
- workflows: { transcribe },
163
+ workflows: { transcribe, transcribeStream, transcribeBatch },
89
164
  // Checked at deploy time, so a missing key is a warning naming it rather than
90
165
  // a run that fails on its second step.
91
166
  requiredEnv: ["ASSEMBLYAI_API_KEY"],
@@ -0,0 +1,206 @@
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
+ { route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
140
+ { route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
141
+ ];
142
+
143
+ /**
144
+ * The whole API, collapsed by default.
145
+ *
146
+ * `<details>` rather than a state hook: the browser owns disclosure, it is
147
+ * keyboard-accessible and findable by in-page search without anyone wiring either,
148
+ * and a page that renders a transcript should not re-render because somebody
149
+ * expanded a help panel.
150
+ */
151
+ export function ApiHelp(): ReactNode {
152
+ return (
153
+ <details className="rounded-md border">
154
+ <summary className="cursor-pointer p-4 text-sm font-medium">
155
+ Use the API without this page
156
+ </summary>
157
+ <div className="flex flex-col gap-6 border-t p-4">
158
+ <p className="text-sm opacity-70">
159
+ This page is one caller. Everything it does is plain HTTP on this agent's own origin,
160
+ unauthenticated unless the deployment sets{" "}
161
+ <code className="text-xs">AAI_WORKFLOW_API_TOKEN</code> (then every route wants{" "}
162
+ <code className="text-xs">Authorization: Bearer …</code>). A run id is the whole handle —
163
+ no session, no cookie — so a transcript can be collected from another machine, days later.
164
+ </p>
165
+ <p className="text-sm">
166
+ {/* The API answering for itself: same JSON this form was rendered from. */}
167
+ <a className="underline" href={API} target="_blank" rel="noreferrer">
168
+ {`GET ${API}`}
169
+ </a>{" "}
170
+ <span className="opacity-70">
171
+ — the live listing, including each workflow's input schema. Set{" "}
172
+ <code className="text-xs">AGENT</code> to this page's origin for the recipes below.
173
+ </span>
174
+ </p>
175
+
176
+ <Recipe
177
+ title="Store it, then transcribe"
178
+ note="Sync API. One request per phase, and the upload has to finish before there is a run."
179
+ script={CLASSIC}
180
+ />
181
+ <Recipe
182
+ title="Transcribe while it uploads"
183
+ note="Sync API, one upload request, no splitting. The run starts on an id you chose and reads the bytes as they land."
184
+ script={STREAMING}
185
+ />
186
+ <Recipe
187
+ title="Let the provider do it"
188
+ note="Async API. No cutting and no seams, it accepts compressed audio, and the wait belongs to the provider's queue."
189
+ script={BATCH}
190
+ />
191
+
192
+ <section className="flex flex-col gap-2">
193
+ <h4 className="text-xs font-medium uppercase tracking-[1.2px]">Every route</h4>
194
+ <ul className="flex flex-col gap-1">
195
+ {ROUTES.map((entry) => (
196
+ <li key={entry.route} className="flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-3">
197
+ <code className="shrink-0 sm:w-72">{entry.route}</code>
198
+ <span className="opacity-70">{entry.does}</span>
199
+ </li>
200
+ ))}
201
+ </ul>
202
+ </section>
203
+ </div>
204
+ </details>
205
+ );
206
+ }