# Cestra documentation (all pages) Source: https://docs.cestra.ai. Condensed version: https://docs.cestra.ai/llms.txt # Cestra Cestra gives your own AI agents a screen on the web. Your Python script, Node bot, Claude Code or Codex session sends you messages, asks questions with buttons, shows progress, and shares tables, charts, forms and files. You answer at [app.cestra.ai](https://app.cestra.ai) on your laptop or phone, and the answer goes straight back to the agent. Cestra doesn't run your agent. It's the relay between the agent and the people it works for. ```text your agent ──HTTPS──▶ api.cestra.ai ──▶ app.cestra.ai (you, your team) (script, bot, ◀── events (answers, ◀── tap a button, type a reply, Claude Code) messages, files) upload a file, press Stop ``` ## The mental model Each agent has **one thread**. Everything the agent sends lands there, in order. Everyone the agent is shared with sees the same thread. | The agent sends | What people see | What comes back | | --- | --- | --- | | **Message** | A chat bubble (Markdown) | Nothing. They can reply with a message | | **Notice** | A one-line status (info, success, warning, error) | Nothing | | **Ask** | A question with up to 4 buttons and/or a text box | `ask.answered` with the choice. The first answer wins | | **Job** | A live progress card: percent, steps, Stop button | `job.stop_requested` if someone presses Stop | | **Artifact** | A table, chart, Markdown doc, form or sandboxed HTML page, versioned by `key` | `form.submitted` for forms and HTML pages | | **File** | A download (≤ 50 MB) | They can upload files back to the agent | People talk back with **messages** and **files**. The agent gets those as `message` events. Events are queued for the agent until it reads them. Your agent can be offline and pick up its messages later. You can read events with a WebSocket, with REST long-polling, or through an SDK that does this for you. ## Builders and Operators - **Builders** create agents and keys under **Developer**. They connect their code, and they can share an agent with other people by email. - **Operators** are the people an agent is shared with. They see the thread, answer questions, press Stop and fill forms. They never see keys or code. Many install the app on their phone and get push notifications when an agent needs them. You can be both. Most people start as a Builder with one agent that messages themselves. ## Pick a path - **[Quickstart](/quickstart)**: create an agent, copy the key and send your first message in about two minutes. - **[Let your AI do it](/ai-setup)**: paste one prompt into Claude Code, Codex or Cursor and it wires itself up. - **[MCP server](/mcp)**: gives Claude Code, Codex and Cursor tools like `ask` and `send_message`. - **[Python SDK](/python)** and **[JavaScript SDK](/javascript)**: for scripts, bots and services. - **[REST API](/rest-api)**: plain HTTPS and JSON from any language. For AI agents reading this site, [llms.txt](/llms.txt) is the condensed guide and [llms-full.txt](/llms-full.txt) has every page. --- # Quickstart Create an agent, copy its key and send your first message. Takes about two minutes. ## 1. Create an agent 1. Sign in at [app.cestra.ai](https://app.cestra.ai). 2. Open **Developer** and click **New agent**. 3. Give it a name, for example "Release bot", and pick a colour. 4. Copy the API key. It starts with `cs_live_` and is **shown once**. If you lose it, create a new key and revoke the old one. Keep the key out of your repository. An environment variable works well: ```sh export CESTRA_KEY=cs_live_... ``` ## 2. Send something Pick the path that matches what you're connecting. ### Claude Code, Codex or Cursor (MCP) ```sh claude mcp add cestra --env CESTRA_KEY=cs_live_... -- npx -y https://docs.cestra.ai/dl/cestra-mcp.tgz ``` Then tell Claude: *"Send me 'Hello from Cestra' on Cestra."* For Codex and Cursor, see [MCP](/mcp). ### Python ```sh pip install https://docs.cestra.ai/dl/cestra.tar.gz ``` ```python from cestra import Agent agent = Agent() # reads CESTRA_KEY agent.send("Hello from Cestra") reply = agent.ask("Deploy to production?", ["Deploy", "Not now"]).wait(timeout=600) print(reply.answer) # "Deploy", "Not now", or None if nobody answered in time ``` ### JavaScript / TypeScript ```sh npm install https://docs.cestra.ai/dl/cestra-sdk.tgz ``` ```js import { Agent } from '@cestra/sdk'; const agent = new Agent({ key: process.env.CESTRA_KEY }); await agent.send('Hello from Cestra'); const ask = await agent.ask('Deploy to production?', ['Deploy', 'Not now']); const reply = await ask.wait({ timeoutSec: 600 }); console.log(reply.answer); ``` ### Any language (curl) ```sh curl https://api.cestra.ai/v1/messages \ -H "Authorization: Bearer $CESTRA_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"Hello from Cestra"}' ``` ## 3. Check the app Your message shows up in the agent's thread. The agent's status dot turns green while it's in contact with Cestra. It goes grey about 90 seconds after the last request. ## Next - Wait for replies in a loop: [Python](/python#receiving-events), [JavaScript](/javascript#receiving-events) or [REST events](/rest-api#events). - Show progress with [jobs](/python#jobs) and results with [artifacts](/artifacts). - Share the agent with a teammate from its info panel, and they become an Operator. --- # Let your AI do it If you work with a coding agent, it can do the setup for you. Create an agent in the app (**Developer → New agent**), copy the key, and paste this prompt into Claude Code, Codex, Cursor or any agent that can read the web: ```text Connect this project to Cestra so you can message me. Read https://docs.cestra.ai/llms.txt. Use this API key: cs_live_YOUR_KEY. When it works, send me 'Hello from Cestra'. ``` Replace `cs_live_YOUR_KEY` with your key. When "Hello from Cestra" appears in your thread, you're connected. ## What the agent will do [llms.txt](/llms.txt) is a condensed, machine-readable version of these docs. From it the agent learns the API and picks a path: - **Inside Claude Code or Codex**, it usually installs the [MCP server](/mcp), so it can use tools like `ask` and `send_message` in later sessions. - **In a Python or Node project**, it installs the [Python](/python) or [JavaScript](/javascript) SDK and writes a small client. - **Anywhere else**, it calls the [REST API](/rest-api) directly. ## Keep the key safe The prompt puts your key in the chat. That's fine for a key you just created, but: - Ask the agent to store it in an environment variable or an ignored `.env` file, never in committed code. - If a key ends up somewhere it shouldn't, revoke it under **Developer → API keys** and create a new one. Revocation takes effect immediately. See [Limits & security](/limits). ## Good follow-up prompts ```text From now on, when you finish a long task, send me a summary on Cestra. ``` ```text Before you run anything destructive (deleting files, force-pushing, migrations), ask me on Cestra with Approve / Cancel buttons and wait for my answer. ``` ```text While you work on this, keep a Cestra job updated with one step per phase. ``` --- # MCP for Claude Code, Codex and Cursor The Cestra MCP server gives a coding agent tools to message you, ask you questions with buttons, show progress, and send or receive files. It runs locally over stdio and talks to `api.cestra.ai` with your agent's key. It needs Node 18 or newer. ## Install ### Claude Code ```sh claude mcp add cestra --env CESTRA_KEY=cs_live_... -- npx -y https://docs.cestra.ai/dl/cestra-mcp.tgz ``` Add `--scope user` to make it available in every project. Check it with `claude mcp list`. ### Codex Add this to `~/.codex/config.toml`: ```toml [mcp_servers.cestra] command = "npx" args = ["-y", "https://docs.cestra.ai/dl/cestra-mcp.tgz"] env = { CESTRA_KEY = "cs_live_..." } ``` ### Cursor Add this to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project: ```json { "mcpServers": { "cestra": { "command": "npx", "args": ["-y", "https://docs.cestra.ai/dl/cestra-mcp.tgz"], "env": { "CESTRA_KEY": "cs_live_..." } } } } ``` ### Other MCP clients Any client that launches stdio servers works. The command is `npx -y https://docs.cestra.ai/dl/cestra-mcp.tgz`, with `CESTRA_KEY` in the environment. Set `CESTRA_URL` only if you run your own Cestra server. ## Tools | Tool | What it does | | --- | --- | | `send_message` | Posts a Markdown message to your thread. | | `notify` | Posts a one-line notice with a tone: `info`, `success`, `warning` or `error`. | | `ask` | Asks a question with up to 4 buttons and/or free text. It waits up to `wait_seconds` (default 120, max 600) for an answer. If nobody answers in time, it returns `pending` with an `ask_id`. | | `check_replies` | Returns new messages, answers to earlier asks, form submissions and Stop requests since the last check, and acknowledges them. Pass `ask_id` to check one ask. `wait_seconds` (max 60) waits for news. | | `start_job` | Starts a progress card with a title and optional steps. Returns the job id. | | `update_job` | Sets percent, step status, title, or finishes the job (`done` or `failed` with an error). | | `send_artifact` | Sends a table, chart, Markdown doc, form or HTML page. Reusing a `key` makes a new version. See [Artifacts](/artifacts). | | `send_file` | Uploads a local file (≤ 50 MB) to the thread. | | `get_file` | Downloads a file you sent to the agent and saves it locally. | ## How asks work over MCP MCP tool calls can't block forever, so `ask` waits a bounded time: 1. The agent calls `ask` with the question and options. 2. If you answer within `wait_seconds`, the tool returns your answer right away. 3. If not, it returns `status: pending` and an id. The agent can keep working and call `check_replies` later. Your answer will be waiting there, even hours later. Asks you create with `expires_in` expire on their own. After that, `check_replies` reports them as expired. ## Prompts that work well ```text When you finish, send me a summary on Cestra with send_message. ``` ```text Before running migrations, use the Cestra ask tool with Approve / Cancel and wait up to 10 minutes. ``` ```text Every few minutes, call check_replies in case I sent new instructions. ``` ## Troubleshooting - **"Missing, invalid or revoked API key"**: the key is wrong or revoked. Create a new one under **Developer**. - **The tools don't show up**: restart the client after adding the server, and check that `npx` is on the `PATH` the client uses. - **Rate limited**: one key gets 60 requests per minute. The server retries after the `Retry-After` delay on its own. --- # JavaScript SDK `@cestra/sdk` runs on Node 18 and newer and has zero dependencies. It ships with TypeScript types. `run()` receives events over a WebSocket on Node 22+, and falls back to REST long-polling elsewhere. ```sh npm install https://docs.cestra.ai/dl/cestra-sdk.tgz ``` ```js import { Agent } from '@cestra/sdk'; const agent = new Agent({ key: process.env.CESTRA_KEY }); await agent.send('Hello from Cestra'); ``` `new Agent()` with no options reads `CESTRA_KEY` and `CESTRA_URL` from the environment. | Option | Default | | | --- | --- | --- | | `key` | `process.env.CESTRA_KEY` | Your `cs_live_` key | | `baseUrl` | `process.env.CESTRA_URL` or `https://api.cestra.ai` | | | `maxRetries` | `4` | Retries for 429, 5xx and network errors | | `timeoutMs` | `30000` | Per request. Long-polls add their wait time | | `transport` | `'auto'` | `'websocket'`, `'poll'` or `'auto'` (WebSocket, then long-polling) | ## Messages and notices ```js await agent.send('Build **passed** in 3m 12s'); // Markdown await agent.notice('Backup done', 'success'); // info | success | warning | error await agent.send('Quiet update', { notify: false }); // no push notification ``` ## Asks ```js const ask = await agent.ask('Commit to main?', ['Approve', { label: 'Discard', style: 'destructive' }], { expiresIn: 3600, // seconds, 60 s … 30 days allowText: false, // true adds a text box detail: '3 files changed', }); const reply = await ask.wait({ timeoutSec: 600 }); // { status: 'answered' | 'pending' | 'expired' | 'cancelled', answer, optionId, text, answeredBy, answeredAt } if (reply.optionId === 'o1') { /* approved */ } ``` Options get ids `o1`, `o2`, … in order. `wait()` never throws on timeout. It returns the ask with `status: 'pending'`. Call `ask.refresh()` or `ask.cancel()` later. To pick an ask back up after a restart, use `agent.getAsk(id)`. ## Jobs ```js const job = await agent.job('Reviewing spec', ['Read', 'Compare', 'Write up']); await job.progress(40); await job.step(0, 'done'); await job.step(1, 'running'); await job.done(); // or: await job.fail('Spec file missing') ``` `job.update({ title, pct, status, steps, step, error })` is the raw form. When someone presses **Stop**, you get a `job.stop_requested` event, and later updates fail with a 409 because the job is now `cancelled`. ## Artifacts ```js await agent.artifact({ type: 'table', title: 'Coverage matrix', key: 'matrix', // same key again = version 2, 3, … data: { columns: ['Requirement', 'Status'], rows: [['Login', { value: 'ok', tone: 'ok' }]] }, }); ``` Schemas for `table`, `chart`, `markdown`, `form` and `html` are on [Artifacts](/artifacts). ## Files ```js await agent.file('report.pdf', { caption: 'Quote' }); // path, Buffer, Uint8Array or Blob, ≤ 50 MB const bytes = await agent.download(fileId); // Buffer const { name, mime, data } = await agent.downloadFile(fileId); ``` ## Receiving events ```js agent.on('message', async (e) => { if (e.data.file) await agent.download(e.data.file.id); else await agent.send(`You said: ${e.data.text}`); }); agent.on('ask.answered', (e) => console.log(e.data.ask_id, e.data.answer)); agent.on('job.stop_requested', (e) => stopWork(e.data.job_id)); agent.on('*', (e) => console.log(e.seq, e.type)); await agent.run(); // resolves after agent.stop() ``` `run()` acknowledges each event after its handlers resolve, so delivery is at-least-once. It reconnects with backoff. It rejects with a `CestraError` (status 401) if the key is invalid or revoked. `on()` returns an unsubscribe function. There are also lifecycle events: `connected`, `disconnected` and `error`. Event types and payloads are listed under [REST API → Events](/rest-api#event-types). ## Errors Everything throws `CestraError` with `status`, `code` and `message`. Creates carry an automatic `clientId`, so retries never duplicate anything. Pass your own `clientId` to stay idempotent across restarts. ## Other calls `agent.me()`, `agent.setStatus('working' | 'online')`, `agent.disconnect()`, `agent.events({ after, wait })`, `agent.ack(seq)`, `agent.jobById(id)`. --- # Python SDK The `cestra` package needs Python 3.9 or newer and only uses the standard library. It's fully typed. ```sh pip install https://docs.cestra.ai/dl/cestra.tar.gz ``` The wheel is at [`/dl/cestra-0.1.0-py3-none-any.whl`](https://docs.cestra.ai/dl/cestra-0.1.0-py3-none-any.whl) if you prefer it. ```python import os from cestra import Agent agent = Agent(key=os.environ["CESTRA_KEY"]) agent.send("Hello from Cestra") ``` `Agent()` with no arguments reads `CESTRA_KEY`. `base_url` defaults to `$CESTRA_URL`, then `https://api.cestra.ai`. Other options are `timeout=30` (seconds per request) and `max_retries=4`. ## Messages and notices ```python agent.send("Build **passed** in 3m 12s") # Markdown; returns the message id agent.notice("Backup done", tone="success") # info | success | warning | error agent.send("Quiet update", notify=False) # no push notification ``` ## Asks ```python ask = agent.ask( "Commit to main?", ["Approve", {"label": "Discard", "style": "destructive"}], expires_in=3600, # seconds, 60 .. 30 days allow_text=False, # True adds a text box detail="3 files changed", ) reply = ask.wait(timeout=600) # Answer(status, answer, option_id, text, answered_by, answered_at, ...) if reply.status == "answered" and reply.option_id == "o1": ... elif reply.status == "pending": ... # nobody answered within 10 minutes; the question stays open ``` Options get ids `o1`, `o2`, … in order (`ask.options` lists them). `wait()` long-polls and never raises on timeout. You get back `status="pending"`. With `timeout=None` it waits until the ask is answered, expires or is cancelled. Other calls: `ask.refresh()`, `ask.cancel()`, `agent.ask_status(ask_id, wait=0)`. ## Jobs ```python with agent.job("Reviewing spec", steps=["Read", "Compare"]) as job: job.update(pct=40) job.step(0, "done") # marks step 0 done and starts step 1 ... # Leaving the block calls job.done(). An exception calls job.fail(str(e)) and re-raises. ``` Without `with`, call `job.done()`, `job.fail("reason")` or `job.cancel()` yourself. `job.update(pct=, title=, steps=, error=)` changes the card. When someone presses **Stop** in the app, the next `job.update()` or `job.step()` raises `JobStopped`. Inside a `with` block that exception is swallowed and the block ends. If `agent.run()` is running, `job.stop_requested` also turns `True` as soon as the stop event arrives. A long loop can check it: ```python with agent.job("Crawling", steps=["Fetch", "Parse"]) as job: for i, url in enumerate(urls): if job.stop_requested: break crawl(url) job.update(pct=100 * (i + 1) / len(urls)) ``` ## Artifacts ```python ref = agent.artifact( type="table", title="Coverage matrix", key="matrix", # same key again = version 2, 3, ... data={"columns": ["Requirement", "Status"], "rows": [["Login", {"value": "ok", "tone": "ok"}]]}, ) ref.id, ref.version # ArtifactRef(id, message_id, key, version) agent.artifact(type="markdown", title="Notes", data={"text": "# Findings\n..."}) agent.artifact(type="html", title="Picker", html="...") ``` Schemas for each type are on [Artifacts](/artifacts). ## Files ```python agent.file("report.pdf", caption="Quote") # path; ≤ 50 MB; returns FileRef agent.file(b"a,b\n1,2\n", name="data.csv") # or bytes + name agent.download(file_id, "downloads/") # writes downloads/, returns the path data = agent.download(file_id) # or get bytes ``` ## Receiving events ```python @agent.on("message") def on_message(event): if event.get("file"): path = agent.download(event["file"]["id"], ".") agent.send(f"Got {path}") else: agent.send(f"You said: {event.text}") @agent.on("ask.answered") def on_answer(event): print(event["ask_id"], event["answer"], event["by"]["name"]) @agent.on("*") def log_all(event): print(event.seq, event.type, event.data) agent.run() # blocks until agent.stop() ``` `event` is an `Event(seq, type, created_at, data)`. `event["x"]` and `event.get("x")` read from `data`. The types are `message`, `ask.answered`, `ask.expired`, `form.submitted`, `job.stop_requested` and `member.joined`, and their payloads are listed [here](/rest-api#event-types). How `run()` behaves: - It long-polls `GET /v1/events` for 30 seconds at a time and resumes from the server-stored cursor, so a restarted script picks up where it left off. - Each event is **acknowledged only after its handlers return**, so delivery is at-least-once. Make handlers safe to run twice if that matters. - If a handler raises, the exception is logged to the `cestra` logger and the loop moves on. - Network errors and 5xx responses back off and retry. A 429 waits for `Retry-After`. - A bad or revoked key raises `CestraError` with status 401. - `agent.stop()` works from a handler or another thread, and `run()` returns within a fraction of a second. To run it alongside other work, use `threading.Thread(target=agent.run, daemon=True).start()`. The low-level calls are `agent.events(after=None, wait=0)`, which returns `{"events": [...], "cursor": n}` (passing `after` acknowledges up to it), and `agent.ack(seq)`. ## Errors and retries Every failure raises `CestraError(status, code, message)`. `status` is 0 for network errors. `JobStopped` is a subclass. Each create sends a random `client_id`, so automatic retries of 429, 5xx and network errors never create duplicates. Pass your own `client_id=` to make a call idempotent across restarts too. File uploads have no `client_id`, so they only retry on 429, 502, 503 and 504. ## Reference | Call | Returns | | --- | --- | | `send(text, *, notify=None, client_id=None)` | message id | | `notice(text, tone="info", *, notify=None)` | message id | | `ask(question, options=(), *, detail, allow_text, expires_in, notify)` | `Ask` | | `Ask.wait(timeout=None)`, `.refresh()`, `.cancel()` | `Answer` | | `ask_status(ask_id, wait=0)` | `Answer` | | `job(title, steps=())` | `Job` | | `Job.update(pct=, title=, steps=, error=)`, `.step(i, status="done", label=None)`, `.done()`, `.fail(msg)`, `.cancel()` | job state dict | | `artifact(type, title, data=None, *, html=None, key=None, summary=None)` | `ArtifactRef` | | `file(path_or_bytes, *, name=None, caption=None, mime=None)` | `FileRef` | | `download(file_id, path=None)` | bytes or path | | `on(type, fn=None)`, `run(wait=30)`, `stop()` | | | `events(after=None, wait=0)`, `ack(seq)` | | | `me()`, `status("working")`, `disconnect()` | | --- # 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. ```sh 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 ```sh 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](#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 ```sh 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. ```sh 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`. ```sh 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](/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. ```sh 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`. ```sh 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= ```sh 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=`. 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 | --- # Artifacts An artifact is a result your agent shows instead of describing it: a table, a chart, a document, a form to fill in, or a small HTML page. Cestra renders the typed ones (`table`, `chart`, `markdown`, `form`) natively, so they look right on a phone. `html` runs your own page in a locked-down sandbox. Every artifact has a `title` (≤ 200 chars) and an optional `summary` (≤ 300 chars) that's shown on the card in the thread. It can also have a `key`. ## Versions If you send an artifact with the same `key` (letters, digits, `. _ -`, ≤ 64 chars) again, it becomes **version 2, 3, …** of the same artifact rather than a new one. People can flip between versions in the app. Use this for anything that evolves, like a report you regenerate or a checklist you update. ```python agent.artifact(type="table", title="Coverage", key="coverage", data=v1) # version 1 agent.artifact(type="table", title="Coverage", key="coverage", data=v2) # version 2 ``` Typed artifacts are limited to 1 MB of JSON. HTML pages can be up to 5 MB. ## table ```json { "type": "table", "title": "Coverage matrix", "key": "matrix", "data": { "columns": ["Requirement", { "label": "Tests", "align": "right" }, "Status"], "rows": [ ["Login", 12, { "value": "covered", "tone": "ok" }], ["Export", 0, { "value": "missing", "tone": "bad" }], ["Search", 3, "partial"] ], "row_tones": [null, "bad", "warn"], "stats": [{ "label": "Covered", "value": "12 / 14", "tone": "ok" }], "note": "Generated from spec v3." } } ``` | Field | | | --- | --- | | `columns` | 1–30 labels, or `{label, align}` with align `left`, `right` or `center` | | `rows` | ≤ 2 000 rows. A cell is a string (≤ 2 000), number, boolean, `null`, or `{value, tone}` | | `row_tones` | Optional per-row `ok`, `bad`, `warn` or `null` | | `stats` | Up to 6 summary tiles: `{label, value, tone?}` | | `note` | Optional footnote, ≤ 5 000 chars | ## chart ```json { "type": "chart", "title": "Weekly signups", "data": { "kind": "line", "labels": ["W40", "W41", "W42", "W43"], "series": [ { "name": "Signups", "values": [120, 140, 135, 180] }, { "name": "Activated", "values": [80, 96, null, 130] } ], "unit": "users", "headline": { "label": "This week", "value": 180 }, "note": "W42 activation data missing." } } ``` | Field | | | --- | --- | | `kind` | `bar` (default) or `line` | | `labels` | 1–500 x-axis labels | | `series` | 1–8 series of `{name, values}`. `values` line up with `labels`, and `null` leaves a gap | | `unit` | Optional, ≤ 20 chars | | `headline` | Optional big number: `{label, value}` | | `note` | Optional | ## markdown ```json { "type": "markdown", "title": "Release notes", "data": { "text": "# 2.4.0\n\n- Faster sync\n- Fixed export" } } ``` `text` can be up to 200 000 characters of GitHub-flavoured Markdown. ## form A form collects structured input. When someone submits it, your agent gets a `form.submitted` event with the values. ```json { "type": "form", "title": "Deploy settings", "key": "deploy", "data": { "intro": "Pick where this build goes.", "fields": [ { "name": "env", "label": "Environment", "type": "select", "options": ["staging", "production"], "required": true }, { "name": "replicas", "label": "Replicas", "type": "number", "min": 1, "max": 10, "default": 2 }, { "name": "notes", "label": "Notes", "type": "textarea", "placeholder": "Anything I should know?" }, { "name": "notify_team", "label": "Tell the team", "type": "checkbox", "default": true }, { "name": "date", "label": "Go-live date", "type": "date" } ], "submit_label": "Deploy" } } ``` | Field property | | | --- | --- | | `name` | An identifier (`[A-Za-z_][A-Za-z0-9_]*`), unique within the form | | `label` | ≤ 120 chars | | `type` | `text` (default), `textarea`, `number`, `select`, `checkbox` or `date` | | `required` | Default `false` | | `options` | Required for `select`, up to 50 | | `placeholder`, `default`, `min`, `max` | Optional | A form has 1–40 fields. `submit_label` defaults to "Submit". The submission is validated against the fields before it reaches you: ```json { "type": "form.submitted", "data": { "artifact_id": "…", "key": "deploy", "version": 1, "title": "Deploy settings", "values": { "env": "production", "replicas": 3, "notes": "", "notify_team": true, "date": "2026-11-20" }, "by": { "id": "u_…", "name": "Ada" } } } ``` ## html For anything the typed artifacts can't show, send a complete HTML page, up to 5 MB: ```json { "type": "html", "title": "Seat picker", "html": "…" } ``` The page runs in a sandbox: - It's served from a separate origin, inside `