CestraDocs Open app

REST API

The API is HTTPS and JSON. The base URL is https://api.cestra.ai, every path below starts with /v1, and request bodies are application/json unless stated otherwise.

#Authentication

Send your agent's key on every request:

HTTP
Authorization: Bearer cs_live_...

X-Cestra-Key: cs_live_... works too. A key belongs to exactly one agent. There's no agent id in the URLs, because the key identifies the agent.

Shell
curl https://api.cestra.ai/v1/me -H "Authorization: Bearer $CESTRA_KEY"
JSON
{
  "agent": { "id": "8c1e…", "name": "Release bot", "description": "" },
  "members": [{ "id": "u_…", "name": "Ada", "role": "owner" }],
  "event_cursor": 41
}

#Errors

Errors always have this shape:

JSON
{ "error": { "code": "bad_request", "message": "options: An ask needs at least one option or allow_text: true", "details": [{ "path": "options", "message": "…" }] } }
Status code When
400 bad_request The body failed validation. details lists the problems
401 invalid_key The key is missing, malformed or revoked, or the owner is disabled
404 not_found No such ask, job or file for this agent
409 conflict The ask is no longer pending, or the job already finished
413 too_large The file is over 50 MB or the owner's storage quota is used up
415 unsupported_media_type Wrong Content-Type
429 rate_limited Over 60 requests per minute. Wait for the Retry-After seconds
500 internal Our fault. Retry with backoff

#Rate limit

Each key gets 60 requests per minute in a fixed window. A file upload costs 5 and a download costs 2. When you hit the limit you get a 429 with a Retry-After header in seconds (also in error.details.retry_after). A long-poll that waits 30 seconds still costs only 1.

#Idempotency

Every create (messages, notices, asks, jobs, artifacts) accepts a client_id. It can be 1–100 characters of letters, digits and . _ : -. If you send the same client_id again for the same agent, you get the original object back (status 200 instead of 201) and nothing is duplicated. Generate one UUID per logical action and reuse it when you retry. The SDKs do this for you. File uploads don't take a client_id.

#Messages

#POST /v1/messages

Shell
curl https://api.cestra.ai/v1/messages \
  -H "Authorization: Bearer $CESTRA_KEY" -H "Content-Type: application/json" \
  -d '{"text":"Build **passed** in 3m 12s","client_id":"build-1842"}'
Field Type
text string, 1–20 000 Markdown
client_id string Optional, see idempotency
notify boolean false skips push and email alerts

Response 201: { "id": "…", "created_at": "2026-11-06T09:12:00.000Z" }

#POST /v1/notices

A one-line status. tone is info (default), success, warning or error.

JSON
{ "text": "Backup done", "tone": "success" }

Response 201: { "id": "…" }

#Asks

#POST /v1/asks

Shell
curl https://api.cestra.ai/v1/asks \
  -H "Authorization: Bearer $CESTRA_KEY" -H "Content-Type: application/json" \
  -d '{
    "question": "Commit to main?",
    "detail": "3 files changed",
    "options": ["Approve", {"label": "Discard", "style": "destructive"}],
    "allow_text": false,
    "expires_in": 3600
  }'
Field Type
question string, 1–2 000
detail string ≤ 2 000 Optional extra text under the question
options array, ≤ 4 Labels (≤ 60 chars, unique) or {label, style} with style primary, secondary or destructive
allow_text boolean Adds a text box. An ask needs options, allow_text: true, or both
expires_in integer seconds 60 to 2 592 000 (30 days). Optional
client_id, notify As above

Response 201:

JSON
{
  "id": "2f0c…",
  "question": "Commit to main?",
  "status": "pending",
  "answer": null, "option_id": null, "text": null,
  "answered_at": null, "answered_by": null,
  "expires_at": "2026-11-06T10:12:00.000Z",
  "options": [
    { "id": "o1", "label": "Approve", "style": "primary" },
    { "id": "o2", "label": "Discard", "style": "destructive" }
  ]
}

Option ids are o1, o2, … in the order you sent them. Anyone the agent is shared with (and who can chat) can answer, and the first answer wins.

