{"openapi":"3.1.0","info":{"title":"Overblast Worker API","version":"2026-08","description":"Public REST surface used by the [`overblast`](https://www.npmjs.com/package/overblast) npm package (CLI + Node.js library) and by any integration holding a workspace API key.\n\n**Authentication.** `Authorization: Bearer ob_live_…`. Keys are minted against a single workspace and carry a fixed scope list, so there is no `computerId` to pass: the server resolves the workspace from the key on every request. `GET /api-keys/me` reports which workspace, which scopes, and — for a key that carries a person's authority — which capabilities that person currently holds.\n\n**What a key cannot do.** Minting or revoking keys, approving outbound work, editing the member permission grid, and connecting or severing a Stripe account are all signed-in operations, refused to keys by name rather than by silence. Those surfaces are noted where they would otherwise look missing.\n\n**Credits.** Balances and ledger deltas are credits — a whole-number count, not a currency. The `…Cents` suffix on the wire fields is historical; one unit is one credit.","contact":{"name":"Overblast","url":"https://overblast.app"},"license":{"name":"Apache-2.0","url":"https://www.apache.org/licenses/LICENSE-2.0"}},"servers":[{"url":"https://brain.deployd.network/v1","description":"Production"},{"url":"https://brain.deployd.network/social","description":"Legacy alias (deprecated — same API, prefer /v1)"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"API keys","description":"Identify the API key in use (manage keys in the app/console)"},{"name":"Sessions","description":"Mint the session token the app and web console authorize on"},{"name":"Accounts","description":"Connected channels (social, email, phone)"},{"name":"Conversations","description":"Cross-platform direct-message inbox"},{"name":"Comments","description":"Post comment inbox + replies"},{"name":"Reviews","description":"Cross-platform review inbox + replies"},{"name":"Posts","description":"Social post publishing + state"},{"name":"Todos","description":"Task pipeline (per-conversation linked tasks)"},{"name":"Knowledge base","description":"R2-backed files + AutoRAG semantic search"},{"name":"Business data","description":"Workspace profile, hours, branding, team"},{"name":"Webchat","description":"Position QR codes + invite links"},{"name":"Email","description":"Outbound, scheduled and threaded inbound email"},{"name":"Asks","description":"An agent's questions to a live operator — the blocking kind and the leave-it-and-go kind"},{"name":"Payments","description":"Spend proposals, customer charges, refunds, payouts, cards"},{"name":"Addons","description":"Account allowance, credit balance + Stripe checkout"},{"name":"AI gateway","description":"OpenAI-compatible drop-in at `/ai/v1`, billed to the workspace credit balance. Point any OpenAI SDK at it with the workspace API key as the bearer."},{"name":"Voice calls","description":"Place + schedule AI voice calls, and configure remotely-defined agents (AgentSpec) driven by your own platform."},{"name":"Workspace","description":"Workspace lifecycle (owner-only)"},{"name":"Public agent simulator","description":"Talk to the workspace's cloud auto-reply agent as a pretend customer on any channel, and read back what it said and did. Nothing reaches a customer, the team, Stripe or the workspace's real records; turns cost AI credits like real replies."}],"paths":{"/api-keys/me":{"get":{"tags":["API keys"],"summary":"Identify the calling key","description":"The bootstrapping call: which workspace this key is bound to, what it may do, and — when the key carries a person's authority rather than an integration's — who that person is and what their role currently allows. Also the cheapest liveness probe.\n\n`member` is ALWAYS present, so there is one shape to decode: branch on `member.role`, which is `owner` for an integration key and `manager` / `member` for a person's. `member.capabilities` is a map of capability name → boolean and never a list of scope strings; `member.modes` carries `approval` / `auto` only for the capabilities that have one, and an absent entry means \"no mode\", never a default. `member.id` is the roster row a task's `assignedToId` is compared against.\n\n`scopes` is already EFFECTIVE — for a person-bound key it is what they consented to intersected with what their role still permits, so a seat change is visible here without the key being reissued.\n\nA signed-in SESSION (the console and the Overblast app) may call it too by naming one workspace in `computerId`, and gets the same shape with `credential: \"session\"`: `member.capabilities` is then the PERSON's authority on that workspace (role ceiling plus allowances, role defaults included and waivers removed), with no key consent to narrow it. A workspace the session does not belong to answers 404. Not declared as a parameter here, because this spec is for keys, and a key ignores it: its binding is its workspace.","responses":{"200":{"$ref":"#/components/responses/ApiKeyMe"},"401":{"description":"Missing key, or a session that named no `computerId`"},"404":{"description":"A session naming a workspace it is not a member of"}}}},"/session-token":{"post":{"tags":["Sessions"],"summary":"Exchange a Firebase ID token for a session token","description":"The credential the app and the web console authorize on — NOT an API-key surface, and listed here because it is the one call that has to succeed before any of the session-only routes noted elsewhere in this document will answer.\n\nSend `Authorization: Bearer <firebase id token>` and no body. This is a MINT, not a refresh: presenting a session token here is a 401, as is an `ob_live_` key. The answer carries the token, its absolute expiry in epoch SECONDS (one hour out), and the map of workspaces the signed-in person can reach with the scopes they hold in each.\n\nEvery error names a machine-readable `code`, and none of them is about MEMBERSHIP any more. A person the membership index has never been told about is derived from the workspace record on the spot and indexed for next time; an account that belongs to no workspace gets a 200 carrying an empty `computers` map, which is the true answer for it. The remaining failures are all about this service — a missing binding, or a fault.","security":[],"responses":{"200":{"description":"`{token, expiresAt, computers}`","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"expiresAt":{"type":"integer","description":"Absolute epoch SECONDS (1h TTL)"},"computers":{"type":"object","additionalProperties":{"type":"object","properties":{"psId":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"}},"ownerId":{"type":["string","null"]},"ownerEmail":{"type":["string","null"]},"name":{"type":["string","null"]}}}}}}}}},"401":{"description":"`missing_token` / `invalid_token`"},"500":{"description":"`mint_failed`"},"503":{"description":"`server_misconfigured` (names the `binding`)"}}}},"/accounts/summary":{"get":{"tags":["Accounts"],"summary":"One-call channel inventory","description":"Every channel this key can reach: connected social accounts, email addresses (inbound + outbound), phone lines, webchat. Cached for ~30s; pass `?fresh=1` to bypass.","parameters":[{"$ref":"#/components/parameters/Fresh"}],"responses":{"200":{"$ref":"#/components/responses/AccountsSummary"}}}},"/conversations":{"get":{"tags":["Conversations"],"summary":"List conversations","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":500,"minimum":1,"maximum":500},"description":"Ceiling 500. A workspace still served by the upstream provider pages by `page`/`limit` instead and answers with `pagination`."},{"$ref":"#/components/parameters/Page"},{"name":"archived","in":"query","schema":{"type":"boolean"},"description":"Include archived threads"}],"responses":{"200":{"$ref":"#/components/responses/ConversationList"}}}},"/conversations/unread":{"get":{"tags":["Conversations"],"summary":"List conversations with unread (unprocessed) messages","description":"Cross-channel poll surface for external agents: returns every conversation whose unread counter is > 0 across social DMs, webchat, email, and phone. Each row carries `channel`, `platform`, and `conversationId` — everything needed to fetch the thread messages and reply. Replying resets the counter; POST `/conversations/{conversationId}/read` skips a conversation without replying.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"OK"}}}},"/conversations/unread-total":{"get":{"tags":["Conversations"],"summary":"One number: how much is waiting","description":"The badge count, without paying for the rows. Use `/conversations/unread` when you intend to act on them.","responses":{"200":{"description":"OK"}}}},"/conversations/changed":{"get":{"tags":["Conversations"],"summary":"List conversations that changed since a cursor (read or unread)","description":"Catch-up surface for an agent that has been offline: returns every conversation whose last message is strictly newer than `since`, newest first, WHETHER OR NOT anything is still unread. `/conversations/unread` cannot show a conversation somebody else already answered (its counter is 0), so an agent polling only that ends up with a hole in its history wherever another surface — web console, phone app, cloud auto-reply — replied first. Rows are the same shape `/conversations/unread` returns, so one decoder serves both; branch on `unreadCount` to tell \"still waiting\" from \"already handled elsewhere\". Keep the newest `lastMessageAt` you were handed and pass it back as the next `since`.","parameters":[{"name":"since","in":"query","required":true,"schema":{"type":"string"},"description":"REQUIRED cursor — an ISO-8601 timestamp (or epoch milliseconds), compared strictly greater against the conversation's `lastMessageAt`. Missing or unparsable is a 400."},{"name":"limit","in":"query","schema":{"type":"integer","default":200,"minimum":1,"maximum":500}}],"responses":{"200":{"description":"OK"},"400":{"description":"`since` missing or unparsable"}}}},"/conversations/{conversationId}/read":{"parameters":[{"$ref":"#/components/parameters/ConversationId"}],"post":{"tags":["Conversations"],"summary":"Mark a conversation as read (skip processing)","description":"Resets the unread counter so the conversation drops out of `/conversations/unread` without sending a reply.","responses":{"200":{"description":"OK"},"404":{"description":"Conversation not found"}}}},"/conversations/{conversationId}":{"parameters":[{"$ref":"#/components/parameters/ConversationId"}],"get":{"tags":["Conversations"],"summary":"Get conversation header + counters","responses":{"200":{"description":"OK"}}}},"/conversations/{conversationId}/messages":{"parameters":[{"$ref":"#/components/parameters/ConversationId"}],"get":{"tags":["Conversations"],"summary":"List messages (paginated, newest first)","parameters":[{"$ref":"#/components/parameters/Limit"},{"name":"before","in":"query","schema":{"type":"string"},"description":"Page backwards from this message id / timestamp"}],"responses":{"200":{"description":"OK"}}},"post":{"tags":["Conversations"],"summary":"Send a message into the conversation","description":"One attachment per message, referenced by URL — there is no `attachments[]` array on this route. `dryRun: true` runs auth + scope checks and answers `{dryRun: true, ok: true}` without reaching the upstream platform or writing anything.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string"},"attachmentUrl":{"type":"string","description":"Publicly fetchable URL of the single attachment"},"attachmentType":{"type":"string","description":"Media kind — 'image', 'video', 'audio', 'file'"},"tmpKey":{"type":"string","description":"Client-side id echoed back so an optimistic bubble can be reconciled"},"dryRun":{"type":"boolean"}}}}}},"responses":{"200":{"description":"OK"}}}},"/conversations/listen/status":{"get":{"tags":["Conversations"],"summary":"Is a realtime listener attached to this workspace?","description":"Diagnostics for the `GET /conversations/listen` WebSocket: one listener per workspace, and a new connection evicts the old one. `configured: false` means this deployment has no listener binding at all, so the socket surface is unavailable rather than idle.","responses":{"200":{"description":"`{listening, configured, …}`","content":{"application/json":{"schema":{"type":"object","properties":{"listening":{"type":"boolean"},"configured":{"type":"boolean"}}}}}}}}},"/w/inbox/comments":{"get":{"tags":["Comments"],"summary":"List posts grouped by unresolved-comment counts","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Page"}],"responses":{"200":{"description":"OK"}}}},"/w/inbox/comments/{postId}":{"parameters":[{"name":"postId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Comments"],"summary":"Threaded comments for a post","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Page"}],"responses":{"200":{"description":"OK"}}}},"/w/inbox/reviews":{"get":{"tags":["Reviews"],"summary":"List reviews across all platforms","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Page"}],"responses":{"200":{"description":"OK"}}}},"/w/posts":{"get":{"tags":["Posts"],"summary":"List posts (paginated)","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Page"},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","scheduled","published","failed"]}}],"responses":{"200":{"description":"OK"}}},"post":{"tags":["Posts"],"summary":"Create a post (draft → scheduled → published)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["platforms","caption"],"properties":{"platforms":{"type":"array","items":{"type":"string"}},"caption":{"type":"string"},"mediaUrls":{"type":"array","items":{"type":"string"}},"scheduleAt":{"type":"string","format":"date-time","description":"Omit to publish immediately"}}}}}},"responses":{"200":{"description":"OK"}}}},"/todos":{"get":{"tags":["Todos"],"summary":"List todos","description":"Not paginated: one page of up to `limit` tasks, no cursor. Passing `conversationId` or `contactId` switches to the per-thread / per-contact read, where `status` is only `open` (active) or `all` (includes archived + completed).","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500}},{"name":"status","in":"query","schema":{"type":"string","default":"open"},"description":"Workspace status (e.g. open, blocked, done), or `all`"},{"name":"assignedToId","in":"query","schema":{"type":"string"},"description":"Only tasks assigned to this member uid"},{"name":"conversationId","in":"query","schema":{"type":"string"},"description":"Only tasks linked to this conversation"},{"name":"contactId","in":"query","schema":{"type":"string"},"description":"Only tasks for this contact, across threads"}],"responses":{"200":{"description":"`{todos: [...]}`"}}},"post":{"tags":["Todos"],"summary":"Create a todo","description":"Skipping the AI reconstruct step: a non-empty `title` is enough — the pipeline reads that as \"the caller already structured this\" and no model is asked to re-derive title/description/dates from `templateData`. `skipAiExtraction: true` forces the same skip when `title` is empty (rare — mostly a pure-template create where `templateId` + `templateData` already say everything). There is no `structuredData` field.\n\nA `description` is rendered with visual chips over the dates, times and places written in it, encoded inline as `«type:display|value»` (see \"Inline tokens\" in the SDK README; descriptions read back through the API carry them). An LLM caller that already knows what those spans resolve to should send them as `inlineTokens` — they are applied directly and no AI call is made. With neither field the server derives them (one AI call, billed to the workspace), unless the description already carries `«…»` tokens; `extractInlineTokens: false` stores the description exactly as sent. Chips an API-key caller writes itself are stored as sent; with `creator: \"public_agent\"` every `«…»` in `title` and `description` is first turned into its display words, since that text is a stranger's. A failed extraction never blocks the create. The response reports which happened as `extraction`: `provided` | `reconstructed` | `fallback` (tokens were sent but refused) | `none`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Either a `title` or a `templateId` is required — a task with neither has nothing to be called.","properties":{"title":{"type":"string"},"description":{"type":"string"},"conversationId":{"type":"string"},"contactId":{"type":"string"},"assignedToId":{"type":"string","description":"Member uid to assign to"},"assignedTo":{"type":"string","description":"Display name of the assignee"},"groupId":{"type":"string"},"status":{"type":"string"},"priority":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"dateTime":{"type":"string","format":"date-time","description":"ISO-8601 start of the task window (the \"due at\" the clients render)"},"endDateTime":{"type":"string","format":"date-time","description":"ISO-8601 end of the window. Mutually exclusive with `duration`."},"duration":{"type":"string","description":"ISO-8601 duration (\"PT1H30M\"). Mutually exclusive with `endDateTime`."},"templateId":{"type":"string","description":"Create from a task template. The template is strict: it validates `templateData`, contributes defaults, and owns pricing (a caller-supplied `taskPricing` is replaced). A frozen snapshot is stored on the task so later template edits never mutate it."},"templateData":{"type":"object","description":"Field values keyed by the template's field paths — enumerate them from `GET /task-templates/computers/{computerId}/task-templates` (`fields` / `jsonSchema`)."},"skipAiExtraction":{"type":"boolean"},"inlineTokens":{"$ref":"#/components/schemas/InlineTokens"},"extractInlineTokens":{"type":"boolean"},"timezone":{"type":"string","description":"IANA timezone for resolving relative dates. Defaults to the workspace's."}}}}}},"responses":{"201":{"description":"`{todo, extraction}` — `extraction` is `provided` | `reconstructed` | `fallback` | `none`"},"400":{"description":"Validation error — `{error, code?, entities?}`"},"409":{"description":"Scheduling or asset conflict — see `conflicts`"}}}},"/todos/{todoId}/files/attach":{"parameters":[{"name":"todoId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Todos"],"summary":"Attach staged files to a task","description":"Promotes bytes out of the 7-day `attachments/…` staging area (or a fetch-able URL) into the task's durable folder. The usual flow is `POST /kb/computers/{computerId}/staging/signed-url` → PUT the bytes → attach with `sourceUrl: \"kb://<key>\"`. Partial success is normal: read both `attached` and `failures`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"type":"object","required":["sourceUrl"],"properties":{"sourceUrl":{"type":"string","description":"`kb://<key>` or an https URL"},"filename":{"type":"string"},"mimeType":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"`{ok, attached: TaskFileRef[], failures: string[]}`"},"400":{"description":"`files[].sourceUrl` is required"}}}},"/todos/{todoId}/files/remove":{"parameters":[{"name":"todoId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Todos"],"summary":"Remove attachments from a task","description":"`fileIds` drops the refs from the task. Pass the matching `kbUrls` too to delete the underlying objects — only keys inside this task's own folder are touched, so a stray url cannot reach another task's bytes.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fileIds"],"properties":{"fileIds":{"type":"array","items":{"type":"string"}},"kbUrls":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"`{ok: true, removed}`"},"400":{"description":"`fileIds` is required"}}}},"/todos/{todoId}":{"parameters":[{"name":"todoId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Todos"],"summary":"Get a todo","responses":{"200":{"description":"OK"}}},"patch":{"tags":["Todos"],"summary":"Update a todo","responses":{"200":{"description":"OK"}}},"delete":{"tags":["Todos"],"summary":"Delete a todo","responses":{"200":{"description":"OK"}}}},"/todos/{todoId}/pricing/estimate":{"parameters":[{"name":"todoId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Todos"],"summary":"Estimate a task's price as if the tracker's counters were final","description":"The task's own lines, priced by the same function the task is priced with when tracking stops: every component (a base fare, per km or per mile, per minute or per hour), the option picks, the line quantity and the tax. For a live trip. READ-ONLY: it writes nothing and never re-prices the stored task. A counter the body leaves out is the task's own. `pricing` has the shape of a stored `taskPricing`; `exact` is false while any line waits for a quote. Scope `todo:read`.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"trackedDistanceMeters":{"type":"number","minimum":0,"description":"GPS path so far, in metres."},"trackedDurationSeconds":{"type":"number","minimum":0,"description":"Elapsed time so far, in seconds."}}}}}},"responses":{"200":{"description":"`{pricing: {items, subtotal, taxAmount, total, currency, needsQuote}, exact}`. Money is `{amount, currency}` in the currency's smallest unit, currency lower case."},"400":{"description":"`bad_counter` (a negative or non-numeric counter), or `mixed_currency` / `no_currency`"},"404":{"description":"`not_found`, or `no_pricing` for a task with no priced lines"}}}},"/todos/{todoId}/request-edit":{"parameters":[{"name":"todoId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Todos"],"summary":"Request an edit or cancellation on behalf of a customer","description":"For AI agents relaying a customer request, as opposed to `PATCH` which is an operator editing their own task. Within 30 minutes of creation — or on a draft — an edit is applied directly. Otherwise it goes to a review queue a team member must approve. Cancellations ALWAYS go to review. Branch on `applied` in the response: it is the difference between \"done\" and \"the team will confirm\". A key caller must name the customer it relays for: the task must be linked to `conversationId` (or `contactId`), otherwise the answer is 404 `not_found`, the same as for a missing task. A relayed request never changes whose the task is, its price or its status — those `changes` keys are dropped and listed in `ignored`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action","reason","conversationId"],"properties":{"action":{"type":"string","enum":["edit","cancel"]},"reason":{"type":"string","description":"The customer's own words. Shown to the team on review."},"conversationId":{"type":"string","description":"The conversation the customer is asking from. Required for API-key callers; the task must be linked to it or to `contactId`."},"contactId":{"type":"string","description":"The customer's contact id, when known — also reaches their tasks from other conversations."},"changes":{"type":"object","description":"Same field names as PATCH, plus an optional `inlineTokens` for `changes.description`. Only used when action = \"edit\". Dropped for a relayed request and listed in `ignored`: conversationId, originConversationId, contactId, contactName, contactPhone, contactEmail, platform, profileSetId, relativePosition, status, taskPricing, payment, trackedDurationSeconds, trackedDistanceMeters, templateId. To cancel, use action = \"cancel\"."},"channel":{"type":"string","description":"Origin channel ('dm', 'voice', …). Relaxes email validation on voice."}}}}}},"responses":{"200":{"description":"`{ok: true, applied: true, review: false, ignored?}` when the edit landed, `{ok: true, applied: false, review: true, notified, ignored?}` when it was queued (`notified: false` = recorded, but no team member was pinged yet)."},"400":{"description":"`{ok: false, code: \"requester_required\"}` — an API-key caller sent no `conversationId`; or a missing/invalid `action` or `reason`."},"404":{"description":"`{ok: false, error: \"Task not found on this conversation\", code: \"not_found\"}` — the task is not linked to this conversation or contact, or does not exist (the two are not told apart)."},"409":{"description":"Scheduling or asset conflict — see `conflicts`. For a relayed request each conflict is `{startTime, endTime, conflictType}` only: no other booking's title, assignee or id."}}}},"/scheduling/computers/{computerId}/available-slots":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Todos"],"summary":"Find free time slots for a member, group, or asset","parameters":[{"name":"searchFrom","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"searchUntil","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"durationMinutes","in":"query","schema":{"type":"integer"}},{"name":"memberId","in":"query","schema":{"type":"string"}},{"name":"groupId","in":"query","schema":{"type":"string"}},{"name":"assetId","in":"query","schema":{"type":"string"}},{"name":"assetUnits","in":"query","schema":{"type":"integer"}},{"name":"maxResults","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"`{slots: [{start, end, durationMinutes}], totalFound}`"}}},"post":{"tags":["Todos"],"summary":"Find free time slots (body form)","description":"Same read as GET, with the parameters in a JSON body.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["searchFrom"],"properties":{"searchFrom":{"type":"string","format":"date-time"},"searchUntil":{"type":"string","format":"date-time"},"durationMinutes":{"type":"integer"},"memberId":{"type":"string"},"groupId":{"type":"string"},"assetId":{"type":"string"},"assetUnits":{"type":"integer"},"maxResults":{"type":"integer"}}}}}},"responses":{"200":{"description":"`{slots: [{start, end, durationMinutes}], totalFound}`"}}}},"/saved-locations/computers/{computerId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Todos"],"summary":"A conversation's saved addresses","parameters":[{"name":"conversationId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"`{locations: [{id, name, address, lat, lng}]}`"}}},"post":{"tags":["Todos"],"summary":"Remember an address on a conversation","description":"Either `address` or the `lat`+`lng` pair is required. The returned `locationId` is usable straight away as a task location reference.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["conversationId"],"properties":{"conversationId":{"type":"string"},"name":{"type":"string","description":"Short label the customer will recognise (\"Home\")."},"address":{"type":"string"},"lat":{"type":"number"},"lng":{"type":"number"}}}}}},"responses":{"201":{"description":"`{ok: true, locationId}`"}}}},"/w/contacts":{"get":{"tags":["Knowledge base"],"summary":"List contacts","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Page"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Free-text search over name / handle"}],"responses":{"200":{"description":"OK"}}}},"/team/computers/{computerId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"The team directory an escalation can name","description":"Members (owner first) and groups, with each group carrying its own `memberIds` — the membership a caller cannot compute itself, since members reference a group but groups carry no roster. Without this, `escalate` has no way to learn a `groupId` or `memberId` and every escalation degrades to a broadcast.","responses":{"200":{"description":"`{members: [{id, name, email?, role?}], groups: [{id, name, memberIds}]}`"},"403":{"description":"No access to this workspace"}}}},"/team/escalate/computers/{computerId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Business data"],"summary":"Ring a human","description":"An agent's \"I need a person\" reaching the same team, the same event and the same push as the in-app escalation. `groupId` defaults to `all` (a broadcast, not a team — it is deliberately absent from the group directory). Unknown ids degrade to the raw id in the notification label rather than failing the call.\n\nFor a question that expects an ANSWER rather than attention, use `/agent-asks` instead: escalation notifies, an ask blocks until somebody replies.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"string","description":"What the human needs to answer. Truncated at 4000 chars."},"urgency":{"type":"string","enum":["low","medium","high"],"default":"medium"},"groupId":{"type":"string","description":"Group to ring, or 'all'"},"memberId":{"type":"string","description":"A single member, or 'none'"},"conversationId":{"type":"string","description":"Thread the question came from"}}}}}},"responses":{"200":{"description":"`{ok: true}`"},"400":{"description":"`message` is required"},"404":{"description":"Workspace not found"}}}},"/agent-asks":{"post":{"tags":["Asks"],"summary":"Put a question to the workspace operator","description":"Two kinds, and the choice is about who waits. A `sync` ask is for a caller holding a line open — it is REFUSED with `409 offline` when the main agent has no listener attached, because a question nobody can see is worse than a question not asked. An `async` ask skips that gate and can be collected later.\n\n`source: 'member'` is refused by name (`400 source_not_allowed`) — a person's own question travels on their own credential, not on an integration key. Anything that is not `voice` is recorded as `public`.\n\n`ttlMs` is clamped: 5s floor either way, 5 minutes maximum for `sync` (45s default), 72 hours for `async` (which is also its default). Reading an expired-but-unanswered ask reports `status: \"expired\"`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["question"],"properties":{"question":{"type":"string","maxLength":4000},"kind":{"type":"string","enum":["sync","async"],"default":"sync"},"source":{"type":"string","enum":["public","voice"],"default":"public"},"sourceRef":{"type":"string","description":"Where the question came from — a call id, a conversation ref"},"sourceLabel":{"type":"string","description":"Human-readable origin shown beside the question"},"ttlMs":{"type":"integer","description":"Clamped — see the description"}}}}}},"responses":{"200":{"description":"`{ask}`"},"400":{"description":"`invalid` / `source_not_allowed`"},"409":{"description":"`offline` — no listener is attached to answer a `sync` ask"},"503":{"description":"`storage_unavailable`"}}}},"/agent-asks/online":{"get":{"tags":["Asks"],"summary":"Is anybody there to answer?","description":"Ask this before a `sync` ask if you would rather degrade than be refused. No scope required — it reports availability, not content.","responses":{"200":{"description":"`{online: boolean}`"}}}},"/agent-asks/pending":{"get":{"tags":["Asks"],"summary":"Questions still waiting for an answer","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"`{asks: [...]}`"}}}},"/agent-asks/member-answered":{"get":{"tags":["Asks"],"summary":"Answered member questions not yet delivered","description":"The collection side of the asynchronous path: questions a member put to the main agent that have been settled but not yet handed back. Limited to the last 48 hours, and a row drops out once `POST /agent-asks/{askId}/delivered` acknowledges it.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"`{asks: [...]}`"}}}},"/agent-asks/{askId}":{"parameters":[{"$ref":"#/components/parameters/AskId"}],"get":{"tags":["Asks"],"summary":"Read one ask","responses":{"200":{"description":"`{ask}`"},"404":{"description":"Ask not found in this workspace"}}}},"/agent-asks/{askId}/delivered":{"parameters":[{"$ref":"#/components/parameters/AskId"}],"post":{"tags":["Asks"],"summary":"Acknowledge that an answer reached its asker","description":"Idempotent, and never a 404 — acknowledging twice is the state you asked for.","responses":{"200":{"description":"`{ok: true}`"}}}},"/agent-asks/{askId}/answer":{"parameters":[{"$ref":"#/components/parameters/AskId"}],"post":{"tags":["Asks"],"summary":"Answer a pending ask","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["answer"],"properties":{"answer":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ask}`"},"400":{"description":"`answer` is required"},"404":{"description":"Ask not found"},"409":{"description":"`not_pending` — names the status it already holds"}}}},"/task-templates/computers/{computerId}/task-templates":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"List task templates","description":"Most-recently-updated first. Alongside the raw template definition, every row carries two derived views an agent needs in order to fill one in: `fields`, the flat leaf listing with the exact paths the evaluator walks (sections and repeatables included), and `jsonSchema`, the shape `templateData` must take on `POST /todos`. Enumerate here, then create — a template you can list but cannot fill is worse than no template.","responses":{"200":{"description":"`{templates: [{…template, fields, jsonSchema}]}`"}}}},"/task-templates/computers/{computerId}/task-templates/{templateId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"templateId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Business data"],"summary":"Get one task template","description":"The stored definition only — `fields` / `jsonSchema` are computed on the list route.","responses":{"200":{"description":"`{template}`"},"404":{"description":"Template not found"}}}},"/task-templates/computers/{computerId}/task-templates/{templateId}/archive":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"templateId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Business data"],"summary":"Archive a task template","description":"Sets `archivedAt`. An archived template takes no NEW task from anybody, the team included (`template_archived`), and leaves every picker and public door; tasks already filed from it keep working. Needs `task-template:update` (the owner or a manager). Fires `task_template.updated` like an edit; `version` does not move. Idempotent: an archived template answers `changed: false`. Optional body `{profileSetId?, ifVersion?}`. The list routes still return archived templates, marked by `archivedAt`.","responses":{"200":{"description":"`{template, changed}`"},"404":{"description":"Template not found"},"409":{"description":"`version_conflict`"}}}},"/task-templates/computers/{computerId}/task-templates/{templateId}/unarchive":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"templateId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Business data"],"summary":"Unarchive a task template","description":"Clears `archivedAt`, so the template takes new tasks and returns to the pickers. Same permission, event and idempotence as archive.","responses":{"200":{"description":"`{template, changed}`"},"404":{"description":"Template not found"},"409":{"description":"`version_conflict`"}}}},"/entity-sync/computers/{computerId}/tasks/{taskId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"taskId","in":"path","required":true,"schema":{"type":"string"}}],"put":{"tags":["Business data"],"summary":"Upsert one task under an id the CALLER minted","description":"For a client that keeps its own copy of this workspace's tasks — a 00 engine on a laptop, which files work offline under a UUID it chose. `POST /todos` cannot serve that: it mints its own id, and it is a business event (pricing, availability, payment holds, `on_creation` release rules) that must not fire again for a job that already happened somewhere else. This REPLICATES a record — it writes the document and its projection and nothing else. Needs BOTH `todo:create` and `todo:update`, because an upsert is a create when the row is absent and an update when it is present. Body is the task in the `object.json` spelling (`dateTime`, `endDateTime`, `assignedTo`, `assignedToIds`, the flat location fields), plus an optional `ifUpdatedAt` naming the copy the caller was holding. `completedAt` / `completedBy` are refused by name; `attachments` is ignored (bytes travel by staging + `POST /todos/{todoId}/files/attach`).","responses":{"200":{"description":"`{task, created: false}` — merged onto what was already stored"},"201":{"description":"`{task, created: true}`"},"400":{"description":"`bad_id` / `bad_body` / `id_mismatch` / `server_owned_field`"},"409":{"description":"`stale_write` — names the `updatedAt` that actually won"}}},"delete":{"tags":["Business data"],"summary":"Remove one task, because it was removed on the client","description":"Idempotent — a task that is already gone is the state the caller asked for. Needs `todo:delete`. NOT the archive: `isArchived` is a field on a live task and travels on the upsert above.","responses":{"200":{"description":"`{ok: true, deleted}`"}}}},"/entity-sync/computers/{computerId}/tasks":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"What changed in the task list since a cursor — tombstones included","description":"`since` is a CURSOR, not a timestamp: a timestamp cannot express \"and these were deleted\", and two writes in the same millisecond are ambiguous under one and ordered under the other. Answers `{changes: [{id, op, cursor, task?}], cursor, more, reset?}`. An `op: \"delete\"` row carries no task. `reset: true` means the cursor is outside the retention window (or above this workspace's head, which happens after a restore) and the caller must refetch whole — a partial answer would leave it holding rows this workspace deleted. Send `cursor` back as the next `since`.","parameters":[{"name":"since","in":"query","schema":{"type":"integer"},"description":"Cursor from the last page. 0 = from the beginning of what is retained."},{"name":"limit","in":"query","schema":{"type":"integer"},"description":"Rows per page; clamped to `maxLimit`, which the answer carries."}],"responses":{"200":{"description":"One page of the delta"}}}},"/entity-sync/computers/{computerId}/task-templates":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Every task template, whole — the template half of the sync","description":"NOT paginated and NOT a delta, deliberately. Templates are R2 objects with no cursor and no tombstone, and there are a handful per workspace, so the sync reads the whole list and derives deletions from ABSENCE. That inference is only sound because the answer is complete, which is what `complete: true` states.","responses":{"200":{"description":"`{templates, complete: true}`"}}}},"/entity-sync/computers/{computerId}/task-templates/{templateId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"templateId","in":"path","required":true,"schema":{"type":"string"}}],"put":{"tags":["Business data"],"summary":"Upsert one task template under an id the CALLER minted","description":"Same save-time gate as the template editor, through the same function. The caller's `version` is STORED rather than renumbered — a task somewhere froze it, and resetting it here would point that snapshot at a version that never existed. `ifVersion` is the same optimistic token `PATCH /task-templates/…` takes, with the same 409. `visibility` defaults to `internal`.","responses":{"200":{"description":"`{template}` — updated"},"201":{"description":"`{template}` — created"},"400":{"description":"`bad_id` / `invalid_field_type` / `invalid_field_config`"},"409":{"description":"`version_conflict`"}}},"delete":{"tags":["Business data"],"summary":"Remove one task template, because it was removed on the client","responses":{"200":{"description":"`{ok: true, deleted}`"}}}},"/kb/computers/{computerId}/folders":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Knowledge base"],"summary":"List KB folders","description":"A key that carries a member's authority sees the folders that member could open, and no others — a private folder's NAME is already a disclosure. The workspace is seeded with defaults on first read.","responses":{"200":{"description":"`{folders: [...]}`"}}},"post":{"tags":["Knowledge base"],"summary":"Create a KB folder","description":"The folder is named, not slugged: `slug` is derived from `name` and is what later paths address the folder by. A name that slugifies to nothing (no alphanumerics) is rejected.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Display name — the slug is derived from it"},"visibility":{"type":"string","enum":["public","private"],"default":"public"},"groupIds":{"type":"array","items":{"type":"string"},"description":"Team groups that can see a private folder"},"icon":{"type":"string"}}}}}},"responses":{"200":{"description":"`{folder}`"},"400":{"description":"`name` is required / has no alphanumerics"},"409":{"description":"A folder with this name already exists"}}}},"/kb/computers/{computerId}/folders/{slug}/files":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Knowledge base"],"summary":"List the files in one KB folder","description":"Files are folder-scoped — there is no workspace-wide file listing. The full folder is walked and returned newest-first, unpaginated.","responses":{"200":{"description":"`{files: [{id, filename, key, size, uploadedAt, contentType}]}`"},"404":{"description":"Folder not found"}}}},"/kb/computers/{computerId}/staging/signed-url":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Knowledge base"],"summary":"Sign a direct upload into the staging area","description":"Bytes arrive in a 7-day staging prefix and become durable only when something claims them — `POST /todos/{todoId}/files/attach` for a task, `POST /kb/computers/{computerId}/move` to promote into a KB folder. PUT the bytes to the returned URL with exactly the headers it echoes back; they were signed, so changing one invalidates the signature.","responses":{"200":{"description":"`{uploadUrl, headers, key, …}`"},"503":{"description":"Direct-upload credentials are not configured on this deployment"}}}},"/kb/computers/{computerId}/move":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Knowledge base"],"summary":"Promote staged bytes into a KB folder","description":"The other half of the staging flow: takes a `kb://<key>` from the staging prefix and files it under a folder, where retention no longer applies.","responses":{"200":{"description":"OK"}}}},"/kb/computers/{computerId}/search":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Knowledge base"],"summary":"Semantic search via AutoRAG","description":"Runs the workspace-scoped AutoRAG index and returns ranked fragments. Supports MMR / token-based result trimming.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query"],"properties":{"query":{"type":"string","maxLength":500},"topK":{"type":"integer","default":6,"minimum":1,"maximum":20},"filters":{"type":"object"}}}}}},"responses":{"200":{"description":"OK"}}}},"/business-knowledge/computers/{computerId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Read full business doc","responses":{"200":{"description":"`{businessData}`"}}},"put":{"tags":["Business data"],"summary":"Replace the full business doc","description":"Full replace, not a patch: the body IS the new document, so read first and send the whole thing back. Validated as a whole — a bad shape answers 400 with the exact schema `issues` and nothing is written. Narrower sections (`/branding`, `/catalog`) have their own PUTs so a key can hold one without the other. A few fields a lagging client may not know (`arrivalKinds`, `scheduling`, `reports`, `taskSettings`, `payments`, `profile.iceBreakers`, the branding's `iconUrl`, `appearance` and `updatedAt`, and a few catalog-item and payments fields) keep their stored value when omitted; send `null` or the empty value to clear one.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"`{ok: true, data}`"},"400":{"description":"`{error, issues}` — schema validation failed"}}}},"/business-knowledge/computers/{computerId}/hours":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Opening hours + timezone","description":"The narrow read an agent needs to answer \"are you open?\" without pulling the whole business document. `hours.timezone` is the IANA zone every other date on this API resolves relative dates against.","responses":{"200":{"description":"OK"}}}},"/business-knowledge/computers/{computerId}/scheduling":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Scheduling and where the business serves, with its version","description":"`{scheduling, version}`: the slot length, buffers, booking window, the service circles (`serviceAreas`) and whole countries (`serviceCountries`, groups of ISO 3166-1 alpha-2 codes), and the `version` (`scheduling.updatedAt`) a change must name. Scope `business-data:read`.","responses":{"200":{"description":"OK"}}},"put":{"tags":["Business data"],"summary":"Change the scheduling","description":"Send `{changes, ifVersion}`: only the keys that changed, each replaced whole (`null` or `[]` clears one), and the `version` you read (`null` when there was none). A country group may name a region `shortcut` (`EU`, `EEA`, `SCHENGEN`, `EFTA`, `UK_IE`, `NORTH_AMERICA`, `MERCOSUR`, `ASEAN`, `GCC`) without `countries`; it is filled from a dated table and the codes saved are the rule. A different stored version answers 409 `version_conflict` with the current scheduling. Scope `business-data:update`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"`{ok: true, scheduling, version}`"},"400":{"description":"`{error, code: if_version_required | bad_scheduling, field?}`"},"409":{"description":"`version_conflict` with the current scheduling and version, or `no_business_data`"}}}},"/business-knowledge/computers/{computerId}/branding":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Branding, its version and the display name","description":"`{branding, version, name, resolved}`: the stored fields (logos, icon, colours, fonts, tagline, tone), the `version` a change must name, the business display name, and the https URL each stored image resolves to. Scope `branding:read`.","responses":{"200":{"description":"OK"}}},"put":{"tags":["Business data"],"summary":"Change the branding","description":"Send `{changes, ifVersion, name?}`: only the fields that changed (`null` clears one; every other stored field is kept) and the `version` you read (`null` when there was none). A different stored version answers 409 `version_conflict` with the current branding, and nothing is written. `name` also needs `business-data:update`. A body without `changes` is the older form: the whole Branding object, which replaces it. Scope `branding:update`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"`{ok: true, branding, version, name, resolved}`"},"400":{"description":"`{error, code, field?}`"},"409":{"description":"`version_conflict`, with the current branding and version"}}}},"/business-knowledge/computers/{computerId}/branding/logo":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Business data"],"summary":"Upload a logo, dark-background logo or square icon","description":"The image bytes with their own `Content-Type` (or multipart `file`), `?slot=primary|secondary|icon`. PNG, JPEG or WebP judged by the bytes, never SVG; at most 2 MB and 32 to 4096 px a side; the icon square and at least 64 px. Answers the durable `kb://` value to PUT as the field; the upload itself changes no branding. Scope `branding:update`.","responses":{"201":{"description":"`{url, previewUrl, field, slot, contentType, size, width, height}`"},"400":{"description":"Too small, too big, or an icon that is not square"},"413":{"description":"Over 2 MB"},"415":{"description":"Not a PNG, JPEG or WebP"}}}},"/business-knowledge/computers/{computerId}/catalog":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Business data"],"summary":"Priced catalog items","responses":{"200":{"description":"OK"}}},"put":{"tags":["Business data"],"summary":"Replace the catalog","description":"Its own route, and its own scope, so a key may hold the catalog without holding the whole business document.","responses":{"200":{"description":"OK"},"400":{"description":"`{error, issues}`"}}}},"/webchat-links":{"get":{"tags":["Webchat"],"summary":"List the workspace's chat links","parameters":[{"name":"sequenceGroup","in":"query","schema":{"type":"string"},"description":"Only links tagged with this batch name"}],"responses":{"200":{"description":"`{links: [...]}`"}}},"post":{"tags":["Webchat"],"summary":"Mint a position chat link (+ QR)","description":"A link is a short, OCR-safe code standing for a place — \"Table 18\", \"Room 203\". A visitor who scans it gets that label attached to their conversation, so the agent knows where they are.\n\nTwo URLs come back. `unsignedUrl` (`?link=<id>`) never expires — that is the one for printed signage. Supply `expiresAt` and `url` becomes the signed form (`?signed=<token>`): the expiry lives inside the signature payload, so an expired scan is rejected without a database lookup and the link itself is never revoked. `qrUrl` / `unsignedQrUrl` are PNG image URLs for each.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"properties":{"label":{"type":"string","example":"Table 18","maxLength":80},"sequenceGroup":{"type":"string","description":"Batch tag for later listing / PDF / bulk delete"},"assetIds":{"type":"array","items":{"type":"string"},"description":"Assets at this spot, scoped onto conversations that start here"},"pricingItemIds":{"type":"array","items":{"type":"string"}},"kind":{"type":["string","null"],"description":"What the person following this link came for — 'supplier', or absent for a customer."},"templateId":{"type":["string","null"],"description":"Task template the intake interview should fill in."},"expiresAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601, must be in the future. Switches `url` to the signed form."}}}}}},"responses":{"200":{"description":"`{id, label, sequenceGroup, assetIds, pricingItemIds, expiresAt, signed, url, unsignedUrl, qrUrl, unsignedQrUrl}`"},"400":{"description":"Missing `label`, or `expiresAt` is invalid / in the past"},"404":{"description":"This credential may not create links in this workspace"},"503":{"description":"Signed expiring links are not configured on this deployment"}}}},"/webchat-links/bulk":{"post":{"tags":["Webchat"],"summary":"Mint a numbered batch of links in one call","description":"Expands `labelTemplate` — which must contain `{n}` — over a sequence, so \"Table {n}\" across `1..20` becomes twenty links sharing one `sequenceGroup`. Assets, pricing items, `kind` and `templateId` are declared once for the whole batch, because a sheet of supplier QR codes is one batch and not twenty decisions. Capped at 500 per call.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["labelTemplate","sequence"],"properties":{"labelTemplate":{"type":"string","example":"Table {n}"},"sequence":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string"},"end":{"type":"string"},"format":{"type":"string","enum":["numeric","alpha","alphanumeric"]},"pad":{"type":"integer","description":"Numeric only — zero-pad to N digits"}}},"sequenceGroup":{"type":"string"},"assetIds":{"type":"array","items":{"type":"string"}},"pricingItemIds":{"type":"array","items":{"type":"string"}},"kind":{"type":["string","null"]},"templateId":{"type":["string","null"]}}}}}},"responses":{"200":{"description":"`{links: [...]}`"},"400":{"description":"`labelTemplate` must contain `{n}`, or the sequence is unexpandable"}}}},"/webchat-links/pdf":{"post":{"tags":["Webchat"],"summary":"Print-ready sheet of QR codes","description":"Renders a batch (`sequenceGroup`) or an explicit `ids` list onto an A4 grid and returns the PDF bytes. Gated with minting rather than with reading: a credential that may not create a door may not print one either.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"sequenceGroup":{"type":"string"},"ids":{"type":"array","items":{"type":"string"}},"columns":{"type":"integer"}}}}}},"responses":{"200":{"description":"The PDF (`application/pdf`, as an attachment)"},"404":{"description":"Nothing in that selection to print"}}}},"/webchat-links/{linkId}":{"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"delete":{"tags":["Webchat"],"summary":"Retire a link","description":"A soft delete: the code stops resolving and stops being listed. Printed signage carrying it becomes inert rather than pointing somewhere wrong.","responses":{"200":{"description":"`{ok: true}`"},"404":{"description":"Unknown id, or already retired"}}}},"/webchat-links/prompts":{"get":{"tags":["Webchat"],"summary":"List page-prompt links","description":"Signed `?obp=` links that make the web chat say a line by itself after a delay, and optionally ask a 1-5 question. Each carries its URL re-signed with the current key. `includeRevoked=1` lists revoked ones too.","parameters":[{"name":"includeRevoked","in":"query","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"`{links: [...], signingConfigured}`"}}},"post":{"tags":["Webchat"],"summary":"Mint a page-prompt link","description":"The chat shows `message` as the business's bubble `delaySeconds` after it opens (default 4, 0-60), once per visitor, and asks `feedback.question` on a 1-5 scale when set. The URL carries only `obp=<id>.<signature>`: the words stay on the server, so a link can be revoked and never carries a personal greeting. `page` limits it to one page (a path, or a pattern with `*`); `kind` states the arrival kind when the visitor answers; `tag` is for your reporting. Every problem is returned at once.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","maxLength":280},"delaySeconds":{"type":"integer","minimum":0,"maximum":60,"default":4},"feedback":{"type":"object","required":["question"],"properties":{"question":{"type":"string","maxLength":140},"scale":{"type":"integer","enum":[5]}}},"page":{"description":"A path such as `/pricing`, or `{pattern, type: exact|prefix|glob}`","oneOf":[{"type":"string"},{"type":"object"}]},"expiresAt":{"type":"string","format":"date-time"},"kind":{"type":"string"},"tag":{"type":"string"},"label":{"type":"string","maxLength":80}}}}}},"responses":{"201":{"description":"The link, with `url`, `token` and `param` (`obp=…`)"},"400":{"description":"`{code: \"invalid\", errors: [...]}`"},"404":{"description":"This credential may not create links in this workspace"},"503":{"description":"`signing_unconfigured`: WEBCHAT_PROMPT_SECRET is not set on this deployment"}}}},"/webchat-links/prompts/{promptId}":{"parameters":[{"name":"promptId","in":"path","required":true,"schema":{"type":"string"}}],"delete":{"tags":["Webchat"],"summary":"Revoke a page-prompt link","description":"It stops showing at once. Its answers stay readable.","responses":{"200":{"description":"`{ok: true}`"},"404":{"description":"Unknown id, or already revoked"}}}},"/webchat-links/prompts/feedback":{"get":{"tags":["Webchat"],"summary":"Page-prompt answers","description":"Per prompt (signed link or page rule): the answer count, the average, the 1-5 distribution, the pages it was answered on, the ten latest comments and how many conversations the bubble started. Scores are the team's: they are never written into a conversation.","parameters":[{"name":"promptId","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"`{results: [...]}`"}}}},"/email/threads":{"get":{"tags":["Email"],"summary":"List email threads, newest activity first","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only threads whose `lastSeenAt` is newer than this"}],"responses":{"200":{"description":"`{threads: [{threadId, conversationId, fromEmail, ticket, subject, status, receivedOnDomain, messageCount, firstSeenAt, lastSeenAt, lastMessage, lastMessageAt, lastMessageIsOutgoing}]}`"}}},"post":{"tags":["Email"],"summary":"Start an outbound email thread","description":"Sending is thread-shaped: this mints a ticket (`[#000019]`, appended to the subject) and that ticket is what re-attaches the recipient’s reply even when they edit the subject line. Reply on an existing thread with `POST /email/threads/{threadId}/reply` instead — a `Re: …` sent through here is a NEW email in the recipient’s client.\n\nOmit `receivedOnDomain` and the workspace’s primary agent domain is used. `dryRun: true` validates without minting a ticket, reserving quota, sending, or persisting anything.\n\n**Three shapes of success.** An immediate send answers with `messageId`. A `scheduledAt` in the future answers `{scheduled: true, scheduledAt, scheduleId}`. And when the sending member works under approval, the mail is PARKED rather than sent — same shape plus `awaitingApproval: true` — and goes out only once a person releases it from the workspace review queue. Branch on those fields rather than assuming the mail left.\n\n**Absence rather than refusal.** A key carrying a member's authority who does not hold the `email.send` allowance gets a 404, not a 403: for that credential this door does not exist.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["toAddr","subject","body"],"properties":{"toAddr":{"type":"string","format":"email"},"toName":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"},"class":{"type":"string","enum":["communication","marketing"],"default":"communication","description":"Which suppression list applies, and whether an unsubscribe footer is required. A recipient who opted out of marketing still receives communication about work in progress."},"scheduledAt":{"type":"string","format":"date-time","description":"ISO-8601. Under two minutes out (or in the past) means send now; more than 6 days out is refused as `schedule_too_far`. Delivery is late-never-early — the sweep runs every 15 minutes."},"attachments":{"type":"array","description":"Storage KEYS, not URLs — the send route reads the bytes from the platform bucket. Upload first via `/email/attachments/signed-url`.","items":{"type":"object","properties":{"key":{"type":"string"},"filename":{"type":"string"},"contentType":{"type":"string"}}}},"tmpKey":{"type":"string"},"dryRun":{"type":"boolean"},"receivedOnDomain":{"type":"string","description":"Which agent domain the thread is anchored to. The four `agent.*` subdomains are the current form; the four apex domains are accepted so that threads which began there stay repliable on the domain the recipient already knows.","enum":["agent.0-0.chat","agent.o-0.chat","agent.0-o.chat","agent.o-o.chat","0-0.chat","o-0.chat","0-o.chat","o-o.chat"]}}}}}},"responses":{"200":{"description":"`{threadId, conversationId, ticket, messageId, emailMessageId}` when it left, or `{threadId, conversationId, ticket, scheduled: true, awaitingApproval?, scheduledAt, scheduleId, emailMessageId}` when it is waiting."},"400":{"description":"Missing `toAddr`/`subject`/`body`, `invalid_recipient`, unknown domain, `invalid_schedule`, `schedule_too_far`, `attachment_missing`"},"403":{"description":"`recipient_suppressed` — this address asked not to be written to"},"404":{"description":"No workspace to send from, or this credential holds no send allowance"},"429":{"description":"Daily send allowance exhausted for the shared agent address"},"502":{"description":"Retryable upstream send failure"},"503":{"description":"`suppression_unavailable` / `storage_unavailable`"}}}},"/email/threads/{threadId}/messages":{"parameters":[{"$ref":"#/components/parameters/ThreadId"}],"get":{"tags":["Email"],"summary":"Read one thread","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"`{threadId, messages: [{messageId, direction: \"in\"|\"out\", fromEmail, toEmail, subject, text, truncated, createdAt}]}`"},"503":{"description":"`storage_unavailable`"}}}},"/email/threads/{threadId}/reply":{"parameters":[{"$ref":"#/components/parameters/ThreadId"}],"post":{"tags":["Email"],"summary":"Reply on an existing thread","description":"The workspace and the recipient both come from the thread, so this route takes neither. The reply reuses the thread's ticket, which is what keeps it in the same conversation in the recipient's client. Approval and scheduling behave exactly as on `POST /email/threads`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string"},"class":{"type":"string","enum":["communication","marketing"]},"attachments":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"filename":{"type":"string"},"contentType":{"type":"string"}}}},"tmpKey":{"type":"string"},"dryRun":{"type":"boolean"}}}}}},"responses":{"200":{"description":"`{threadId, ticket, messageId, emailMessageId}`, or the parked shape"},"400":{"description":"`body` is required, or an attachment key could not be read"},"403":{"description":"`recipient_suppressed`, or the thread belongs to another workspace"},"404":{"description":"Thread not found, or this credential holds no send allowance"},"429":{"description":"Daily send allowance exhausted"}}}},"/email/quota":{"get":{"tags":["Email"],"summary":"Today's send allowance","description":"Under the shared agent address a workspace has a daily ceiling; connect your own SMTP server and the ceiling is lifted, which `regime: \"own_smtp\"` reports with `limit` and `remaining` both null and `unlimited: true`. Nothing else about sending changes — suppression, classes and the unsubscribe target stay the workspace's.","responses":{"200":{"description":"`{used, limit, remaining, unlimited, regime}`"},"503":{"description":"`storage_unavailable`"}}}},"/email/scheduled":{"get":{"tags":["Email"],"summary":"Mail that has not gone yet","description":"Everything queued and still pending, soonest first (up to 200; there is no paging). `id` is the row to cancel; `messageId` is the stored message. `holdReason` is present when a past-due send has been deferred rather than delivered — a full daily allowance, or a suppression registry that could not be reached — and the sweep will retry it.\n\nMail parked for a person to approve does NOT appear here: it is not scheduled, it is waiting on somebody, and it lives in the workspace review queue instead.","responses":{"200":{"description":"`{scheduled: [{id, messageId, threadId, toAddr, toName, subject, scheduledAt, class, holdReason}]}`"}}}},"/email/scheduled/{scheduleId}":{"parameters":[{"name":"scheduleId","in":"path","required":true,"schema":{"type":"string"}}],"delete":{"tags":["Email"],"summary":"Cancel a scheduled send","description":"Only a still-pending row can be cancelled. Once the sweep has taken it the answer is `409 not_scheduled` — the mail has already left, or is being handled.","responses":{"200":{"description":"`{ok: true, canceled: true}`"},"409":{"description":"`not_scheduled` — no longer waiting"}}}},"/email/attachments/signed-url":{"post":{"tags":["Email"],"summary":"Sign a direct upload for an email attachment","description":"The preferred path for anything but the smallest file: sign, PUT the bytes straight to storage with exactly the headers echoed back (they were signed), then name the returned `key` in the send call's `attachments`. Ceiling is reported as `maxBytes`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["filename"],"properties":{"filename":{"type":"string"},"contentType":{"type":"string","default":"application/octet-stream"}}}}}},"responses":{"200":{"description":"`{uploadUrl, headers, key, filename, contentType, maxBytes}`"},"400":{"description":"`filename` is required"},"503":{"description":"`r2_credentials_not_configured` — the answer names `/email/attachments` as the fallback"}}}},"/email/attachments":{"post":{"tags":["Email"],"summary":"Upload an attachment through the worker","description":"The fallback for deployments without direct-upload credentials, and the simpler path for small files. `multipart/form-data` with a single `file` part.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"}}}}}},"responses":{"200":{"description":"`{key, filename, contentType, size}`"},"400":{"description":"Not multipart, or no `file` part"},"413":{"description":"`{error: \"attachment_too_large\", maxBytes, size}`"}}}},"/email/cross-channel/send":{"post":{"tags":["Email"],"summary":"Answer a non-email conversation by email","description":"For a customer who started on a social DM or webchat and needs something an email can carry — a quote, an attachment, a record they can keep. Naming the source conversation keeps the reply attached to the thread it answers.\n\nThis door does not park: a member sending under approval is refused with `409 approval_not_supported_here` rather than queued.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sourceConversationId","sourcePlatform","toAddr","body"],"properties":{"sourceConversationId":{"type":"string"},"sourcePlatform":{"type":"string"},"toAddr":{"type":"string","format":"email"},"toName":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"},"class":{"type":"string","enum":["communication","marketing"]},"attachments":{"type":"array","items":{"type":"object"}},"tmpKey":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ticket, messageId, crossChannel: true}`"},"400":{"description":"A required field is missing, or `invalid_recipient`"},"409":{"description":"`approval_not_supported_here`"}}}},"/smtp/{computerId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Email"],"summary":"Read the workspace's own sending server","description":"Never returns the password, in any form. `config: null` means no server is connected and the workspace sends through the shared agent address.","responses":{"200":{"description":"`{config: null}` or `{config: {host, port, username, fromEmail, fromName, providerHint, verified, verifiedAt}}`"}}},"put":{"tags":["Email"],"summary":"Connect a sending server","description":"The credentials are tested against the server before anything is stored, so a typo is a 400 here rather than a silent failure at send time. Port defaults to 587; 465 is refused by name in favour of 587 with STARTTLS. A successful write resets verification: the address has to prove itself again, which is what `POST /smtp/{computerId}/verify/start` begins.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["host","username","password","fromEmail"],"properties":{"host":{"type":"string"},"port":{"type":"integer","default":587},"username":{"type":"string"},"password":{"type":"string"},"fromEmail":{"type":"string","format":"email"},"fromName":{"type":"string"},"providerHint":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, verified: false}`"},"400":{"description":"A field is missing or malformed, or the server refused the credentials"},"403":{"description":"The workspace's own sender is the owner's to configure"}}},"delete":{"tags":["Email"],"summary":"Disconnect the sending server","description":"Unconditional; the workspace falls back to the shared agent address.","responses":{"200":{"description":"`{ok: true}`"}}}},"/smtp/{computerId}/verify/start":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Email"],"summary":"Send a verification code to the claimed address","description":"Proves the workspace controls the address it wants to send as. One code per minute, valid for 15 minutes, five attempts.","responses":{"200":{"description":"`{ok: true, toEmail}`"},"404":{"description":"No server configured to verify"},"429":{"description":"Wait a minute before resending"}}}},"/smtp/{computerId}/verify/confirm":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Email"],"summary":"Confirm the verification code","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"6 digits"}}}}}},"responses":{"200":{"description":"`{ok: true, verified: true}`"},"400":{"description":"Wrong code, malformed code, or the code expired"},"404":{"description":"No verification in progress"},"429":{"description":"Too many attempts — send a new code"}}}},"/payments/computers/{computerId}/proposals":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Payments"],"summary":"Propose a payment for a human to approve","description":"The agent-facing half of spending: an ask, not a transfer. The row lands as `proposed` and the workspace owner is notified — unless a matching pre-approval already covers it, in which case it comes back `approved` and nobody is disturbed.\n\nAmounts are whole minor units and must be positive. `currency` must be one of the supported two-decimal codes; zero-decimal currencies are refused rather than silently mis-scaled. A proposal against a task is held to that task's budget envelope, and to the currency the envelope was opened in.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["payee","amountCents","reason"],"properties":{"payee":{"type":"string","maxLength":200},"amountCents":{"type":"integer","minimum":1},"currency":{"type":"string","default":"EUR"},"reason":{"type":"string","maxLength":2000,"description":"What the approver will read"},"agentId":{"type":"string"},"taskId":{"type":"string"},"conversationId":{"type":"string"},"paymentDetails":{"type":"object","description":"Free-form: IBAN, payment link, reference — whatever settles it"}}}}}},"responses":{"201":{"description":"`{ok: true, proposal}`"},"400":{"description":"`bad_input` / `bad_currency` / `currency_mismatch` / `over_budget`"}}},"get":{"tags":["Payments"],"summary":"List spend proposals","parameters":[{"name":"status","in":"query","schema":{"type":"string"},"description":"Comma-separated. One of `proposed`, `approved`, `executing`, `paid`, `declined`, `cancelled`, `manual_required`, `manually_paid`. Omit for all."}],"responses":{"200":{"description":"`{proposals: [...]}`"}}}},"/payments/computers/{computerId}/proposals/{proposalId}/approve":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/ProposalId"}],"post":{"tags":["Payments"],"summary":"Approve a proposal","description":"Needs `payment:approve`. The approver is always the authenticated caller — never a body field. An illegal transition is a `bad_transition` 400 rather than a silent no-op.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, proposal}`"},"400":{"description":"`bad_transition`"},"404":{"description":"Unknown proposal"}}}},"/payments/computers/{computerId}/proposals/{proposalId}/decline":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/ProposalId"}],"post":{"tags":["Payments"],"summary":"Decline a proposal","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, proposal}`"},"400":{"description":"`bad_transition`"}}}},"/payments/computers/{computerId}/proposals/{proposalId}/manual-paid":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/ProposalId"}],"post":{"tags":["Payments"],"summary":"Record that somebody paid this by hand","description":"Walks the row to `manually_paid` from wherever it is. Pressing it twice is an answer, not an error: the second call returns `alreadySettled: true`.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string"},"manualReceipt":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, proposal, alreadySettled?}`"}}}},"/payments/computers/{computerId}/preapprovals":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Payments"],"summary":"Approve a payee in advance, within limits","description":"A standing decision: proposals naming this payee, under both ceilings, are approved on arrival instead of waking somebody. `expiresAt` is REQUIRED and must be in the future — a permission to spend that nobody ever revisits is not a permission anybody meant to give. Payee matching is exact after trimming and lower-casing; there is no fuzzy match.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["payee","maxPerPaymentCents","maxPerWeekCents","expiresAt"],"properties":{"payee":{"type":"string","maxLength":200},"maxPerPaymentCents":{"type":"integer","minimum":1},"maxPerWeekCents":{"type":"integer","minimum":1},"currency":{"type":"string","default":"EUR"},"expiresAt":{"type":"string","format":"date-time"},"note":{"type":"string","maxLength":2000}}}}}},"responses":{"201":{"description":"`{ok: true, preapproval}`"},"400":{"description":"`bad_input` (including a per-payment ceiling above the weekly one) / `no_expiry`"}}},"get":{"tags":["Payments"],"summary":"List pre-approvals","description":"Reads need `payment:approve` too, deliberately: a propose-only credential must not be able to enumerate the ceilings it is operating under. Each row carries a derived `active` and `usedThisWeekCents`.","parameters":[{"name":"active","in":"query","schema":{"type":"string","enum":["1","true","yes"]},"description":"Live rules only. Omit to include revoked and expired ones."}],"responses":{"200":{"description":"`{preapprovals: [...]}`"}}}},"/payments/computers/{computerId}/preapprovals/{preapprovalId}/revoke":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"preapprovalId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Payments"],"summary":"Withdraw a pre-approval","responses":{"200":{"description":"`{ok: true, preapproval}`"},"400":{"description":"`already_revoked`"},"404":{"description":"Unknown pre-approval"}}}},"/payments/computers/{computerId}/cost":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Payments"],"summary":"What each rail would cost for this amount","description":"A quote, not a charge: nothing is written and nothing is reserved. Answers a `rails` map over `manual`, `stripe-collect`, `stripe-payout`, `stripe-transfer` and `stripe-card`, each with its cost breakdown and what it needs funded. The route exists so a client never has to hardcode the platform fee — read `platformFeeBps` from the answer.","parameters":[{"name":"amountCents","in":"query","required":true,"schema":{"type":"integer","minimum":1}},{"name":"currency","in":"query","schema":{"type":"string","default":"EUR"}}],"responses":{"200":{"description":"`{amountCents, currency, platformFeeBps, rails}`"},"400":{"description":"`bad_input`"}}}},"/payments/computers/{computerId}/budgets":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Payments"],"summary":"Open a spend envelope on a task","description":"Needs `payment:approve` — this is the ceiling proposals are then checked against. `kind` is `budget` (the default), `credit` or `refund`. `fee` and `real` are refused at the door: fees are recorded from the payment processor when money lands, not added by hand.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["taskId","amountCents"],"properties":{"taskId":{"type":"string"},"amountCents":{"type":"integer","minimum":1},"currency":{"type":"string","default":"EUR"},"kind":{"type":"string","enum":["budget","credit","refund"],"default":"budget"},"note":{"type":"string"}}}}}},"responses":{"201":{"description":"`{ok: true, budget}`"},"400":{"description":"`bad_input`"}}}},"/payments/computers/{computerId}/budgets/{taskId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"taskId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Payments"],"summary":"A task's envelope and what is left of it","description":"`budget: null` is a real answer and not a 404 — an unbudgeted task limits nothing, which is a different fact from an envelope drawn down to zero.","responses":{"200":{"description":"`{budget}`"}}}},"/payments/computers/{computerId}/customer-payments":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Payments"],"summary":"Record money taken from a customer","description":"The payer ledger — money IN, with somebody attached to it. `taskId` is optional on purpose: a walk-in paying at the counter is a real charge with no job behind it. `recordedBy` is always the authenticated caller.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amountCents"],"properties":{"amountCents":{"type":"integer","minimum":1},"currency":{"type":"string","default":"EUR"},"rail":{"type":"string","enum":["stripe-checkout","stripe-terminal","cash","transfer","other"],"default":"cash"},"contactId":{"type":"string"},"conversationId":{"type":"string"},"channel":{"type":"string"},"payerName":{"type":"string"},"payerEmail":{"type":"string","format":"email"},"taskId":{"type":"string"},"paymentIntentId":{"type":"string"},"chargeId":{"type":"string"},"note":{"type":"string"},"paidAt":{"type":"string","format":"date-time"}}}}}},"responses":{"201":{"description":"`{ok: true, payment}`"}}},"get":{"tags":["Payments"],"summary":"List customer payments","description":"Each row carries `refundableCents` — what is left to give back — alongside `amountCents` and `refundedCents`. `feeCents: null` means \"not known yet\", never \"none\".","parameters":[{"name":"contactId","in":"query","schema":{"type":"string"}},{"name":"conversationId","in":"query","schema":{"type":"string"}},{"name":"channel","in":"query","schema":{"type":"string"}},{"name":"taskId","in":"query","schema":{"type":"string"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"untasked","in":"query","schema":{"type":"boolean"},"description":"Only charges with no task attached"},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"`{payments: [...]}`"}}}},"/payments/computers/{computerId}/customer-payments/totals":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Payments"],"summary":"Totals by contact or by channel","description":"`netCents` is paid minus refunded, floored at zero.","parameters":[{"name":"groupBy","in":"query","schema":{"type":"string","enum":["contact","channel"],"default":"contact"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"`{groupBy, totals: [...]}`"}}}},"/payments/computers/{computerId}/customer-payments/{paymentId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"paymentId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Payments"],"summary":"One customer payment, with its refund history","description":"Needs `payment:collect`, like the list. `refunds` is one line per refund that moved the ledger (amount, rail, reason, note, who asked), oldest first. `outsideCents` is the part of `refundedCents` no line accounts for: money given back in the processor's own dashboard.","responses":{"200":{"description":"`{payment, refunds: [...], outsideCents}`"},"404":{"description":"Unknown payment"}}}},"/payments/computers/{computerId}/customer-payments/{paymentId}/refund":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"paymentId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Payments"],"summary":"Refund a customer payment","description":"Needs `payment:refund`, which is a separate grant from collecting. Omit `amountCents` for the full refundable remainder. The rail is the ORIGINAL payment's, never the caller's choice: a card charge is reversed at the processor, a cash charge is a book entry. Send a stable `requestKey` so a retried call cannot refund twice.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"amountCents":{"type":"integer","minimum":1},"reason":{"type":"string","enum":["requested_by_customer","duplicate","fraudulent"],"description":"Stripe's own word, sent to Stripe; anything else is a 400 `bad_reason`"},"note":{"type":"string","description":"Kept in the refund history, cut at 500 characters"},"requestKey":{"type":"string","description":"Idempotency token — send one"}}}}}},"responses":{"200":{"description":"`{ok: true, rail: 'stripe'|'by-hand', refundedCents, ledger: 'recorded'|'already-recorded', payment}`"},"400":{"description":"`bad_reason`"},"404":{"description":"Unknown payment"},"409":{"description":"`over_refundable` — the answer names `refundableCents`"}}}},"/payments/computers/{computerId}/payouts":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Payments"],"summary":"Payout history, and what is payable right now","description":"One call answers both, because a form that shows history without headroom invites a request that will fail. Use `availability.payableCents` — the settled balance minus what is already earmarked — and never the raw available figure. `availability.pendingCents` is money not settled yet, and `availability.schedule` ({interval, delayDays?, weeklyAnchor?, monthlyAnchor?}) is the account's automatic payout schedule, absent when unread. Read by the owner and managers (a plain member gets 403 `owner_or_manager_only`); a key needs `payment:read` or `payment:approve`.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"`{payouts, availability, totals}`"}}},"post":{"tags":["Payments"],"summary":"Move money to the business's own bank account","description":"Owner only: the workspace owner's session, or their own key carrying `payment:approve`. A manager keeps the reads (history, balance, Stripe's list, refresh) and is refused here with 403 `owner_only`, as is every other person; a member-bound key with 403 `member_key_refused`. There is deliberately no asking half — nothing proposes a payout and no agent tool reaches it. Send a stable `requestKey`: without one the server generates a fresh id and a retried call is a second payout.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amountCents"],"properties":{"amountCents":{"type":"integer","minimum":1},"currency":{"type":"string","description":"Defaults to the connected account's settlement currency"},"requestKey":{"type":"string","description":"Idempotency token — send one"},"note":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, payout, replayed: true}` — this `requestKey` was already used"},"201":{"description":"`{ok: true, payout, replayed: false}`"},"400":{"description":"`bad_amount` / `insufficient_balance` / `payouts_disabled` / `stripe_failed`"},"403":{"description":"`owner_only` (a person who is not the owner) / `member_key_refused`"},"500":{"description":"`balance_unreadable` and other storage faults"}}}},"/payments/computers/{computerId}/payouts/stripe":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Payments"],"summary":"The account's payouts as the processor lists them","description":"Automatic payouts included, which the history above never sees because nothing here asked for them. The owner and managers read it (a key needs `payment:read` or `payment:approve`). A workspace with no connected account lists nothing.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}}],"responses":{"200":{"description":"`{payouts: [{id, amountCents, currency, status, arrivalDate, automatic, failureMessage, created}]}`"}}}},"/payments/computers/{computerId}/payouts/{payoutId}/refresh":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"payoutId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Payments"],"summary":"Re-read a payout from the processor","parameters":[{"name":"was","in":"query","schema":{"type":"string"},"description":"The status you last saw — decides whether a change is broadcast"}],"responses":{"200":{"description":"`{ok: true, payout}`"}}}},"/payments/computers/{computerId}/issuing":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"get":{"tags":["Payments"],"summary":"Virtual-card rail state and every card issued","description":"A card is one of the approval queue's OUTCOMES, not a separate product — hence the shared prefix. `configured: false` means this deployment holds no issuing credential at all. Card numbers never appear here.","responses":{"200":{"description":"`{configured, testMode, cardholder, cards: [...]}`"}}}},"/payments/computers/{computerId}/issuing/cardholder":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Payments"],"summary":"Register the business as a cardholder","description":"Idempotent: a workspace that already has one gets it back untouched with `created: false`. There is deliberately no edit door — the name is printed on cards that already exist.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Printed on the card — letters and spaces, short"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"line1":{"type":"string"},"city":{"type":"string"},"postalCode":{"type":"string"},"country":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, cardholder, created: false}`"},"201":{"description":"`{ok: true, cardholder, created: true}`"},"503":{"description":"`no_issuing` — the capability is not switched on for this deployment"}}}},"/payments/computers/{computerId}/proposals/{proposalId}/issue-card":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/ProposalId"}],"post":{"tags":["Payments"],"summary":"Mint the one virtual card an approved proposal gets","description":"Exactly one card per proposal, with both spending limits pinned to the approved amount, and the proposal moves to `executing`. A retry replays the same card rather than minting a second. The proposal must be `approved`; anything else is a 409 naming the state.","responses":{"200":{"description":"`{ok: true, card, replayed: true, proposalStatus}`"},"201":{"description":"`{ok: true, card, replayed: false, proposalStatus}`"},"400":{"description":"`no_cardholder` — register one first"},"409":{"description":"`wrong_state`"}}}},"/payments/computers/{computerId}/cards/{cardId}/reveal":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/CardId"}],"get":{"tags":["Payments"],"summary":"Read the card details for one checkout","description":"Served `no-store` and never persisted or logged — the schema has nowhere to put a card number. Only an `active` card reveals.","responses":{"200":{"description":"`{ok: true, card: {number, cvc, expMonth, expYear, last4, amountCents, currency}}`"},"409":{"description":"`wrong_state` — the card is used or cancelled"}}}},"/payments/computers/{computerId}/cards/{cardId}/refresh":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/CardId"}],"post":{"tags":["Payments"],"summary":"Ask whether the card was actually spent","description":"Idempotent. A spent card closes its proposal as `paid` and the card becomes `used`.","responses":{"200":{"description":"`{ok: true, card, spentCents, proposalStatus}`"}}}},"/payments/computers/{computerId}/cards/{cardId}/cancel":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/CardId"}],"post":{"tags":["Payments"],"summary":"Withdraw the card rail","description":"The proposal lands in `manual_required` — somebody pays by hand — and never back in `approved`, so nothing re-issues against it by accident.","responses":{"200":{"description":"`{ok: true, card, proposalStatus}`"},"409":{"description":"`wrong_state`"}}}},"/payments/computers/{computerId}/conversations/{conversationId}/commerce":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"$ref":"#/components/parameters/ConversationId"}],"get":{"tags":["Payments"],"summary":"Billing and payee details remembered on a conversation","description":"`commerce: null` with a 200, never a 404 — \"we have not asked them yet\" is a state, not a missing resource. Reads are gated as tightly as writes: a tax id and a bank account are exactly what a read-only credential should not be able to harvest.","responses":{"200":{"description":"`{commerce}`"}}},"put":{"tags":["Payments"],"summary":"Remember billing / payee details on a conversation","description":"A patch: an absent key changes nothing, an empty string clears the field. `billingCountry` is ISO 3166-1 alpha-2.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"billingName":{"type":"string"},"billingLine1":{"type":"string"},"billingLine2":{"type":"string"},"billingCity":{"type":"string"},"billingPostalCode":{"type":"string"},"billingCountry":{"type":"string","example":"PT"},"taxId":{"type":"string"},"taxIdType":{"type":"string"},"incomingPref":{"type":"string"},"payeeName":{"type":"string"},"payeeIban":{"type":"string"},"payeePaymentLink":{"type":"string"},"payeeReference":{"type":"string"},"note":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ok: true, commerce}`"},"400":{"description":"`bad_input` — e.g. a country code that is not two letters"}}},"delete":{"tags":["Payments"],"summary":"Forget them","description":"`removed: false` with a 200 when there was nothing to forget.","responses":{"200":{"description":"`{ok: true, removed}`"}}}},"/stripe-connect/status":{"get":{"tags":["Payments"],"summary":"Is this workspace able to take money?","description":"The read an integration needs before offering to charge anybody: whether an account is connected, and whether it can actually accept charges and receive payouts yet. `connected: false` is the whole answer when nothing is linked. `stale: true` means Stripe could not be reached and this is the last known state.\n\nNeeds `payment:collect`. Connecting, opening the dashboard and disconnecting are session-only — an integration has no business creating or severing the account.","parameters":[{"$ref":"#/components/parameters/ComputerIdQuery"}],"responses":{"200":{"description":"`{connected: false}`, or `{connected: true, accountId, chargesEnabled, payoutsEnabled, detailsSubmitted, country, defaultCurrency, capabilities, tapToPayReady, stale?}`"},"403":{"description":"No access to this workspace, or the key lacks `payment:collect`"}}}},"/stripe-payments/checkout":{"post":{"tags":["Payments"],"summary":"Mint a hosted payment page for a customer","description":"The link an agent sends a customer. `taskId` is optional — a charge does not need a job behind it — and `contactId` / `conversationId` / `channel` / `payerName` are stamped onto the payment so it can be attributed back when it lands.\n\nSessions are reused for an identical repeat request, so re-asking for the same link does not create a second one. When what is being sold is no longer available the answer is `409 RESOURCE_CONFLICT` with the specific `conflicts`; pass `force: true` only when you have shown those to a person.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["computerId","amountCents"],"properties":{"computerId":{"$ref":"#/components/schemas/ComputerIdField"},"amountCents":{"type":"integer","minimum":1},"currency":{"type":"string"},"taskId":{"type":"string"},"contactId":{"type":"string"},"conversationId":{"type":"string"},"channel":{"type":"string"},"payerName":{"type":"string"},"title":{"type":"string","maxLength":250,"default":"Task payment"},"description":{"type":"string","maxLength":500},"successUrl":{"type":"string"},"cancelUrl":{"type":"string"},"force":{"type":"boolean"},"tax":{"type":"object","properties":{"enabled":{"type":"boolean"},"behavior":{"type":"string","enum":["inclusive","exclusive"],"default":"exclusive"}}}}}}}},"responses":{"200":{"description":"`{url, sessionId, expiresAt, amountCents, currency, applicationFeeCents, taxEnabled?, reservationValidUntilMs?}`"},"400":{"description":"A required field is missing"},"403":{"description":"No access, or the key lacks `payment:collect`"},"409":{"description":"`RESOURCE_CONFLICT` (see `conflicts`), or the connected account is not ready"},"422":{"description":"Unsupported currency"}}}},"/credits/ledger":{"get":{"tags":["Addons"],"summary":"Credit movements, newest first","description":"Every grant, top-up, deduction and clawback against a pool, with the balance the pool held after each one. Two pools exist: `workspace` (the default, and the one an agent spends from first) and `account` (the owner's shared spill-over). A key reads its own workspace; `scope=account` reads the account behind it and takes no id.\n\n`deltaCents`, `balanceCents` and `balanceAfterCents` count CREDITS — whole numbers, not a currency. The suffix is historical. `balanceCents: null` means the pool has never been seeded, which is a different fact from a balance of zero.","parameters":[{"name":"scope","in":"query","schema":{"type":"string","enum":["workspace","account"],"default":"workspace"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"`{entries: [{id, deltaCents, reason, createdAt, balanceAfterCents?}], balanceCents}`"},"400":{"description":"Unrecognised `scope`"}}}},"/addons/allowance":{"get":{"tags":["Addons"],"summary":"Account allowance + credit balance","description":"What the account is entitled to — connected-account slots, seats — alongside the credit balance the AI surfaces spend from. Credits are a count, not a currency; the balance stays `null` rather than falling back to `0` when it cannot be read, so a client renders \"—\" instead of telling somebody they are out.\n\nWhat a subscription INCLUDES belongs to each workspace (`seatBase`, `accountBase`) and is never accumulated across the workspaces an account owns. What the account BUYS is bought by the ACCOUNT and ACTIVATED into one workspace: a purchased slot is credited until somebody places it, and enlarges nothing in the meantime. So a workspace's allowance is `seatBase + extraSeatsHere` (and `accountBase + extraAccountsHere`), while `totalSeatSlots` / `totalAccountSlots` are a ceiling — every workspace's base summed, plus every extra wherever it is — and not something any one workspace may spend.\n\nSo a page about ONE workspace reads `seatBase` / `accountBase` and `extraSeatsHere` / `extraAccountsHere` against `seatsUsedHere` / `accountsUsedHere`. `extraSeatsUsed` is how many of the account's extras are activated somewhere; `extraSeatsAvailable` is how many are credited and placed nowhere — one activation away from being room. The `extraAccounts*` fields are the same four for connected accounts.\n\nThe `*UsedHere` and `*Here` fields appear only when the read is scoped to a workspace the caller can reach and the account owns — absent means \"not asked\", never zero.\n\n`offers` is what is on sale and at what price, as published: `purchasablePlans` (each interval with its `hours`, `creditsIncludedCents`, `connectionsIncluded` and `trialDays`), `purchasableCloudHours`, `liveAgentTiers`, `connectionAddon`, `seatAddon`, `creditPacks`, and `checkoutKinds` (the `kind`s `POST /addons/checkout` sells today). Every priced row carries `priceCents` (minor units) and the `currency` its checkout charges; both are ABSENT when the price could not be read, never zero. Rows that name a workspace at checkout are quoted in that workspace's currency (the scoped workspace, or the one an API key is bound to); seats and credit packs in the account's. `offers` is `null` when the catalogue could not be read, and absent from workers that predate it.","responses":{"200":{"description":"OK"}}}},"/addons/extras":{"get":{"tags":["Addons"],"summary":"Where each purchased extra slot is standing","description":"Per slot type: `total` owned, `unassigned` (credited to the account and activated nowhere), `assignments` (one entry per workspace, with the slots and the purchase rows placed there), `stranded` (assigned to a workspace the account no longer owns — still billed, recoverable by deactivating it) and the `base` every workspace includes.\n\nSession-only: an API key is bound to one workspace, and this is an account-level view.","responses":{"200":{"description":"OK"},"403":{"description":"API key — this needs a signed-in person"}}}},"/addons/extras/activate":{"post":{"tags":["Addons"],"summary":"Activate a credited extra slot into a workspace","description":"Body `{ slotType: \"extra-seat\" | \"extra-account\", computerId }`. Takes the oldest slot the account has credited and places it in that workspace, enlarging its allowance. In-app purchases can never name a workspace — the store sheet has none in it — so every IAP extra arrives credited and is placed here.\n\nSession-only, and only in a workspace the caller's account owns. A purchase row moves whole, so a slot bought as a pack of N adds N.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["slotType","computerId"],"properties":{"slotType":{"type":"string","enum":["extra-account","extra-seat"]},"computerId":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ ok, slotType, computerId, activated, room }`"},"400":{"description":"Missing or unrecognised `slotType` / `computerId`"},"403":{"description":"API key, or a workspace belonging to another account"},"409":{"description":"`no_unassigned_slot` — nothing credited to activate; `workspace_unlinked` — the workspace has no account behind it yet"}}}},"/addons/extras/deactivate":{"post":{"tags":["Addons"],"summary":"Return an activated slot to the account’s credit","description":"Body `{ slotType, computerId }`. Takes the newest slot back out of that workspace so it can be activated somewhere else.\n\nREFUSED WITH 409 `would_strand_workspace` when the workspace is using the room the slot provides. Nothing is ever evicted to make space for this: remove the team member or disconnect the account first, then deactivate. The refusal carries `used`, `allowance` and `wouldRemove` so a client can say exactly how much has to go.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["slotType","computerId"],"properties":{"slotType":{"type":"string","enum":["extra-account","extra-seat"]},"computerId":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ ok, slotType, computerId, deactivated, room }`"},"400":{"description":"Missing or unrecognised `slotType` / `computerId`"},"403":{"description":"API key, or a workspace belonging to another account"},"409":{"description":"`would_strand_workspace` — the room is in use; `nothing_to_deactivate` — no extras are activated there; `workspace_unlinked`"}}}},"/addons/checkout":{"post":{"tags":["Addons"],"summary":"Mint a Stripe Checkout URL for extras / credits","description":"Server-priced checkout — clients only choose `kind` (and `quantity`, `pack`, or `billingCycle`). Amount and redirect URLs come from worker configuration only, so a tampered client can neither lower the price nor steer the redirect. Returns a hosted-page URL; the payment webhook finalizes the grant.\n\n`computerId` here is not the caller's identity — it is the DESTINATION. On a credit top-up it decides which pool the credits land in: name a workspace and they refill that workspace's wallet, omit it and they land in the account pool that every workspace can spill over into. A workspace belonging to another account is refused.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"title":"Connection add-on","type":"object","required":["kind","computerId"],"description":"One more social connection per unit for one workspace, recurring beside the plan. Priced as `offers.connectionAddon` on `GET /addons/allowance`.","properties":{"kind":{"type":"string","enum":["connection"]},"quantity":{"type":"integer","minimum":1,"maximum":50,"default":1},"computerId":{"type":"string","description":"The workspace the connection is added to"}}},{"title":"Slot extras","type":"object","required":["kind"],"description":"`extra-seat` is one more team seat, priced as `offers.seatAddon`. `extra-account` is still accepted for older callers; new ones buy `connection`.","properties":{"kind":{"type":"string","enum":["extra-account","extra-seat"]},"quantity":{"type":"integer","minimum":1,"maximum":50,"default":1}}},{"title":"Credit top-up","type":"object","required":["kind","pack"],"properties":{"kind":{"type":"string","enum":["credits"]},"pack":{"type":"string","enum":["small","medium","large"]},"computerId":{"type":"string","description":"Destination wallet — omit for the account pool"}}},{"title":"Channel package","type":"object","required":["kind"],"description":"Subscribe a workspace to the phone and/or social channel package. `all` is the bundle and always uses the current packaging; `phone` / `social` stay on the legacy flat monthly price unless `billingCycle` is given, which opts them into it too.","properties":{"kind":{"type":"string","enum":["phone","social","all"]},"computerId":{"type":"string","description":"Workspace the entitlement is granted to"},"billingCycle":{"type":"string","enum":["monthly","yearly"]}}}]}}}},"responses":{"200":{"description":"`{url}` — the Stripe-hosted checkout page"},"400":{"description":"Missing / unknown `kind`, or an invalid credits `pack`"},"403":{"description":"`forbidden_workspace` — that workspace belongs to another account"},"503":{"description":"Stripe not configured on this deployment"}}}},"/ai/v1/chat/completions":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Chat completion (OpenAI-compatible)","description":"Point any OpenAI SDK at `https://brain.deployd.network/ai/v1` with the workspace API key as the bearer. The request is forwarded as sent — tools, temperature, modalities and the rest pass through untouched — and the answer is the provider's, verbatim. `model` takes any upstream model id; there is no allow-list. `stream: true` returns SSE.\n\n**Two things a client must handle.** First, a STREAMING request never returns a 402: an out-of-credits stream arrives as a 200 whose single chunk carries the message in `delta.content` and an `insufficient_credits` code alongside it, so check for that rather than for a status. Second, upstream faults are masked — a non-streaming caller gets `502 upstream_error` or `503 upstream_quota`, never the provider's own body.\n\nThis is a server-to-server surface: browser origins are not permitted.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["messages"],"description":"The OpenAI chat-completions body.","properties":{"model":{"type":"string","description":"Any id from `GET /ai/models`. Defaults to automatic routing."},"messages":{"type":"array","items":{"type":"object"}},"stream":{"type":"boolean"}},"additionalProperties":true}}}},"responses":{"200":{"description":"A `chat.completion`, or an SSE stream of `chat.completion.chunk`"},"400":{"description":"Invalid JSON, or no `messages` array"},"401":{"$ref":"#/components/responses/GatewayUnauthorized"},"402":{"$ref":"#/components/responses/InsufficientCredits"},"403":{"description":"Spending is locked on this workspace or account"},"429":{"$ref":"#/components/responses/GatewayRateLimited"},"502":{"description":"`upstream_error`"},"503":{"description":"`upstream_quota` (retryable) / `balance_unavailable`"}}}},"/ai/v1/embeddings":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Embeddings (OpenAI-compatible)","description":"Takes `input` and returns the standard embeddings shape. **The model is pinned:** a `model` field in the request is ignored, so that every vector this workspace stores is comparable with every other one. Ask for a different model and you will still get the pinned one — this is stated here rather than enforced with an error because the point is a single vector space, not a refusal.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["input"],"properties":{"input":{},"model":{"type":"string","description":"Ignored — the model is pinned"}},"additionalProperties":true}}}},"responses":{"200":{"description":"The OpenAI embeddings shape"},"400":{"description":"Missing `input`"},"401":{"$ref":"#/components/responses/GatewayUnauthorized"},"402":{"$ref":"#/components/responses/InsufficientCredits"},"429":{"$ref":"#/components/responses/GatewayRateLimited"}}}},"/ai/v1/audio/transcriptions":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Transcribe audio (OpenAI-compatible)","description":"`multipart/form-data` with a `file` part. Naming a `model` forwards the whole form to that provider and returns its answer verbatim, so `response_format`, `language`, `prompt` and `temperature` all behave as they do upstream. Omitting `model` takes a simpler path that honours only `language` and `prompt` and always answers `{text}`.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"},"model":{"type":"string"},"language":{"type":"string"},"prompt":{"type":"string"},"response_format":{"type":"string"}}}}}},"responses":{"200":{"description":"The provider's transcription, or `{text}` on the model-less path"},"400":{"description":"Invalid multipart, or no `file` part"},"401":{"$ref":"#/components/responses/GatewayUnauthorized"},"402":{"$ref":"#/components/responses/InsufficientCredits"},"429":{"$ref":"#/components/responses/GatewayRateLimited"}}}},"/ai/v1/images":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Generate images","description":"The OpenAI images body, forwarded as sent. Streaming is refused by name (`image_streaming_unsupported`) rather than silently ignored. Enumerate models with `GET /ai/media-models`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"`{data: [...], usage}`"},"400":{"description":"`image_streaming_unsupported`"},"401":{"$ref":"#/components/responses/GatewayUnauthorized"},"402":{"$ref":"#/components/responses/InsufficientCredits"},"429":{"$ref":"#/components/responses/GatewayRateLimited"}}}},"/ai/v1/videos":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Submit a video render","description":"Asynchronous: this returns a job, and the bytes are collected later from `/ai/v1/videos/{jobId}/content`. Video carries a per-render credit reserve on top of the ordinary balance check, and it counts renders already in flight — so a workspace cannot queue more work than it can pay for. A submit that the provider refuses bills nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"The provider's job body — `{id, status, polling_url, usage, …}`"},"401":{"$ref":"#/components/responses/GatewayUnauthorized"},"402":{"description":"`insufficient_credits`, with `requiredCents` and `runningJobs`"},"429":{"$ref":"#/components/responses/GatewayRateLimited"}}}},"/ai/v1/videos/{jobId}":{"parameters":[{"$ref":"#/components/parameters/JobId"}],"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Poll a video job","description":"Free — the render was billed when it was submitted. A job belonging to another workspace answers `job_not_found`: unreachable and non-existent are deliberately the same answer.","responses":{"200":{"description":"The provider's job body"},"404":{"description":"`job_not_found`"}}}},"/ai/v1/videos/{jobId}/content":{"parameters":[{"$ref":"#/components/parameters/JobId"}],"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Download the finished video","responses":{"200":{"description":"The video bytes, streamed"},"404":{"description":"`job_not_found`"}}}},"/ai/v1/media/video":{"post":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Submit a video render against the curated catalog","description":"The second video door, and not OpenAI-shaped. `model` must be an id from `GET /ai/media/video-models` — an unknown one is a 400 that lists the whole menu — and the catalog pins the provider settings, so the same id always renders on the same terms. Text-to-video needs `prompt`; image-to-video needs `imageUrl` or `imageDataUri`, and `endImageUrl` / `endImageDataUri` only on the models that support an end frame.\n\nCollect the result from `GET /ai/v1/media/jobs/{jobId}`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["model"],"properties":{"model":{"type":"string","description":"An id from `GET /ai/media/video-models`"},"prompt":{"type":"string"},"imageUrl":{"type":"string"},"imageDataUri":{"type":"string"},"endImageUrl":{"type":"string"},"endImageDataUri":{"type":"string"},"seconds":{"type":"number","description":"Must be one of the model's advertised `durations`"}}}}}},"responses":{"200":{"description":"`{jobId, status: 'running', model, seconds, estimatedCents}`"},"400":{"description":"`unknown_video_model` / `invalid_image` / `image_required` / `end_frame_unsupported` / `invalid_duration` / `empty_request`"},"402":{"description":"`insufficient_credits`"},"502":{"description":"`upstream_submit_failed` — nothing was billed"},"503":{"description":"`provider_not_configured`"}}}},"/ai/v1/media/jobs/{jobId}":{"parameters":[{"$ref":"#/components/parameters/JobId"}],"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Poll a catalog video job","responses":{"200":{"description":"`{jobId, status, model, url?, error?, billedCents?}`"},"404":{"description":"`job_not_found`"}}}},"/ai/models":{"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Text model catalog","description":"Open, cached, and needs no credentials — it is a menu, not a workspace read. Prices are final per-million-token figures; `null` means the provider publishes none.","security":[],"responses":{"200":{"description":"`{models: [{id, name, contextLength, promptUsdPerM, completionUsdPerM}], note}`"},"503":{"description":"Model catalog unavailable upstream"}}}},"/ai/media-models":{"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Image + video model catalog","description":"The menu behind `/ai/v1/images` and `/ai/v1/videos`. Rows are the upstream provider's own, enriched with a pricing breakdown where one is published.","security":[],"responses":{"200":{"description":"`{imageModels: [...], videoModels: [...], note}`"},"503":{"description":"Media model catalog unavailable upstream"}}}},"/ai/media/video-models":{"get":{"tags":["AI gateway"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Curated video model catalog","description":"The menu behind `/ai/v1/media/video`, and the authority on what that door accepts: each row names its allowed `durations`, whether it does text-to-video, image-to-video and end frames, whether it produces audio, and what it costs. Models whose provider is not configured on this deployment are omitted rather than listed and refused.","security":[],"responses":{"200":{"description":"`{models: [{id, label, provider, note, durations, textToVideo, imageToVideo, endFrame, hasAudio, pricing, usdPerSecond?, fromUsd, fromSeconds}], verifiedOn, note}`"}}}},"/calls":{"get":{"tags":["Voice calls"],"summary":"List calls","responses":{"200":{"description":"OK"}}},"post":{"tags":["Voice calls"],"summary":"Place or schedule an AI voice call","description":"Queues an outbound call. Either provide a `goal` (workspace-driven agent) or an `agentSpecUrl` (remotely-defined agent — see [Remote agent specs](https://brain.deployd.network/docs)). When an `agentSpecUrl` is given, the spec is fetched immediately (signed) and the request **fails fast** with `502` if the source is unreachable or invalid. The spec source is also saved on the conversation for 48h so an inbound callback from this number reuses the same agent. Use `scheduleAt` (ISO-8601, >60s out) to schedule; scheduled calls re-fetch the spec fresh at dial time.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCallRequest"}}}},"responses":{"200":{"description":"Call queued (or `dryRun` echo)"},"400":{"description":"Validation error (missing goal/spec, bad URL/method, …)"},"402":{"description":"Insufficient credits"},"422":{"description":"Destination blocked or too expensive"},"502":{"description":"agent_spec_unavailable — the spec source could not be fetched or returned an invalid AgentSpec"},"503":{"description":"no_shared_line_configured — this workspace has no outbound caller-id"}}}},"/calls/shared-numbers":{"get":{"tags":["Voice calls"],"summary":"Caller-ids this workspace may dial from","description":"Read this before `POST /calls`: `fromPhoneE164` must be one of these, and a rejection echoes the same list back.","responses":{"200":{"description":"OK"}}}},"/calls/{callId}":{"parameters":[{"$ref":"#/components/parameters/CallId"}],"get":{"tags":["Voice calls"],"summary":"Read one call, with its outcome and transcript state","responses":{"200":{"description":"OK"},"404":{"description":"Unknown call"}}}},"/calls/{callId}/schedule":{"parameters":[{"$ref":"#/components/parameters/CallId"}],"patch":{"tags":["Voice calls"],"summary":"Move a scheduled call","description":"Only a call that has not been dialled yet can be moved.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["scheduleAt"],"properties":{"scheduleAt":{"type":"string","format":"date-time"}}}}}},"responses":{"200":{"description":"OK"},"409":{"description":"The call is no longer schedulable"}}}},"/calls/{callId}/cancel":{"parameters":[{"$ref":"#/components/parameters/CallId"}],"post":{"tags":["Voice calls"],"summary":"Cancel a queued or scheduled call","responses":{"200":{"description":"OK"},"409":{"description":"Already dialled or already finished"}}}},"/calls/agent-spec-settings":{"get":{"tags":["Voice calls"],"summary":"Read agent-spec settings","description":"Returns the workspace default inbound spec URL/method and whether a call-tools signing secret is configured (the secret value is never returned).","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"inboundSpecUrl":{"type":["string","null"]},"inboundSpecMethod":{"type":["string","null"]},"hmacSecretConfigured":{"type":"boolean"}}}}}}}},"put":{"tags":["Voice calls"],"summary":"Set the default inbound spec URL","description":"Sets (or clears, with `inboundSpecUrl: null`) the URL the worker fetches to resolve an inbound agent when there is no recent outbound spec source (fresh callers, or callbacks after the 48h window). The caller's phone number is passed to the URL so a dynamic endpoint can identify them.\n\nThe workspace's own telephone setup is not a member's: a key carrying a member's authority is refused here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"inboundSpecUrl":{"type":["string","null"],"description":"https only; private/internal hosts are rejected"},"inboundSpecMethod":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"],"default":"POST"}}}}}},"responses":{"200":{"description":"OK"},"400":{"description":"URL rejected (not https / private network) or bad method"},"404":{"description":"Workspace not found"}}}},"/calls/agent-spec/rotate-secret":{"post":{"tags":["Voice calls"],"summary":"Rotate the call-tools signing secret","description":"Generates a new per-workspace shared secret and returns it **once**. The worker signs every spec fetch and every spec-defined tool call with it (`x-agent-signature` = HMAC-SHA256(secret, `${ts}.${body}`); GET signs the full URL instead of the body). Configure the returned secret on your external platform to verify requests. Rotating invalidates the previous secret.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string"},"note":{"type":"string"}}}}}},"503":{"description":"Secret storage not configured on this deployment"}}}},"/api/computers/{computerId}":{"parameters":[{"name":"computerId","in":"path","required":true,"schema":{"type":"string"},"description":"The workspace to destroy."}],"delete":{"tags":["Workspace"],"servers":[{"url":"https://brain.deployd.network","description":"Production (worker root)"}],"summary":"Delete a workspace and everything in it","description":"Owner only, on a signed-in session — an API key gets a 401 here, and a shared member a 403, because delete is not a member's verb. There is no confirmation parameter: the method is the confirmation.\n\n**Irreversible, and not a transaction.** The teardown spans phone numbers, connected payment accounts, containers, DNS, storage, databases and the workspace record, and no two of those can commit together. So the answer is a PER-STEP REPORT: `steps` is an ordered array of `{step, ok, error?}`, and a step that failed names why. Read it — a 200 with a failed step in it means something was left behind, and calling DELETE again will 404 because the record it authorizes against is already gone.\n\nThe status is decided by one step alone, `record`: while the workspace row survives, nothing is really deleted, so its failure is the 500. When it succeeds, two further steps run — the read projection and the tombstone that tells connected devices the workspace is gone — which is why a successful report is longer than a failed one.\n\nTo keep a workspace but empty it, use the wipe instead: roles, configuration and the element survive a wipe, and a wipe is not a delete.","responses":{"200":{"description":"`{success: true, computerId, message, steps}`","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"computerId":{"type":"string"},"message":{"type":"string"},"steps":{"$ref":"#/components/schemas/PurgeSteps"}}}}}},"401":{"description":"Not a signed-in session (API keys included)"},"403":{"description":"Signed in, but not the owner"},"404":{"description":"No such workspace"},"500":{"description":"`{error, computerId, steps}` — the record could not be deleted"},"503":{"description":"Workspace records are not configured on this deployment"}}}},"/public-agent/sim/presets":{"get":{"tags":["Public agent simulator"],"summary":"Scenario presets a client can offer","description":"A bare array. Each `scenario` can be posted as-is to create a session, and `firstMessage` is a suggested opening line the tester may change.","responses":{"200":{"description":"The presets","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["id","label","scenario"],"properties":{"id":{"type":"string","example":"table-qr"},"label":{"type":"string","example":"Table 5 via QR"},"description":{"type":"string"},"scenario":{"$ref":"#/components/schemas/SimScenario"},"firstMessage":{"type":"string"}}}}}}}}}},"/public-agent/sim/computers/{computerId}/sessions":{"parameters":[{"$ref":"#/components/parameters/ComputerId"}],"post":{"tags":["Public agent simulator"],"summary":"Open a simulation session","description":"Scope `auto-reply:manage`. The session speaks on a synthetic conversation (`sim_<sessionId>`) that no real channel can reach, and lives 24 hours. `live` says whether real customers on this channel get the agent today: a simulation runs even when the channel is switched off, so it can be tested first. At most 20 live sessions per workspace.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["scenario"],"properties":{"scenario":{"$ref":"#/components/schemas/SimScenario"}}},"example":{"scenario":{"channel":"webchat","contact":{"name":"Table 5"},"arrival":{"positionLabel":"Table 5"},"payments":"test","seed":7}}}}},"responses":{"201":{"description":"`{sessionId, conversationId, expiresAt, scenario, arrival?, openingLine?, live: {autoReply, answersCustomers, payments: {connectReady, note}}}`"},"400":{"description":"`{error, code: \"invalid_scenario\"}`, named (a `model` is refused too)"},"404":{"description":"No such workspace for this caller"},"429":{"description":"`code: \"too_many_sessions\"`"}}},"get":{"tags":["Public agent simulator"],"summary":"List live simulation sessions","description":"Scope `auto-reply:read`.","responses":{"200":{"description":"`{sessions: [{sessionId, conversationId, scenario, turns, createdAt, expiresAt}]}`"}}}},"/public-agent/sim/computers/{computerId}/sessions/{sessionId}":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Public agent simulator"],"summary":"Read a session: transcript, per-turn traces and everything it created","description":"Scope `auto-reply:read`. Every task carries `simulation: true`.","responses":{"200":{"description":"`{session, transcript, turns, tasks, escalations, payments, links, locations, actions, memory, live}`"},"404":{"description":"No such session in this workspace, or it expired"}}},"delete":{"tags":["Public agent simulator"],"summary":"Delete a session and everything it created","description":"Scope `auto-reply:manage`. Removes the transcript, traces, tasks, escalations, links, memory and the agent's per-conversation turn history. Credits spent are not refunded.","responses":{"200":{"description":"`{ok: true, sessionId, purged: {items}}`"},"404":{"description":"No such session in this workspace"}}}},"/public-agent/sim/computers/{computerId}/sessions/{sessionId}/messages":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Public agent simulator"],"summary":"Send a customer message and get the agent's turn back","description":"Scope `auto-reply:manage`. Runs the real public agent (plan and credit gates, active hours, prompt, tools, managed model). Read-only tools run live; tasks, escalations, memory, saved places, links and moderation actions are captured in the session; a payment link is only ever a test link on `simulation.invalid`. `ask_human` is recorded and never pushed. One turn at a time per session (409 `turn_in_progress`), 50 per session. For repeatable code tests set `scenario.seed` (honoured where the provider supports it) and assert on `toolCalls` and `created` rather than on exact wording.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","maxLength":4000},"attachments":{"type":"array","maxItems":4,"items":{"type":"object","required":["url","type"],"properties":{"url":{"type":"string","description":"https only"},"type":{"type":"string","example":"image/jpeg"},"filename":{"type":"string"}}}}}},"example":{"text":"Two coffees and a croissant please"}}}},"responses":{"200":{"description":"The turn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimTurn"}}}},"400":{"description":"`code: \"invalid_message\" | \"invalid_attachments\" | \"model_not_allowed\"`"},"404":{"description":"No such session in this workspace, or it expired"},"409":{"description":"`code: \"turn_in_progress\" | \"turn_limit\"`"}}}},"/public-agent/sim/computers/{computerId}/sessions/{sessionId}/customer":{"parameters":[{"$ref":"#/components/parameters/ComputerId"},{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Public agent simulator"],"summary":"Suggest the simulated customer's next message","description":"Scope `auto-reply:manage`. A second model, from a different vendor than the agent, plays a customer of this business from its public profile and the conversation so far, and writes one short message in the session's channel style and language. Nothing is written into the session and it does not count toward the turn limit: send the text to `…/messages` to use it. `done: true` when the customer would end the conversation. Charged like a turn (`simulation: true` on the ledger row).","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"persona":{"type":"string","enum":["random","booking","price","complaint","offtopic","tricky"],"description":"Omitted or `random`: the worker picks one."}}},"example":{"persona":"booking"}}}},"responses":{"200":{"description":"`{text, done, persona, model, usage}`"},"400":{"description":"Unknown persona"},"402":{"description":"`code: \"no_credits\"`, before any model runs"},"404":{"description":"No such session in this workspace, or it expired"},"502":{"description":"`code: \"customer_failed\"`"}}}}},"components":{"schemas":{"SimScenario":{"type":"object","required":["channel"],"properties":{"channel":{"type":"string","enum":["webchat","whatsapp","instagram","facebook","email","comment","review","phone"]},"platform":{"type":"string","description":"comment/review only, e.g. `instagram`, `googlebusiness`"},"contact":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"}}},"arrival":{"type":"object","description":"webchat only","properties":{"kind":{"type":"string"},"positionLabel":{"type":"string","example":"Table 5"},"linkId":{"type":"string","description":"A position link id; its label, kind and pinned form apply"},"pagePromptId":{"type":"string","description":"A page-prompt link id; its line opens the thread"}}},"payments":{"type":"string","enum":["off","test"],"default":"off"},"language":{"type":"string","description":"Client metadata; never shown to the agent"},"notes":{"type":"string","description":"Client metadata; never shown to the agent"},"seed":{"type":"integer","minimum":0,"description":"Sampling seed, best effort"}}},"SimTurn":{"type":"object","required":["reply","toolCalls","created","usage"],"properties":{"turn":{"type":"integer"},"reply":{"type":["string","null"]},"handled":{"type":"boolean"},"skipped":{"type":["string","null"],"description":"Why the agent did not answer: `no_credits`, `plan_required`, `outside_active_hours`, `contact_blocked`, …"},"delivered":{"type":"array","description":"What the customer would have received, signature included","items":{"type":"object","properties":{"kind":{"type":"string","enum":["message","reaction"]},"text":{"type":"string"}}}},"toolCalls":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"args":{},"result":{},"ms":{"type":"integer"},"mode":{"type":"string","enum":["live","simulated","blocked"]}}}},"routing":{"type":["object","null"],"description":"Who `ask_human` would have reached (never pushed)","properties":{"group":{},"member":{},"urgency":{"type":"string"},"message":{"type":"string"}}},"created":{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"object"},"description":"Each carries `simulation: true`"},"escalations":{"type":"array","items":{"type":"object"}},"payments":{"type":"array","items":{"type":"object"}},"links":{"type":"array","items":{"type":"object"}},"locations":{"type":"array","items":{"type":"object"}},"memory":{"type":"object","properties":{"changed":{"type":"boolean"},"content":{"type":"string"}}},"actions":{"type":"array","items":{"type":"object"}}}},"usage":{"type":"object","description":"`{billed: true, model, inputTokens, outputTokens, costCents, basis, note}` or `{billed: false, costCents: 0, reason}`"}}},"ComputerIdField":{"type":"string","description":"The workspace. An API key may only name the one it is bound to — read it from `GET /api-keys/me` — and any other id is refused. Session callers name whichever workspace they are acting in."},"PurgeSteps":{"type":"array","description":"One entry per teardown step, in execution order. `error` is present only on a failure and is truncated. The step names are stable: `profile-sets.read`, `stripe.subscriptions`, `tasks.cancel`, `zernio`, `twilio.phone-lines`, `stripe.connect`, `container`, `engine-dns`, `durable-objects`, `r2`, `firestore.subcollections`, `kv`, `kv.addressing`, `d1.social`, `d1.rate-limit`, `record`, and — only when `record` succeeded — `firestore.record` and `tombstone`.","items":{"type":"object","required":["step","ok"],"properties":{"step":{"type":"string"},"ok":{"type":"boolean"},"error":{"type":"string"}}}},"InlineTokens":{"type":"array","description":"Dates, times and places written as plain text in `description`, resolved to absolute values. Applied verbatim — no AI call. The set is validated as a whole and refused as a whole: every `original` must occur character for character in the prose of `description` (never inside an existing `«…»` chip), date values must parse, `displayText` and `value` may not contain the opening `«`, and `original` may contain neither `«` nor `»`. A `|` or a `»` inside `displayText` or `value` is fine — it is escaped when the token is written, so a place genuinely called \"Rua A | B\" survives. A location is written as `address=<value>`, with any `;` or `=` in the address made harmless. Only these three types are accepted; people, groups, assets and catalog items carry workspace ids and travel in their own fields.","items":{"type":"object","required":["original","displayText","type","value"],"properties":{"original":{"type":"string","description":"Exact text span in `description` this token replaces."},"displayText":{"type":"string","description":"The chip's display: `Apr 15, 14:00` for a datetime, `Apr 15` for a date (24-hour, English), a short place name for a location."},"type":{"type":"string","enum":["datetime","date","location"]},"value":{"type":"string","description":"For datetime/date, the wall-clock time in the business time zone as `YYYY-MM-DDTHH:mm:ss.sss` with no offset (a date ends in T00:00:00.000); for location, the address only."}}}},"CreateCallRequest":{"type":"object","properties":{"phoneE164":{"type":"string","description":"Callee, E.164 (required)"},"fromPhoneE164":{"type":"string","description":"Caller-id to dial from (required). Must be one of the workspace’s configured shared-line numbers — the rejection echoes `sharedLineNumbers` so a caller can pick a valid one. Read them from `GET /calls/shared-numbers`."},"goal":{"type":"string","description":"Required unless agentSpecUrl is set"},"contactName":{"type":"string"},"scheduleAt":{"type":"string","format":"date-time","description":"ISO-8601; >60s in the future to schedule, else dials ASAP"},"voice":{"type":"string","enum":["ava","eve","leo","sal"],"description":"A voice of the default call voice model (xAI Grok Voice). When the workspace has chosen another call voice model (`GET /calls/voice-model`), a voice that model does not have is ignored and the workspace’s chosen voice speaks instead."},"language":{"type":"string","description":"BCP-47 advisory hint (e.g. en, pt-PT)"},"agentSpecUrl":{"type":"string","description":"Remote AgentSpec source URL. When set, the call is driven by the fetched spec instead of the workspace agent. Fetched at create (fail-fast) and re-fetched at dispatch for scheduled calls. https only."},"agentSpecMethod":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"],"default":"POST"},"metadata":{"type":"object","additionalProperties":true,"description":"Opaque object echoed to the spec URL on every fetch (outbound + the mirrored inbound callback) so a dynamic endpoint can identify the call."},"dryRun":{"type":"boolean","description":"Validate + price without queuing or charging"}},"required":["phoneE164","fromPhoneE164"]},"AgentSpec":{"type":"object","description":"JSON returned by your `agentSpecUrl`. Static file or dynamically generated from the signed request body ({callId, direction, phoneE164, metadata}).","properties":{"systemPrompt":{"type":"string","description":"Authoritative agent role/instructions (required)"},"history":{"type":"array","description":"Prior conversation to seed (e.g. earlier call transcript / ticket notes)","items":{"type":"object","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]}},"voice":{"type":["string","null"],"description":"Overrides workspace voice for this call"},"language":{"type":["string","null"]},"allowBuiltinTools":{"type":"boolean","default":false,"description":"Expose the workspace built-in tools alongside spec tools"},"tools":{"type":"array","items":{"$ref":"#/components/schemas/SpecTool"}}},"required":["systemPrompt"]},"SpecTool":{"type":"object","description":"A tool the agent can call. The agent invokes it by name; the worker forwards to `endpoint` (signed). The endpoint never reaches the agent.","properties":{"name":{"type":"string","description":"Identifier [a-zA-Z0-9_], becomes the LLM function name"},"description":{"type":"string"},"parameters":{"type":"object","description":"JSON Schema for the tool arguments"},"endpoint":{"type":"object","properties":{"url":{"type":"string","description":"https; receives {callId, name, arguments}"},"method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"],"default":"POST"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"timeoutMs":{"type":"integer","maximum":30000}},"required":["url"]}},"required":["name","description","endpoint"]}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"API key (ob_live_…)","description":"Mint workspace-scoped keys via the in-app API Keys panel or `overblast api-keys create`. The same key is the bearer for the AI gateway at `/ai/v1`; a key with no workspace binding is refused there by name."}},"parameters":{"Limit":{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"Page":{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1},"description":"1-based page number; the response echoes `pagination`"},"Fresh":{"name":"fresh","in":"query","schema":{"type":"integer","enum":[0,1]},"description":"1 to bypass the 30s freshness cache"},"ConversationId":{"name":"conversationId","in":"path","required":true,"schema":{"type":"string"}},"ThreadId":{"name":"threadId","in":"path","required":true,"schema":{"type":"string"}},"AskId":{"name":"askId","in":"path","required":true,"schema":{"type":"string"}},"CallId":{"name":"callId","in":"path","required":true,"schema":{"type":"string"}},"JobId":{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}},"ProposalId":{"name":"proposalId","in":"path","required":true,"schema":{"type":"string"}},"CardId":{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}},"ComputerId":{"name":"computerId","in":"path","required":true,"schema":{"type":"string"},"description":"The workspace. A key is bound to exactly one and may only name that one — read it from `GET /api-keys/me` (`computerId`); any other id answers 403. Session callers name whichever workspace they are acting in."},"ComputerIdQuery":{"name":"computerId","in":"query","required":true,"schema":{"type":"string"},"description":"Required on this route even for a key caller, which must pass the workspace it is bound to (`GET /api-keys/me`). Any other id answers 403."}},"responses":{"Unauthorized":{"description":"Missing or invalid Bearer token"},"GatewayUnauthorized":{"description":"Unknown, inactive or expired key. A key with no workspace binding is refused by name: \"This API key is account-wide. Create a key bound to a workspace to use its AI credits.\""},"InsufficientCredits":{"description":"`insufficient_credits` — the body carries the remaining `balanceCents` (credits, not a currency) and a `topUp` object with the paths to buy more. NOTE: a STREAMING chat request receives this as a 200 SSE chunk instead of a 402 status."},"GatewayRateLimited":{"description":"`proxy_rate_limited`, with a `Retry-After` header and `retryAfterSeconds` in the body. The permitted rate scales with the workspace credit balance."},"ApiKeyMe":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string"},"computerId":{"type":["string","null"],"description":"The workspace this key is bound to — the one every call acts on."},"profileSetId":{"type":["string","null"],"deprecated":true,"description":"Internal. Kept for clients that already read it; address the workspace by `computerId`."},"name":{"type":["string","null"],"description":"The key's own name"},"workspaceName":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"},"description":"Effective scopes. For a person-bound key this is already what they consented to intersected with what their role still permits."},"permissions":{"type":"object","description":"Legacy SDK view of the same scopes"},"member":{"type":"object","description":"Always present, so there is one shape to decode. Branch on `role`.","properties":{"id":{"type":"string","description":"Roster row id — what a task's `assignedToId` is compared against"},"name":{"type":["string","null"]},"email":{"type":["string","null"]},"role":{"type":"string","enum":["owner","manager","member"]},"groups":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}}},"capabilities":{"type":"object","additionalProperties":{"type":"boolean"},"description":"Capability name → boolean. Always booleans, never scope strings."},"modes":{"type":"object","additionalProperties":{"type":"string","enum":["approval","auto"]},"description":"Sparse. Only the capabilities that carry a mode appear; an absent entry means no mode, never a default. `approval` means outbound work is parked for a person to release."}}},"moderationMode":{"type":"string","enum":["none","manual"],"description":"`manual` queues published work for human approval."}}}}}},"AccountsSummary":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}},"ConversationList":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}}