> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stefanbrain.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Follow the content contract in AGENTS.md.
> Treat text as the canonical explanation; video and screenshots enhance it.
> Do not publish unverified product behavior or duplicate an existing canonical article.

# TypeScript SDK

> Install the official StefanBrain TypeScript SDK and use its typed clients for asks, runs, tools, jobs, and files.

`@stefanbrain/sdk` is the official TypeScript client for the StefanBrain Developer API. It supports ESM and CommonJS, has no runtime dependencies, and requires Node.js 20.9 or later.

## Install and authenticate

```bash theme={"system"}
npm install @stefanbrain/sdk
export STEFANBRAIN_API_KEY="stefan_sk_your_key_here"
```

```ts theme={"system"}
import StefanBrain from "@stefanbrain/sdk";

const client = new StefanBrain();
```

In CommonJS, load the client with `const { StefanBrain } = require("@stefanbrain/sdk");`.

The constructor reads `STEFANBRAIN_API_KEY`. If the variable is unset and you pass no `apiKey`, the constructor throws `AuthenticationError` before it sends any request. Never expose the key in browser code or commit it to your repository.

| Option | Default | Use |
| - | - | - |
| `apiKey` | `STEFANBRAIN_API_KEY` | A credential from your server-side secret manager. |
| `baseUrl` | `https://stefanbrain.com` | Another API origin. The SDK does not read `STEFANBRAIN_BASE_URL`. |
| `maxRetries` | `2` | Automatic retries for requests that are safe to repeat. `0` turns them off. |
| `fetch` | The global `fetch` | Your own `fetch`, for example one that routes through a proxy. |

An API key uses the prepaid API wallet. An OAuth access token uses the member's plan usage. See [Pricing](/developers/pricing) for the complete billing boundary.

## Ask StefanBrain

`client.ask` completes one full StefanBrain turn. A short turn answers in the first response. When a turn takes longer than about 55 seconds, the server returns a handle instead, and `client.ask` polls it for up to 20 minutes.

```ts theme={"system"}
const { answer, chat } = await client.ask(
  "Give me five Meta ad hooks for a sleep supplement.",
);

if (chat) {
  await client.ask("Rewrite the second hook for a younger audience.", { chat });
}
```

Preserve the returned `chat` identifier to continue the conversation. Without `chat`, asks share one Developer API chat: your account's, or the one for the `project` and `session` you pass. Pass `chat: "new"` to start a fresh chat.

