# AudioFlow Agent Quickstart

Give this document to a coding Agent. It defines the inputs, security boundaries, HTTP contract, and a runnable Node.js 24 TypeScript client for one asynchronous transcription.

> AudioFlow uses OpenAI-shaped transcription parameters, but media bytes must be uploaded through a signed OSS request. Never send multipart/form-data to the transcription endpoint.

## 1. Agent contract

Implement a client that accepts one local media path and prints the terminal AudioFlow task as JSON.

- Runtime: Node.js 24. No third-party packages are required for the reference client.
- Input: one non-empty FLAC, M4A, MP3, MP4, MPEG, OGG, WAV, or WebM file below 64 MiB.
- Output: the complete terminal task. Treat succeeded and no_speech as successful terminal states; failed and cancelled are unsuccessful terminal states.
- Machine-readable source: https://audioflow123.com/guide/quickstart.md

## 2. Security boundaries

The control plane, ASR data plane, and OSS signed URL have different trust boundaries. Keep their credentials separate.

| Destination | Purpose | Credential rule |
| --- | --- | --- |
| https://audioflow123.com | Agent browser authorization | Send only token and poll-secret hashes when creating a request; send the local poll secret only to poll and acknowledge it. |
| https://asr.audioflow123.com | Upload sessions and transcription tasks | Send Authorization: Bearer vf_stt_... on every customer API request. |
| Signed OSS URL | Private media upload | Send exactly the returned signed headers and Content-Length. Never send the AudioFlow Authorization header. |

> Never ask for or process an AudioFlow password, browser session, or payment credential. Never log, display, commit, or place the complete vf_stt_ token in a command argument.

## 3. Authorize the Agent

Use VOICEFLOW_TOKEN when it is already available. Otherwise generate the token locally and ask the user to approve it in their browser.

- Generate vf_stt_ plus 32 random bytes encoded as Base64URL, and a separate 32-byte Base64URL poll secret.
- POST their SHA-256 hashes and the masked token prefix to /api/agent/token-requests. The complete token remains local.
- Show verification_uri_complete and user_code to the user. Poll no faster than the returned interval; honor HTTP 429 Retry-After.
- After approved, use the locally generated token and POST /api/agent/token-requests/ack. Handle access_denied and expired as terminal authorization failures.
- A manually created dashboard key is a fallback: place it in VOICEFLOW_TOKEN instead of embedding it in source code.

```http
POST https://audioflow123.com/api/agent/token-requests
POST https://audioflow123.com/api/agent/token-requests/poll
POST https://audioflow123.com/api/agent/token-requests/ack
```

## 4. Run the reference client

Save the following source as audioflow-quickstart.ts. Node.js 24 can run this erasable TypeScript directly.

```bash
node audioflow-quickstart.ts /absolute/path/sample.mp3

# Optional fallback when a dashboard key already exists:
VOICEFLOW_TOKEN=vf_stt_replace_me node audioflow-quickstart.ts /absolute/path/sample.mp3
```

