Demo. Harbor & Vale is a fictional agency. Replies and CRM records land in sandbox tables, never in a real inbox.

Inbound Triage

OpenAPI 3.1.0 · version 0.1.0

Inbound Triage API

Send an inbound message in, watch a durable pipeline triage it.

Start here

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.

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.

Masking. Email addresses and phone numbers are masked in every response (p•••@acme.example). Nothing here returns a full address.

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.

Base URL

export BASE=https://triage.phoenixcodes.org

Every curl command below uses $BASE. For a local run, set it to http://localhost:3000.

Inbound

Submit a message to be triaged.

post/api/inbound

Submit a message

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.

Open to anyone. Optionally signed with X-Triage-Signature.

Request body

application/json · InboundRequest

Web form

{
  "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 payload

{
  "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."
}

One of the 20 sample messages

{
  "channel": "fixture",
  "fixtureId": "tallgrass-rebrand"
}

curl

curl -sS -X POST "$BASE/api/inbound" \
  -H 'content-type: application/json' \
  -d '{"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?"}'

Responses

  • 202Successapplication/jsonInboundAccepted

    Accepted. The run has started; follow it through the links.

    Headers: Location, RateLimit-Limit, RateLimit-Remaining

    Example body
    {
      "id": "run_k3x9m2qaz7",
      "status": "queued",
      "links": {
        "page": "/runs/run_k3x9m2qaz7",
        "events": "/api/runs/run_k3x9m2qaz7/events",
        "run": "/api/runs/run_k3x9m2qaz7"
      }
    }
  • 400Errorapplication/jsonError

    The body is not valid JSON.

    Example body
    {
      "error": {
        "code": "invalid_json",
        "message": "The request body is not valid JSON."
      }
    }
  • 401Errorapplication/jsonError

    A signature was sent and does not match the body.

    Example body
    {
      "error": {
        "code": "bad_signature",
        "message": "X-Triage-Signature does not match the request body."
      }
    }
  • 404Errorapplication/jsonError

    The fixture id is not one of the 20 samples.

    Example body
    {
      "error": {
        "code": "unknown_fixture",
        "message": "Unknown fixture \"nope\""
      }
    }
  • 413Errorapplication/jsonError

    The body is larger than 16 KB.

    Example body
    {
      "error": {
        "code": "payload_too_large",
        "message": "Request bodies are limited to 16 KB."
      }
    }
  • 422Errorapplication/jsonError

    The body or query did not pass validation.

    Example body
    {
      "error": {
        "code": "invalid_payload",
        "message": "The message did not pass validation.",
        "details": [
          {
            "path": "email",
            "message": "Invalid input"
          }
        ]
      }
    }
  • 429Errorapplication/jsonError

    Too many requests from this address in the current window.

    Headers: Retry-After, RateLimit-Limit, RateLimit-Remaining

    Example body
    {
      "error": {
        "code": "rate_limited",
        "message": "Too many requests: 30 per 10 minutes. Try again in 212 seconds."
      }
    }
  • 503Errorapplication/jsonError

    Today's model budget is used up. It resets at 00:00 UTC.

    Headers: Retry-After

    Example body
    {
      "error": {
        "code": "daily_budget_exhausted",
        "message": "Today's model budget for this demo is used up. It resets at 00:00 UTC."
      }
    }

Runs

Read runs, follow them live, retry a failed one.

get/api/runs

List runs

Newest first. Page backwards by passing the oldest createdAt you have as before.

Parameters

  • limitqueryinteger

    How many runs to return.

    min 1, max 100. Default 50.

  • beforequerystring

    Only runs created before this timestamp.

    Format: date-time.

curl

curl -sS "$BASE/api/runs"

Responses

  • 200Successapplication/jsonRunList

    Runs, newest first.

    Example body
    {
      "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"
        }
      ]
    }
  • 422Errorapplication/jsonError

    A query parameter is out of range.

    Example body
    {
      "error": {
        "code": "invalid_query",
        "message": "limit must be 1 to 100 and before must be an ISO timestamp."
      }
    }
get/api/runs/{id}

Get a run with every step

The run, the message body (masked) and its eight steps with status, attempts, timings, inputs, outputs, errors and token cost.

Parameters

  • idpathstringrequired

    Run id, as returned by POST /api/inbound.

    Pattern ^run_[a-z0-9]{10}$

