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:

  1. Create the cell with gateway egress, so the cell gets a short-lived token for model calls, never a provider key.
  2. Install autopilot-ai in the cell and start its bridge.
  3. Open a session and send the task as one prompt.
  4. 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=value and "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). An OPENROUTER_API_KEY binding overrides its key.
  • Gating: before each mutating tool, the object checks allowedTools and 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 --strict reports 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 after N, 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.