API Reference

Complete HTTP API for the Clawback control-plane service. All endpoints live on the control-plane server (default http://localhost:3001).

Audience: Developers integrating with Clawback programmatically.

Related guides: Getting Started | Admin Guide | Security Overview


Authentication

Clawback uses session cookies for authentication. The flow is:

  1. Login (POST /api/auth/login) with email and password.
  2. The response sets a signed clawback_session cookie and returns a CSRF token.
  3. Include the cookie on all subsequent requests (browsers do this automatically; with curl, use -b / -c to manage a cookie jar).

The session cookie is:

PropertyValue
Nameclawback_session
httpOnlytrue
signedtrue
sameSitelax
securetrue in production, false in development
maxAge7 days (604800 seconds)

Login example

# Login and save cookies to a jar file
curl -c cookies.txt -X POST http://localhost:3001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "password"}'

Response:

{
  "user": {
    "id": "usr_abc123",
    "email": "admin@example.com",
    "display_name": "Admin"
  },
  "workspace": {
    "id": "ws_xyz789",
    "slug": "acme",
    "name": "Acme Corp"
  },
  "membership": {
    "role": "admin"
  },
  "csrf_token": "a1b2c3d4e5f6"
}

Save the csrf_token from the response -- you need it for all mutating requests.


CSRF Protection

All mutating requests (POST, PATCH) require a CSRF token sent via the x-csrf-token header. The token is returned in every authenticated session response (login, bootstrap, session refresh).

curl -b cookies.txt -X POST http://localhost:3001/api/agents \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"name": "My Agent", "scope": "shared"}'

If the CSRF token is missing or invalid, the server returns 403 Forbidden.


Error Format

All errors follow this shape:

{
  "code": "unauthorized",
  "error": "Authentication is required."
}

Zod validation failures return:

{
  "error": "Invalid request payload."
}

Common error codes:

HTTP StatuscodeMeaning
400(none)Validation error (malformed body/params)
401unauthorizedMissing or invalid session
403forbiddenInsufficient permissions (e.g. not admin)
403(none)Missing or invalid CSRF token
404not_foundResource does not exist
409conflictConflict (e.g. workspace already exists)
500(none)Internal server error

Endpoints

Health Check

GET /healthz

Returns service health status. No authentication required.

Response:

{
  "ok": true,
  "service": "control-plane"
}

Setup

GET /api/setup/status

Check whether the workspace has been bootstrapped (first admin created). No authentication required.

Response:

{
  "bootstrapped": false
}

POST /api/setup/bootstrap-admin

Create the first admin user and workspace. Only works when bootstrapped is false.

Request body:

{
  "workspace_name": "Acme Corp",
  "workspace_slug": "acme",
  "email": "admin@example.com",
  "display_name": "Admin",
  "password": "password"
}
FieldTypeRequiredNotes
workspace_namestringyesHuman-readable name
workspace_slugstringyesURL-safe identifier
emailstringyesValid email address
display_namestringyesUser's display name
passwordstringyesMinimum 8 characters

Response (201): Same shape as the login response -- sets a session cookie and returns csrf_token.

curl:

curl -c cookies.txt -X POST http://localhost:3001/api/setup/bootstrap-admin \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_name": "Acme Corp",
    "workspace_slug": "acme",
    "email": "admin@example.com",
    "display_name": "Admin",
    "password": "password"
  }'

Errors:

StatusCodeWhen
409conflictWorkspace is already bootstrapped

Auth

POST /api/auth/login

Authenticate with email and password.

Request body:

{
  "email": "admin@example.com",
  "password": "password"
}
FieldTypeRequiredNotes
emailstringyesValid email address
passwordstringyesMinimum 8 characters

Response (200):

{
  "user": {
    "id": "usr_abc123",
    "email": "admin@example.com",
    "display_name": "Admin"
  },
  "workspace": {
    "id": "ws_xyz789",
    "slug": "acme",
    "name": "Acme Corp"
  },
  "membership": {
    "role": "admin"
  },
  "csrf_token": "a1b2c3d4e5f6"
}

Errors:

StatusCodeWhen
401unauthorizedInvalid email or password

GET /api/auth/session

Get the current authenticated session. Requires a valid session cookie.

Response (200): Same shape as the login response (includes a fresh csrf_token).

curl:

curl -b cookies.txt http://localhost:3001/api/auth/session

Errors:

StatusCodeWhen
401unauthorizedNo valid session found

POST /api/auth/logout

End the current session. Requires CSRF token.

Request headers:

HeaderValue
x-csrf-tokenCurrent CSRF token

Response: 204 No Content (empty body).

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/auth/logout \
  -H "x-csrf-token: a1b2c3d4e5f6"

Invitations

POST /api/invitations

Create an invitation to join the workspace. Admin only. Requires CSRF token.

Request body:

{
  "email": "teammate@example.com",
  "role": "user",
  "expires_at": "2026-04-01T00:00:00.000Z"
}
FieldTypeRequiredNotes
emailstringyesInvitee's email address
rolestringyes"admin" or "user"
expires_atstringnoISO 8601 timestamp; default 7 days from creation

Response (201):

{
  "invitation": {
    "id": "inv_abc123",
    "email": "teammate@example.com",
    "role": "user",
    "expires_at": "2026-04-01T00:00:00.000Z",
    "accepted_at": null,
    "created_at": "2026-03-11T12:00:00.000Z"
  },
  "token": "invite_token_string"
}

Errors:

StatusCodeWhen
401unauthorizedNot authenticated
403forbiddenCaller is not an admin

POST /api/invitations/claim

Claim an invitation and create a user account. No session required (the invitation token authenticates the request).

Request body:

{
  "token": "invite_token_string",
  "display_name": "New User",
  "password": "securepass"
}
FieldTypeRequiredNotes
tokenstringyesInvitation token
display_namestringyesUser's display name
passwordstringyesMinimum 8 characters

Response (201): Same shape as the login response -- sets a session cookie and returns csrf_token.

Errors:

StatusCodeWhen
404not_foundInvalid or expired invitation token
409conflictInvitation already claimed

Agents

All agent endpoints require an authenticated session.

GET /api/agents

List all agents visible to the current user.

Response (200):

{
  "agents": [
    {
      "id": "agt_abc123",
      "workspace_id": "ws_xyz789",
      "name": "Support Bot",
      "slug": "support-bot",
      "scope": "shared",
      "status": "active",
      "owner_user_id": null,
      "created_at": "2026-03-01T10:00:00.000Z",
      "updated_at": "2026-03-10T15:30:00.000Z",
      "draft_version": {
        "id": "ver_draft1",
        "agent_id": "agt_abc123",
        "version_number": 2,
        "status": "draft",
        "published_at": null,
        "created_at": "2026-03-10T15:30:00.000Z"
      },
      "published_version": {
        "id": "ver_pub1",
        "agent_id": "agt_abc123",
        "version_number": 1,
        "status": "published",
        "published_at": "2026-03-05T12:00:00.000Z",
        "created_at": "2026-03-01T10:00:00.000Z"
      }
    }
  ]
}

curl:

curl -b cookies.txt http://localhost:3001/api/agents

POST /api/agents

Create a new agent. Requires CSRF token.

Request body:

{
  "name": "Support Bot",
  "scope": "shared"
}
FieldTypeRequiredNotes
namestringyesAgent display name
scopestringyes"personal" or "shared"

Response (201): An agent record (same shape as items in the list response).

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/agents \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"name": "Support Bot", "scope": "shared"}'

GET /api/agents/:agentId

Get a single agent by ID.

Path parameters:

ParamTypeNotes
agentIdstringAgent ID

Response (200): An agent record (same shape as items in the list response).


PATCH /api/agents/:agentId

Update an agent's name or status. Requires CSRF token.

Request body (all fields optional):

{
  "name": "Renamed Bot",
  "status": "archived"
}
FieldTypeRequiredNotes
namestringnoNew display name
statusstringno"active" or "archived"

Response (200): The updated agent record.


GET /api/agents/:agentId/draft

Get the current draft version of an agent, including its full configuration.

Response (200):

{
  "agent": {
    "id": "agt_abc123",
    "workspace_id": "ws_xyz789",
    "name": "Support Bot",
    "slug": "support-bot",
    "scope": "shared",
    "status": "active",
    "owner_user_id": null,
    "created_at": "2026-03-01T10:00:00.000Z",
    "updated_at": "2026-03-10T15:30:00.000Z"
  },
  "draft": {
    "id": "ver_draft1",
    "agent_id": "agt_abc123",
    "version_number": 2,
    "status": "draft",
    "published_at": null,
    "created_at": "2026-03-10T15:30:00.000Z",
    "persona": {},
    "instructions_markdown": "You are a helpful support assistant.",
    "model_routing": {
      "provider": "openai",
      "model": "gpt-4o"
    },
    "tool_policy": {
      "mode": "allow_list",
      "allowed_tools": []
    },
    "connector_policy": {
      "enabled": false,
      "connector_ids": []
    }
  },
  "published_version": null
}

PATCH /api/agents/:agentId/draft

Update the draft version of an agent. Requires CSRF token. All fields are optional -- only provided fields are changed.

Request body:

{
  "instructions_markdown": "You are a support assistant for Acme Corp.",
  "model_routing": {
    "provider": "openai",
    "model": "gpt-4o"
  },
  "tool_policy": {
    "mode": "allow_list",
    "allowed_tools": ["search", "calculator"]
  },
  "connector_policy": {
    "enabled": true,
    "connector_ids": ["conn_abc"]
  }
}
FieldTypeRequiredNotes
personaobjectnoKey-value persona metadata
instructions_markdownstringnoSystem instructions in Markdown
model_routingobjectno{ provider: string, model: string }
tool_policyobjectno{ mode: "allow_list", allowed_tools: string[] }
connector_policyobjectno{ enabled: boolean, connector_ids: string[] }