curl

curl -sS "$BASE/api/runs/run_k3x9m2qaz7"

Responses

  • 200Successapplication/jsonRunDetail

    The run.

    Example body
    {
      "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
        }
      ]
    }
  • 404Errorapplication/jsonError

    No such run.

    Example body
    {
      "error": {
        "code": "not_found",
        "message": "No run with id \"run_k3x9m2qaz7\"."
      }
    }
get/api/runs/{id}/events

Follow a run live (server-sent events)

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).

The 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

  • idpathstringrequired

    Run id, as returned by POST /api/inbound.

    Pattern ^run_[a-z0-9]{10}$

curl

curl -sS -N "$BASE/api/runs/run_k3x9m2qaz7/events"

Responses

  • 200Successtext/event-streamstring

    An event stream.

    Example body
    retry: 1000
    
    event: snapshot
    data: {"id":"run_k3x9m2qaz7","status":"running", ...}
    
    event: done
    data: {"id":"run_k3x9m2qaz7","status":"completed"}
    
    
  • 404Errorapplication/jsonError

    The id is not a run id.

    Example body
    {
      "error": {
        "code": "not_found",
        "message": "No run with id \"nope\"."
      }
    }
post/api/runs/{id}/retry

Retry a failed run from a step

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

  • idpathstringrequired

    Run id, as returned by POST /api/inbound.

    Pattern ^run_[a-z0-9]{10}$

Request body

application/json · RetryRequest

From the step that failed

{}

From a chosen step

{
  "fromStep": "crm_upsert"
}

curl

curl -sS -X POST "$BASE/api/runs/run_k3x9m2qaz7/retry" \
  -H 'content-type: application/json' \
  -d '{}'

Responses

  • 202Successapplication/jsonRetryResult

    The run is moving again.

    Example body
    {
      "id": "run_k3x9m2qaz7",
      "status": "running",
      "fromStep": "crm_upsert",
      "links": {
        "page": "/runs/run_k3x9m2qaz7",
        "events": "/api/runs/run_k3x9m2qaz7/events",
        "run": "/api/runs/run_k3x9m2qaz7"
      }
    }
  • 400Errorapplication/jsonError

    The body is not valid JSON.

    Example body
    {
      "error": {
        "code": "invalid_json",
        "message": "The request body is not valid JSON."
      }
    }
  • 404Errorapplication/jsonError

    No such run.

    Example body
    {
      "error": {
        "code": "not_found",
        "message": "No run with id \"run_k3x9m2qaz7\"."
      }
    }
  • 409Errorapplication/jsonError

    The run is not in a retryable state, is already moving, or the step cannot be restarted.

    Example body
    {
      "error": {
        "code": "not_retryable",
        "message": "This run is already moving again."
      }
    }
  • 422Errorapplication/jsonError

    The body or query did not pass validation.

    Example body
    {
      "error": {
        "code": "invalid_payload",
        "message": "fromStep must be one of the eight step keys.",
        "details": [
          {
            "path": "fromStep",
            "message": "Invalid input"
          }
        ]
      }
    }
  • 429Errorapplication/jsonError

    Too many requests from this address in the current window.

    Headers: Retry-After, RateLimit-Limit, RateLimit-Remaining

    Example body
    {
      "error": {
        "code": "rate_limited",
        "message": "Too many requests: 30 per 10 minutes. Try again in 212 seconds."
      }
    }

Approvals

Human decisions for leads that need one.

get/api/approvals

List approval cards

Newest first. Cards for leads that scored at or above the threshold are created already decided (auto_approved).

Parameters

  • statusquerystring

    `pending` for cards waiting on a person, `decided` for the rest.

    One of "pending", "decided".

  • limitqueryinteger

    min 1, max 100. Default 60.

curl

curl -sS "$BASE/api/approvals"

Responses

  • 200Successapplication/jsonApprovalList

    Approvals.

    Example body
    {
      "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"
          }
        }
      ]
    }
  • 422Errorapplication/jsonError

    A query parameter is out of range.

    Example body
    {
      "error": {
        "code": "invalid_query",
        "message": "status must be pending or decided; limit must be 1 to 100."
      }
    }
post/api/approvals/{id}

Approve or reject

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.

Idempotent. 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

  • idpathstringrequired

    Approval id, as listed by GET /api/approvals.

    Pattern ^apr_[a-z0-9]{10}$

