Developer Docs

StefanBrain Developer API

One clean API surface: API-key auth, chat requests, file attachments, structured outputs, tools, agent runs, and MCP.

Overview

What the API gives you, and which surface to use.

Overview

The StefanBrain API gives your code the same agent that operates the app: research, hooks, ads, emails, long-form copy, and all the tools. Each StefanBrain plan has API access. The trial also has API access. There is nothing separate to buy:

  • MCP is included in your subscription. MCP usage bills your plan's monthly usage — the same pool as your team's in-app work. It never touches the API wallet.
  • The REST API bills a prepaid API wallet. Your plan adds credit to the wallet every month, automatically. Purchase more credit only when you use the included credit up. See Pricing.

Create an API key in Settings → Developers. Then select the surface that matches your task:

You want to…UseStart at
Send a message, get finished work backAgent RunsPOST /api/developers/v1/runs
Call one specific capability yourself (web search, Copy Chief review, ad generation, …)ToolsGET /api/developers/v1/tools
Follow a long tool job to completionJobsreturned by async tools
Use StefanBrain inside Claude, Cursor, ChatGPT, or another assistantMCP/api/developers/v1/mcp

Important properties:

  • Agent Runs are the primary surface. The agent makes a plan, selects tools, does the work, and returns the final text in output. Send "sync": true to wait up to about 90 seconds for the result in one request, or poll the run URLs.
  • Runs accept file attachments (multipart) and JSON-schema structured outputs (output_config).
  • The full assistant response always returns in output. API turns never write content into in-app canvas documents.
  • Do you build with an AI coding agent? Give it /llms-full.txt — these docs as one markdown file. Also give it the OpenAPI spec at /api/developers/v1/openapi. See For agents & tooling.

Quick Start

Send your first request with one curl command.

Quick Start

Create an API key in Settings → Developers. Then start your first run. "sync": true holds the connection until the run ends, for up to about 90 seconds:

curl -X POST https://stefanbrain.com/api/developers/v1/runs \
  -H "Authorization: Bearer stefan_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Write three Meta ad hooks for a sleep supplement.",
    "sync": true
  }'

Response shape (sync mode, completed):

{
  "object": "agent_run",
  "run_id": "run_550e8400e29b41d4a716446655440000",
  "chat": "chat_6ba7b8109dad41d180b400c04fd430c8",
  "status": "completed",
  "output": "...the finished hooks...",
  "structured_output": null
}

A run that ends inside the sync window returns 200 with its final status. Failed and cancelled runs also return 200, so check status. A run that needs longer returns 202 with the run id and URLs — nothing is lost. Poll GET /api/developers/v1/runs/{run_id} until status is completed, failed, or cancelled, then read output. Set your HTTP client timeout to 120 seconds (see Sync & Polling). Keep the chat id and send it with your next run to continue the same conversation.

Authentication

Create keys, send them safely, and restrict each key.

Authentication

Create API keys in Settings → Developers. You must accept the current Developer API terms each time you create a key. StefanBrain shows the full secret one time, at creation. Store the secret in a safe location.

Send the key in one of these two headers:

Authorization: Bearer stefan_sk_your_key_here

or

x-api-key: stefan_sk_your_key_here

An API key has the access of the account that owns it. Invalid, revoked, or expired access returns 401 Unauthorized.

Key scopes and budgets

When you create a key (POST /api/developers/keys), you can restrict it:

  • scopes — an array with values from runs, tools, jobs, mcp. A scoped key gets 403 api_key_scope_forbidden on all other surfaces. Unknown values return 400. They are not ignored. Omit the field, or send an empty array, for a full-access key. Keys created before scopes existed have full access. The job polling endpoints accept tools or jobs. A tools-scoped key can always poll the jobs that it submitted.
  • monthly_budget_cents — a spend limit for one key, for one billing cycle. When the key spend is at the limit, requests with that key return 429 api_key_budget_exhausted until the cycle resets. The account wallet also limits total spend. Omit the field for no key limit.

Use scopes and budgets when you give keys to team members or services. Example: a CI key with scope ["tools"] and a $10 monthly limit.

Pricing

What your plan includes, per-token rates, and the wallet.

Pricing

Your subscription includes both API surfaces. There are no separate seats, tokens, or add-ons to buy. The two surfaces meter differently:

SurfaceWhat it costs
MCP (Claude, ChatGPT, Cursor, …)Included in your plan. Usage bills your plan's monthly usage — the same pool as in-app work. The wallet is not touched.
REST API (runs, tools, jobs)With a stefan_sk_ key: bills the prepaid API wallet. Your plan adds credit to the wallet every month (see below). With an OAuth sign-in (stefan_oat_): bills your plan's monthly usage, like MCP.

MCP is for interactive use inside an assistant. Automated, machine-scale traffic must use the REST API.

REST API rates

The wallet bills per token. One rate applies to every request — there is no model parameter:

Rate (per MTok)
Input$3.50
Cached input (cache reads)$0.35
Output$17.50

Agent Runs execute the same model that operates the StefanBrain product.

  • These rates sit 30% below Anthropic's published Claude Opus 4.8 list prices on every line item — input, output, and cached input.
  • Tokens that repeat across requests in a conversation bill at the cached-input rate automatically. Cache writes bill as ordinary input — there is no write premium, no per-seat fee, and no minimum.
  • Non-token usage in a request (image generation, web search) bills per use. The exact charge shows in your wallet history. Video generation stays on its own per-minute meter.
  • The wallet is separate from your plan's app usage. The credential decides the lane: stefan_sk_ API keys bill only the wallet — never your team's in-app usage — while stefan_oat_ OAuth sign-ins bill only the plan pool, on REST and MCP alike. App usage never uses the wallet.

