# StefanBrain Developer API > REST and MCP access to StefanBrain, the agentic copywriting platform. Send chat requests, call tools, poll jobs, and run full agents. MCP is included in every plan; REST usage bills per token against a prepaid API wallet that your plan funds with monthly credit. Canonical HTML version: https://stefanbrain.com/docs — OpenAPI 3.1 spec: https://stefanbrain.com/api/developers/v1/openapi # Get started ## 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": true` to 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](/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 Create an API key in **Settings → Developers**. Then start your first run. `"sync": true` holds the connection until the run finishes: ```bash 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): ```json { "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 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: ```http Authorization: Bearer stefan_sk_your_key_here ``` or ```http 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. # Billing & limits ## 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) | Bills the prepaid **API wallet**. Your plan adds credit to the wallet every month (see below). | 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. There is no model parameter: | | Input (per MTok) | Output (per MTok) | | --- | --- | --- | | Agent Runs | $1.30 | $7.80 | Agent Runs execute the same model that operates the StefanBrain product. - Each token that a request processes bills at these rates. Cached tokens bill as usual input tokens. There is no separate cache price, 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. API calls never use your team's in-app usage. 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 | | Ultra 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 Request-count limits change with your plan: | Plan | Per key / minute | Per account / minute | Per day | | --- | --- | --- | --- | | Trial | 20 | 40 | 500 | | Base | 60 | 120 | 2,500 | | Elite | 120 | 240 | 5,000 | | Ultra Pro | 240 | 480 | 10,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. - Each account can process a maximum of 10M tokens each day through the external API. 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 `429` response contains your account's effective limits: ```json { "error": { "message": "Rate limit exceeded.", "type": "rate_limit_error", "code": "per_user_tokens_per_day" }, "limits": { "per_minute": 120, "per_user_per_minute": 240, "per_day": 5000, "global_tokens_per_day": null, "per_user_tokens_per_day": 10000000 }, "state": { "current": "normal", "expires_at": null } } ``` Polling (run status, run events, job status) does not count against rate limits. Only requests that start work count. # Runs in depth ## 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 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. - File support is the same as regular chat: images, PDFs, common Office documents, text files, and supported transcript-backed audio or video. ```bash 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 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 `["", "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. ```json { "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 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. # Tools & agents ## 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: ```http GET /api/developers/v1/tools GET /api/developers/v1/tools/{tool_name} POST /api/developers/v1/tools/{tool_name} ``` Each list entry has a `kind`: - `sync` — the POST returns the result in the response (`object: "tool_result"`). Examples: `web_search`, `review_copy_with_copy_chief`, `knowledge_search`, `knowledge_read`, `list_projects`, `get_project_context`, `amazon_search`, `google_trends`, `get_video_transcript`, and the `managed_connectors_*` family. - `async_submit` — the POST starts a background job and returns `202` with `object: "tool_job"` (see Jobs). Examples: `submit_static_ad_generation`, `submit_video_ad_analysis`, `submit_create_cuts`, `submit_angle_finder`, `submit_cro_funnel_review`, `research_shortform_content`. Call shape: ```bash 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`. - `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. 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 tools (plan, canvas, and turn tools such as `todo`, `create_canvas`, `export_document`) are not served here. They operate only in an agent run. They return `404 tool_not_available` with the reason. ## Jobs Async submit tools return a job envelope: ```json { "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`, `video_ad`, `create_cuts`, `cro_funnel_review`, `angle_finder`, `shortform_research`, `copy_chief`): ```http 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. The `data` field carries the family's payload: a `status` (`queued`, `running`, `succeeded`, `failed`, or `cancelled`), a `next_action`, and per-family result fields. Poll the status until `next_action` is `get_result`. Wait a minimum of `recommended_poll_after_ms` between polls. 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`. ## 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. ```bash 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), and `output_config` (a JSON-schema constraint on the final answer, see Structured Outputs). 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: ```http 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. # MCP ## MCP StefanBrain is also a remote MCP server (streamable HTTP): ```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. ### MCP is included in your plan MCP usage costs nothing extra. Tool calls 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 ```bash 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): ```json { "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`): ```json { "mcpServers": { "stefanbrain": { "serverUrl": "https://stefanbrain.com/api/developers/v1/mcp", "headers": { "Authorization": "Bearer ${env:STEFANBRAIN_API_KEY}" } } } } ``` ### Codex CLI `~/.codex/config.toml`: ```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: ```json { "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`: ```json { "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: - **Research & context**: `knowledge_search` (StefanBrain's internal evidence layer; pass `queries`, then read a hit id with `knowledge_read`), `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_with_copy_chief`, `submit_angle_finder`, `submit_static_ad_generation`, `submit_video_ad_analysis`, `submit_create_cuts`, `submit_cro_funnel_review` - **Shortform**: `research_shortform_content` (use `mode: "content"` for content research or `mode: "creators"` for creator sourcing) - **Connectors**: the `managed_connectors_*` family calls tools on services connected to your account - **Async jobs**: `submit_*` tools return a `family` and a `run_id`. Poll `get_job_status`. Get the result with `get_job_result`. Stop the job with `cancel_job`. Harness-internal tools (plan, canvas, and turn tools such as `todo`, `create_canvas`, `export_document`) are not served. They operate only in an agent run. `tools/call` messages count against the standard rate limits. 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: ```http 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`. 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. # Reference ## 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**](/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**](/llms.txt) — a short index of the docs. It follows the [llms.txt convention](https://llmstxt.org). - **`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 Each error response uses this envelope: ```json { "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, or an invalid structured-output config - `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`) - `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 failure - `503`: a transient upstream or infrastructure failure 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 Structured extraction with a multipart upload: ```bash 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`.