Request body

application/json · ApprovalDecisionRequest

Approve with a note

{
  "decision": "approved",
  "note": "Good fit. Send the reply."
}

Reject

{
  "decision": "rejected"
}

curl

curl -sS -X POST "$BASE/api/approvals/apr_q8w2e5r7t1" \
  -H 'content-type: application/json' \
  -d '{"decision":"approved","note":"Good fit. Send the reply."}'

Responses

  • 200Successapplication/jsonApprovalDecisionResult

    The decision stands.

    Example body
    {
      "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"
      }
    }
  • 400Errorapplication/jsonError

    The body is not valid JSON.

    Example body
    {
      "error": {
        "code": "invalid_json",
        "message": "The request body is not valid JSON."
      }
    }
  • 404Errorapplication/jsonError

    No such approval.

    Example body
    {
      "error": {
        "code": "not_found",
        "message": "No approval with id \"apr_q8w2e5r7t1\"."
      }
    }
  • 409Errorapplication/jsonError

    Already decided the other way, or the run is not waiting for a decision.

    Example body
    {
      "error": {
        "code": "already_decided",
        "message": "This approval was already rejected by Demo reviewer. The first decision stands."
      }
    }
  • 422Errorapplication/jsonError

    The body or query did not pass validation.

    Example body
    {
      "error": {
        "code": "invalid_payload",
        "message": "Send {\"decision\":\"approved\"|\"rejected\",\"note\"?:string}.",
        "details": [
          {
            "path": "decision",
            "message": "Invalid input"
          }
        ]
      }
    }
  • 429Errorapplication/jsonError

    Too many requests from this address in the current window.

    Headers: Retry-After, RateLimit-Limit, RateLimit-Remaining

    Example body
    {
      "error": {
        "code": "rate_limited",
        "message": "Too many requests: 30 per 10 minutes. Try again in 212 seconds."
      }
    }
post/api/slack/interact

Slack interactivity endpoint

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.

Every 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.

Slack treats anything other than 200 as a failed delivery, so a repeat click or a lost race answers 200 with applied: false.

Signed request. Needs X-Slack-Signature and X-Slack-Request-Timestamp; the server rejects anything it cannot verify.

Request body

application/x-www-form-urlencoded · object

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

  • 200Successapplication/jsonSlackAck

    Acknowledged.

    Example body
    {
      "ok": true,
      "applied": false,
      "error": {
        "code": "already_decided",
        "message": "This approval was already rejected by Demo reviewer. The first decision stands."
      }
    }
  • 400Errorapplication/jsonError

    The payload is not what Slack sends.

    Example body
    {
      "error": {
        "code": "invalid_payload",
        "message": "The request has no payload field."
      }
    }
  • 401Errorapplication/jsonError

    The signature does not verify, or the timestamp is outside the five minute window.

    Example body
    {
      "error": {
        "code": "invalid_signature",
        "message": "The Slack signature could not be verified."
      }
    }
  • 503Errorapplication/jsonError

    No signing secret is configured on this deployment.

    Example body
    {
      "error": {
        "code": "slack_not_configured",
        "message": "Slack interactivity is not configured on this deployment (SLACK_SIGNING_SECRET is not set)."
      }
    }

Chaos

Global failure switches for the demo.

get/api/chaos

Read the chaos switches

Which failures are being simulated right now, and when each one expires.

curl

curl -sS "$BASE/api/chaos"