Included monthly credit

Your plan adds API credit to the wallet each billing cycle. You do not purchase anything to start — purchase more credit only when you use the included credit up:

PlanIncluded API credit
Trial$5 (one-time)
Base$50 / month
Elite$100 / month
CA Pro$500 / month

The monthly credit resets each cycle. The wallet spends the monthly credit first. Top-ups that you purchase (in Settings → Developers) stay until spent, for a maximum of 12 months.

When the wallet is empty

Each request that starts wallet-billed work (a run start or a tool call) first places a hold on the wallet — currently $4.38. A synchronous tool call's hold ends when the call returns. A run's or async job's hold stays until that work ends, for 8 hours at most. A start is refused when your balance minus the live holds is below one hold: the request returns 429 with the code api_wallet_exhausted. The response includes a wallet object with your balance and the next reset time. Responses that start billable work (run starts, tool calls) also show the live balance in the x-wallet-remaining-cents and x-wallet-resets-at headers. MCP is not affected — an empty wallet never blocks MCP or in-app use.

Rate Limits

Throughput for each plan, automatic upgrades, and 429 responses.

Rate Limits

Request-count limits change with your plan:

PlanPer key / minutePer account / minutePer day
Trial20402,500
Base6012010,000
Elite12024025,000
CA Pro24048050,000
  • When your active top-up balance is $100 or more, your limits move one row down this table automatically. You do not need a plan change to get more throughput.
  • The global_tokens_per_day and per_user_tokens_per_day fields in a rate-limit body are informational: no Developer API request counts against a token limit. Abuse safeguards also apply. Normal sustained integration traffic does not activate them.
  • Do you need more? Contact us. Per-account overrides are routine for production integrations.

Spend limits and throughput limits are separate. The API wallet controls REST spend (429 api_wallet_exhausted, see Pricing). Your plan's monthly pool controls interactive MCP usage (429 monthly_usage_limit_reached).

Each rate-limit 429 carries a Retry-After header (seconds), and the body names the binding limit, your observed counts, and the reset time:

{
  "error": {
    "message": "Daily request limit reached (2500/2500 requests today on the Trial plan). Resets at 2026-08-15T00:00:00.000Z. Job status/result polling never counts against rate limits.",
    "type": "rate_limit_error",
    "code": "request_per_day"
  },
  "limits": {
    "per_minute": 20,
    "per_user_per_minute": 40,
    "per_day": 2500,
    "global_tokens_per_day": null,
    "per_user_tokens_per_day": 10000000
  },
  "observed": {
    "minute_count": 3,
    "user_minute_count": 3,
    "day_count": 2501
  },
  "plan": "trial",
  "reset_at": "2026-08-15T00:00:00.000Z",
  "state": {
    "current": "normal",
    "expires_at": null
  }
}

Polling (run status, run events, job status) does not count against rate limits — on REST and on MCP, where get_job_status, get_job_result, and cancel_job calls are free. Only requests that start work count.

Models

Runs execute the product model. There is no model parameter.

Models

There is no model request field. You do not select a model. If a request includes a model field, StefanBrain ignores it.

Agent Runs (POST /v1/runs) execute the same model that operates the StefanBrain product. A new chat starts on the current model, and the chat keeps that setup for its whole life. When the product model changes, new chats start on the new one — your integration stays current without code changes. Continuing an older chat after a model change returns 403 chat_setup_retired. Omit chat to continue in a new chat.

Attachments

Upload files with multipart run requests.

Attachments

Use multipart/form-data on POST /v1/runs when StefanBrain must read files with the message.

  • Put the JSON request body in a payload field.
  • Add each upload as a files field.
  • The maximum is 10 files for each run. Size limits match in-app chat uploads.
  • Supported types: images, PDFs, common Office documents, spreadsheets, and text files. Video and audio files are not accepted — the run can still read video links, so host the file and put its URL in the run's message.
curl -X POST https://stefanbrain.com/api/developers/v1/runs \
  -H "Authorization: Bearer stefan_sk_your_key_here" \
  -F 'payload={
    "message":"Summarize the attached deck and give me three CTA options.",
    "sync": true
  }' \
  -F "files=@/absolute/path/to/deck.pdf"

Use uploaded files only. Internal attachment ids and referenced document ids are not part of the public API.

Structured Outputs

Get JSON that matches your schema.

Structured Outputs

Add output_config.format to a run request and the run's final answer is constrained to your JSON schema. The raw text returns in output; the parsed JSON returns in structured_output.

Structured outputs run in strict mode. Strict mode has three rules beyond ordinary JSON Schema:

  • The root schema must have "type": "object".
  • Every object must set "additionalProperties": false.
  • Every object must list ALL of its properties keys in required. Mark a field optional with a ["<type>", "null"] type union instead.

A schema that breaks a rule returns 400 with the code invalid_output_config and a message that names the exact path.

{
  "message": "Extract the offer details from this landing page summary.",
  "sync": true,
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "headline": { "type": "string" },
          "audience": { "type": "string" },
          "benefits": {
            "type": "array",
            "items": { "type": "string" }
          }
        },
        "required": ["headline", "audience", "benefits"],
        "additionalProperties": false
      }
    }
  }
}

structured_output is null when the run is not completed, or when the final text is not valid JSON (a refusal, or a hard failure). The agent can still use tools during a structured-output run; the schema constrains the final answer only.

Sync & Polling

Wait for a run in one request, or poll for the result.

Sync & Polling

Runs execute in the background. Your HTTP connection has no effect on them. Two ways to get the result:

Sync mode. Add "sync": true to the run request. The connection holds until the run ends or the sync window closes. The window is about 90 seconds, counted from when StefanBrain receives the request, so upload time counts against it. A run that ends inside the window returns 200 with its final status, output, and structured_output. Failed and cancelled runs also return 200, so check status. Otherwise the response is the normal 202 envelope - no work is lost; finish by polling. A keyed retry that replays an earlier run (Idempotency-Key) answers at once.

Set your HTTP client timeout to 120 seconds. The gateway in front of the API closes a connection after 120 seconds without a response. A 504 or a dropped connection does not stop the run: it keeps running and is billed. If you sent an Idempotency-Key, retry with the same key on the same UTC day to get the run back.

Polling. The 202 envelope carries the run id and URLs. Poll GET /api/developers/v1/runs/{run_id} one time each 3–10 seconds until status is completed, failed, or cancelled. Polling does not count against rate limits. Only requests that start work count.

Use sync mode for interactive requests and short tasks. Use polling for long agentic work, batch pipelines, and any place a held HTTP connection is fragile.

Busy chats: on_busy

A chat runs one turn at a time. Starting a run in a busy chat returns a 409 busy error. ask_stefanbrain returns a chat_busy receipt with the blocking run and retry guidance. Only on_busy: "reject" (the default) is supported.

Poll the blocking run and retry after it finishes, or use a separate chat for independent work. A REST request without chat starts in a new chat.

Tools

List and call each platform tool directly.

Tools

Your code can call each tool that StefanBrain's own agents use. Get the live catalog (names, JSON Schemas, sync or async shape). Then call a tool by name:

GET  /api/developers/v1/tools
GET  /api/developers/v1/tools/{tool_name}
POST /api/developers/v1/tools/{tool_name}

Each catalog entry carries the tool's input_schema and its output_schema — the result contract for structured_content (null for tools without a declared one). These are the same JSON Schemas the MCP surface advertises; generate types from them instead of hand-writing result shapes.

Each list entry has a kind:

  • sync — the POST returns the result in the response (object: "tool_result"). Examples: web_search, list_projects, get_project_context, amazon_search, google_trends, and meta_ads_insights.
  • async_submit — the POST starts a background job and returns 202 with object: "tool_job" (see Jobs). Examples: create_images, find_angles, review_funnel, research_shortform.

Call shape:

curl -X POST "$BASE/api/developers/v1/tools/web_search" \
  -H "Authorization: Bearer stefan_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"arguments": {"query": "best hook formats for supplement ads"}}'

Request fields:

  • arguments — an object that matches the tool's input_schema. Schema violations return 400 invalid_tool_arguments.
  • Idempotency-Key (header) — safe blind retries for submit tools and ask_stefanbrain. Resubmitting the same key within a 24-hour window (UTC day) returns the ORIGINAL job envelope — the same job_id, no new work, no second charge — so retry a timed-out submit with the same key instead of guessing whether it landed. The header value becomes the tool's idempotency_key argument (MCP callers pass that argument directly); other tools ignore it. Keys are 1–128 characters of A-Z a-z 0-9 _ . : - (400 invalid_idempotency_key otherwise), scoped to your account and the chat the call resolves. Because the chat is part of that scope, a keyed retry must carry the same session/project arguments — a different or missing label resolves a different chat and starts new work. ask_stefanbrain uses the same UTC-day window and returns the original turn's run_id. Exception: research_shortform is not idempotent (documented boundary — mint a fresh request or check your chat before retrying).
  • chat — an optional chat_... reference. Tool activity and artifacts attach to a chat that you own. Without chat, StefanBrain uses your account's "Developer API" chat, or creates it. The response always contains the chat that was used.
  • project — an optional proj_... id (from list_projects) or project UUID. The call runs in that project. Without chat, the project gets its own "Developer API" chat. Project-aware tools, for example get_project_context, then read the project's files. An unknown or inaccessible project returns 404 project_not_found. A chat that belongs to a different project returns 409 chat_project_mismatch. The response contains the effective project.
  • arguments.project / arguments.session — ask_stefanbrain and the submit_* tools also take addressing INSIDE arguments (the same params their MCP schemas advertise). project there beats the body project field. session is a label (1–128 characters of A-Z a-z 0-9 _ . : -; 400 invalid_session otherwise) that gives the call its own chat, "Developer API — <label>", reused across calls with the same label and beating the body chat field. Full precedence table: see the MCP section's "Chats and projects".

Tool-level failures (a provider error, an empty scrape) return 200 with is_error: true and diagnostic content. Only transport, auth, validation, and limit problems use error status codes.

Some harness-internal turn tools are not served here. They operate only in an agent run and return 404 tool_not_available.

Breaking change (2026-09-22): create_skill, import_skill, and use_skill were removed from the Developer API. User-authored skills were retired; create_skill and import_skill no longer exist anywhere, and use_skill now only loads StefanBrain's built-in reference files inside an agent run, so it returns 404 tool_not_found here. @stefanbrain/sdk and @stefanbrain/cli 0.3.0 drop the three tools from their generated types and catalog.

Direct video tools are not yet available on the developer surface; ask_stefanbrain can still read video links.

Jobs

Poll and control long tool jobs.

Jobs

Async submit tools return a job envelope:

{
  "object": "tool_job",
  "family": "static_ad",
  "job_id": "…",
  "chat": "chat_…",
  "status": "queued",
  "recommended_poll_after_ms": 4000,
  "status_url": "/api/developers/v1/jobs/static_ad/…?chat=chat_…",
  "result_url": "/api/developers/v1/jobs/static_ad/…/result?chat=chat_…",
  "cancel_url": "/api/developers/v1/jobs/static_ad/…/cancel?chat=chat_…"
}