```typescript
import { createHash, randomBytes } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { basename, extname } from "node:path";
import { pathToFileURL } from "node:url";

export const CONTROL_ORIGIN = "https://audioflow123.com";
export const ASR_ORIGIN = "https://asr.audioflow123.com";
export const SINGLE_UPLOAD_LIMIT_BYTES = 64 * 1024 * 1024;

const TOKEN_PATTERN = /^vf_stt_[A-Za-z0-9_-]{43}$/;
const UUID_PATTERN =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const USER_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{8}$/;
const TERMINAL_STATUSES = new Set([
  "succeeded",
  "no_speech",
  "failed",
  "cancelled",
]);
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
  flac: "audio/flac",
  m4a: "audio/mp4",
  mp3: "audio/mpeg",
  mp4: "video/mp4",
  mpeg: "audio/mpeg",
  ogg: "audio/ogg",
  wav: "audio/wav",
  webm: "audio/webm",
});

type FetchLike = typeof fetch;
type Sleep = (milliseconds: number) => Promise<void>;
type Logger = (message: string) => void;

type RuntimeOptions = Readonly<{
  fetchImpl?: FetchLike;
  sleep?: Sleep;
  log?: Logger;
  now?: () => number;
}>;

type MediaFile = Readonly<{
  path: string;
  filename: string;
  contentType: string;
  sizeBytes: number;
}>;

type JsonRecord = Record<string, unknown>;

const defaultSleep: Sleep = milliseconds =>
  new Promise(resolve => setTimeout(resolve, milliseconds));

function record(value: unknown, label: string): JsonRecord {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new Error(`${label} returned invalid JSON.`);
  }
  return value as JsonRecord;
}

function stringField(value: unknown, label: string): string {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(`${label} is missing from the response.`);
  }
  return value;
}

function sha256(value: string): string {
  return createHash("sha256").update(value, "utf8").digest("hex");
}

function maskedToken(token: string): string {
  return `${token.slice(0, 15)}…${token.slice(-4)}`;
}

async function jsonResponse(response: Response): Promise<JsonRecord> {
  const text = await response.text();
  if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
    throw new Error("AudioFlow returned an unexpectedly large response.");
  }
  try {
    return record(JSON.parse(text), "AudioFlow");
  } catch {
    throw new Error("AudioFlow returned invalid JSON.");
  }
}

function publicError(body: JsonRecord): string {
  const error = body.error;
  if (typeof error !== "object" || error === null || Array.isArray(error)) {
    return "request_failed";
  }
  const code = (error as JsonRecord).code;
  return typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code)
    ? code
    : "request_failed";
}

function retryAfterMilliseconds(response: Response, fallback: number): number {
  const seconds = Number(response.headers.get("retry-after"));
  return Number.isFinite(seconds) && seconds > 0
    ? Math.ceil(seconds * 1000)
    : fallback;
}

async function postJson(
  fetchImpl: FetchLike,
  url: URL,
  body: JsonRecord
): Promise<Response> {
  return fetchImpl(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    redirect: "error",
    signal: AbortSignal.timeout(30_000),
  });
}

export async function authorizeAgent(
  environment: NodeJS.ProcessEnv = process.env,
  options: RuntimeOptions = {}
): Promise<string> {
  const configured = environment.VOICEFLOW_TOKEN?.trim();
  if (configured !== undefined && configured !== "") {
    if (!TOKEN_PATTERN.test(configured)) {
      throw new Error("VOICEFLOW_TOKEN is not a valid vf_stt_ API key.");
    }
    return configured;
  }

  const fetchImpl = options.fetchImpl ?? fetch;
  const sleep = options.sleep ?? defaultSleep;
  const log = options.log ?? (message => console.error(message));
  const now = options.now ?? Date.now;
  const token = `vf_stt_${randomBytes(32).toString("base64url")}`;
  const pollSecret = randomBytes(32).toString("base64url");

  const createdResponse = await postJson(
    fetchImpl,
    new URL("/api/agent/token-requests", CONTROL_ORIGIN),
    {
      client_name: "audioflow-node-quickstart",
      client_version: "1.0.0",
      token_hash: sha256(token),
      token_prefix: maskedToken(token),
      poll_secret_hash: sha256(pollSecret),
    }
  );
  const created = await jsonResponse(createdResponse);
  if (createdResponse.status !== 201) {
    throw new Error(
      `Agent authorization could not start (${publicError(created)}).`
    );
  }

  const requestId = stringField(created.request_id, "request_id");
  const userCode = stringField(created.user_code, "user_code");
  const verificationUri = stringField(
    created.verification_uri_complete,
    "verification_uri_complete"
  );
  const expiresAt = Date.parse(stringField(created.expires_at, "expires_at"));
  const intervalSeconds = Number(created.interval);
  const verificationUrl = new URL(verificationUri);
  if (
    !UUID_PATTERN.test(requestId) ||
    !USER_CODE_PATTERN.test(userCode) ||
    verificationUrl.origin !== CONTROL_ORIGIN ||
    verificationUrl.pathname !== "/connect-agent" ||
    !Number.isFinite(expiresAt) ||
    !Number.isSafeInteger(intervalSeconds) ||
    intervalSeconds < 5
  ) {
    throw new Error("Agent authorization returned invalid metadata.");
  }

  log(`Open this URL to approve the Agent: ${verificationUrl.href}`);
  log(`Verification code: ${userCode}`);

  let waitMilliseconds = intervalSeconds * 1000;
  while (now() < expiresAt) {
    await sleep(waitMilliseconds);
    const pollResponse = await postJson(
      fetchImpl,
      new URL("/api/agent/token-requests/poll", CONTROL_ORIGIN),
      { request_id: requestId, poll_secret: pollSecret }
    );
    const polled = await jsonResponse(pollResponse);
    if (pollResponse.status === 202) {
      waitMilliseconds = Math.max(
        waitMilliseconds,
        Number(polled.interval ?? intervalSeconds) * 1000
      );
      continue;
    }
    if (pollResponse.status === 429) {
      waitMilliseconds = retryAfterMilliseconds(pollResponse, waitMilliseconds);
      continue;
    }
    if (pollResponse.status !== 200 || polled.status !== "approved") {
      throw new Error(`Agent authorization failed (${publicError(polled)}).`);
    }

    const ackResponse = await postJson(
      fetchImpl,
      new URL("/api/agent/token-requests/ack", CONTROL_ORIGIN),
      { request_id: requestId, poll_secret: pollSecret }
    );
    if (ackResponse.status !== 204) {
      throw new Error("Agent authorization acknowledgement failed.");
    }
    return token;
  }

  throw new Error("Agent authorization expired before approval.");
}

export async function inspectMedia(filePath: string): Promise<MediaFile> {
  const fileStat = await stat(filePath);
  const filename = basename(filePath);
  const extension = extname(filename).slice(1).toLowerCase();
  const contentType = CONTENT_TYPES[extension];
  if (!fileStat.isFile() || fileStat.size <= 0 || contentType === undefined) {
    throw new Error(
      "Use a non-empty FLAC, M4A, MP3, MP4, MPEG, OGG, WAV, or WebM file."
    );
  }
  if (fileStat.size >= SINGLE_UPLOAD_LIMIT_BYTES) {
    throw new Error(
      "This quickstart handles files below 64 MiB. Implement the multipart production checklist for larger files."
    );
  }
  return Object.freeze({
    path: filePath,
    filename,
    contentType,
    sizeBytes: fileStat.size,
  });
}

async function authenticatedJson(
  fetchImpl: FetchLike,
  token: string,
  url: URL,
  init: RequestInit = {}
): Promise<Readonly<{ response: Response; body: JsonRecord }>> {
  const headers = new Headers(init.headers);
  headers.set("Authorization", `Bearer ${token}`);
  if (init.body !== undefined) headers.set("Content-Type", "application/json");
  const response = await fetchImpl(url, {
    ...init,
    headers,
    redirect: "error",
    signal: AbortSignal.timeout(30_000),
  });
  return { response, body: await jsonResponse(response) };
}

function expectStatus(
  response: Response,
  body: JsonRecord,
  expected: number,
  operation: string
): void {
  if (response.status !== expected) {
    throw new Error(`${operation} failed (${publicError(body)}).`);
  }
}

export async function transcribeFile(
  filePath: string,
  token: string,
  options: RuntimeOptions = {}
): Promise<JsonRecord> {
  if (!TOKEN_PATTERN.test(token)) throw new Error("The API key is invalid.");
  const fetchImpl = options.fetchImpl ?? fetch;
  const sleep = options.sleep ?? defaultSleep;
  const now = options.now ?? Date.now;
  const media = await inspectMedia(filePath);

  const uploadCreated = await authenticatedJson(
    fetchImpl,
    token,
    new URL("/v1/audio/uploads", ASR_ORIGIN),
    {
      method: "POST",
      body: JSON.stringify({
        filename: media.filename,
        content_type: media.contentType,
        size_bytes: media.sizeBytes,
        mode: "single",
      }),
    }
  );
  expectStatus(
    uploadCreated.response,
    uploadCreated.body,
    201,
    "Creating the upload session"
  );
  const uploadId = stringField(uploadCreated.body.id, "upload id");
  if (!UUID_PATTERN.test(uploadId) || uploadCreated.body.mode !== "single") {
    throw new Error("AudioFlow returned an invalid single-upload session.");
  }

  const signedRequest = record(
    uploadCreated.body.request,
    "The upload session"
  );
  const putUrl = new URL(stringField(signedRequest.url, "signed PUT URL"));
  const signedHeaders = record(signedRequest.headers, "Signed PUT headers");
  const contentLength = Number(signedRequest.content_length);
  if (
    putUrl.protocol !== "https:" ||
    signedRequest.method !== "PUT" ||
    contentLength !== media.sizeBytes
  ) {
    throw new Error("AudioFlow returned an invalid signed PUT request.");
  }

  const putHeaders = new Headers();
  for (const [name, value] of Object.entries(signedHeaders)) {
    if (typeof value !== "string" || name.toLowerCase() === "authorization") {
      throw new Error("AudioFlow returned unsafe signed PUT headers.");
    }
    putHeaders.set(name, value);
  }
  putHeaders.set("Content-Length", String(contentLength));
  const fileBytes = await readFile(media.path);
  if (fileBytes.byteLength !== contentLength) {
    throw new Error(
      "The file size changed after the upload session was created."
    );
  }
  const uploadBody = new Uint8Array(fileBytes.byteLength);
  uploadBody.set(fileBytes);
  const putResponse = await fetchImpl(putUrl, {
    method: "PUT",
    headers: putHeaders,
    body: uploadBody,
    redirect: "error",
    signal: AbortSignal.timeout(15 * 60 * 1000),
  });
  if (!putResponse.ok) {
    throw new Error(`OSS upload failed (HTTP ${putResponse.status}).`);
  }

  const submitted = await authenticatedJson(
    fetchImpl,
    token,
    new URL("/v1/audio/transcriptions", ASR_ORIGIN),
    {
      method: "POST",
      body: JSON.stringify({
        upload_id: uploadId,
        model: "whisper-1",
        provider: "auto",
        response_format: "verbose_json",
      }),
    }
  );
  expectStatus(
    submitted.response,
    submitted.body,
    202,
    "Submitting the transcription"
  );
  const taskId = stringField(submitted.body.id, "task id");
  if (!UUID_PATTERN.test(taskId)) {
    throw new Error("AudioFlow returned an invalid task id.");
  }

  const deadline = now() + 30 * 60 * 1000;
  let waitMilliseconds = 1000;
  while (now() < deadline) {
    await sleep(waitMilliseconds);
    const current = await authenticatedJson(
      fetchImpl,
      token,
      new URL(`/v1/audio/transcriptions/${taskId}`, ASR_ORIGIN)
    );
    if (current.response.status === 429) {
      waitMilliseconds = retryAfterMilliseconds(
        current.response,
        waitMilliseconds
      );
      continue;
    }
    expectStatus(
      current.response,
      current.body,
      200,
      "Polling the transcription"
    );
    const status = stringField(current.body.status, "task status");
    if (TERMINAL_STATUSES.has(status)) return current.body;
    if (status !== "queued" && status !== "running") {
      throw new Error(`AudioFlow returned an unknown task status: ${status}.`);
    }
    waitMilliseconds = Math.min(waitMilliseconds * 2, 10_000);
  }

  throw new Error("The transcription did not finish within 30 minutes.");
}

export async function main(
  argumentsList = process.argv.slice(2)
): Promise<void> {
  if (argumentsList.length !== 1) {
    throw new Error(
      "Usage: node audioflow-quickstart.ts /absolute/path/audio.mp3"
    );
  }
  const token = await authorizeAgent();
  const task = await transcribeFile(argumentsList[0], token);
  process.stdout.write(`${JSON.stringify(task, null, 2)}\n`);
  if (task.status === "failed" || task.status === "cancelled") {
    process.exitCode = 1;
  }
}

if (
  process.argv[1] !== undefined &&
  import.meta.url === pathToFileURL(process.argv[1]).href
) {
  main().catch(error => {
    console.error(
      error instanceof Error ? error.message : "Quickstart failed."
    );
    process.exitCode = 1;
  });
}
```

