{"openapi":"3.1.0","info":{"title":"Inbound Triage API","version":"0.1.0","summary":"Send an inbound message in, watch a durable pipeline triage it.","description":"The API behind the Inbound Triage demo for Harbor & Vale, a fictional agency. `POST /api/inbound` accepts a message and answers `202` at once; the run (normalize, classify, score, enrich, CRM, approval, draft reply, send) executes after the response and every step is readable through `/api/runs/{id}` or streamed from `/api/runs/{id}/events`.\n\n**Public demo.** There are no accounts or API keys. Writes are rate limited per client address, the model budget is capped per day, and the chaos switches are global demo state that expires on its own.\n\n**Masking.** Email addresses and phone numbers are masked in every response (`p•••@acme.example`). Nothing here returns a full address.\n\n**Errors.** Every error is `{ \"error\": { \"code\", \"message\", \"details\"? } }` with a stable `code`. Every response carries `X-Request-Id`; send your own `X-Request-Id` (8 to 64 plain characters) to join logs, and quote it when you report a problem."},"servers":[{"url":"https://triage.phoenixcodes.org","description":"This deployment"},{"url":"http://localhost:3000","description":"Local development"}],"tags":[{"name":"Inbound","description":"Submit a message to be triaged."},{"name":"Runs","description":"Read runs, follow them live, retry a failed one."},{"name":"Approvals","description":"Human decisions for leads that need one."},{"name":"Chaos","description":"Global failure switches for the demo."},{"name":"n8n","description":"The same pipeline as an importable n8n workflow."},{"name":"Meta","description":"This document."}],"paths":{"/api/inbound":{"post":{"tags":["Inbound"],"operationId":"acceptInbound","summary":"Submit a message","description":"Validates the message, applies the per-address rate limit (10 runs per 10 minutes by default) and the daily model budget, creates a run and answers `202`. Bodies are limited to 16 KB. If the deployment sets `TRIAGE_WEBHOOK_SECRET` and the request carries `X-Triage-Signature` (hex HMAC-SHA256 of the raw body), the signature must verify.","security":[{},{"TriageSignature":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InboundRequest","discriminator":{"propertyName":"channel"}},"examples":{"web_form":{"summary":"Web form","value":{"channel":"web_form","name":"Sam Rivera","email":"sam@acme.example","company":"Acme","message":"We are rebranding in March and have set aside a budget of 60,000 USD. Can we talk this week?"}},"email":{"summary":"Email payload","value":{"channel":"email","from":"Dana Cole <dana@acme.example>","subject":"Quote for lifecycle email","text":"Hello, we would like a quote for a lifecycle email programme."}},"fixture":{"summary":"One of the 20 sample messages","value":{"channel":"fixture","fixtureId":"tallgrass-rebrand"}}}}}},"responses":{"202":{"description":"Accepted. The run has started; follow it through the links.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Location":{"description":"The run as JSON.","schema":{"type":"string"}},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InboundAccepted"},"example":{"id":"run_k3x9m2qaz7","status":"queued","links":{"page":"/runs/run_k3x9m2qaz7","events":"/api/runs/run_k3x9m2qaz7/events","run":"/api/runs/run_k3x9m2qaz7"}}}}},"400":{"description":"The body is not valid JSON.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_json","message":"The request body is not valid JSON."}}}}},"401":{"description":"A signature was sent and does not match the body.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"bad_signature","message":"X-Triage-Signature does not match the request body."}}}}},"404":{"description":"The fixture id is not one of the 20 samples.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unknown_fixture","message":"Unknown fixture \"nope\""}}}}},"413":{"description":"The body is larger than 16 KB.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"payload_too_large","message":"Request bodies are limited to 16 KB."}}}}},"422":{"description":"The body or query did not pass validation.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_payload","message":"The message did not pass validation.","details":[{"path":"email","message":"Invalid input"}]}}}}},"429":{"description":"Too many requests from this address in the current window.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Too many requests: 30 per 10 minutes. Try again in 212 seconds."}}}}},"503":{"description":"Today's model budget is used up. It resets at 00:00 UTC.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"daily_budget_exhausted","message":"Today's model budget for this demo is used up. It resets at 00:00 UTC."}}}}}}}},"/api/runs":{"get":{"tags":["Runs"],"operationId":"listRuns","summary":"List runs","description":"Newest first. Page backwards by passing the oldest `createdAt` you have as `before`.","parameters":[{"name":"limit","in":"query","description":"How many runs to return.","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"before","in":"query","description":"Only runs created before this timestamp.","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Runs, newest first.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunList"},"example":{"data":[{"id":"run_k3x9m2qaz7","status":"completed","terminal":true,"sourceKind":"fixture","fixtureId":"tallgrass-rebrand","channel":"web_form","from":"p•••@tallgrassfreight.example","name":"Priya Raman","company":"Tallgrass Freight Systems","subject":"Rebrand and paid social ahead of our February trade show","intent":"sales_inquiry","urgency":4,"leadScore":99,"outcome":"Reply sent","currentStep":7,"tokensIn":2398,"tokensOut":230,"costUsd":0.0012,"activeMs":12400,"createdAt":"2026-10-22T09:00:00.000Z","finishedAt":"2026-10-22T09:00:12.700Z"}]}}}},"422":{"description":"A query parameter is out of range.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_query","message":"limit must be 1 to 100 and before must be an ISO timestamp."}}}}}}}},"/api/runs/{id}":{"get":{"tags":["Runs"],"operationId":"getRun","summary":"Get a run with every step","description":"The run, the message body (masked) and its eight steps with status, attempts, timings, inputs, outputs, errors and token cost.","parameters":[{"name":"id","in":"path","required":true,"description":"Run id, as returned by POST /api/inbound.","schema":{"type":"string","pattern":"^run_[a-z0-9]{10}$"},"example":"run_k3x9m2qaz7"}],"responses":{"200":{"description":"The run.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunDetail"},"example":{"id":"run_k3x9m2qaz7","status":"completed","terminal":true,"sourceKind":"fixture","fixtureId":"tallgrass-rebrand","channel":"web_form","from":"p•••@tallgrassfreight.example","name":"Priya Raman","company":"Tallgrass Freight Systems","subject":"Rebrand and paid social ahead of our February trade show","intent":"sales_inquiry","urgency":4,"leadScore":99,"outcome":"Reply sent","currentStep":7,"tokensIn":2398,"tokensOut":230,"costUsd":0.0012,"activeMs":12400,"createdAt":"2026-10-22T09:00:00.000Z","finishedAt":"2026-10-22T09:00:12.700Z","receivedAt":"2026-10-22T09:00:00.000Z","body":"Hello Harbor & Vale, we are a regional freight company about 600 people strong ...","steps":[{"idx":0,"key":"normalize","label":"Normalize","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":260,"input":{},"output":{"channel":"web_form","subject":"Rebrand and paid social ahead of our February trade show"},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null},{"idx":1,"key":"classify","label":"Classify","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":4700,"input":{},"output":{"intent":"sales_inquiry","urgency":4,"language":"en"},"error":null,"tokensIn":1056,"tokensOut":113,"costUsd":0.000564,"model":"@cf/meta/llama-3.3-70b-instruct-fp8-fast"},{"idx":2,"key":"score","label":"Score","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":270,"input":{},"output":{"leadScore":99,"band":"hot"},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null},{"idx":3,"key":"enrich","label":"Enrich","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":280,"input":{},"output":{"domain":"tallgrassfreight.example","source":"fixture"},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null},{"idx":4,"key":"crm_upsert","label":"CRM upsert","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":1020,"input":{},"output":{"provider":"sandbox","contactCreated":true},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null},{"idx":5,"key":"approval","label":"Approval","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":760,"input":{},"output":{"decision":"auto_approved","decidedBy":"policy"},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null},{"idx":6,"key":"draft_reply","label":"Draft reply","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":4300,"input":{},"output":{"subject":"Your rebrand and the February show","wordCount":118},"error":null,"tokensIn":1342,"tokensOut":117,"costUsd":0.000636,"model":"@cf/meta/llama-3.3-70b-instruct-fp8-fast"},{"idx":7,"key":"send","label":"Send","status":"succeeded","attempts":1,"startedAt":"2026-10-22T09:00:00.300Z","finishedAt":"2026-10-22T09:00:00.600Z","durationMs":810,"input":{},"output":{"provider":"sandbox","deduplicated":false},"error":null,"tokensIn":0,"tokensOut":0,"costUsd":0,"model":null}]}}}},"404":{"description":"No such run.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No run with id \"run_k3x9m2qaz7\"."}}}}}}}},"/api/runs/{id}/events":{"get":{"tags":["Runs"],"operationId":"streamRun","summary":"Follow a run live (server-sent events)","description":"A `text/event-stream` that sends the run whenever something about it changes. Events: `snapshot` (first message, the full run), `update` (the full run after a change), `done` (the run reached a terminal status), `missing` (no such run), `continue` (the stream is closing after 55 seconds; reconnect with the same URL).\n\nThe stream polls the database every 500 ms and sends a `: keepalive` comment every 15 seconds. It also restarts a run whose executor was lost.","parameters":[{"name":"id","in":"path","required":true,"description":"Run id, as returned by POST /api/inbound.","schema":{"type":"string","pattern":"^run_[a-z0-9]{10}$"},"example":"run_k3x9m2qaz7"}],"responses":{"200":{"description":"An event stream.","headers":{},"content":{"text/event-stream":{"schema":{"type":"string"},"example":"retry: 1000\n\nevent: snapshot\ndata: {\"id\":\"run_k3x9m2qaz7\",\"status\":\"running\", ...}\n\nevent: done\ndata: {\"id\":\"run_k3x9m2qaz7\",\"status\":\"completed\"}\n\n"}}},"404":{"description":"The id is not a run id.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No run with id \"nope\"."}}}}}}}},"/api/runs/{id}/retry":{"post":{"tags":["Runs"],"operationId":"retryRun","summary":"Retry a failed run from a step","description":"Only for runs in `dead_letter` or `failed`. The run is claimed and its steps from the chosen step onward are reset before the response; they execute afterwards. Steps before it keep their results. The body is optional: without `fromStep` the run restarts at the step that failed. Rate limited per address.","parameters":[{"name":"id","in":"path","required":true,"description":"Run id, as returned by POST /api/inbound.","schema":{"type":"string","pattern":"^run_[a-z0-9]{10}$"},"example":"run_k3x9m2qaz7"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetryRequest"},"examples":{"failedStep":{"summary":"From the step that failed","value":{}},"chosenStep":{"summary":"From a chosen step","value":{"fromStep":"crm_upsert"}}}}}},"responses":{"202":{"description":"The run is moving again.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetryResult"},"example":{"id":"run_k3x9m2qaz7","status":"running","fromStep":"crm_upsert","links":{"page":"/runs/run_k3x9m2qaz7","events":"/api/runs/run_k3x9m2qaz7/events","run":"/api/runs/run_k3x9m2qaz7"}}}}},"400":{"description":"The body is not valid JSON.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_json","message":"The request body is not valid JSON."}}}}},"404":{"description":"No such run.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No run with id \"run_k3x9m2qaz7\"."}}}}},"409":{"description":"The run is not in a retryable state, is already moving, or the step cannot be restarted.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_retryable","message":"This run is already moving again."}}}}},"422":{"description":"The body or query did not pass validation.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_payload","message":"fromStep must be one of the eight step keys.","details":[{"path":"fromStep","message":"Invalid input"}]}}}}},"429":{"description":"Too many requests from this address in the current window.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Too many requests: 30 per 10 minutes. Try again in 212 seconds."}}}}}}}},"/api/approvals":{"get":{"tags":["Approvals"],"operationId":"listApprovals","summary":"List approval cards","description":"Newest first. Cards for leads that scored at or above the threshold are created already decided (`auto_approved`).","parameters":[{"name":"status","in":"query","description":"`pending` for cards waiting on a person, `decided` for the rest.","schema":{"type":"string","enum":["pending","decided"]}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":60}}],"responses":{"200":{"description":"Approvals.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalList"},"example":{"data":[{"id":"apr_q8w2e5r7t1","runId":"run_m4n7b2v9cx","status":"pending","provider":"sandbox","leadScore":77,"card":{"title":"New lead: Quillstack","summary":"Director of Growth asks for a walkthrough of paid and lifecycle reporting before a November decision.","fields":[{"label":"Lead score","value":"77 of 100 (warm)"},{"label":"Intent","value":"sales inquiry"},{"label":"Urgency","value":"3 of 5"},{"label":"Language","value":"en"},{"label":"Company","value":"Quillstack"},{"label":"Subject","value":"Demo request: how you report on paid and lifecycle performance"}]},"decidedBy":null,"reason":null,"createdAt":"2026-10-22T09:05:00.000Z","decidedAt":null,"run":{"id":"run_m4n7b2v9cx","status":"awaiting_approval","company":"Quillstack","name":"Marcus Webb"}}]}}}},"422":{"description":"A query parameter is out of range.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_query","message":"status must be pending or decided; limit must be 1 to 100."}}}}}}}},"/api/approvals/{id}":{"post":{"tags":["Approvals"],"operationId":"decideApproval","summary":"Approve or reject","description":"Records a decision and lets the run continue: an approval drafts and sends the reply, a rejection ends the run as `rejected`. The decision is stored before the response; drafting and sending run afterwards.\n\nIdempotent. The first decision wins. Repeating it returns `200` with `applied: false` and changes nothing. The opposite decision afterwards is a `409` (`already_decided`). The web UI records decisions as \"Demo reviewer\". Rate limited per address.","parameters":[{"name":"id","in":"path","required":true,"description":"Approval id, as listed by GET /api/approvals.","schema":{"type":"string","pattern":"^apr_[a-z0-9]{10}$"},"example":"apr_q8w2e5r7t1"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalDecisionRequest"},"examples":{"approve":{"summary":"Approve with a note","value":{"decision":"approved","note":"Good fit. Send the reply."}},"reject":{"summary":"Reject","value":{"decision":"rejected"}}}}}},"responses":{"200":{"description":"The decision stands.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalDecisionResult"},"example":{"id":"apr_q8w2e5r7t1","runId":"run_m4n7b2v9cx","status":"approved","applied":true,"decidedBy":"Demo reviewer","run":{"id":"run_m4n7b2v9cx","status":"running"},"links":{"run":"/runs/run_m4n7b2v9cx","events":"/api/runs/run_m4n7b2v9cx/events","approvals":"/approvals"}}}}},"400":{"description":"The body is not valid JSON.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_json","message":"The request body is not valid JSON."}}}}},"404":{"description":"No such approval.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No approval with id \"apr_q8w2e5r7t1\"."}}}}},"409":{"description":"Already decided the other way, or the run is not waiting for a decision.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"already_decided","message":"This approval was already rejected by Demo reviewer. The first decision stands."}}}}},"422":{"description":"The body or query did not pass validation.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_payload","message":"Send {\"decision\":\"approved\"|\"rejected\",\"note\"?:string}.","details":[{"path":"decision","message":"Invalid input"}]}}}}},"429":{"description":"Too many requests from this address in the current window.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Too many requests: 30 per 10 minutes. Try again in 212 seconds."}}}}}}}},"/api/slack/interact":{"post":{"tags":["Approvals"],"operationId":"slackInteract","summary":"Slack interactivity endpoint","description":"Set this as the Request URL under Interactivity & Shortcuts in your Slack app. Slack posts a form-encoded body with one field, `payload`, a JSON string. Only `block_actions` for the Approve and Reject buttons (`triage_approve`, `triage_reject`) are acted on; anything else is acknowledged and ignored.\n\nEvery request is verified before it is parsed: the `v0` HMAC-SHA256 signature over `v0:{timestamp}:{raw body}` is compared in constant time, and a timestamp more than five minutes from the server clock is refused. The endpoint answers `503` until `SLACK_SIGNING_SECRET` is set. It maps onto the same idempotent decision as `POST /api/approvals/{id}`, recorded as the Slack member who clicked.\n\nSlack treats anything other than `200` as a failed delivery, so a repeat click or a lost race answers `200` with `applied: false`.","security":[{"SlackSignature":[],"SlackTimestamp":[]}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["payload"],"properties":{"payload":{"type":"string","description":"JSON string of the Slack interaction payload."}}},"example":"payload=%7B%22type%22%3A%22block_actions%22%2C%22user%22%3A%7B%22id%22%3A%22U024BE7LH%22%7D%2C%22actions%22%3A%5B%7B%22action_id%22%3A%22triage_approve%22%2C%22value%22%3A%22apr_q8w2e5r7t1%22%7D%5D%7D"}}},"responses":{"200":{"description":"Acknowledged.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAck"},"example":{"ok":true,"applied":false,"error":{"code":"already_decided","message":"This approval was already rejected by Demo reviewer. The first decision stands."}}}}},"400":{"description":"The payload is not what Slack sends.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_payload","message":"The request has no payload field."}}}}},"401":{"description":"The signature does not verify, or the timestamp is outside the five minute window.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_signature","message":"The Slack signature could not be verified."}}}}},"503":{"description":"No signing secret is configured on this deployment.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"slack_not_configured","message":"Slack interactivity is not configured on this deployment (SLACK_SIGNING_SECRET is not set)."}}}}}}}},"/api/chaos":{"get":{"tags":["Chaos"],"operationId":"getChaos","summary":"Read the chaos switches","description":"Which failures are being simulated right now, and when each one expires.","responses":{"200":{"description":"The switches.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChaosState"},"example":{"data":[{"key":"crm_outage","label":"CRM outage","breaks":"Every CRM call fails with a 503. The CRM upsert step retries at 1s, 4s and 9s, then the run dead-letters.","enabled":true,"expiresAt":"2026-10-22T09:12:00.000Z"},{"key":"llm_timeout","label":"Model timeout","breaks":"Classify and Draft reply time out before the model answers. The step retries, then the run dead-letters.","enabled":false,"expiresAt":null},{"key":"slack_outage","label":"Slack outage","breaks":"The approval request fails with a 503. Leads that need a person stop at Approval; policy approvals are unaffected.","enabled":false,"expiresAt":null}],"scope":"global","ttlMinutes":10}}}}}},"post":{"tags":["Chaos"],"operationId":"setChaos","summary":"Turn a chaos switch on or off","description":"**Global demo state.** A switch applies to every visitor and every run, not to your session, and turns itself off 10 minutes after it was flipped so nobody can leave the demo broken. Rate limited per address.\n\n`crm_outage`: CRM calls fail with a 503, so CRM upsert retries and the run dead-letters. `llm_timeout`: Classify and Draft reply time out. `slack_outage`: the approval request fails for leads that need a person.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChaosSetRequest"},"example":{"key":"crm_outage","enabled":true}}}},"responses":{"200":{"description":"The switches after the change.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChaosState"},"example":{"data":[{"key":"crm_outage","label":"CRM outage","breaks":"Every CRM call fails with a 503. The CRM upsert step retries at 1s, 4s and 9s, then the run dead-letters.","enabled":true,"expiresAt":"2026-10-22T09:12:00.000Z"},{"key":"llm_timeout","label":"Model timeout","breaks":"Classify and Draft reply time out before the model answers. The step retries, then the run dead-letters.","enabled":false,"expiresAt":null},{"key":"slack_outage","label":"Slack outage","breaks":"The approval request fails with a 503. Leads that need a person stop at Approval; policy approvals are unaffected.","enabled":false,"expiresAt":null}],"scope":"global","ttlMinutes":10}}}},"400":{"description":"The body is not valid JSON.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_json","message":"The request body is not valid JSON."}}}}},"422":{"description":"The body or query did not pass validation.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_payload","message":"Send {\"key\":\"crm_outage\"|\"llm_timeout\"|\"slack_outage\",\"enabled\":boolean}.","details":[{"path":"key","message":"Invalid input"}]}}}}},"429":{"description":"Too many requests from this address in the current window.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Too many requests: 30 per 10 minutes. Try again in 212 seconds."}}}}}}}},"/api/n8n/workflow.json":{"get":{"tags":["n8n"],"operationId":"getN8nWorkflow","summary":"Download the n8n workflow","description":"The same pipeline as an importable n8n workflow: webhook, model call, scoring, HubSpot, Slack approval, reply draft, Gmail and an error branch. No credentials are included, only placeholders. See `/n8n` for the node-to-step mapping.","responses":{"200":{"description":"The workflow export.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"Content-Disposition":{"description":"Suggests the file name inbound-triage.workflow.json.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/N8nWorkflow"}}}}}}},"/api/openapi.json":{"get":{"tags":["Meta"],"operationId":"getOpenApi","summary":"This document","description":"The OpenAPI 3.1 description of this API. Human-readable version at `/api/docs`.","responses":{"200":{"description":"The OpenAPI document.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"schemas":{"ErrorBody":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable, machine-readable."},"message":{"type":"string","description":"Plain language, safe to show."},"details":{"type":"array","description":"Present on validation errors: one entry per field.","items":{"type":"object","required":["path","message"],"properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ErrorBody"}}},"RunLinks":{"type":"object","required":["page","events","run"],"properties":{"page":{"type":"string","description":"The run page."},"events":{"type":"string","description":"Server-sent events for this run."},"run":{"type":"string","description":"The run as JSON."}}},"InboundRequest":{"oneOf":[{"type":"object","properties":{"channel":{"type":"string","const":"fixture"},"fixtureId":{"type":"string","pattern":"^[a-z0-9-]{3,64}$"}},"required":["channel","fixtureId"]},{"type":"object","properties":{"channel":{"type":"string","const":"web_form"},"name":{"type":"string","minLength":1,"maxLength":120},"email":{"type":"string","maxLength":254,"format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"company":{"type":"string","maxLength":160},"subject":{"type":"string","maxLength":200},"message":{"type":"string","minLength":1,"maxLength":4000}},"required":["channel","name","email","message"]},{"type":"object","properties":{"channel":{"type":"string","const":"email"},"from":{"type":"string","minLength":3,"maxLength":320},"subject":{"type":"string","minLength":1,"maxLength":200},"text":{"type":"string","minLength":1,"maxLength":4000},"date":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["channel","from","subject","text"]}]},"InboundAccepted":{"type":"object","required":["id","status","links"],"properties":{"id":{"type":"string","pattern":"^run_[a-z0-9]{10}$"},"status":{"type":"string","enum":["queued","running","awaiting_approval","completed","archived","rejected","failed","dead_letter"]},"links":{"$ref":"#/components/schemas/RunLinks"}}},"Step":{"type":"object","required":["idx","key","label","status","attempts","startedAt","finishedAt","durationMs","input","output","error","tokensIn","tokensOut","costUsd","model"],"properties":{"idx":{"type":"integer","minimum":0,"maximum":7},"key":{"type":"string","enum":["normalize","classify","score","enrich","crm_upsert","approval","draft_reply","send"]},"label":{"type":"string"},"status":{"type":"string","enum":["pending","running","retrying","waiting","succeeded","skipped","failed","dead_letter"]},"attempts":{"type":"integer","minimum":0,"description":"One attempt plus up to three retries, so at most 4."},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":["integer","null"]},"input":{"description":"What the step read. Emails and phone numbers are masked."},"output":{"description":"What the step produced. Emails and phone numbers are masked."},"error":{"oneOf":[{"type":"null"},{"type":"object","required":["message","code","transient"],"properties":{"message":{"type":"string"},"code":{"type":"string"},"transient":{"type":"boolean","description":"Transient errors are retried."}}}]},"tokensIn":{"type":"integer"},"tokensOut":{"type":"integer"},"costUsd":{"type":"number","description":"At list price for the model."},"model":{"type":["string","null"]}}},"RunSummary":{"type":"object","required":["id","status","terminal","sourceKind","fixtureId","channel","from","name","company","subject","intent","urgency","leadScore","outcome","currentStep","tokensIn","tokensOut","costUsd","activeMs","createdAt","finishedAt"],"properties":{"id":{"type":"string","pattern":"^run_[a-z0-9]{10}$"},"status":{"type":"string","enum":["queued","running","awaiting_approval","completed","archived","rejected","failed","dead_letter"]},"terminal":{"type":"boolean","description":"True once the run can no longer change."},"sourceKind":{"type":"string","enum":["fixture","web_form","email"]},"fixtureId":{"type":["string","null"]},"channel":{"type":"string","enum":["web_form","email","demo_request"]},"from":{"type":"string","description":"Masked address, for example p•••@acme.example."},"name":{"type":["string","null"]},"company":{"type":["string","null"]},"subject":{"type":"string"},"intent":{"oneOf":[{"type":"null"},{"type":"string","enum":["sales_inquiry","support","partnership","recruiting","spam","other"]}]},"urgency":{"type":["integer","null"],"minimum":1,"maximum":5},"leadScore":{"type":["integer","null"],"minimum":0,"maximum":100},"outcome":{"type":["string","null"],"description":"One line on how the run ended."},"currentStep":{"type":"integer","minimum":0},"tokensIn":{"type":"integer"},"tokensOut":{"type":"integer"},"costUsd":{"type":"number"},"activeMs":{"type":"integer","description":"Time spent executing steps, waits excluded."},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"}}},"RunDetail":{"allOf":[{"$ref":"#/components/schemas/RunSummary"},{"type":"object","required":["receivedAt","body","steps"],"properties":{"receivedAt":{"type":"string"},"body":{"type":"string","description":"The message text, masked."},"steps":{"type":"array","items":{"$ref":"#/components/schemas/Step"}}}}]},"RunList":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RunSummary"}}}},"RetryRequest":{"type":"object","properties":{"fromStep":{"description":"Step to restart from. Default: the step that failed. Earlier steps keep their results.","type":"string","enum":["normalize","classify","score","enrich","crm_upsert","approval","draft_reply","send"]}}},"RetryResult":{"type":"object","required":["id","status","fromStep","links"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["queued","running","awaiting_approval","completed","archived","rejected","failed","dead_letter"]},"fromStep":{"type":"string","enum":["normalize","classify","score","enrich","crm_upsert","approval","draft_reply","send"]},"links":{"$ref":"#/components/schemas/RunLinks"}}},"ApprovalCard":{"type":"object","required":["title","summary","fields"],"properties":{"title":{"type":"string"},"summary":{"type":"string"},"fields":{"type":"array","items":{"type":"object","required":["label","value"],"properties":{"label":{"type":"string"},"value":{"type":"string"}}}}}},"Approval":{"type":"object","required":["id","runId","status","provider","leadScore","card","decidedBy","reason","createdAt","decidedAt","run"],"properties":{"id":{"type":"string","pattern":"^apr_[a-z0-9]{10}$"},"runId":{"type":"string"},"status":{"type":"string","enum":["pending","approved","rejected","auto_approved"]},"provider":{"type":"string","enum":["sandbox","slack"]},"leadScore":{"type":"integer"},"card":{"$ref":"#/components/schemas/ApprovalCard"},"decidedBy":{"type":["string","null"]},"reason":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"decidedAt":{"type":["string","null"],"format":"date-time"},"run":{"type":"object","required":["id","status","company","name"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["queued","running","awaiting_approval","completed","archived","rejected","failed","dead_letter"]},"company":{"type":["string","null"]},"name":{"type":["string","null"]}}}}},"ApprovalList":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Approval"}}}},"ApprovalDecisionRequest":{"type":"object","properties":{"decision":{"type":"string","enum":["approved","rejected"],"description":"The reviewer decision."},"note":{"description":"Optional note, shown in the run timeline. Emails and phone numbers are masked.","type":"string","maxLength":280}},"required":["decision"]},"ApprovalDecisionResult":{"type":"object","required":["id","runId","status","applied","decidedBy","run","links"],"properties":{"id":{"type":"string"},"runId":{"type":"string"},"status":{"type":"string","enum":["pending","approved","rejected","auto_approved"]},"applied":{"type":"boolean","description":"True when this call recorded the decision. False when the same decision was already stored (a repeat click or a Slack retry)."},"decidedBy":{"type":["string","null"]},"run":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["queued","running","awaiting_approval","completed","archived","rejected","failed","dead_letter"]}}},"links":{"type":"object","required":["run","events","approvals"],"properties":{"run":{"type":"string"},"events":{"type":"string"},"approvals":{"type":"string"}}}}},"SlackAck":{"description":"Slack only needs a 200. A decision, an ignored payload and a lost race all answer 200 and say which in the body.","anyOf":[{"$ref":"#/components/schemas/ApprovalDecisionResult"},{"type":"object","required":["ok","ignored"],"properties":{"ok":{"const":true},"ignored":{"const":true}}},{"type":"object","required":["ok","applied","error"],"properties":{"ok":{"const":true},"applied":{"const":false},"error":{"$ref":"#/components/schemas/ErrorBody"}}}]},"ChaosSetRequest":{"type":"object","properties":{"key":{"type":"string","enum":["crm_outage","llm_timeout","slack_outage"],"description":"Which failure to simulate."},"enabled":{"type":"boolean","description":"true turns the failure on, false turns it off."}},"required":["key","enabled"]},"ChaosSwitch":{"type":"object","required":["key","label","breaks","enabled","expiresAt"],"properties":{"key":{"type":"string","enum":["crm_outage","llm_timeout","slack_outage"]},"label":{"type":"string"},"breaks":{"type":"string","description":"What this switch breaks, in a sentence."},"enabled":{"type":"boolean"},"expiresAt":{"type":["string","null"],"format":"date-time","description":"When an enabled switch turns itself off. Null while it is off."}}},"ChaosState":{"type":"object","required":["data","scope","ttlMinutes"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ChaosSwitch"}},"scope":{"const":"global","description":"The switches are shared by every visitor and every run."},"ttlMinutes":{"type":"number","description":"A switch turns itself off after this long."}}},"N8nWorkflow":{"type":"object","description":"An n8n workflow export. Import it from the n8n editor.","required":["name","nodes","connections"],"properties":{"name":{"type":"string"},"nodes":{"type":"array","items":{"type":"object"}},"connections":{"type":"object"},"active":{"type":"boolean"},"settings":{"type":"object"}}}},"headers":{"RequestId":{"description":"Id of this request, also written to the server log.","schema":{"type":"string"}},"RetryAfter":{"description":"Seconds to wait before trying again.","schema":{"type":"integer"}},"RateLimitLimit":{"description":"Requests allowed per window.","schema":{"type":"integer"}},"RateLimitRemaining":{"description":"Requests left in the current window.","schema":{"type":"integer"}}},"securitySchemes":{"TriageSignature":{"type":"apiKey","in":"header","name":"X-Triage-Signature","description":"Optional. Hex HMAC-SHA256 of the raw request body, keyed with the deployment secret. Verified only when the deployment has a secret and the header is present."},"SlackSignature":{"type":"apiKey","in":"header","name":"X-Slack-Signature","description":"Slack v0 signature: `v0=` plus hex HMAC-SHA256 of `v0:{timestamp}:{raw body}`."},"SlackTimestamp":{"type":"apiKey","in":"header","name":"X-Slack-Request-Timestamp","description":"Unix seconds. Requests more than five minutes old are refused."}}}}