Response (200): The full draft detail (same shape as GET /api/agents/:agentId/draft).

curl:

curl -b cookies.txt -X PATCH http://localhost:3001/api/agents/agt_abc123/draft \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"instructions_markdown": "You are a support assistant for Acme Corp."}'

POST /api/agents/:agentId/publish

Publish the current draft as a new version. Requires CSRF token. The expected_draft_version_id prevents accidental overwrites when multiple users edit concurrently.

Request body:

{
  "expected_draft_version_id": "ver_draft1"
}
FieldTypeRequiredNotes
expected_draft_version_idstringyesMust match the current draft version ID

Response (200):

{
  "agent": { "...agent summary..." },
  "published_version": {
    "id": "ver_pub2",
    "agent_id": "agt_abc123",
    "version_number": 2,
    "status": "published",
    "published_at": "2026-03-11T09:00:00.000Z",
    "created_at": "2026-03-10T15:30:00.000Z"
  },
  "draft_version": { "...new draft record..." },
  "runtime_publication": {
    "status": "materialized",
    "runtime_agent_id": "rt_agent_abc",
    "detail": null
  }
}

The runtime_publication field indicates whether the published version has been materialized in the runtime. Possible statuses: "pending", "materialized", "restart_required", "failed".

Errors:

StatusCodeWhen
409conflictDraft version ID does not match current draft

Conversations

All conversation endpoints require an authenticated session.

GET /api/conversations

List conversations, optionally filtered by agent.

Query parameters:

ParamTypeRequiredNotes
agent_idstringnoFilter to a specific agent

Response (200):

{
  "conversations": [
    {
      "id": "conv_abc123",
      "workspace_id": "ws_xyz789",
      "agent_id": "agt_abc123",
      "agent_version_id": "ver_pub1",
      "channel": "web",
      "started_by": "usr_abc123",
      "status": "active",
      "title": null,
      "last_message_at": "2026-03-11T10:00:00.000Z",
      "created_at": "2026-03-11T09:55:00.000Z",
      "updated_at": "2026-03-11T10:00:00.000Z"
    }
  ]
}

curl:

# All conversations
curl -b cookies.txt http://localhost:3001/api/conversations

# Filtered by agent
curl -b cookies.txt "http://localhost:3001/api/conversations?agent_id=agt_abc123"

POST /api/conversations

Start a new conversation with an agent. Requires CSRF token.

Request body:

{
  "agent_id": "agt_abc123"
}
FieldTypeRequiredNotes
agent_idstringyesID of the agent to talk to

Response (201): A conversation record (same shape as items in the list response).


GET /api/conversations/:conversationId

Get a conversation with its full message transcript.

Path parameters:

ParamTypeNotes
conversationIdstringConversation ID

Response (200):

{
  "conversation": {
    "id": "conv_abc123",
    "workspace_id": "ws_xyz789",
    "agent_id": "agt_abc123",
    "agent_version_id": "ver_pub1",
    "channel": "web",
    "started_by": "usr_abc123",
    "status": "active",
    "title": null,
    "last_message_at": "2026-03-11T10:05:00.000Z",
    "created_at": "2026-03-11T09:55:00.000Z",
    "updated_at": "2026-03-11T10:05:00.000Z"
  },
  "messages": [
    {
      "id": "msg_001",
      "workspace_id": "ws_xyz789",
      "conversation_id": "conv_abc123",
      "run_id": null,
      "sequence": 0,
      "role": "user",
      "author_user_id": "usr_abc123",
      "content": [{ "type": "text", "text": "Hello, I need help." }],
      "citations": null,
      "token_usage": null,
      "created_at": "2026-03-11T10:00:00.000Z"
    },
    {
      "id": "msg_002",
      "workspace_id": "ws_xyz789",
      "conversation_id": "conv_abc123",
      "run_id": "run_abc123",
      "sequence": 1,
      "role": "assistant",
      "author_user_id": null,
      "content": [{ "type": "text", "text": "Hi! How can I help you today?" }],
      "citations": null,
      "token_usage": { "prompt_tokens": 42, "completion_tokens": 12 },
      "created_at": "2026-03-11T10:00:05.000Z"
    }
  ]
}

Runs

All run endpoints require an authenticated session.

POST /api/runs

Create a new run (send a message to an agent in a conversation). Requires CSRF token.

Request body:

{
  "conversation_id": "conv_abc123",
  "input": {
    "type": "text",
    "text": "What is the refund policy?"
  }
}
FieldTypeRequiredNotes
conversation_idstringyesConversation to add the message to
input.typestringyesAlways "text"
input.textstringyesThe user's message text (minimum 1 character)

