Docs / Serverless Objects / Autopilot runs
Autopilot runs as Durable Objects
Use serverless-objects/autopilot to hold each Autopilot run in a Durable Object, with budgets, durable wake timers, event streams and a registry.
serverless-objects/autopilot runs Autopilot runs as Durable Objects. Each run is a RunObject that holds the run record and its state machine, tool-boundary events, usage budgets, durable wake timers and a Camouflage session log. A RunRegistry per workspace answers cross-run queries: list by status, and reconcile after a restart.
It ports the per-run part of Autopilot's RunStore and RunWakeScheduler to Durable Object SQLite, with the same statuses, transitions, budgets, lease and retry rules. A pending wake is a durable alarm, so it fires after the node restarts.
The module is pre-alpha and ships in the serverless-objects npm package.
| Import | Exports | Use |
|---|---|---|
serverless-objects/autopilot |
RunObject, RunRegistry |
Run records with your own executor. |
serverless-objects/autopilot/cell |
AutopilotCellRun |
Run the task with Autopilot in a Hotcell cell. |
serverless-objects/autopilot/native |
AutopilotNativeRun |
Run the task in-process on the native engine. Trusted workspaces only. |
Use your own executor
import { RunObject, RunRegistry } from "serverless-objects/autopilot";
export { RunRegistry };
// Subclass to connect your executor. onWake runs when a wake timer fires.
export class Run extends RunObject {
async onWake(event) {
// Start or resume the executor for event.runId. Delivery is at least once:
// be idempotent by event.timerId. Throwing retries with backoff.
}
}
{
"durable_objects": {
"bindings": [
{ "name": "RUNS", "class_name": "Run" },
{ "name": "RUN_REGISTRY", "class_name": "RunRegistry" }
]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Run", "RunRegistry"] }]
}
To use other binding names, set the static registryBinding on your RunObject subclass and runBinding on a RunRegistry subclass.
const run = env.RUNS.getByName(runId); // the object name is the run id
await run.create({ id: runId, workspace: "acme", cwd: "/workspace", task: "Fix the flaky test", maxCostUsd: 2 });
await run.transition("running");
await run.recordToolIntent("call-1", "bash", { command: "npm test" }); // stores a SHA-256 of the arguments
await run.recordToolResult("call-1", "bash", { ok: true, content: output }); // stores a hash and byte count
await run.recordCheckpoint();
await run.recordUsage({ promptTokens: 1200, completionTokens: 300, costUsd: 0.01 });
await run.scheduleTimer({ condition: "time", wakeAt: Date.now() + 60_000 });
await run.transition("waiting");
await env.RUN_REGISTRY.getByName("acme").list({ status: "waiting" });
Run statuses: queued, running, waiting, needs_input, completed, failed, cancelled, interrupted_unknown.
Run Autopilot in a Hotcell cell
AutopilotCellRun is a RunObject that runs its task with Autopilot inside a Hotcell cell:
- Create the cell with gateway egress, so the cell gets a short-lived token for model calls, never a provider key.
- Install
autopilot-aiin the cell and start its bridge. - Open a session and send the task as one prompt.
- Follow the bridge's event cursor.
Control requests go through exec, so microVM cells without a network interface work too.
import { RunRegistry } from "serverless-objects/autopilot";
import { AutopilotCellRun } from "serverless-objects/autopilot/cell";
export { RunRegistry };
export class Run extends AutopilotCellRun {}
await env.RUNS.getByName(id).create({ id, workspace: "acme", cwd: "/workspace", task, maxCostUsd: 1 });
await env.RUNS.getByName(id).start({ model: "moonshotai/kimi-k2.6", egress: { spendCapUsd: 1, ttlMs: 3_600_000 } });
Set HOTCELL_ENDPOINT and HOTCELL_API_KEY in the Worker's vars and secrets.
start() options
| Option | Default | Meaning |
|---|---|---|
model |
moonshotai/kimi-k2.6 |
OpenRouter model id. |
packageSpec |
autopilot-ai@1.25.1 |
Autopilot package to install in the cell. |
image |
ghcr.io/sinameraji/hotcell-base:latest |
Cell image, with Node 20 and git. |
egress |
{ spendCapUsd: 1, ttlMs: 3_600_000 } |
Hotcell egress policy. |
Behavior
| Topic | Behavior |
|---|---|
| Steps | Each step (provision, install, bridge, session, prompt, poll) runs on the object's alarm and is persisted with the cursor. After a restart it resumes where it stopped while the cell keeps working. The bridge de-duplicates the prompt, so a retried prompt never runs twice. |
| Retries | A failed setup step is retried 6 times with backoff; polling is retried for about 15 minutes. The run then fails as cell_<step>_failed. |
| Recording | Cell events become run records (tool intent and result hashes, usage, final state) and Camouflage events (assistant and reasoning streams, tool start and finish, usage, todos, warnings and errors). |
| Budgets | A tripped tool, token or cost budget aborts the cell's turn, and the run waits in needs_input. The object sees tool calls as the cell reports them, so the call that tripped a budget may already have run. |
| Resuming | resume(budgets, { message? }) applies the new budgets and starts a follow-up turn in the same cell session. It is refused until the cell has finished stopping the previous turn. |
| Turn lost | If the cell reports no active turn and stops producing events, the run fails as cell_turn_lost. |
| Other methods | cancel() aborts the turn; executorState() returns the step, cell id, cursor and last error. |
| Concurrency | Each step runs under a lease, and every executor write checks it. npm install runs in the background in the cell. |
The cell is kept after the run so you can inspect its workspace. Destroy it through Hotcell when you're done.
Redaction
The session log includes the task, assistant text, tool commands and up to 16,000 characters of each tool's output. Likely secrets are redacted before an event is stored:
- provider keys (
sk-..., OpenRouter), GitHub and Slack tokens, AWS access key IDs, JWTs and PEM private keys; NAME=valueand"name": "value"pairs whose name contains SECRET, TOKEN, PASSWORD or API_KEY.
The control log (stream=run) keeps only hashes. Redaction is pattern-based, so treat the session log as sensitive anyway.
Run Autopilot in-process on the native engine
AutopilotNativeRun runs the task with Autopilot's SDK inside the object, on the native engine. Tools run on the host in the run's cwd, so use it only for trusted workspaces.
import { RunRegistry } from "serverless-objects/autopilot";
import { AutopilotNativeRun } from "serverless-objects/autopilot/native";
export { RunRegistry };
export class Run extends AutopilotNativeRun {}
npm install serverless-objects autopilot-ai
sobjects dev . --engine native
- Credentials: the session reads your Autopilot config (
~/.config/kimiflare/config.json). AnOPENROUTER_API_KEYbinding overrides its key. - Gating: before each mutating tool, the object checks
allowedToolsand the tool budget, so a call that would exceed a budget never runs. - Pausing: a tripped budget pauses the run in
needs_input.resume(budgets, { message })continues the same session. - Restarts: a turn cut off by a restart is reconciled when the object next loads, and
start()continues the same session. - Portability:
sobjects build --strictreports such an app as native-only.
RunObject methods
| Method | Notes |
|---|---|
create(input) |
id (must equal the object name), workspace, cwd, task; optional sessionId, allowedTools, maxToolIterations, maxRuntimeMs, maxTotalTokens, maxCostUsd. Idempotent for the same id. |
get() |
The run record, or null. |
transition(status, reason?) |
Autopilot's transition table. Finishing a run cancels its timers. |
resume(budgets) |
Leaves needs_input, applying new budgets atomically. |
recordToolIntent(id, name, args), recordToolResult(id, name, {ok, content, errorCode}) |
Hashes only, never raw arguments or output. A tool budget throws RunToolBudgetExceededError and moves the run to needs_input. |
recordCheckpoint() |
The executor saved its session after its last tool results. |
recordUsage({promptTokens, completionTokens, costUsd?}) |
Token and cost budgets move the run to needs_input. |
scheduleTimer({condition, wakeAt, intervalMs?, jobId?, maxAttempts?}), cancelTimer(id), listTimers() |
time and job timers drive the alarm. job timers call your checkJob(jobId) override. approval timers take an optional expiresAt instead of wakeAt. |
approve(timerId, {decision, reason?}) |
Resolves an approval timer of a waiting run exactly once, with decision "approved" or "denied", then delivers a wake with condition: "approval". An approval left past expiresAt fails the timer and the waiting run. |
reconcileInterrupted() |
After an executor restart: requeue from a checkpoint, or mark interrupted_unknown if a tool action may have been cut off. Usually called through RunRegistry.reconcile(). |
appendSessionEvents(events), listSessionEvents({after, limit}) |
The Camouflage session log. |
listEvents({after, limit}) |
Control events: state, tool_intent, tool_result, usage, checkpoint, approval. |
RunRegistry methods
| Method | Notes |
|---|---|
list({status?, limit?}) |
Newest first. |
get(id) |
One summary. |
reconcile() |
Calls reconcileInterrupted() on every run the registry has as running. Call it once when the executor host starts. |
refresh(id) |
Re-reads a run from its object. The run object is the source of truth. |
Event streams
fetch() on a run object serves either log from a cursor: ?stream=session (the default) or ?stream=run, with &after=N.
- With
Upgrade: websocket, it sends the backlog afterN, then every new event as it is committed. Sockets use the hibernation API. - A plain GET returns the same events as NDJSON.
The object assigns seq, session_id and timestamp_ms to each Camouflage event, so the log replays in order:
curl -s "https://agents.example.com/runs/run-42/stream" > run-42.ndjson
camouflage-validate run-42.ndjson
camouflage-tui --play run-42.ndjson
The object doesn't check event_type against Camouflage's schema. Run camouflage-validate in your tests.
What has been tested
Unit tests run the SQL against node:sqlite, and the cell executor is tested against a fake bridge. A real run on celld 0.6.2 with Hotcell 0.1.19 (container driver), autopilot-ai 1.25.1 and moonshotai/kimi-k2.6 through the Hotcell gateway went from provision to completion in about 30 seconds. In a second run, the node was stopped for about 45 seconds during a tool call; after up, the object resumed from its saved cursor and the run completed.
The examples/autopilot-runs demo shows wake after restart, event streams and reconcile over HTTP. Full details are in the source doc.