---
title: Deploy
summary: "caveman-agent serve: one agent behind HTTP with every run journaled."
canonical: https://docs.caveman.so/docs/agent-sdk/deploy
license: Apache-2.0
capability: agent-sdk
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# Deploy

> caveman-agent serve: one agent behind HTTP with every run journaled.
`caveman-agent serve` puts one agent definition behind HTTP. Every run is journaled before it spends anything, so an instance that dies mid-run is picked up by the next one instead of starting over. The same server is available as a library, and as a web-standard `fetch(Request)` handler for runtimes that have no Node HTTP server.

```bash
export CAVE_SERVE_TOKEN=$(openssl rand -hex 24)
caveman-agent serve . --port 8080
```

```console
cave: support serves with host execution — tools are not isolated
caveman-agent serve: listening on 0.0.0.0:8080 (journal: local disk)
```

Unreleased: this page documents `@caveman-ai/agent` 0.2.0 from source at commit 311be40 in caveman-ai/agent-sdk. Build it from the checkout as described on [Install](/docs/agent-sdk/install).

Those two lines are different streams, merged above: the first is stderr, the second stdout. The first line appears when the definition declares no `sandbox` posture. Declare one to make the posture intentional: [Sandbox and execution](/docs/agent-sdk/sandbox).

## The command

`caveman-agent serve [dir] [--port N] [--host H] [--locked]`. The directory argument comes first. With `instructions.md` present it loads the [agent directory](/docs/agent-sdk/define); otherwise it loads `src/agent.ts` under that directory.

| Setting | Source | Default |
|---|---|---|
| Bearer token | `CAVE_SERVE_TOKEN` | required, the command refuses to start without it |
| Port | `--port`, then `PORT` | `8080` |
| Host | `--host`, then `HOST` | `0.0.0.0` |
| Journal | `CAVE_JOURNAL_URL` with `CAVE_JOURNAL_TOKEN` | local disk under `<dir>/.caveman/runs/durable` |
| Build lock | `--locked` | unlocked, reading `.caveman/agent.lock.json` when set |

Both refusals name themselves. Each was captured in its own shell, not the one the setup block above exported a token into:

```console
$ caveman-agent serve .
caveman-agent: caveman-agent serve: set CAVE_SERVE_TOKEN; an unauthenticated agent endpoint spends money for anyone who finds it

$ CAVE_SERVE_TOKEN=… CAVE_JOURNAL_URL=https://journal.example.com caveman-agent serve .
caveman-agent: caveman-agent serve: CAVE_JOURNAL_URL needs CAVE_JOURNAL_TOKEN
```

The token check runs before the directory is loaded. The journal check runs after, so the second capture needs a directory that loads: `src/agent.ts` or an `instructions.md` beside it, and a provider credential for `auto()`.

`SIGTERM` and `SIGINT` drain for up to 30 seconds, then exit. Anything still unfinished stays journaled and the next instance resumes it.

## The same server as a library

```ts
import { agent, auto } from "@caveman-ai/agent";
import { createAgentServer } from "@caveman-ai/agent/serve";

const support = agent({ id: "support", instructions: "Answer from policy.", model: auto() });

const server = createAgentServer({
  definition: support,
  token: process.env.CAVE_SERVE_TOKEN!,
});
const port = await server.listen(8080);
```

`token` must be at least 16 characters; a shorter one throws `cave_serve_token_required` at construction. `listen(port, host = "0.0.0.0")` returns the bound port, which is the real one when you pass `0`. `recover()` runs one recovery pass, `nextWakeAt()` returns the earliest time a sleeping run is due, and `close(graceMs = 30_000)` stops accepting work and waits for active runs.

`runOptions` accepts an object or a factory `({ sessionId, runId, principal }) => RunOptions`, and the server owns `durable`, `controller`, `conversation`, and `signal`. Passing any of those throws rather than being silently ignored.

### On Cloudflare, Deno, and Bun

```ts
import { createAgentHandler } from "@caveman-ai/agent/serve-handler";

const handler = createAgentHandler({
  definition: support,
  token: process.env.CAVE_SERVE_TOKEN!,
});

export default { fetch: (request: Request) => handler.fetch(request) };
```

It is the same routing, sessions, journaling, and recovery as `createAgentServer`, minus the Node listener. WebSocket upgrades are host-owned: supply `upgrade(request)` returning `{ response, socket }` (a Cloudflare `WebSocketPair`, for instance), or the `/ws` route answers `501 {"error":"cave_serve_websocket_unavailable"}`.

## Routes

Sessions are on [Sessions](/docs/agent-sdk/sessions). One run at a time, addressed by its own id, is here.

