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… | Use | Start at |
|---|---|---|
| Send a message, get finished work back | Agent Runs | POST /api/developers/v1/runs |
| Call one specific capability yourself (web search, Copy Chief review, ad generation, …) | Tools | GET /api/developers/v1/tools |
| Follow a long tool job to completion | Jobs | returned by async tools |
| Use StefanBrain inside Claude, Cursor, ChatGPT, or another assistant | MCP | /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": trueto wait 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 finishes:
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 }
When a run needs longer than the sync wait window, the same request returns 202 with the run id and URLs. Poll GET /api/developers/v1/runs/{run_id} until status is completed and read output. 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 fromruns,tools,jobs,mcp. A scoped key gets403 api_key_scope_forbiddenon all other surfaces. Unknown values return400. 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 accepttoolsorjobs. 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 return429 api_key_budget_exhausteduntil 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:
| Surface | What 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 — whilestefan_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:
| Plan | Included 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
Requests return 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:
| Plan | Per key / minute | Per account / minute | Per day |
|---|---|---|---|
| Trial | 20 | 40 | 2,500 |
| Base | 60 | 120 | 10,000 |
| Elite | 120 | 240 | 25,000 |
| CA Pro | 240 | 480 | 50,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.
- A separate 10M-tokens-per-day guardrail applies to platform-billed chat requests only; tool, job, run, and MCP traffic never draws on it. 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. When the product model changes, runs change with it — your integration stays current without code changes.
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
payloadfield. - Add each upload as a
filesfield. - 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
propertieskeys inrequired. 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 finishes and the response is the completed run (200) with output and structured_output. When the run needs longer than the sync wait window (about 90 seconds), the response is the normal 202 envelope instead — no work is lost; finish by polling.
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 (or an ask_stefanbrain call) into a chat that already has an active run returns a 409 busy error by default — no wait-and-retry loop is needed, because both surfaces take an optional on_busy field:
"reject"(default): the409busy error, exactly as before."queue": the run is accepted and queued behind the chat's active run. The202envelope (or the ask's parked envelope) is the normal one —run_id,status: "queued"— plusqueued_behind_run_id, the run it waits on. It starts automatically when the chat frees up; multiple queued runs on one chat start in creation order (FIFO). Poll exactly as usual:statusstaysqueueduntil the run starts.
Queueing is per-chat: it applies to whatever chat the request addressed — the connector session chat, a session-labeled chat, a project's session chat, or an explicit chat id. A queued run that has not started within 30 minutes fails with status: "failed" and a last_error starting with queue_timeout: — retry it, or start it in a fresh chat. Cancelling a queued run works like cancelling any other run.
A queued run holds one of your plan's concurrent-agent slots while it waits. Stacking queued asks past that limit returns 429 agentcore_user_concurrency_reached instead of queueing deeper, so queue depth is bounded by your concurrency limit.
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 themanaged_connectors_*family.async_submit— the POST starts a background job and returns202withobject: "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'sinput_schema. Schema violations return400 invalid_tool_arguments.Idempotency-Key(header) — safe blind retries for submit tools andask_stefanbrain. Resubmitting the same key within a 24-hour window (UTC day) returns the ORIGINAL job envelope — the samejob_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'sidempotency_keyargument (MCP callers pass that argument directly); other tools ignore it. Keys are 1–128 characters ofA-Z a-z 0-9 _ . : -(400 invalid_idempotency_keyotherwise), scoped to your account and the chat the call resolves. Because the chat is part of that scope, a keyed retry must carry the samesession/projectarguments — a different or missing label resolves a different chat and starts new work. Exceptions:ask_stefanbraindedupes for at least 6 hours after the turn finishes, within the same UTC day — its dedupe rides the run record, which is purged 6 hours after completion, so it is narrower than the submit doors' full 24 hours;research_shortformis not idempotent (documented boundary — mint a fresh request or check your chat before retrying); andreview_copyalso keys on the request body, so the same key with different arguments starts a new job.chat— an optionalchat_...reference. Tool activity and artifacts attach to a chat that you own. Withoutchat, StefanBrain uses your account's "Developer API" chat, or creates it. The response always contains the chat that was used.project— an optionalproj_...id (fromlist_projects) or project UUID. The call runs in that project. Withoutchat, the project gets its own "Developer API" chat. Project-aware tools, for exampleget_project_context, then read the project's files. An unknown or inaccessible project returns404 project_not_found. Achatthat belongs to a different project returns409 chat_project_mismatch. The response contains the effective project.arguments.project/arguments.session—ask_stefanbrainand thesubmit_*tools also take addressing INSIDEarguments(the same params their MCP schemas advertise).projectthere beats the bodyprojectfield.sessionis a label (1–128 characters ofA-Z a-z 0-9 _ . : -;400 invalid_sessionotherwise) that gives the call its own chat, "Developer API — <label>", reused across calls with the same label and beating the bodychatfield. 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.
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):
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, orcancelled.nullonly when the family returned non-JSON text.recommended_poll_after_ms— wait a minimum of this between polls.nullmeans poll at your default interval.data— the family's full payload: anext_action, progress fields, and per-family result fields.error—nullon success. Whenis_erroristrue, an object withcode(the typed error kind, for examplenot_foundorupstream_unavailable;tool_errorwhen untyped) andmessage(the human-readable text, same ascontent).
Poll the status until data.next_action is get_result. Then get the result.
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
Jobs that produce files list them in the result payload's artifacts[] — today static_ad results carry one entry per generated image. Each entry has id, filename, content_type, bytes, sha256, and a url:
GET /api/developers/v1/jobs/{family}/{job_id}/artifacts/{artifact_id}?chat=chat_…
curl -L -o ad.png \ -H "Authorization: Bearer stefan_sk_..." \ "$BASE/api/developers/v1/jobs/static_ad/{job_id}/artifacts/{artifact_id}?chat=chat_..."
The url field is this exact path with the chat already applied — request it verbatim, with the same credentials as the job endpoints. Downloads are reads of finished work: they never count against rate limits. Bytes are immutable; verify against the advertised sha256. Families without stored artifacts return 404 artifact_not_found — discover artifact support from the result payload, not by probing.
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"}'
Request fields: message (required), chat (an optional chat_... to continue a conversation), project (an optional proj_... id or project UUID), sync (wait for the result, see Sync & Polling), on_busy (what to do when the chat already has a run in progress, see Sync & Polling), and output_config (a JSON-schema constraint on the final answer, see Structured Outputs). Send an Idempotency-Key header to make blind retries safe: resubmitting the same key returns the ORIGINAL run's envelope (202 while it is running, the full status once it finished) — no second chat, no second billed run, and attachment staging is skipped. The runs dedupe window is at least 6 hours after the run finishes (terminal runs are purged after that); the submit tools keep their 24-hour window, and ask_stefanbrain shares this run-backed 6-hour window. Multipart requests attach files (see Attachments). Runs execute the same model that operates the StefanBrain product (see Models). A run with project executes in that project. The agent gets the project's instructions, file inventory, and retrieved file excerpts, the same as an in-app project chat. Use the list_projects tool to find project ids.
The response is 202 (or 200 with the completed run in sync mode) with object: "agent_run", a run_... id, and the lifecycle URLs:
GET /api/developers/v1/runs/{run_id} # status, turn_state, and final output when terminal 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. The final assistant text is in output; 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_contextretrieves the relevant excerpts, and project-scoped asks read the same material. - Each worker gets its own chat via a
sessionlabel.ask_stefanbrain, everysubmit_*tool, and the job verbs takesessionas a plain tool argument. Calls sharing a label use one dedicated chat namedDeveloper 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 truly in parallel. Two calls into the SAME chat contend: the second is rejected with a
chat_busyreceipt, or queues behind the active run when you passon_busy: "queue". Use one shared label pluson_busy: "queue"deliberately when serializing a pipeline of dependent asks through one chat is what you want (FIFO; a queued ask that waits 30 minutes fails with aqueue_timeouterror). - Poll with the same address you submitted with. Job lookups are chat-scoped:
get_job_status/get_job_result/cancel_jobtake the sameproject/sessionarguments 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). Exceptions: ask and runs.create dedupe for at least 6 hours after the turn finishes, within the same UTC day (run-record-backed, narrower than the submit tools' 24 hours); research_shortform is not idempotent (a key never enables retries for it); and review_copy also keys on the request body.
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)
| Code | Meaning |
|---|---|
| 0 | success — stdout carries the result JSON |
| 1 | failed for a non-retryable reason (details on stderr) |
| 2 | usage error — the command line was wrong; stderr shows the fix |
| 3 | not found — unknown tool, job family, job, run, or file id |
| 4 | retryable — rate limit, timeout, network, or 5xx; safe to re-run |
| 5 | authentication — 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. 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_urlsand it looks at the pictures (jpeg, png, webp, or gif). For a video or a web page, put the link in yourmessageinstead — 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/fileswith the same Authorization header the MCP connection uses (astefan_sk_...key or astefan_oat_...OAuth token) and passes the returned ids infile_ids. In a connector client (claude.ai, Claude Desktop) the assistant never holds your credentials, so it calls thestage_filetool 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_busyreceipt; passon_busy: "queue"to queue the ask behind the active run instead — it starts automatically when the chat frees up (see Sync & Polling, "Busy chats"). - 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(usemode: "content"for content research ormode: "creators"for creator sourcing) - Connectors: the
managed_connectors_*family calls tools on services connected to your account - Async jobs:
submit_*tools return afamilyand arun_id. Pollget_job_status. Get the result withget_job_result. Stop the job withcancel_job. Parkedask_stefanbrainturns use the same three verbs with familymcp_ask. Job lookups are chat-scoped, so poll with the samesession/projectyou 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 emptyDeveloper API — <label>chat behind: lookups resolve their chat with the same find-or-create the submits use. - Files:
stage_file— stage one file forask_stefanbrainfrom inside the conversation: a public URL, or the file's bytes as base64. Returns anupload_...id forfile_ids; ids last 24 hours. Built for connector clients that cannot callPOST /api/developers/v1/filesthemselves. - 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— aproj_...id fromlist_projects, or the project UUID. Resolved exactly likex-stefanbrain-project, and it wins when both are present.session— a label (1–128 characters ofA-Z a-z 0-9 _ . : -) that gives the call its own chat, namedDeveloper 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: pollget_job_status/get_job_result/cancel_jobwith the samesession/projectyou submitted with, and carry the same arguments on anIdempotency-Keyretry (see Idempotency).
Precedence, most explicit wins:
| Scope | 1. Tool argument | 2. Header | 3. Default |
|---|---|---|---|
| project | project argument | x-stefanbrain-project | none |
| chat | session argument | x-stefanbrain-chat | the "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, withproj_...ids.get_project_context— a project's custom instructions, its maintained digest, and the file roster with per-file summaries. Passqueryto also get the most relevant excerpts from its indexed files. When the session has thex-stefanbrain-projectheader, 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 invalidon_busyvalue (invalid_on_busy)401: an API key that is missing, invalid, revoked, or not authorized403: 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_stefanbrainreports this in-band as achat_busyreceipt that names the blocking run —active_run_id, itsactive_run_status, itsactive_run_started_at— plus a machine-readablehint(suggestion: "wait_and_retry" | "use_own_chat"): pollget_job_statuswith familymcp_askand that run id, passon_busy: "queue"to queue behind the blocking run instead (see Sync & Polling), or ask again in a fresh chat. A409is alsochat_project_mismatch(the pinned chat lives in a different project)429: a rate limit (rate_limit_error), an empty API wallet (api_wallet_exhausted), or an empty per-key budget (api_key_budget_exhausted)500: an unexpected server failure503: 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. One typed case to handle: a last_error starting with queue_timeout: means an on_busy: "queue" run waited 30 minutes behind other runs in its chat and never started — retry it, or start it in a fresh chat.
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
chatfor follow-up turns in a conversation. - Store
run_idfor traceability. - Give schemas explicit required fields and
additionalProperties: false.