Responses

  • 200Successapplication/jsonChaosState

    The switches.

    Example body
    {
      "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/api/chaos

Turn a chaos switch on or off

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.

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.

Request body

application/json · ChaosSetRequest

Example

{
  "key": "crm_outage",
  "enabled": true
}

curl

curl -sS -X POST "$BASE/api/chaos" \
  -H 'content-type: application/json' \
  -d '{"key":"crm_outage","enabled":true}'

Responses

  • 200Successapplication/jsonChaosState

    The switches after the change.

    Example body
    {
      "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
    }
  • 400Errorapplication/jsonError

    The body is not valid JSON.

    Example body
    {
      "error": {
        "code": "invalid_json",
        "message": "The request body is not valid JSON."
      }
    }
  • 422Errorapplication/jsonError

    The body or query did not pass validation.

    Example body
    {
      "error": {
        "code": "invalid_payload",
        "message": "Send {\"key\":\"crm_outage\"|\"llm_timeout\"|\"slack_outage\",\"enabled\":boolean}.",
        "details": [
          {
            "path": "key",
            "message": "Invalid input"
          }
        ]
      }
    }
  • 429Errorapplication/jsonError

    Too many requests from this address in the current window.

    Headers: Retry-After, RateLimit-Limit, RateLimit-Remaining

    Example body
    {
      "error": {
        "code": "rate_limited",
        "message": "Too many requests: 30 per 10 minutes. Try again in 212 seconds."
      }
    }

n8n

The same pipeline as an importable n8n workflow.

get/api/n8n/workflow.json

Download the n8n workflow

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.

curl

curl -sS "$BASE/api/n8n/workflow.json"

Responses

  • 200Successapplication/jsonN8nWorkflow

    The workflow export.

    Headers: Content-Disposition

Meta

This document.

get/api/openapi.json

This document

The OpenAPI 3.1 description of this API. Human-readable version at /api/docs.

curl

curl -sS "$BASE/api/openapi.json"

Responses

  • 200Successapplication/jsonobject

    The OpenAPI document.

Schemas

Request schemas are generated from the validators that guard the routes. Response schemas are checked against real handler output in the test suite.

ErrorBody

Fields of ErrorBody
FieldNotes
coderequired
string

Stable, machine-readable.

messagerequired
string

Plain language, safe to show.

details
object[]

Present on validation errors: one entry per field.

details[].pathrequired
string
details[].messagerequired
string

InboundRequest

One of these shapes:

channel = fixture

InboundRequest, channel = fixture
FieldNotes
channelrequired
"fixture"
fixtureIdrequired
string

Pattern ^[a-z0-9-]{3,64}$

channel = web_form

InboundRequest, channel = web_form
FieldNotes
channelrequired
"web_form"
namerequired
string

min 1 chars, max 120 chars.

emailrequired
string

Format: email. max 254 chars.

company
string

max 160 chars.

subject
string

max 200 chars.

messagerequired
string

min 1 chars, max 4000 chars.

channel = email

InboundRequest, channel = email
FieldNotes
channelrequired
"email"
fromrequired
string

min 3 chars, max 320 chars.

subjectrequired
string

min 1 chars, max 200 chars.

textrequired
string

min 1 chars, max 4000 chars.

date
string

Format: date-time.

InboundAccepted

Fields of InboundAccepted
FieldNotes
idrequired
string

Pattern ^run_[a-z0-9]{10}$

statusrequired
string

One of "queued", "running", "awaiting_approval", "completed", "archived", "rejected", "failed", "dead_letter".

linksrequired

Step

Fields of Step
FieldNotes
idxrequired
integer

min 0, max 7.

keyrequired
string

One of "normalize", "classify", "score", "enrich", "crm_upsert", "approval", "draft_reply", "send".

labelrequired
string
statusrequired
string

One of "pending", "running", "retrying", "waiting", "succeeded", "skipped", "failed", "dead_letter".

attemptsrequired
integer

One attempt plus up to three retries, so at most 4.

min 0.

startedAtrequired
string | null

Format: date-time.

finishedAtrequired
string | null

Format: date-time.

durationMsrequired
integer | null
inputrequired
any

What the step read. Emails and phone numbers are masked.

outputrequired
any

What the step produced. Emails and phone numbers are masked.

errorrequired
null | object
tokensInrequired
integer
tokensOutrequired
integer
costUsdrequired
number

At list price for the model.

modelrequired
string | null

RunSummary

Fields of RunSummary
FieldNotes
idrequired
string

Pattern ^run_[a-z0-9]{10}$

statusrequired
string

One of "queued", "running", "awaiting_approval", "completed", "archived", "rejected", "failed", "dead_letter".

terminalrequired
boolean

True once the run can no longer change.

sourceKindrequired
string

One of "fixture", "web_form", "email".

fixtureIdrequired
string | null
channelrequired
string

One of "web_form", "email", "demo_request".

fromrequired
string

Masked address, for example p•••@acme.example.

namerequired
string | null
companyrequired
string | null
subjectrequired
string
intentrequired
null | string
urgencyrequired
integer | null

min 1, max 5.

leadScorerequired
integer | null

min 0, max 100.

outcomerequired
string | null

One line on how the run ended.

currentSteprequired
integer

min 0.

tokensInrequired
integer
tokensOutrequired
integer
costUsdrequired
number
activeMsrequired
integer

Time spent executing steps, waits excluded.

createdAtrequired
string

Format: date-time.

finishedAtrequired
string | null

Format: date-time.

RunDetail

any

RetryRequest

Fields of RetryRequest
FieldNotes
fromStep
string

Step to restart from. Default: the step that failed. Earlier steps keep their results.

One of "normalize", "classify", "score", "enrich", "crm_upsert", "approval", "draft_reply", "send".

RetryResult

Fields of RetryResult
FieldNotes
idrequired
string
statusrequired
string

One of "queued", "running", "awaiting_approval", "completed", "archived", "rejected", "failed", "dead_letter".

fromSteprequired
string

One of "normalize", "classify", "score", "enrich", "crm_upsert", "approval", "draft_reply", "send".

linksrequired

ApprovalCard

Fields of ApprovalCard
FieldNotes
titlerequired
string
summaryrequired
string
fieldsrequired
object[]
fields[].labelrequired
string
fields[].valuerequired
string

Approval

Fields of Approval
FieldNotes
idrequired
string

Pattern ^apr_[a-z0-9]{10}$

runIdrequired
string
statusrequired
string

One of "pending", "approved", "rejected", "auto_approved".

providerrequired
string

One of "sandbox", "slack".

leadScorerequired
integer
cardrequired
decidedByrequired
string | null
reasonrequired
string | null
createdAtrequired
string

Format: date-time.

decidedAtrequired
string | null

Format: date-time.

runrequired
object
run.idrequired
string
run.statusrequired
string

One of "queued", "running", "awaiting_approval", "completed", "archived", "rejected", "failed", "dead_letter".

run.companyrequired
string | null
run.namerequired
string | null

ApprovalList

Fields of ApprovalList
FieldNotes
datarequired

ApprovalDecisionRequest

Fields of ApprovalDecisionRequest
FieldNotes
decisionrequired
string

The reviewer decision.

One of "approved", "rejected".

note
string

Optional note, shown in the run timeline. Emails and phone numbers are masked.

max 280 chars.

ApprovalDecisionResult

Fields of ApprovalDecisionResult
FieldNotes
idrequired
string
runIdrequired
string
statusrequired
string

One of "pending", "approved", "rejected", "auto_approved".

appliedrequired
boolean

True when this call recorded the decision. False when the same decision was already stored (a repeat click or a Slack retry).

decidedByrequired
string | null
runrequired
object
run.idrequired
string
run.statusrequired
string

One of "queued", "running", "awaiting_approval", "completed", "archived", "rejected", "failed", "dead_letter".

linksrequired
object
links.runrequired
string
links.eventsrequired
string
links.approvalsrequired
string

SlackAck

Slack only needs a 200. A decision, an ignored payload and a lost race all answer 200 and say which in the body.

One of these shapes:

ApprovalDecisionResult

ApprovalDecisionResult

ok = true

SlackAck, ok = true
FieldNotes
okrequired
true
ignoredrequired
true

ok = true

SlackAck, ok = true
FieldNotes
okrequired
true
appliedrequired
false
errorrequired

ChaosSetRequest

Fields of ChaosSetRequest
FieldNotes
keyrequired
string

Which failure to simulate.

One of "crm_outage", "llm_timeout", "slack_outage".

enabledrequired
boolean

true turns the failure on, false turns it off.

ChaosSwitch

Fields of ChaosSwitch
FieldNotes
keyrequired
string

One of "crm_outage", "llm_timeout", "slack_outage".

labelrequired
string
breaksrequired
string

What this switch breaks, in a sentence.

enabledrequired
boolean
expiresAtrequired
string | null

When an enabled switch turns itself off. Null while it is off.

Format: date-time.

ChaosState

Fields of ChaosState
FieldNotes
datarequired
scoperequired
"global"

The switches are shared by every visitor and every run.

ttlMinutesrequired
number

A switch turns itself off after this long.

N8nWorkflow

An n8n workflow export. Import it from the n8n editor.

Fields of N8nWorkflow
FieldNotes
namerequired
string
nodesrequired
object[]
connectionsrequired
object
active
boolean
settings
object