| Route | Body | Answer |
|---|---|---|
| `POST /runs` | `{runId, input, context?}` | `202 {"runId":…,"status":"running"}`, or `"resuming"` for a journaled run, or `"sleeping"` with `wakeAt` |
| `GET /runs/{id}` | | `200` with the journal summary, `404 {"status":"missing"}` |
| `GET /runs/{id}/events` | | Server-Sent Events while the events are still retained |
| `DELETE /runs/{id}` | | `202 {"status":"requested",…}`, `409 already_settled`, `404` |
| `GET /healthz` | | `200 {"status":"ok"}`, no bearer needed |
| `GET /readyz` | | `200 {"status":"ready","active":1,"queued":0}`, `503` while recovering |

```console
$ curl -s -X POST localhost:8080/runs -H "authorization: Bearer $CAVE_SERVE_TOKEN" \
    -H "content-type: application/json" \
    -d '{"runId":"nightly-2026-09-16","input":"Where is order A-123?"}'
{"runId":"nightly-2026-09-16","status":"running"}

$ curl -s localhost:8080/runs/nightly-2026-09-16 -H "authorization: Bearer $CAVE_SERVE_TOKEN"
{"status":"completed","runId":"nightly-2026-09-16","agentId":"support","result":{…},"settledAt":"2026-09-17T03:49:06.041Z"}

$ curl -s -X DELETE localhost:8080/runs/nightly-2026-09-16 -H "authorization: Bearer $CAVE_SERVE_TOKEN"
{"status":"already_settled","terminal":"completed"}
```

The `result` object is trimmed above; it is the whole `RunResult`, receipt included. Posting the same `runId` again after it settled returns the journaled summary and spends nothing, which is what makes the id an idempotency key: [Durable runs](/docs/agent-sdk/durable).

Errors name themselves the same way: `cave_serve_run_id_required`, `cave_durable_run_id_invalid`, `cave_serve_input_must_be_text`, `cave_serve_body_too_large` with `413`, `cave_serve_queue_full` with `503` and `retry-after: 5`.

A run id ending in a dot and digits is reserved for session runs, because that is the form the server mints for them. `POST /runs` with one answers `400 {"error":"cave_serve_run_id_reserved"}`:

```console
$ curl -s -X POST localhost:8080/runs -H "authorization: Bearer $CAVE_SERVE_TOKEN" \
    -H "content-type: application/json" -d '{"runId":"case-42.1","input":"Where is order A-123?"}'
{"error":"cave_serve_run_id_reserved"}
```

`GET /runs/{id}/events` serves an in-memory window per run. The window is held five minutes after the run settles, and at most 256 settled runs are retained at once, oldest dropped first. Past either bound it answers `409 {"error":"cave_serve_events_not_retained"}` and points at `GET /runs/{id}`, which reads the journal and is always authoritative.

Configuring `authenticate` makes `/runs` answer `403 {"error":"cave_serve_runs_require_single_principal"}`, because a raw run id carries no tenant. Use `/sessions` there.

## Unfinished runs at boot

`listen()` claims the store's instance lease, starts the listener, then sweeps the journal store once and every 60 seconds after that. The sweep is also how a run stranded by another instance's death gets picked up.

```json
{
  "listable": true,
  "resumed": ["nightly-2026-09-16"],
  "skipped": [],
  "sleeping": []
}
```

That is `recover()` on a store whose driving process was killed mid-call. Each pending journal lands in exactly one bucket.

**resumed.** The run had no terminal event, is due now, and its journaled input is the literal input. It goes back on the queue and re-drives from its last checkpoint.

**sleeping.** A durable sleep is outstanding, so the entry carries `wakeAt` and the instance leaves it alone. Nothing holds a process open for it, which is the point: a run waiting on tomorrow morning costs no container time.

**skipped.** A cancellation was requested before the sweep saw it, or the journal could not be read, or the input is the multimodal digest form and cannot be replayed unattended (`cave_serve_resume_needs_original_input`). Re-post that run with its original input to continue it.

`listable: false` means the store cannot enumerate its runs, so nothing is swept. The disk, HTTP, SQL, and object stores all implement `list()`.

## Verify and undo

```bash
curl -s localhost:8080/readyz
```

```console
{"status":"ready","active":1,"queued":0}
```

`ready` appears after the first recovery pass finishes. Stop the server with `SIGTERM`; it drains, releases the instance lease, and leaves the journal on disk. Restarting against the same journal directory picks up exactly where it stopped, and deleting that directory throws the history away.

## Limits

One live instance per journal store. Session state lives in the process that created it, so `listen()` refuses to start with `cave_serve_instance_already_active` while another instance holds the lease. Active/standby works because a dead instance's lease expires.

Defaults: two concurrent runs, 64 queued, 1 MiB request bodies. Raise them with `maxConcurrentRuns`, `maxQueuedRuns`, and `maxBodyBytes`.

The endpoint spends money on behalf of whoever holds the token. Terminate TLS in front of it, and put authorization in your own route rather than handing the token to a client.