Endpoints (families: static_ad, cro_funnel_review, angle_finder, shortform_research, copy_chief, meta_ad_lookup):

GET  /api/developers/v1/jobs/{family}/{job_id}
GET  /api/developers/v1/jobs/{family}/{job_id}/result
POST /api/developers/v1/jobs/{family}/{job_id}/cancel

Responses are object: "job" envelopes. Read the top-level fields; data stays available for per-family detail:

  • status — the job status, on the envelope itself: queued, running, succeeded, failed, or cancelled. null only when the family returned non-JSON text.
  • recommended_poll_after_ms — wait a minimum of this between polls. null means poll at your default interval.
  • data — the job's payload: run_id, family, status, progress_message, progress, and error. The result endpoint adds result; the cancel endpoint adds accepted.
  • error — null on success. When is_error is true, an object with code (the typed error kind, for example not_found or upstream_unavailable; tool_error when untyped) and message (the human-readable text, same as content).

Poll until status is succeeded, failed, or cancelled, then fetch the result URL.

A job belongs to the chat where you submitted it. Keep the chat query parameter from the URLs in the submit response. A wrong or missing chat returns 404 job_not_found.

Artifacts

A finished job's result is in data.result, in one of two shapes:

  • {"kind": "files", "summary": "…", "files": [{"path": "…"}]} — the job wrote files into its chat's workspace.
  • {"kind": "inline", "summary": "…", "data": …} — the result data is in the response itself.

To download files, list the chat's workspace files. Each entry carries its path, its sha256, and a download_url with the chat and path already applied. Request that URL verbatim. The files routes need a key with the runs scope, or a full-access key:

GET /api/developers/v1/files?chat_id=chat_…
GET /api/developers/v1/files/{sha256}/download?chat_id=chat_…&path=…

A file's id is the SHA-256 of its bytes, so verify each download against it. static_ad creatives also stream from their own route. Take the creative id from creatives[].id in the job's ad.json file, and pass the chat the job was submitted under:

GET /api/developers/v1/jobs/{family}/{job_id}/artifacts/{artifact_id}?chat=chat_…

Downloads are reads of finished work: they never count against rate limits. Families other than static_ad return 404 artifact_not_found on the artifacts route.

Agent Runs

Run full tool-enabled StefanBrain agents.

Agent Runs

Agent runs give you the full StefanBrain agent over the API. The agent makes a plan, selects tools, runs them, and reports progress. This is the same harness that the product uses.

curl -X POST "$BASE/api/developers/v1/runs" \
  -H "Authorization: Bearer stefan_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"message": "Research trending TikTok hooks for my skincare brand and draft 5 ad angles"}'

Only message is required. Runs use SB 1.5 (Brain I). Use chat to continue a conversation, project only to address the chat inside a project, sync to wait for the result, on_busy to select busy-chat behavior, and output_config to constrain the final answer. Project instructions and project summaries are not injected into the Rust runtime. Send an Idempotency-Key header to make blind retries safe: resubmitting the same key returns the original run's envelope. Multipart requests attach files. Runs execute the product model.

The response is 202 with object: "agent_run", a run_... id, and the lifecycle URLs. In sync mode, a run that ends inside the sync window returns 200 with its final status instead:

GET  /api/developers/v1/runs/{run_id}          # status, turn_state, and the latest output
GET  /api/developers/v1/runs/{run_id}/events   # progress events; page with ?after={last_event_id}
POST /api/developers/v1/runs/{run_id}/cancel

Poll the status until status is completed, failed, or cancelled. output holds the latest assistant text; it is the final answer only once status is completed. For structured-output runs the parsed JSON is in structured_output. The events endpoint returns the run's progress stream (turn phases, tool activity, text sections). next_after is the cursor for the next poll. is_done marks the terminal event.

The events endpoint can also stream. Send Accept: text/event-stream and the same events arrive as SSE. Each id: is the event id. Heartbeats come approximately each 15 seconds. The stream closes after the terminal event. To resume at the same position, reconnect with Last-Event-ID or ?after=. JSON cursor polling and SSE carry identical data. Select one for each client.

Runs execute in the background. Your HTTP connection has no effect on them. You can reconnect and poll again at any time.

Parallel Agent Sessions

N agent forks against one account: a shared project, per-fork session labels.

Parallel Agent Sessions

Run N agent forks against ONE StefanBrain account without collisions. The architecture:

  • One project is the shared ground truth. Put the playbook, briefs, and reference documents in the project's files and instructions. Every fork grounds its work there — get_project_context retrieves the relevant excerpts, and project-scoped asks read the same material.
  • Each worker gets its own chat via a session label. ask_stefanbrain, every submit_* tool, and the job verbs take session as a plain tool argument. Calls sharing a label use one dedicated chat named Developer API — <label> inside the project. Different labels get different chats.
  • Chats serialize one run at a time - that is the isolation boundary. Distinct labels run in parallel. A second call into a busy chat is refused (on_busy: "reject", the default). Poll the blocking run and retry after it finishes; use separate session labels for independent work.
  • Poll with the same address you submitted with. Job lookups are chat-scoped: get_job_status / get_job_result / cancel_job take the same project/session arguments as the submit tools.

Claude Code workers, copy-paste

In each worker's directory, one CLI command wires the MCP config:

npm install -g @stefanbrain/cli
sb init --project proj_...
export STEFANBRAIN_API_KEY=stefan_sk_...