Response (201):

{
  "run_id": "run_abc123",
  "conversation_id": "conv_abc123",
  "input_message_id": "msg_003",
  "stream_url": "/api/runs/run_abc123/stream"
}

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/runs \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{
    "conversation_id": "conv_abc123",
    "input": { "type": "text", "text": "What is the refund policy?" }
  }'

GET /api/runs/:runId

Get the status and metadata of a run.

Path parameters:

ParamTypeNotes
runIdstringRun ID

Response (200):

{
  "id": "run_abc123",
  "workspace_id": "ws_xyz789",
  "agent_id": "agt_abc123",
  "agent_version_id": "ver_pub1",
  "conversation_id": "conv_abc123",
  "input_message_id": "msg_003",
  "initiated_by": "usr_abc123",
  "channel": "web",
  "status": "completed",
  "started_at": "2026-03-11T10:05:01.000Z",
  "completed_at": "2026-03-11T10:05:08.000Z",
  "current_step": null,
  "summary": "The refund policy allows returns within 30 days.",
  "created_at": "2026-03-11T10:05:00.000Z",
  "updated_at": "2026-03-11T10:05:08.000Z"
}

Run statuses: "queued", "running", "waiting_for_approval", "completed", "failed", "canceled".


GET /api/runs/:runId/events

Get all domain events for a run. Useful for debugging or replaying the run lifecycle.

Response (200):

{
  "events": [
    {
      "event_id": "evt_001",
      "event_type": "run.created",
      "workspace_id": "ws_xyz789",
      "run_id": "run_abc123",
      "sequence": 1,
      "occurred_at": "2026-03-11T10:05:00.000Z",
      "actor": { "type": "user", "id": "usr_abc123" },
      "payload": {}
    },
    {
      "event_id": "evt_002",
      "event_type": "run.claimed",
      "workspace_id": "ws_xyz789",
      "run_id": "run_abc123",
      "sequence": 2,
      "occurred_at": "2026-03-11T10:05:01.000Z",
      "actor": { "type": "service", "id": "worker-1" },
      "payload": {}
    },
    {
      "event_id": "evt_003",
      "event_type": "run.output.delta",
      "workspace_id": "ws_xyz789",
      "run_id": "run_abc123",
      "sequence": 5,
      "occurred_at": "2026-03-11T10:05:03.000Z",
      "actor": { "type": "service", "id": "worker-1" },
      "payload": { "delta": "The refund policy " }
    }
  ]
}

Event types (in lifecycle order):

Event TypeDescription
run.createdRun was queued
run.snapshot.createdImmutable run snapshot was persisted
run.claimedWorker claimed the queue job
run.dispatch.acceptedRuntime accepted the run dispatch
run.model.startedModel execution started
run.output.deltaStreamed output chunk from the model
run.tool.requestedTool invocation requested
run.tool.completedTool invocation completed
run.waiting_for_approvalRun paused pending human approval
run.approval.resolvedApproval decision recorded
run.completedRun finished successfully
run.failedRun failed with an error

Approvals

All approval endpoints require an authenticated session.

GET /api/approvals

List all approval requests visible to the current user. Typically used by admins to review pending tool invocations that require human sign-off.

Response (200):

{
  "approvals": [
    {
      "id": "apr_abc123",
      "workspace_id": "ws_xyz789",
      "run_id": "run_abc123",
      "tool_invocation_id": "tinv_abc123",
      "tool_name": "create_ticket",
      "action_type": "ticket.create",
      "risk_class": "approval_gated",
      "status": "pending",
      "requested_by": "usr_abc123",
      "approver_scope": {
        "mode": "workspace_admin",
        "allowed_roles": ["admin"]
      },
      "request_payload": {},
      "decision_due_at": null,
      "resolved_at": null,
      "created_at": "2026-03-11T10:00:00.000Z",
      "updated_at": "2026-03-11T10:00:00.000Z"
    }
  ]
}

Key fields:

FieldTypeNotes
idstringApproval request ID
run_idstringThe run that triggered the approval
tool_invocation_idstringThe specific tool call awaiting approval
tool_namestringName of the tool being invoked
action_typestringCategorized action type
risk_classstring"safe", "guarded", "approval_gated", or "restricted"
statusstring"pending", "approved", "denied", "expired", or "canceled"
approver_scopeobjectWho is allowed to resolve; currently always workspace_admin
request_payloadobjectTool-specific context for the reviewer

curl:

curl -b cookies.txt http://localhost:3001/api/approvals

GET /api/approvals/:approvalId

Get a single approval request along with its decision history.

Path parameters:

ParamTypeNotes
approvalIdstringApproval request ID

Response (200):

