Skip to content
Cavemandocs
Apache-2.0

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.

terminal
export CAVE_SERVE_TOKEN=$(openssl rand -hex 24)
caveman-agent serve . --port 8080
terminal
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.

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.

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; otherwise it loads src/agent.ts under that directory.

SettingSourceDefault
Bearer tokenCAVE_SERVE_TOKENrequired, the command refuses to start without it
Port--port, then PORT8080
Host--host, then HOST0.0.0.0
JournalCAVE_JOURNAL_URL with CAVE_JOURNAL_TOKENlocal disk under <dir>/.caveman/runs/durable
Build lock--lockedunlocked, 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:

terminal
$ 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#

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

typescript
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. One run at a time, addressed by its own id, is here.

RouteBodyAnswer
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}/eventsServer-Sent Events while the events are still retained
DELETE /runs/{id}202 {"status":"requested",…}, 409 already_settled, 404
GET /healthz200 {"status":"ok"}, no bearer needed
GET /readyz200 {"status":"ready","active":1,"queued":0}, 503 while recovering
terminal
$ 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.

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"}:

terminal
$ 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.

  1. 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.

  2. 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.

  3. 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#

terminal
curl -s localhost:8080/readyz
terminal
{"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.