sb init merges an existing .mcp.json non-destructively and pins the project with the x-stefanbrain-project header. Session labels are per-call arguments because there is no session header. Give each worker one label; the pinned project header is its fallback scope.

sb ask "Draft prelander 3 from the project playbook." --project proj_... --session worker-3
sb jobs status static_ad job_... --project proj_... --session worker-3
await client.ask("Draft prelander 3 from the project playbook.", {
  project,
  session: "worker-3",
});

One boundary to know: CLI/SDK 0.2.1+ resolve the --session/session label through list_chats' exact name filter — no recency window, and the first match is the oldest-created chat, the same one the server's own label resolution routes work to. On servers without exact-name lookup (pre-#2959), resolution falls back to the 50-most-recent window (one list_chats page; scoping with the project narrows it), where a labeled chat older than that needs the submit acceptance's chat id instead — clients at 0.2.0 or older always resolve through that window. (Over MCP the job verbs' session argument resolves server-side and has no such window on any version.) Building your own lookup? list_chats' name filter is case-sensitive; matches return oldest-created first, and each row carries created_at and project_id. Scope the lookup the way the work was submitted: pass project when the label was submitted with one, and when it was submitted WITHOUT a project, keep only rows with project_id: null client-side — the name filter alone spans ALL projects, so the first match could otherwise be an older same-named chat from another project.

TypeScript SDK

The official @stefanbrain/sdk package: typed client for runs, tools, jobs, and files.

TypeScript SDK

@stefanbrain/sdk is the official TypeScript client for everything on this page. Zero runtime dependencies, Node 20.9+, ESM and CommonJS, and every tool's input and output is typed from the live registry — your editor autocompletes tool names, arguments, and result shapes.

npm install @stefanbrain/sdk
export STEFANBRAIN_API_KEY="stefan_sk_your_key_here"
import StefanBrain from "@stefanbrain/sdk";

const client = new StefanBrain(); // reads STEFANBRAIN_API_KEY

// One call = a full StefanBrain turn. Long turns are polled for you.
const { answer, chat } = await client.ask(
  "Give me 5 Meta ad hooks for a sleep supplement."
);

// Continue the conversation:
await client.ask("Rewrite #2 for a younger audience.", { chat });

The credential decides the billing lane, same as everywhere on this surface: a stefan_sk_... key bills the prepaid API wallet; a stefan_oat_... OAuth token bills your plan's monthly pool and never touches the wallet.

Jobs, artifacts, files, runs

// Async tools: submit → poll (server hints) → result, in one call.
const result = await client.jobs.submitAndWait("create_images", {
  request: {
    kind: "ad",
    brief: "Bold before/after ad for a sleep supplement",
    outputs: { mode: "count", count: 1 },
  },
});
await client.jobs.downloadArtifacts("static_ad", result.job_id, {
  chat: result.chat,
  outDir: "./creatives",
});

// Files: stage local files, then ask about them.
const staged = await client.files.upload(["./ad.png", "./brief.pdf"]);
await client.ask("Review this ad against the brief.", {
  fileIds: staged.files.map((file) => file.id),
});

// Agent runs with live progress (SSE, auto-resume on drops):
const stream = await client.runs.startAndStream({ message: "Deep research..." });
for await (const event of stream) console.log(event.payload);
const finished = await stream.finalStatus();

Errors and retries

Every deliberate error extends StefanBrainError and carries status, requestId, and code. The important ones: AuthenticationError (the message names STEFANBRAIN_API_KEY), RateLimitError (carries retryAfterSeconds and resetAt), ToolResultError (a tool failed with HTTP 200 — the message is the tool's own fix text, verbatim), JobFailedError (the work failed; the transport did not), and JobTimeoutError (the work is still running — resume by id, do not resubmit).

Reads and cancels retry automatically on 408, 429, 500, 502, 503, and 504 (honoring Retry-After, max 2, maxRetries: 0 opts out). Submits are never blind-retried unless you pass idempotencyKey, which the SDK sends as the Idempotency-Key header — the server returns the ORIGINAL job for a repeated key within 24 hours (UTC day). ask and runs.start use the same UTC-day window. Exception: research_shortform is not idempotent (a key never enables retries for it).

For coding agents

The tarball ships its own README.md, an AGENTS.md quickstart, and three runnable scripts in examples/. Add this to your project's AGENTS.md:

## StefanBrain
- SDK: `@stefanbrain/sdk` (official, typed). `new StefanBrain()` reads STEFANBRAIN_API_KEY.
- `client.ask(msg, {fileIds})` = full StefanBrain turn; `client.jobs.submitAndWait(tool, args)` = async tools; `client.runs.startAndStream({message})` = agent runs with SSE progress.
- Job/run polling is free; never blind-retry submits without `idempotencyKey`.
- Docs: https://stefanbrain.com/llms-full.txt (single file) · OpenAPI: https://stefanbrain.com/api/developers/v1/openapi

CLI

The sb command line: StefanBrain from any shell, built for coding agents.

CLI

sb (@stefanbrain/cli) is the official command line for everything on this page — a thin wrapper over the SDK, designed for coding agents driving a shell. Strict output discipline: stdout carries the API's /v1 envelope or a CLI-owned receipt; progress, warnings, and next: command suggestions arrive on stderr. It never prompts, pages, or prints ANSI. Node 20.9+.

npm install -g @stefanbrain/cli
sb login        # browser sign-in (loopback OAuth + PKCE); bills your plan's monthly pool
sb whoami       # which credential + billing lane is active, and why

Headless boxes (containers, CI, SSH servers) cannot finish the browser login — set STEFANBRAIN_API_KEY=stefan_sk_... instead (Settings → Developers). The env key always wins over the stored login and bills the prepaid API wallet; sb login bills the plan pool. No credential flags exist: argv lands in shell transcripts.

Three recipes

# 1. Ask StefanBrain (one full turn; long turns are polled for you):
sb ask "Give me 5 Meta ad hooks for a sleep supplement."

# 2. Async job end to end (payloads always via --input, never inline JSON):
sb jobs submit static_ad --input payload.json
sb jobs wait static_ad job_... --chat chat_...        # stderr suggests this exact line
sb jobs artifacts static_ad job_... --chat chat_... --out ./creatives

# 3. Files + ask:
sb files upload ./brief.pdf
sb ask "Summarize this brief into 3 angles." --file-id upload_...

# 4. Parallel agent workers (see Parallel Agent Sessions):
sb init --project proj_...
sb ask "Draft prelander 2" --project proj_... --session worker-2

Exit codes (the scripting contract)

CodeMeaning
0success — stdout carries the result JSON
1failed for a non-retryable reason (details on stderr)
2usage error — the command line was wrong; stderr shows the fix
3not found — unknown tool, job family, job, run, or file id
4retryable — rate limit, timeout, network, or 5xx; stderr says if a re-run is safe
5authentication — run sb login or set STEFANBRAIN_API_KEY

Commands resolve tool names and job families from the live GET /v1/tools catalog at run time, so a stale installed binary never advertises removed tools; sb version handshakes the served spec version and warns on drift. --base-url (or STEFANBRAIN_BASE_URL) points at staging/dev servers. Pass --idempotency-key on submits, runs start, and ask to make blind retries safe: after a 5xx, a dropped connection, or --timeout, a command that starts work may already have been accepted, so re-run it only with the same key, or check the chat first. The tarball ships AGENTS.md (agent quickstart) and SKILL.md (installable Agent Skill); sb --help prints the full contract.

MCP

Ask StefanBrain from Claude, ChatGPT, Cursor, or any MCP client — real answers, included in every plan.

MCP

StefanBrain is also a remote MCP server (streamable HTTP):

POST https://stefanbrain.com/api/developers/v1/mcp

claude.ai / Claude Desktop / ChatGPT: add the URL above as a custom connector. The OAuth sign-in flow does the rest (see Supported clients below). All other clients: send Authorization: Bearer stefan_sk_... as the per-client configs below show. An OAuth access token (stefan_oat_...) works in the same header — on this endpoint and on every REST route — and bills your plan pool instead of the API wallet.

Ask StefanBrain — the main tool

ask_stefanbrain is the main tool. In your assistant, say "use StefanBrain" (or "use SB"), then say what you want. Your assistant calls this tool. StefanBrain does the work on our servers and sends back the answer. You get the same quality as the StefanBrain website. This works with any client and any model.

  • Ask for anything StefanBrain does. For example: ad angles, copy, reviews, strategy, research, or a full work product.
  • Show StefanBrain images. Put up to 10 image URLs in image_urls and it looks at the pictures (jpeg, png, webp, or gif). For a video or a web page, put the link in your message instead — StefanBrain opens it and reads it.
  • Share files from your computer. In a coding assistant (Claude Code, Codex, Cursor), just attach or point at the files and ask — the assistant uploads them to POST /api/developers/v1/files with the same Authorization header the MCP connection uses (a stefan_sk_... key or a stefan_oat_... OAuth token) and passes the returned ids in file_ids. In a connector client (claude.ai, Claude Desktop) the assistant never holds your credentials, so it calls the stage_file tool instead — a URL or the file's bytes in the tool call — and gets the same id. Works for images, PDFs, docs, and spreadsheets — StefanBrain sees the real files, not a description. Up to 10 per ask (links and files combined); uploads last 24 hours.
  • Each answer runs in a real chat in your account. The reply gives you a link to that chat.
  • See the work. When StefanBrain runs tools (research, Copy Chief, writing, and more), the reply lists the main steps it took, so you can trust the answer.
  • Keep asking to go deeper. Your asks stay in one chat, so StefanBrain remembers the earlier ones. Say "new" to start a fresh chat.
  • Short tasks answer right away. Long tasks reply with a ticket. Your assistant checks the ticket until the answer is ready.
  • A chat runs one ask at a time. Asking into a busy chat returns a chat_busy receipt; poll the blocking run and retry after it finishes, or use a fresh chat.
  • Some actions finish in the app to keep you safe — sending email, launching ads, and changing automations. The reply gives you the link.

Use the other tools only when you want raw data to work with yourself. For everything else, just use StefanBrain.

MCP is included in your plan

MCP usage costs nothing extra. Tool calls — including ask_stefanbrain turns — bill your plan's monthly usage, the same pool as your team's in-app work, never the API wallet. You do not need API credit to use MCP.

Select the surface by who does the work:

  • Use MCP when a person works inside an assistant (Claude, ChatGPT, Cursor) and wants StefanBrain's tools in that conversation.
  • Use the REST API when your code calls StefanBrain: integrations, pipelines, and automated or machine-scale traffic. REST usage bills the API wallet (see Pricing).

The server is stateless. It never issues an Mcp-Session-Id. Each client message is one authenticated POST. A reconnect is always safe. GET (the optional standalone SSE stream) returns 405 by design. There are no server-initiated messages to stream.

Keep the key out of committed config files. Export it one time as STEFANBRAIN_API_KEY. Then use each client's environment interpolation, as the examples below show.

Claude Code

claude mcp add --transport http stefanbrain https://stefanbrain.com/api/developers/v1/mcp \
  --header "Authorization: Bearer stefan_sk_..."

Add --scope user to make the server available in each project, not only the current one.

Cursor

.cursor/mcp.json in your project (or ~/.cursor/mcp.json globally):

{
  "mcpServers": {
    "stefanbrain": {
      "url": "https://stefanbrain.com/api/developers/v1/mcp",
      "headers": { "Authorization": "Bearer ${env:STEFANBRAIN_API_KEY}" }
    }
  }
}

Windsurf

~/.codeium/windsurf/mcp_config.json (note serverUrl, not url):

{
  "mcpServers": {
    "stefanbrain": {
      "serverUrl": "https://stefanbrain.com/api/developers/v1/mcp",
      "headers": { "Authorization": "Bearer ${env:STEFANBRAIN_API_KEY}" }
    }
  }
}

Codex CLI

~/.codex/config.toml:

[mcp_servers.stefanbrain]
url = "https://stefanbrain.com/api/developers/v1/mcp"
bearer_token_env_var = "STEFANBRAIN_API_KEY"

Any streamable-HTTP client

Most other MCP clients accept a JSON block with this shape:

{
  "type": "http",
  "url": "https://stefanbrain.com/api/developers/v1/mcp",
  "headers": { "Authorization": "Bearer stefan_sk_..." }
}

Clients without remote HTTP support (stdio fallback)

Some clients can only launch local stdio servers (for example, older Claude Desktop builds). For these clients, bridge with mcp-remote:

{
  "mcpServers": {
    "stefanbrain": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://stefanbrain.com/api/developers/v1/mcp",
        "--header", "Authorization: Bearer ${STEFANBRAIN_API_KEY}"
      ]
    }
  }
}