{
  "approval": {
    "id": "apr_abc123",
    "workspace_id": "ws_xyz789",
    "run_id": "run_abc123",
    "tool_invocation_id": "tinv_abc123",
    "tool_name": "create_ticket",
    "action_type": "ticket.create",
    "risk_class": "approval_gated",
    "status": "approved",
    "requested_by": "usr_abc123",
    "approver_scope": {
      "mode": "workspace_admin",
      "allowed_roles": ["admin"]
    },
    "request_payload": {},
    "decision_due_at": null,
    "resolved_at": "2026-03-11T10:05:00.000Z",
    "created_at": "2026-03-11T10:00:00.000Z",
    "updated_at": "2026-03-11T10:05:00.000Z"
  },
  "decisions": [
    {
      "id": "adec_abc123",
      "workspace_id": "ws_xyz789",
      "approval_request_id": "apr_abc123",
      "run_id": "run_abc123",
      "decision": "approved",
      "decided_by": "usr_abc123",
      "rationale": "Looks correct, proceed.",
      "payload": {},
      "occurred_at": "2026-03-11T10:05:00.000Z",
      "created_at": "2026-03-11T10:05:00.000Z"
    }
  ]
}

curl:

curl -b cookies.txt http://localhost:3001/api/approvals/apr_abc123

POST /api/approvals/:approvalId/resolve

Approve or deny a pending approval request. Admin only. Requires CSRF token.

Path parameters:

ParamTypeNotes
approvalIdstringApproval request ID

Request body:

{
  "decision": "approved",
  "rationale": "Looks correct, proceed."
}
FieldTypeRequiredNotes
decisionstringyes"approved" or "denied"
rationalestringnoFree-text explanation (max 2000 chars), nullable

Response (200): Same shape as GET /api/approvals/:approvalId -- returns the updated approval and its decisions.

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/approvals/apr_abc123/resolve \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"decision": "approved", "rationale": "Looks correct, proceed."}'

Errors:

StatusCodeWhen
401unauthorizedNot authenticated
403forbiddenCaller is not an admin
404not_foundApproval request does not exist

Approval Surfaces

Approval-surface endpoints manage external approver identities and allow runtime channels such as WhatsApp to resolve the same review records used by the web UI.

GET /api/workspace/approval-surfaces/identities

List configured approval-surface identities for the current workspace.

Response (200):

{
  "identities": [
    {
      "id": "asid_abc123",
      "workspace_id": "ws_xyz789",
      "channel": "whatsapp",
      "user_id": "usr_abc123",
      "external_identity": "+15551234567",
      "label": "Dave mobile",
      "status": "allowed",
      "created_at": "2026-03-22T10:00:00.000Z",
      "updated_at": "2026-03-22T10:00:00.000Z"
    }
  ]
}
FieldTypeNotes
channelstringCurrently only "whatsapp"
user_idstringWorkspace user/person who may act through this surface
external_identitystringNormalized external identity, e.g. phone number
labelstringOperator-friendly display label
statusstring"allowed" or "disabled"

curl:

curl -b cookies.txt http://localhost:3001/api/workspace/approval-surfaces/identities

POST /api/workspace/approval-surfaces/identities

Create or upsert an approval-surface identity. Admin only. Requires CSRF token.

Request body:

{
  "channel": "whatsapp",
  "user_id": "usr_abc123",
  "external_identity": "+15551234567",
  "label": "Dave mobile"
}
FieldTypeRequiredNotes
channelstringyesCurrently only "whatsapp"
user_idstringyesMust match a real workspace person
external_identitystringyesExternal actor identifier, max 256 chars
labelstringnoFriendly label, max 256 chars

Response (201):

{
  "id": "asid_abc123",
  "workspace_id": "ws_xyz789",
  "channel": "whatsapp",
  "user_id": "usr_abc123",
  "external_identity": "+15551234567",
  "label": "Dave mobile",
  "status": "allowed",
  "created_at": "2026-03-22T10:00:00.000Z",
  "updated_at": "2026-03-22T10:00:00.000Z"
}

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/workspace/approval-surfaces/identities \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{
    "channel": "whatsapp",
    "user_id": "usr_abc123",
    "external_identity": "+15551234567",
    "label": "Dave mobile"
  }'

Errors:

StatusCodeWhen
400invalid_person_iduser_id does not map to a workspace person
401unauthorizedNot authenticated
403forbiddenCaller is not an admin

PATCH /api/workspace/approval-surfaces/identities/:id

Update an approval-surface identity. Admin only. Requires CSRF token.

Path parameters:

ParamTypeNotes
idstringApproval-surface identity ID

Request body:

{
  "label": "Dave phone",
  "status": "disabled"
}

All fields are optional:

FieldTypeNotes
external_identitystringReplace the normalized external identity
labelstringReplace the friendly label
statusstring"allowed" or "disabled"

Response (200): Same shape as the identity record returned from POST.