#GET /v1/asks/:id?wait=seconds

Returns the ask's current state. With wait (0–600), the request blocks until the ask stops being pending or the time runs out.

Shell
curl "https://api.cestra.ai/v1/asks/2f0c…?wait=60" -H "Authorization: Bearer $CESTRA_KEY"
JSON
{
  "id": "2f0c…", "question": "Commit to main?", "status": "answered",
  "answer": "Approve", "option_id": "o1", "text": null,
  "answered_at": "2026-11-06T09:13:05.000Z", "answered_by": "u_…", "expires_at": "…"
}

status is pending, answered, expired or cancelled. For a typed answer, answer and text hold the text and option_id is null. Set your HTTP client's timeout longer than wait.

#POST /v1/asks/:id/cancel

Withdraws a pending ask and returns its state with status: "cancelled". You get a 409 if it was already answered, expired or cancelled.

#Jobs

#POST /v1/jobs

JSON
{ "title": "Reviewing spec", "steps": ["Read", "Compare", "Write up"] }

Up to 30 steps. The first step starts as running. Response 201:

JSON
{ "id": "9a7d…", "status": "running", "title": "Reviewing spec", "pct": null,
  "steps": [{ "label": "Read", "status": "running" }, { "label": "Compare", "status": "todo" }, { "label": "Write up", "status": "todo" }],
  "started_at": "…" }

#PATCH /v1/jobs/:id

Send any of these fields:

Field
pct 0–100
title New title
step {index, status, label?}. Sets one step. index may equal the step count to append one
steps [{label, status}]. Replaces all steps
status done, failed or cancelled ends the job
error Text shown on a failed job, ≤ 1 000 chars

Step status is one of todo, running, done or failed.

Shell
curl -X PATCH https://api.cestra.ai/v1/jobs/9a7d… \
  -H "Authorization: Bearer $CESTRA_KEY" -H "Content-Type: application/json" \
  -d '{"pct": 40, "step": {"index": 0, "status": "done"}}'

The response is the full job state. Finishing with done sets pct to 100 (if you ever set one) and marks every non-failed step done. Finishing with failed marks the running step failed. Updating a job that has already ended returns a 409. That includes a job someone stopped in the app, which is then cancelled.

#Artifacts

#POST /v1/artifacts

JSON
{
  "type": "table",
  "title": "Coverage matrix",
  "key": "matrix",
  "summary": "12 of 14 covered",
  "data": { "columns": ["Requirement", "Status"], "rows": [["Login", "ok"]] }
}

type is table, chart, markdown, form or html. HTML pages use html (a string) instead of data. Sending the same key again creates a new version of the same artifact. Without a key, every artifact is new. Each type's schema is on Artifacts.

Response 201: { "id": "…", "message_id": "…", "key": "matrix", "version": 2 }

#Files

#POST /v1/files

Send multipart/form-data with the file in a field named file. An optional caption field (≤ 500 chars) must come before the file part. The size limit is 50 MB.

Shell
curl https://api.cestra.ai/v1/files \
  -H "Authorization: Bearer $CESTRA_KEY" \
  -F caption="Quote for ACME" -F file=@report.pdf

Response 201: { "id": "…", "message_id": "…", "name": "report.pdf", "size": 48213, "mime": "application/pdf", "sha256": "…" }

#GET /v1/files/:id

Downloads any file in the agent's thread: one it uploaded, or one a person sent it. The body is the raw bytes, served as Content-Type: application/octet-stream. The original name is in Content-Disposition and the real type is in X-Cestra-Mime.

Shell
curl -OJ https://api.cestra.ai/v1/files/5b3e… -H "Authorization: Bearer $CESTRA_KEY"

#Events

Everything people do that the agent should know about becomes an event in the agent's outbox. Events stay there until you acknowledge them, so you don't miss anything while your agent is offline.

#GET /v1/events?after=&wait=&limit=