> The example keeps a newly authorized token only for the current process. A production integration must store it in the platform's secret store or an owner-only credential file, never in the repository.

## 5. HTTP data flow

The reference client performs these operations in order. Preserve this boundary when adapting it to another codebase.

- POST /v1/audio/uploads with the exact filename, MIME type, byte length, and mode: single; require 201 and a single-mode signed request.
- PUT the unchanged bytes to request.url with every request.headers entry and the signed Content-Length. Reject any signed Authorization header.
- POST /v1/audio/transcriptions as JSON with upload_id, whisper-1, provider auto, and verbose_json; require 202 Accepted.
- GET /v1/audio/transcriptions/:task_id with bounded exponential backoff until a terminal status is returned.

## 6. Production checklist

Complete these items before using the quickstart logic as a durable production client.

- Files at or above 64 MiB: request auto or multipart mode, upload 8 MiB parts, request at most 32 signatures per call, and support at most 64 parts.
- Resume and refresh: inspect GET /v1/audio/uploads/:upload_id, upload only missing parts, and refresh expired URLs through POST /v1/audio/uploads/:upload_id/signatures without editing signed query strings.
- Retries: retry identical transcription submissions only; changed normalized parameters for the same upload_id return 409. Honor Retry-After and use bounded jittered backoff.
- Cancellation and cleanup: DELETE an unsubmitted upload or DELETE its task after submission; stop work on expired, cancelled, or terminal resources.
- Secrets: persist tokens only in a secret store or an owner-only file, support revocation, redact logs, and never forward credentials across origins.

## 7. Definition of done

The integration is ready for its first real transcription when every check below passes.

- The Agent can obtain approval without receiving a password or browser session, and the complete token never leaves the local runtime except in the ASR Authorization header.
- OSS receives the exact file bytes and signed headers, but no AudioFlow Authorization header.
- An HTTP 202 submission is polled to succeeded, no_speech, failed, or cancelled, and the caller handles each terminal state explicitly.
- A real supported file below 64 MiB completes end to end and the final task JSON is preserved for the caller.

## Full API reference

For endpoint fields, multipart response shapes, supported option combinations, and the complete error catalog, see https://audioflow123.com/guide/api.