curl:

curl -b cookies.txt -X PATCH http://localhost:3001/api/workspace/approval-surfaces/identities/asid_abc123 \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"status": "disabled"}'

POST /api/runtime/reviews/:id/approval-surfaces/whatsapp/resolve

Resolve a pending review through the WhatsApp approval surface. This is a runtime-authenticated endpoint for channel or webhook infrastructure, not a browser session endpoint.

Path parameters:

ParamTypeNotes
idstringReview ID

Request body:

{
  "approval_token": "cb_wa_approval_abc123",
  "actor_identity": "+15551234567",
  "rationale": "Ship it",
  "interaction_id": "wamid.HBgN..."
}
FieldTypeRequiredNotes
approval_tokenstringyesSigned token for a specific review, actor, and decision
actor_identitystringyesMust match the normalized identity encoded in the token
rationalestringnoOptional explanation, max 2000 chars, nullable
interaction_idstringnoOptional channel interaction/message ID

Response (200):

{
  "review": {
    "id": "rev_abc123",
    "workspace_id": "ws_xyz789",
    "action_kind": "send_email",
    "worker_id": "wrk_abc123",
    "work_item_id": "wi_abc123",
    "source_route_kind": "forward_email",
    "action_destination": "smtp_relay",
    "status": "approved",
    "reviewer_ids": ["usr_abc123"],
    "assignee_ids": [],
    "requested_at": "2026-03-22T10:00:00.000Z",
    "resolved_at": "2026-03-22T10:05:00.000Z",
    "created_at": "2026-03-22T10:00:00.000Z",
    "updated_at": "2026-03-22T10:05:00.000Z"
  },
  "decision": {
    "id": "rdec_abc123",
    "workspace_id": "ws_xyz789",
    "review_id": "rev_abc123",
    "surface": "whatsapp",
    "decision": "approved",
    "decided_by_user_id": "usr_abc123",
    "actor_external_id": "+15551234567",
    "rationale": "Ship it",
    "payload": {
      "approval_surface_identity_id": "asid_abc123",
      "interaction_id": "wamid.HBgN..."
    },
    "occurred_at": "2026-03-22T10:05:00.000Z",
    "created_at": "2026-03-22T10:05:00.000Z"
  },
  "already_resolved": false
}

curl:

curl -X POST http://localhost:3001/api/runtime/reviews/rev_abc123/approval-surfaces/whatsapp/resolve \
  -H "Content-Type: application/json" \
  -H "x-clawback-runtime-api-token: clawback-local-runtime-api-token" \
  -d '{
    "approval_token": "cb_wa_approval_abc123",
    "actor_identity": "+15551234567",
    "rationale": "Ship it",
    "interaction_id": "wamid.HBgN..."
  }'

Notes:

  • The same review-resolution service is used by both the web UI and WhatsApp.
  • Repeated callbacks are harmless. If the review is already resolved, the route returns the current review plus already_resolved: true.
  • A mismatched token and route review ID returns 400 with code review_id_mismatch.
  • Actor identity must both match the signed token and belong to an allowed, eligible approval-surface identity.

Tickets

Ticket endpoints provide visibility into tickets created by agent tool invocations. These are admin-only endpoints under the /api/admin prefix.

GET /api/admin/mock-tickets

List all tickets in the workspace.

Response (200):

{
  "tickets": [
    {
      "id": "tkt_abc123",
      "workspace_id": "ws_xyz789",
      "run_id": "run_abc123",
      "approval_request_id": "apr_abc123",
      "provider": "mock",
      "status": "created",
      "external_ref": null,
      "title": "Billing discrepancy for customer #4821",
      "summary": "Customer was double-charged on 2026-03-10.",
      "body": {},
      "created_by": null,
      "created_at": "2026-03-11T10:10:00.000Z",
      "updated_at": "2026-03-11T10:10:00.000Z"
    }
  ]
}

Key fields:

FieldTypeNotes
idstringTicket ID
run_idstringThe run that created the ticket (nullable)
approval_request_idstringLinked approval request (nullable)
providerstringTicket provider; currently always "mock"
statusstring"draft", "created", or "failed"
external_refstringExternal system reference (nullable)
titlestringTicket title
summarystringShort description
bodyobjectProvider-specific ticket payload

curl:

curl -b cookies.txt http://localhost:3001/api/admin/mock-tickets

GET /api/admin/mock-tickets/:ticketId

Get a single ticket by ID.

Path parameters:

ParamTypeNotes
ticketIdstringTicket ID

Response (200): A single ticket record (same shape as items in the list response, without the tickets wrapper).