| Option | Effect |
| - | - |
| `chat` | A `chat_...` identifier to continue, or `"new"`. |
| `project` | A `proj_...` identifier or project UUID to work in. |
| `session` | A label that gives a parallel workstream its own chat. See [Parallel agent sessions](/developers/parallel-sessions). |
| `fileIds`, `imageUrls` | Staged `upload_...` identifiers and public image URLs. One ask takes up to 10 attachments in total. |
| `onBusy` | `"reject"`, the default, throws `ChatBusyError` when the chat already has a run in progress. Only `"reject"` is supported. |
| `idempotencyKey` | Makes a retry return the original turn. See [Retry without duplicating work](#retry-without-duplicating-work). |
| `timeoutMs`, `pollIntervalMs` | How long the whole ask may take, first call included, 20 minutes by default, and the poll interval to use when the server sends no hint. |
| `signal` | An `AbortSignal` that stops the request and the wait. |

The result contains `answer`, `chat`, `appUrl`, `runId`, `steps`, and the server's receipt in `raw`.

When `timeoutMs` passes first, `client.ask` throws `JobTimeoutError` and the turn keeps running. Resume it by its identifier instead of asking again. Address the resume with the same `chat`, or the same `project` and `session`:

```ts theme={"system"}
import { JobTimeoutError } from "@stefanbrain/sdk";

const scope = { project: "proj_your_project", session: "research" };

try {
  const { answer } = await client.ask("Research this category in depth.", scope);
  console.log(answer);
} catch (error) {
  if (!(error instanceof JobTimeoutError) || !error.jobId) throw error;
  await client.jobs.wait("mcp_ask", error.jobId, scope);
  const result = await client.jobs.result("mcp_ask", error.jobId, scope);
  console.log(result.data?.answer);
}
```

Since SDK 0.4.0, the error's `chat` names the chat the turn runs in, so `{ chat: error.chat }` addresses the resume too. A `jobId` of `null` means the first call never answered: retry it with the same `idempotencyKey`, or check the chat before asking again.

## Start a run and wait

`client.runs.start` starts an Agent Run and returns without polling. With `sync: true`, StefanBrain holds the connection until the run ends, or for about 90 seconds after it receives the request. A run that ends inside that window comes back with HTTP `200`, whether it completed, failed, or was cancelled. A longer run comes back as the `202` acceptance with `status: "queued"` and keeps running.

`client.runs.wait(runId, { timeoutMs, pollIntervalMs, signal })` polls a run every 3 seconds, for up to 20 minutes, until it is `completed`, `failed`, or `cancelled`. It returns a failed or cancelled run without throwing, so check `status`. At the deadline it throws `JobTimeoutError`, and the run keeps going. A run that already ended returns on the first poll.

```ts theme={"system"}
const started = await client.runs.start({
  message: "Draft five ad angles for a sleep supplement.",
  sync: true,
  idempotencyKey: "angles-2026-09-30",
});
// 200: the finished run. 202: the acceptance, with status "queued".
const run = await client.runs.wait(started.run_id);

if (run.status === "completed") console.log(run.output);
else console.error(run.status, run.last_error);
```

`client.runs.start` also accepts these parameters:

| Parameter | Effect |
| - | - |
| `chat`, `project` | Where the run happens. |
| `outputConfig` | A JSON Schema for the final answer, sent as `output_config`. See [Structured outputs](/developers/structured-outputs). |
| `onBusy` | Only `"reject"` (the default) is supported. |
| `files` | File paths, `Blob` objects, or `{ data, filename }` values. The SDK sends them as multipart form data. |
| `idempotencyKey` | Sent as the `Idempotency-Key` header. |
| `signal` | An `AbortSignal` for the request. |

The SDK sets no request timeout of its own. If you bound a sync start with `signal`, allow at least 120 seconds.

## Stream a run

`client.runs.startAndStream` starts a run without `sync` and follows its server-sent events. When the connection drops, the stream reconnects and resumes after the last event it received, using `Last-Event-ID`. Iteration has no deadline of its own, so bound it with a `signal`, or, since SDK 0.4.0, with `{ timeoutMs }` as the second argument. Past `timeoutMs`, the start, the iteration, and `stream.finalStatus()` throw `JobTimeoutError`, and the run keeps going. Aborting stops the stream; it does not cancel the run.

```ts theme={"system"}
const stream = await client.runs.startAndStream({
  message: "Research this category and return a positioning brief.",
  idempotencyKey: "positioning-brief-2026-09-30",
  signal: AbortSignal.timeout(30 * 60_000),
});

for await (const event of stream) {
  console.log(event.payload);
}

const finished = await stream.finalStatus();
console.log(finished.status, finished.output);
```

`stream.finalStatus()` returns the finished run. Since SDK 0.4.0, when an `idempotencyKey` replays a run that has already finished, the stream yields no events and `stream.finalStatus()` returns that run. These methods work on any run:

* `client.runs.get(runId)` reads the run. A finished run carries `output` and `structured_output`.
* `client.runs.cancel(runId)` requests cancellation. It is safe to repeat.
* `client.runs.events(runId, { after })` reads one page of events. Pass the page's `next_after` as `after` to read the next one.
* `client.runs.iterateEvents(runId)` reads every page until the run ends.

Status and event reads do not count against rate limits.

## Submit jobs

`client.jobs.submitAndWait` submits an asynchronous tool, waits for the job to finish, and returns its result.

```ts theme={"system"}
const result = await client.jobs.submitAndWait(
  "create_images",
  {
    request: {
      kind: "ad",
      brief: "Create a bold static ad for a sleep supplement.",
      outputs: { mode: "count", count: 1 },
    },
  },
  { idempotencyKey: "sleep-ad-2026-09-30" },
);

await client.jobs.downloadArtifacts("static_ad", result.job_id, {
  chat: result.chat,
  outDir: "./creatives",
});
```

To run each step yourself:

* `client.jobs.submit(tool, args, { chat, project, session, idempotencyKey })` returns the `202` `tool_job` acceptance with `family`, `job_id`, and `chat`.
* `client.jobs.wait(family, jobId, { chat })` polls until the job ends. It follows the server's `recommended_poll_after_ms`. Without that hint, it waits 2 seconds between polls and 1.5 times longer after each one, up to 15 seconds. It returns when the job succeeds. It throws `JobFailedError` when the job fails or is cancelled, and `JobTimeoutError` after `timeoutMs`, 20 minutes by default.
* `client.jobs.status`, `client.jobs.result`, and `client.jobs.cancel` read or cancel one job.

`client.jobs.submitAndWait` throws the same errors as `client.jobs.wait`. Job lookups are chat-scoped. Pass the acceptance's `chat`, or the same `project` and `session` you submitted with.

`jobs.submit` and `jobs.submitAndWait` accept `create_images`, `edit_image`, `find_angles`, `get_meta_ad`, `research_shortform`, `review_copy`, and `review_funnel`. An `edit_image` job belongs to the `static_ad` family. Before SDK 0.4.0, call `edit_image` with `client.tools.invoke`, which returns the same `tool_job` acceptance.

A finished job's result names the files it published into its chat's workspace (`kind: "files"`), or carries inline data (`kind: "inline"`). Since SDK 0.4.0, `client.jobs.downloadArtifacts` finds the named files in the job chat's listing and downloads each one. An inline result has no files, so it returns an empty list.

## Call tools

```ts theme={"system"}
const search = await client.tools.invoke("web_search", {
  query: "sleep supplement hook formats",
});
```

Tool input and output types are generated from the tool registry when each SDK version is built. The exported `DEVELOPER_SDK_SPEC_VERSION` names the specification that build used. Call `client.tools.list()` for the live catalog, or `client.tools.describe(name)` for one tool, before assuming that a tool name or schema is available.

A tool that returns HTTP `200` with `is_error: true` throws `ToolResultError`.

## Stage and list files

`client.files.upload` stages local files for an ask. The returned `upload_...` identifiers stay valid for 24 hours.

```ts theme={"system"}
const staged = await client.files.upload(["./ad.png", "./brief.pdf"]);

await client.ask("Review the ad against the brief.", {
  fileIds: staged.files.map((file) => file.id),
});
```

`client.files.list(chatId)` lists the current workspace files in a chat. Since SDK 0.4.0, `client.files.download` takes a listed file, its `download_url`, or `{ chatId, path }`, and returns the bytes:

```ts theme={"system"}
import { writeFile } from "node:fs/promises";

const { files } = await client.files.list("chat_your_chat");

for (const file of files) {
  await writeFile(file.file_name, await client.files.download(file));
}
```

A file id alone is not enough, because the download also needs the file's chat and path. With an earlier SDK, request each entry's `download_url`, a path on the API origin, with the same `Authorization` header.

See [Files](/developers/files) for limits and error codes.

## Send feedback

Since SDK 0.5.0, `client.feedback` tells the StefanBrain team where the API got in your way and reads the team's replies. Reports are free.

```ts theme={"system"}
const receipt = await client.feedback.send({
  summary: "POST /v1/runs returned 500 when output_config had a json_schema",
  category: "bug",
  impact: "blocked",
  endpoint: "POST /api/developers/v1/runs",
});

const report = await client.feedback.get(receipt.id);
if (report.reply) console.log(report.status, report.reply);
```

* `client.feedback.send(params)` sends one report and returns the receipt with its `fb_` id and `guidance`. Fields use camelCase, so `errorCode` and `requestId` send `error_code` and `request_id`.
* `client.feedback.list({ status, limit })` returns your reports, newest first, with the team's replies.
* `client.feedback.get(feedbackId)` returns one report.

`client.feedback.send` is never retried automatically, because a retry could file the report twice. If a send fails without a response, check `client.feedback.list` before you send the report again. `list` and `get` are reads and retry like other reads. The SDK exports the `DeveloperExternalFeedback`, `DeveloperExternalFeedbackReceipt`, and `DeveloperExternalFeedbackList` types. With an earlier SDK, send the report to `POST /api/developers/v1/feedback` with `fetch`.

See [Agent feedback](/developers/feedback) for the fields, limits, and statuses.

## Retry without duplicating work

The SDK retries requests that are safe to repeat:

* Reads, such as run status, job results, events, and the tool catalog.
* Cancellations.
* File staging.
* Event-stream reconnects.
* Submissions that carry `idempotencyKey`, except `research_shortform`.

It retries after network failures and `408`, `429`, `500`, `502`, `503`, and `504` responses, at most twice by default. Set `maxRetries: 0` to opt out. The SDK waits out a `Retry-After` of 60 seconds or less. A longer `Retry-After` throws at once. Without `Retry-After`, the first retry waits up to half a second, and each later retry waits up to twice as long.

The SDK never creates an idempotency key for you. Send `idempotencyKey` on every run start, ask, and job submission. The SDK sends it in the `Idempotency-Key` header, so a retry returns the original run, turn, or job instead of starting duplicate work.

A key is 1–128 characters from `A–Z`, `a–z`, `0–9`, `_`, `.`, `:`, or `-`. The SDK drops an empty key. Any other key outside that format returns `400 invalid_idempotency_key`.

These calls deduplicate on the key for the rest of the same UTC day:

* `client.runs.start`
* `client.ask`
* The asynchronous submit tools `create_images`, `edit_image`, `find_angles`, `get_meta_ad`, `review_copy`, and `review_funnel`

A reused key returns the original work even when the arguments changed. Use a new key for new work.

`research_shortform` is not idempotent. A key does not make it safe to retry; inspect the chat before resubmitting.

Pass a key only to the calls above. Other tools ignore it, but the SDK still retries them when a key is present, which can repeat a side effect such as `send_email`.

<Warning>
  Keep the same chat, project, and session scope when retrying an idempotent request. Changing the scope can address a different chat and start new work.
</Warning>

## Handle typed errors

Every deliberate SDK error extends `StefanBrainError`. It carries `status` (the HTTP status, or `null` when no HTTP error occurred), `code` (a machine-readable error code), and `requestId`. `requestId` is set only for tool-level errors, such as `ToolResultError` and `ChatBusyError`. HTTP error responses carry no request identifier.

| Error | When it is thrown |
| - | - |
| `AuthenticationError` | The credential is missing, or the server rejected it with `401`. |
| `RateLimitError` | A `429`. `retryAfterSeconds` comes from `Retry-After`, and `resetAt` from a throughput limit's `reset_at`. Both are `null` for `api_wallet_exhausted`, `api_key_budget_exhausted`, and `monthly_usage_limit_reached`, which reset with the billing cycle. |
| `RequestTooLargeError` | A `413`. Shrink the request instead of retrying it. |
| `APIConnectionError` | No HTTP response arrived, after any retries. |
| `ToolResultError` | A direct tool returned HTTP `200` with `is_error: true`. |
| `ChatBusyError` | Only `client.ask` throws it, when another run owns the chat. Its `receipt` names that run. Nothing started, and nothing was billed. |
| `JobFailedError` | A job ended `failed` or `cancelled`. |
| `JobTimeoutError` | A wait deadline passed while the work was still running. `jobId` names the work. Since SDK 0.4.0, `chat` names its chat, and a `null` `jobId` means the request that starts the work never answered. |

`client.runs.start` into a busy chat throws `StefanBrainError` with status `409` and code `agent_run_rejected`. Wait for the blocking run to finish or use a separate chat.

A timeout does not mean the work failed. Resume it by its identifier instead of resubmitting it.

See [Parallel agent sessions](/developers/parallel-sessions) for project and session isolation, or [Agent Runs](/developers/agent-runs) for the REST lifecycle.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.