Supported clients

Each MCP client connects in one of two ways. Both ways end at the same tool surface.

OAuth (claude.ai, Claude Desktop, ChatGPT, and other connector UIs). StefanBrain operates a full OAuth 2.1 authorization server for the MCP endpoint. "Custom connector" screens work with zero configuration. Paste https://stefanbrain.com/api/developers/v1/mcp as the connector URL. The client finds the sign-in service, registers itself, and sends you to StefanBrain to approve access. Sign in if necessary. Then select Allow access. No API key, no Client ID, no advanced settings. To revoke access, disconnect the connector in the client. This flow is the MCP authorization spec: RFC 9728/8414 discovery, dynamic client registration, and the PKCE authorization-code flow with refresh-token rotation.

API keys (Claude Code, Cursor, Windsurf, Codex CLI, SDKs, scripts). Each client that can send an Authorization: Bearer stefan_sk_... header works with the configs above. Bridge stdio-only clients with mcp-remote.

To approve an OAuth connector, you need the same access as to create an API key: a StefanBrain plan, or the trial. Did you see "Couldn't register with StefanBrain's sign-in service" when you added the connector? That message is older than OAuth support. Remove the connector and add it again.

What you can do

The MCP surface serves the same tools as /api/developers/v1/tools. tools/list and tools/call behave identically. The tool areas:

  • Ask StefanBrain: ask_stefanbrain — one call runs a full StefanBrain turn and returns its real answer, grounded in Stefan's knowledge, skills, and your projects. This is the main tool (see above).
  • Research & context: search_chats (scope: "this_chat" | "other_chats"), read_chat, list_chats, web_search, web_extract, amazon_search, google_trends
  • Projects: list_projects, get_project_context (see below)
  • Copy & creative: review_copy, find_angles, create_images, review_funnel
  • Shortform: research_shortform (use mode: "content" for content research or mode: "creators" for creator sourcing)
  • Connectors: ask_stefanbrain works with the services connected to your account. Meta ads tools are served directly: meta_ad_accounts_search, meta_ads_insights, get_meta_ad, prepare_launch, and upload_creative.
  • Async jobs: submit_* tools return a family and a run_id. Poll get_job_status until status is succeeded, failed, or cancelled. Then get the result with get_job_result. Stop the job with cancel_job. Parked ask_stefanbrain turns use the same three verbs with family mcp_ask. Job lookups are chat-scoped, so poll with the same session/project you submitted with — the three verbs take the same optional arguments as the submit tools; a poll without the label resolves a different chat and misses the job. A poll with a wrong or never-used label also leaves a new empty Developer API — <label> chat behind: lookups resolve their chat with the same find-or-create the submits use.
  • Files: stage_file — stage one file for ask_stefanbrain from inside the conversation: a public URL, or the file's bytes as base64. Returns an upload_... id for file_ids; ids last 24 hours. Built for connector clients that cannot call POST /api/developers/v1/files themselves.
  • Account: account_status — check that your key is connected. See your plan, trial, how much StefanBrain usage is left this cycle, and your API wallet balance. Read-only.