{
  "id": "tkt_abc123",
  "workspace_id": "ws_xyz789",
  "run_id": "run_abc123",
  "approval_request_id": "apr_abc123",
  "provider": "mock",
  "status": "created",
  "external_ref": null,
  "title": "Billing discrepancy for customer #4821",
  "summary": "Customer was double-charged on 2026-03-10.",
  "body": {},
  "created_by": null,
  "created_at": "2026-03-11T10:10:00.000Z",
  "updated_at": "2026-03-11T10:10:00.000Z"
}

curl:

curl -b cookies.txt http://localhost:3001/api/admin/mock-tickets/tkt_abc123

Connectors

Connector endpoints manage knowledge-base connectors that index local files for retrieval-augmented generation. All endpoints require an authenticated session.

GET /api/connectors

List all connectors in the workspace.

Response (200):

{
  "connectors": [
    {
      "id": "conn_abc123",
      "workspace_id": "ws_xyz789",
      "type": "local_directory",
      "name": "Product Docs",
      "status": "active",
      "config": {
        "root_path": "/data/docs",
        "recursive": true,
        "include_extensions": [".md", ".mdx", ".txt"]
      },
      "created_by": "usr_abc123",
      "created_at": "2026-03-01T10:00:00.000Z",
      "updated_at": "2026-03-10T15:30:00.000Z"
    }
  ]
}

curl:

curl -b cookies.txt http://localhost:3001/api/connectors

POST /api/connectors

Create a new connector. Requires CSRF token.

Request body:

{
  "name": "Product Docs",
  "type": "local_directory",
  "config": {
    "root_path": "/data/docs",
    "recursive": true,
    "include_extensions": [".md", ".txt"]
  }
}
FieldTypeRequiredNotes
namestringyesHuman-readable connector name
typestringyesCurrently only "local_directory"
config.root_pathstringyesAbsolute path to the directory to index
config.recursivebooleannoIndex subdirectories (default true)
config.include_extensionsstring[]noFile extensions to include (default: .md, .mdx, .txt, .json, .yaml, .yml, etc.)

Response (201): A connector record (same shape as items in the list response).

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/connectors \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{
    "name": "Product Docs",
    "type": "local_directory",
    "config": {"root_path": "/data/docs", "recursive": true}
  }'

GET /api/connectors/:connectorId

Get a single connector by ID.

Path parameters:

ParamTypeNotes
connectorIdstringConnector ID

Response (200): A connector record.

curl:

curl -b cookies.txt http://localhost:3001/api/connectors/conn_abc123

PATCH /api/connectors/:connectorId

Update a connector's name, status, or configuration. Requires CSRF token.

Request body (all fields optional):

{
  "name": "Updated Docs",
  "status": "disabled",
  "config": {
    "root_path": "/data/new-docs",
    "recursive": false
  }
}
FieldTypeRequiredNotes
namestringnoNew display name
statusstringno"active" or "disabled"
configobjectnoUpdated local_directory config

Response (200): The updated connector record.

curl:

curl -b cookies.txt -X PATCH http://localhost:3001/api/connectors/conn_abc123 \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"status": "disabled"}'

GET /api/connectors/:connectorId/sync-jobs

List sync jobs for a connector, ordered by most recent first.

Path parameters:

ParamTypeNotes
connectorIdstringConnector ID

Response (200):

{
  "sync_jobs": [
    {
      "id": "sync_abc123",
      "workspace_id": "ws_xyz789",
      "connector_id": "conn_abc123",
      "status": "completed",
      "requested_by": "usr_abc123",
      "started_at": "2026-03-11T10:00:01.000Z",
      "completed_at": "2026-03-11T10:00:15.000Z",
      "error_summary": null,
      "stats": {
        "scanned_file_count": 42,
        "indexed_document_count": 38,
        "updated_document_count": 5,
        "deleted_document_count": 0,
        "skipped_file_count": 4,
        "error_count": 0
      },
      "created_at": "2026-03-11T10:00:00.000Z",
      "updated_at": "2026-03-11T10:00:15.000Z"
    }
  ]
}

Sync job statuses: "queued", "running", "completed", "failed".

curl:

curl -b cookies.txt http://localhost:3001/api/connectors/conn_abc123/sync-jobs

POST /api/connectors/:connectorId/sync

Request a new sync job for a connector. Requires CSRF token. The sync runs asynchronously in the background.

Path parameters:

ParamTypeNotes
connectorIdstringConnector ID

Response (202):

{
  "sync_job": {
    "id": "sync_def456",
    "workspace_id": "ws_xyz789",
    "connector_id": "conn_abc123",
    "status": "queued",
    "requested_by": "usr_abc123",
    "started_at": null,
    "completed_at": null,
    "error_summary": null,
    "stats": null,
    "created_at": "2026-03-11T10:15:00.000Z",
    "updated_at": "2026-03-11T10:15:00.000Z"
  }
}

curl:

curl -b cookies.txt -X POST http://localhost:3001/api/connectors/conn_abc123/sync \
  -H "x-csrf-token: a1b2c3d4e5f6"

