
# 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=<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 |