Harness-internal turn tools are not served. They operate only in an agent run. Direct video tools are not yet available on the developer surface; ask_stefanbrain can still read video links. tools/call messages that start work count against the standard rate limits. Job polling (get_job_status, get_job_result, cancel_job), handshake, and tools/list traffic does not count.

Chats and projects

Each tool call runs in a real chat on your account. Job rows and artifacts live in that chat. Two optional headers control the location:

x-stefanbrain-chat: chat_...       # pin to a specific chat
x-stefanbrain-project: proj_...    # pin the session to a project (UUID also accepted)

With no headers, StefanBrain uses your account's "Developer API" chat. With x-stefanbrain-project, the project gets its own "Developer API" chat. Project-aware tools then read the project scope. The most important is get_project_context. Pass query and it retrieves the most relevant excerpts from the project's indexed files. An inaccessible project fails with 404 project_not_found. If the two headers point to a chat in a different project, the call fails with 409 chat_project_mismatch.

No headers? Use the project and session arguments. Connector clients (claude.ai, Claude Desktop, ChatGPT) cannot set headers, so ask_stefanbrain, every submit_* tool, and the job verbs (get_job_status, get_job_result, cancel_job — read-only: they address the chat-scoped lookup, never start work) take the same addressing as plain tool arguments:

  • project — a proj_... id from list_projects, or the project UUID. Resolved exactly like x-stefanbrain-project, and it wins when both are present.
  • session — a label (1–128 characters of A-Z a-z 0-9 _ . : -) that gives the call its own chat, named Developer API — <label>, inside the resolved project. Calls sharing a label share one chat; different labels get different chats. Built for parallel workstreams and agent forks: each fork passes its own label and gets its own thread, no header choreography. The label is part of the call's chat scope, so repeat it wherever the chat matters: poll get_job_status/get_job_result/cancel_job with the same session/project you submitted with, and carry the same arguments on an Idempotency-Key retry (see Idempotency).