Streaming (SSE)

GET /api/runs/:runId/stream

Opens a Server-Sent Events (SSE) connection that streams run events in real time. Requires an authenticated session cookie.

Response headers:

Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive

Envelope format

Every SSE message is a single data: line containing a JSON envelope:

data: {"type":"assistant.delta","run_id":"run_abc123","conversation_id":"conv_abc123","sequence":5,"data":{"delta":"Hello "}}

data: {"type":"assistant.delta","run_id":"run_abc123","conversation_id":"conv_abc123","sequence":6,"data":{"delta":"world!"}}

data: {"type":"assistant.completed","run_id":"run_abc123","conversation_id":"conv_abc123","sequence":7,"data":{"assistant_text":"Hello world!"}}

The envelope schema:

{
  type: "run.status" | "assistant.delta" | "assistant.completed" | "run.failed" | "run.approval.required" | "run.approval.resolved" | "keepalive";
  run_id: string;
  conversation_id: string;
  sequence: number;  // monotonically increasing, 0-based
  data: Record<string, unknown>;
}

Event types

Envelope typeMapped fromdata contents
assistant.deltarun.output.delta{ delta: string } -- an incremental text chunk
assistant.completedrun.completed{ assistant_text: string } -- the full completed response
run.failedrun.failed{ error: string } -- error description
run.approval.requiredrun.waiting_for_approvalApproval payload -- a tool invocation is awaiting human approval
run.approval.resolvedrun.approval.resolvedResolution payload -- an approval was approved or denied
run.statusAll other event types{ event_type: string, ...payload } -- lifecycle status updates
keepalive(synthetic){} -- empty; sent every 15 seconds to keep the connection alive

Keepalive

The server sends a keepalive envelope every 15 seconds if no other events have been emitted. This prevents proxies and load balancers from closing idle connections.

Stream termination

The stream closes automatically when the run reaches a terminal state (completed or failed) and all events have been flushed. The server polls for new events every 250ms internally.

Client-side usage

Using the browser EventSource API:

const source = new EventSource(
  "http://localhost:3001/api/runs/run_abc123/stream",
  { withCredentials: true }
);

source.onmessage = (event) => {
  const envelope = JSON.parse(event.data);

  switch (envelope.type) {
    case "assistant.delta":
      // Append envelope.data.delta to the UI
      process.stdout.write(envelope.data.delta);
      break;
    case "assistant.completed":
      // Run finished -- envelope.data.assistant_text has the full response
      console.log("\nDone:", envelope.data.assistant_text);
      source.close();
      break;
    case "run.failed":
      console.error("Run failed:", envelope.data.error);
      source.close();
      break;
    case "keepalive":
      // Ignore
      break;
    case "run.status":
      // Lifecycle update (e.g. run.claimed, run.model.started)
      console.log("Status:", envelope.data.event_type);
      break;
  }
};

source.onerror = () => {
  source.close();
  // Reconnect or fall back to GET /api/runs/:runId/events for the full event log
};

Using curl:

curl -b cookies.txt -N http://localhost:3001/api/runs/run_abc123/stream

Reconnection

If the connection drops, the client can reconnect by opening a new EventSource to the same URL. The server replays all events from sequence: 0 on each new connection. Deduplicate on the client side using the sequence field.


Full Workflow Example

A complete session from login to streaming a run response:

# 1. Login
curl -c cookies.txt -X POST http://localhost:3001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "password"}'
# Save the csrf_token from the response, e.g. "a1b2c3d4e5f6"

# 2. Create an agent
curl -b cookies.txt -X POST http://localhost:3001/api/agents \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"name": "Demo Agent", "scope": "shared"}'
# Save the agent id, e.g. "agt_abc123"

# 3. Configure the draft
curl -b cookies.txt -X PATCH http://localhost:3001/api/agents/agt_abc123/draft \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"instructions_markdown": "You are a helpful assistant.", "model_routing": {"provider": "openai", "model": "gpt-4o"}}'

# 4. Publish the agent (use the draft version id from step 3)
curl -b cookies.txt -X POST http://localhost:3001/api/agents/agt_abc123/publish \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"expected_draft_version_id": "ver_draft1"}'

# 5. Start a conversation
curl -b cookies.txt -X POST http://localhost:3001/api/conversations \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"agent_id": "agt_abc123"}'
# Save the conversation id, e.g. "conv_abc123"

# 6. Send a message (create a run)
curl -b cookies.txt -X POST http://localhost:3001/api/runs \
  -H "Content-Type: application/json" \
  -H "x-csrf-token: a1b2c3d4e5f6" \
  -d '{"conversation_id": "conv_abc123", "input": {"type": "text", "text": "Hello!"}}'
# Save the run_id from the response

# 7. Stream the response
curl -b cookies.txt -N http://localhost:3001/api/runs/run_abc123/stream