Shell
curl "https://api.cestra.ai/v1/events?wait=30" -H "Authorization: Bearer $CESTRA_KEY"
JSON
{
  "events": [
    { "seq": 42, "type": "message", "created_at": "…", "data": { "text": "Use the staging DB", "from": { "id": "u_…", "name": "Ada" }, "message_id": "…" } },
    { "seq": 43, "type": "ask.answered", "created_at": "…", "data": { "ask_id": "2f0c…", "question": "Commit to main?", "answer": "Approve", "option_id": "o1", "text": null, "by": { "id": "u_…", "name": "Ada" } } }
  ],
  "cursor": 43
}
Param
after The last seq you finished processing. Passing it acknowledges every event up to and including it. Leave it out to resume from the server-stored cursor (where you last acknowledged)
wait 0–60. If there are no events, block up to this many seconds for one
limit 1–100, default 50

cursor is the last returned seq. If nothing came back, it's the position you asked from.

The loop: call without after once, handle the events, then call again with after=<cursor>. That acknowledges the batch and waits for the next one. If your process crashes before the next call, the unacknowledged events come back. Delivery is at-least-once, so make your handlers tolerate a repeat. A person's message shows as "queued" in the app until the agent acknowledges it.

#POST /v1/events/ack

Acknowledges without fetching. Useful before shutting down.

JSON
{ "seq": 43 }

Response: { "ok": true }

#Event types

type data
message {text, from: {id, name}, message_id} for text, or {file: {id, name, size, mime}, from, message_id} for an upload
ask.answered {ask_id, question, answer, option_id, text, by: {id, name}}
ask.expired {ask_id, question}
job.stop_requested {job_id, title, by: {id, name}}. The job is already cancelled, so stop working on it
form.submitted {artifact_id, key, version, title, values, by: {id, name}}
member.joined {user: {id, name}}

#Presence

An agent shows as online while it's in contact (any request, an open long-poll or a WebSocket). It shows as working while a job is running. It goes offline about 90 seconds after the last contact.

Endpoint Body
POST /v1/status {"status": "working"} or "online" Shows working without a job
POST /v1/disconnect {} Goes offline now instead of timing out

#WebSocket

wss://api.cestra.ai/v1/ws pushes events as they happen. All writes still go over REST.

Authenticate with the Authorization: Bearer header on the upgrade request, or (for clients that can't set headers) send this as the first message within 10 seconds:

JSON
{ "type": "auth", "key": "cs_live_...", "after": 41 }

after is optional. Without it, the socket starts from the server-stored cursor.

The server then sends:

JSON
{ "type": "hello", "agent": { "id": "…", "name": "Release bot" }, "cursor": 41 }
{ "type": "event", "event": { "seq": 42, "type": "message", "created_at": "…", "data": { … } } }
{ "type": "pong" }
{ "type": "error", "code": "invalid_key" }

The client sends:

Message
{"type": "ack", "seq": 42} Acknowledges through 42. Send it after handling each event
{"type": "ping"} Keeps presence fresh. The answer is {"type": "pong"}. Every 30 s is plenty

Events that were waiting are sent right after hello, and new ones arrive as they happen. The server also sends WebSocket ping frames every 25 seconds and drops connections that don't answer them.

Close codes: 4401 means the key is invalid, the auth timed out, or the key was revoked while connected. Don't reconnect with the same key. 4429 means rate limited, so reconnect after a delay. For anything else, reconnect with backoff. On reconnect, delivery resumes after the last event you acknowledged.

Error codes in {"type": "error"} are invalid_key, unauthorized (a message arrived before auth), bad_json, rate_limited and internal.

#All endpoints

Method Path
GET /v1/me Agent, members, event cursor
POST /v1/messages Send a message
POST /v1/notices Send a notice
POST /v1/asks Ask a question
GET /v1/asks/:id?wait= Ask state, optionally long-polled
POST /v1/asks/:id/cancel Withdraw a pending ask
POST /v1/jobs Start a job
PATCH /v1/jobs/:id Update or finish a job
POST /v1/artifacts Send an artifact (same key = new version)
POST /v1/files Upload a file (multipart)
GET /v1/files/:id Download a file
GET /v1/events?after=&wait= Read and acknowledge events
POST /v1/events/ack Acknowledge events
POST /v1/status online or working
POST /v1/disconnect Go offline
GET /v1/ws WebSocket event stream