# Cestra > Cestra gives a person's own AI agents (Python scripts, Node bots, Claude Code, Codex, Cursor) a screen on the web. The agent sends messages, notices, asks (questions with up to 4 buttons and/or free text), jobs (live progress cards), artifacts (table, chart, markdown, form, sandboxed html) and files. People answer at https://app.cestra.ai (desktop or phone). Their answers, messages, uploads, form submissions and Stop presses come back to the agent as events. Cestra is a relay: it does not run the agent. Full docs: https://docs.cestra.ai (all pages in one file: https://docs.cestra.ai/llms-full.txt) ## If you are an AI agent asked to "connect this project to Cestra" 1. You need an API key `cs_live_` + 32 chars. The user creates it at app.cestra.ai → Developer → New agent (shown once). Store it in an env var `CESTRA_KEY` or an ignored `.env` file, never in committed code. 2. Pick one path: - You are Claude Code: `claude mcp add cestra --env CESTRA_KEY=cs_live_… -- npx -y https://docs.cestra.ai/dl/cestra-mcp.tgz` (then the user restarts the session; tools appear as send_message, ask, …). - You are Codex: add to ~/.codex/config.toml: [mcp_servers.cestra] command = "npx" args = ["-y", "https://docs.cestra.ai/dl/cestra-mcp.tgz"] env = { CESTRA_KEY = "cs_live_…" } - Cursor: ~/.cursor/mcp.json → {"mcpServers":{"cestra":{"command":"npx","args":["-y","https://docs.cestra.ai/dl/cestra-mcp.tgz"],"env":{"CESTRA_KEY":"cs_live_…"}}}} - Python project: `pip install https://docs.cestra.ai/dl/cestra.tar.gz` (Python ≥ 3.9, stdlib only). - Node project: `npm install https://docs.cestra.ai/dl/cestra-sdk.tgz` (Node ≥ 18, zero deps). - Anything else / to verify immediately: plain HTTPS (below). 3. Verify by sending the message the user asked for, e.g.: curl https://api.cestra.ai/v1/messages -H "Authorization: Bearer $CESTRA_KEY" -H "Content-Type: application/json" -d '{"text":"Hello from Cestra"}' Success = HTTP 201 {"id":"…","created_at":"…"}. A 401 means the key is wrong or revoked. ## Auth and conventions - Base URL: https://api.cestra.ai . All paths start with /v1. JSON bodies (Content-Type: application/json) unless noted. - Header: `Authorization: Bearer cs_live_…` (or `X-Cestra-Key: cs_live_…`). The key identifies exactly one agent; no agent id in URLs. - Errors: {"error":{"code":"…","message":"…","details"?:…}}. Codes: 400 bad_request (details = [{path,message}]), 401 invalid_key, 404 not_found, 409 conflict, 413 too_large, 415 unsupported_media_type, 429 rate_limited (Retry-After header, seconds; also details.retry_after), 500 internal. - Rate limit: 60 requests/minute per key (fixed window). File upload costs 5, download 2. Long-polls cost 1. - Idempotency: every create (messages, notices, asks, jobs, artifacts) accepts "client_id" (1–100 chars of [A-Za-z0-9._:-]). Same client_id again → the original object (200 instead of 201), no duplicate. Use one UUID per logical action and reuse it on retry. Files have no client_id. - Retry 429 (after Retry-After), 5xx and network errors with exponential backoff. Do not retry other 4xx. - "notify": false on a create skips push/email alerts. ## Endpoints (request → response) GET /v1/me → {"agent":{"id","name","description"},"members":[{"id","name","role":"owner"|"member"}],"event_cursor":41} POST /v1/messages {"text":"Markdown, 1–20000 chars","client_id"?,"notify"?} → 201 {"id","created_at"} POST /v1/notices {"text":"≤1000","tone":"info"|"success"|"warning"|"error" (default info),"client_id"?,"notify"?} → 201 {"id"} POST /v1/asks {"question":"1–2000","detail"?:"≤2000","options":["Approve",{"label":"Discard","style":"primary"|"secondary"|"destructive"}] (≤4, unique labels ≤60),"allow_text"?:false,"expires_in"?:seconds 60–2592000,"client_id"?,"notify"?} (needs ≥1 option or allow_text:true) → 201 {"id","question","status":"pending","answer":null,"option_id":null,"text":null,"answered_at":null,"answered_by":null,"expires_at":iso|null,"options":[{"id":"o1","label":"Approve","style":"primary"},{"id":"o2","label":"Discard","style":"destructive"}]} Option ids are o1, o2, … in order. First answer by any member wins. GET /v1/asks/:id?wait=0..600 (long-polls while status is pending; set client timeout > wait) → {"id","question","status":"pending"|"answered"|"expired"|"cancelled","answer":"Approve"|typed text|null,"option_id":"o1"|null,"text":typed text|null,"answered_at","answered_by":user id|null,"expires_at"} POST /v1/asks/:id/cancel {} → ask state with status "cancelled"; 409 if no longer pending. POST /v1/jobs {"title":"≤200","steps"?:["Read","Compare"] (≤30),"client_id"?} → 201 {"id","status":"running","title","pct":null,"steps":[{"label":"Read","status":"running"},{"label":"Compare","status":"todo"}],"started_at"} PATCH /v1/jobs/:id any of {"pct":0–100,"title","step":{"index":0,"status":"todo"|"running"|"done"|"failed","label"?},"steps":[{"label","status"}],"status":"done"|"failed"|"cancelled","error":"≤1000"} → full job state {"id","status","title","pct","steps","started_at","ended_at"?,"error"?} status done → pct 100 (if set) and all non-failed steps done. failed → running step failed. 409 if the job already ended — including when a person pressed Stop (job becomes "cancelled" and the agent gets job.stop_requested). POST /v1/artifacts {"type":"table"|"chart"|"markdown"|"form","title":"≤200","key"?:"[A-Za-z0-9._-]{1,64}","summary"?:"≤300","data":{…},"client_id"?,"notify"?} or {"type":"html","title","key"?,"summary"?,"html":"… ≤5MB"} → 201 {"id":artifact id,"message_id","key","version":1} Same key again → same artifact id, version+1. POST /v1/files multipart/form-data: optional field "caption" (≤500, must come BEFORE the file part), field "file" (≤50 MB) curl https://api.cestra.ai/v1/files -H "Authorization: Bearer $CESTRA_KEY" -F caption="Quote" -F file=@report.pdf → 201 {"id","message_id","name","size","mime","sha256"} GET /v1/files/:id (any file in this agent's thread, incl. ones people uploaded) → raw bytes; Content-Type application/octet-stream; Content-Disposition has the name; X-Cestra-Mime has the real type. GET /v1/events?after=&wait=0..60&limit=1..100(50) → {"events":[{"seq":42,"type":"message","created_at":iso,"data":{…}}],"cursor":42} Passing after=N ACKNOWLEDGES every event with seq ≤ N. Omit after to resume from the server-stored cursor. cursor = last returned seq (or the position asked from if none). Loop: GET /v1/events?wait=30 → handle → GET /v1/events?after=&wait=30 → … (at-least-once: unacked events are redelivered; make handlers idempotent). POST /v1/events/ack {"seq":42} → {"ok":true} POST /v1/status {"status":"online"|"working"} → {"ok":true,"status"} POST /v1/disconnect {} → {"ok":true} (go offline now; otherwise offline ~90 s after last contact) ## Event types (event.data) - message: {"text","from":{"id","name"},"message_id"} or for an upload {"file":{"id","name","size","mime"},"from","message_id"} → download with GET /v1/files/:id - 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"}} — stop that work; the job is already cancelled - form.submitted: {"artifact_id","key","version","title","values":{…},"by":{"id","name"}} (forms and html artifacts) - member.joined: {"user":{"id","name"}} ## WebSocket (optional, push instead of polling): wss://api.cestra.ai/v1/ws - Auth: Authorization: Bearer header on the upgrade, or first message within 10 s: {"type":"auth","key":"cs_live_…","after"?:41} - Server → {"type":"hello","agent":{"id","name"},"cursor":41}, then {"type":"event","event":{seq,type,created_at,data}} for backlog and live events, {"type":"pong"}, {"type":"error","code":"invalid_key"|"unauthorized"|"bad_json"|"rate_limited"|"internal"} - Client → {"type":"ack","seq":42} after handling each event; {"type":"ping"} every ~30 s (keeps presence) → pong - Close 4401 = invalid/revoked key or auth timeout (do not reconnect with that key); 4429 = rate limited (reconnect later); otherwise reconnect with backoff, resumes after last ack. - Writes always go over REST. ## Artifact schemas (data) table: {"columns":[ "Name" | {"label","align":"left"|"right"|"center"} ] (1–30), "rows":[[cell,…]] (≤2000; cell = string≤2000 | number | boolean | null | {"value":string|number|null,"tone":"ok"|"bad"|"warn"}), "row_tones"?:["ok"|"bad"|"warn"|null], "stats"?:[{"label","value","tone"?}] (≤6), "note"?:"≤5000"} chart: {"kind":"bar"|"line" (default bar), "labels":["W1",…] (1–500), "series":[{"name","values":[number|null,…]}] (1–8), "unit"?:"≤20", "headline"?:{"label","value"}, "note"?} markdown: {"text":"GitHub-flavoured Markdown, ≤200000 chars"} form: {"fields":[{"name":"identifier, unique","label":"≤120","type":"text"|"textarea"|"number"|"select"|"checkbox"|"date" (default text), "required"?:false,"options"?:[…] (required for select, ≤50),"placeholder"?,"default"?,"min"?,"max"?}] (1–40), "submit_label"?:"Submit","intro"?:"≤2000"} Submission → form.submitted event with validated "values". html: top-level "html" string (not data). Runs in