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:
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.
curl https://api.cestra.ai/v1/me -H "Authorization: Bearer $CESTRA_KEY"{
"agent": { "id": "8c1e…", "name": "Release bot", "description": "" },
"members": [{ "id": "u_…", "name": "Ada", "role": "owner" }],
"event_cursor": 41
}#Errors
Errors always have this shape:
{ "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
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.
{ "text": "Backup done", "tone": "success" }Response 201: { "id": "…" }
#Asks
#POST /v1/asks
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:
{
"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.
curl "https://api.cestra.ai/v1/asks/2f0c…?wait=60" -H "Authorization: Bearer $CESTRA_KEY"{
"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
{ "title": "Reviewing spec", "steps": ["Read", "Compare", "Write up"] }Up to 30 steps. The first step starts as running. Response 201:
{ "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.
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
{
"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.
curl https://api.cestra.ai/v1/files \
-H "Authorization: Bearer $CESTRA_KEY" \
-F caption="Quote for ACME" -F file=@report.pdfResponse 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.
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=
curl "https://api.cestra.ai/v1/events?wait=30" -H "Authorization: Bearer $CESTRA_KEY"{
"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.
{ "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:
{ "type": "auth", "key": "cs_live_...", "after": 41 }after is optional. Without it, the socket starts from the server-stored cursor.
The server then sends:
{ "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 |