Precedence, most explicit wins:

Scope1. Tool argument2. Header3. Default
projectproject argumentx-stefanbrain-projectnone
chatsession argumentx-stefanbrain-chatthe "Developer API" chat

ask_stefanbrain's own chat argument (a concrete chat id, or "new") is the strongest chat address of all — it beats session. One request resolves ONE scope: a batch whose calls name two different projects or sessions fails with 400 conflicting_project / 400 conflicting_session. An invalid label fails with 400 invalid_session. On the REST surface the same arguments work inside arguments, beating the body project and chat fields the same way.

Use the project tools to bring a project's context into the client conversation. In-app project chats use the same material:

  • list_projects — the projects your account can access, with proj_... ids.
  • get_project_context — a project's custom instructions, its maintained digest, and the file roster with per-file summaries. Pass query to also get the most relevant excerpts from its indexed files. When the session has the x-stefanbrain-project header, call this tool with no arguments.

A typical flow in Claude Code: list_projects → get_project_context with your question as query → an answer grounded in the project's instructions and files. No copy-paste is necessary.

For Agents & Tooling

Machine-readable docs, OpenAPI, and agent briefs.

For Agents & Tooling

All content on this page is also published in machine-friendly formats. An AI coding agent can build against the API without HTML scraping:

  • /llms-full.txt — these docs as one markdown document. Paste the URL into Claude Code, Cursor, or ChatGPT. Then ask the agent to implement your integration.
  • /llms.txt — a short index of the docs. It follows the llms.txt convention.
  • GET /api/developers/v1/openapi — the OpenAPI 3.1 contract for SDK generators, request validation, and API tooling. No auth is necessary.
  • Copy instructions for AI agents — the button at the top of this page copies a compact implementation brief. Paste the brief into a coding agent.

Does your agent support MCP? Then MCP is the richest integration. The agent gets StefanBrain's tools natively, not through generated HTTP calls. See the MCP section.

Errors

What each non-success response means.

Errors

Each error response uses this envelope:

{
  "error": {
    "message": "Human-readable explanation.",
    "type": "invalid_request_error",
    "code": "invalid_json"
  }
}
  • 400: invalid JSON, a malformed multipart payload, an attachment that is too large, an unsupported tool request, an invalid structured-output config, or an invalid on_busy value (invalid_on_busy)
  • 401: an API key that is missing, invalid, revoked, or not authorized
  • 403: a suspended API key, account access that cannot use the Developer API, or a key without the scope for this surface (api_key_scope_forbidden)
  • 409: a conflict - most often a busy chat: the target chat already has an active run. Chats serialize one run at a time, so parallel agent sessions should use per-session chats in a shared project instead of one shared chat. ask_stefanbrain reports this in-band as a chat_busy receipt that names the blocking run - active_run_id, its active_run_status, its active_run_started_at - plus a machine-readable hint (suggestion: "wait_and_retry" | "use_own_chat"): poll get_job_status with family mcp_ask and that run id, or ask again in a fresh chat. A 409 is also chat_project_mismatch (the pinned chat lives in a different project)
  • 429: a rate limit (rate_limit_error), an empty API wallet (api_wallet_exhausted), an empty per-key budget (api_key_budget_exhausted), or too many active runs for your account at once (runtime_capacity_busy, with a Retry-After header)
  • 500: an unexpected server failure
  • 503: a transient upstream or infrastructure failure

Run-level failures surface on the run itself, not as HTTP errors: a run that ends with status: "failed" carries the reason in last_error.

Retry only transient infrastructure failures. Do not retry malformed requests, authorization failures, permission failures, or attachment size errors. Retry after a rate limit only with backoff.

Examples

Reference requests for common flows.

Examples

Structured extraction with a multipart upload:

curl -X POST https://stefanbrain.com/api/developers/v1/runs \
  -H "Authorization: Bearer stefan_sk_your_key_here" \
  -F 'payload={
    "message":"Extract product name, claim set, and CTA recommendations from this PDF.",
    "sync": true,
    "output_config":{
      "format":{
        "type":"json_schema",
        "schema":{
          "type":"object",
          "properties":{
            "productName":{"type":"string"},
            "claims":{"type":"array","items":{"type":"string"}},
            "ctaRecommendations":{"type":"array","items":{"type":"string"}}
          },
          "required":["productName","claims","ctaRecommendations"],
          "additionalProperties":false
        }
      }
    }
  }' \
  -F "files=@/absolute/path/to/brief.pdf"

Best practices:

  • Reuse chat for follow-up turns in a conversation.
  • Store run_id for traceability.
  • Give schemas explicit required fields and additionalProperties: false.