pylot-cli
v2.8.0
Published
pylot devbox: self-hosted devboxes for coding agents (AWS Lambda MicroVMs)
Readme
@pylot-devboxes/cli
The pylot binary. Every devbox command lives under pylot devbox (pylot devboxes also works); pylot mcp serves and registers the MCP server and pylot skills installs the agent skills.
Every <id> accepts a unique prefix, like a short git SHA: pylot devbox wait 1a2b.
The global --region <name|auto> (before or after the command) picks a region profile. Without it, a devbox id is found in whichever region holds it, ls lists every region, and up and build use defaultRegion.
Commands
| Command | What it does |
|---|---|
| build <recipe> [--source-dir repo=path]... [--wait] [--regions all\|a,b] | Packs the runtime bundle, resolves repo SHAs (git ls-remote), reads image.dockerfile (relative to the recipe file), starts an image build and prints the build id. With lambda-microvm it first prints the recipe's tier and estimated cost on stderr, then the harness set the image installs (harness.install), which the build records so up --harness can refuse a missing one. For source: bake it also archives every repo at its SHA (see Baked images). --wait polls with backoff; when a build fails, it reads the build log for the runtime's bake failed record and prints it as a bake failure (step, command, exit code, redacted output tail; Lambda itself only says Ready hook check failed: HTTP 500), then the builds --logs command for the full output. --regions builds in several region profiles at once (each region has its own bucket, role and image, so its own version number) and prints <region>/<buildId> per region; a region that fails is reported and the others carry on (exit 1 if any failed). |
| builds [recipe] [--logs <buildId>] [--json] | Build history: id, recipe, status, age, source (clone, bake or bake+secrets) and the repo SHAs each build was made from. --logs prints one build's log stream instead (Dockerfile steps, and every bake command's redacted output as bake output lines, up to 256 KiB per stream, whether the bake failed or not); with a recipe, the image version alone works: builds web --logs 3. --json prints the builds as an array (buildId, recipe, status, createdAt, source, repoShas, error, ...). |
| builds prune <recipe\|--all> --keep <n> [--yes] [--regions all\|a,b] | Deletes old builds (image versions), which count toward the per-image version quota (50 on Lambda) and are billed for at least 7 days. A dry run by default: prints each build with KEEP, DELETE or SKIP and the reasons. It never deletes a recipe's latest succeeded build, nor the latest succeeded build of each distinct harness set (a build without a recorded set counts as both), a build younger than 7 days (an up may have just resolved it), a build used by any VM that is not terminated (RUNNING, SUSPENDED, PENDING, ...), a build in progress, or a build in any provider state but FAILED or SUCCESSFUL (kept as unknown state <x>); --keep n (digits only) also keeps each recipe's n newest succeeded builds. Failed builds older than 7 days are deletable. --yes deletes only the DELETE rows, re-reading VM use first; a conflict (the version is busy or already gone) is skipped with its reason. With --regions, each region plans and deletes on its own: AccessDenied, or a region that cannot prune, skips that region with a message and the others carry on. --region auto is refused (exit 2): name a region or --regions. Exit 1 if any region or delete failed. CLI only, never an MCP tool. |
| up <recipe> [--harness claude\|codex] [--model M] [--build <id>\|latest] [--restore-from <id>] [--direct] [--wait [--keep-failed\|--terminate-failed]] [--force] [--refresh] [--capture-debug <tools> --capture-debug-hours <1-24>] | Refuses when maxConcurrent devboxes are already live, unless --force. Then the quota preflight (see Memory quota) refuses, before any session or boot, a build that does not fit the region's memory quota, printing every consumer (box, recipe, state, MiB); --force skips it and --refresh re-reads the quota. Mints a broker session for a new devbox, boots it from the latest successful build (or the one given), and prints its id. The first-release profile defaults to native direct subscription auth; --direct explicitly selects the same route. API-key and proxy-model launches reject. --wait waits until the runtime reports ready (phase waiting shows as booting), then prints the endpoint host on stderr. It exits 1 when the boot fails or the box is not ready within 15 minutes, and 3 when the box stops being live. When the provider stops the VM, --wait prints the provider's reason (e.g. Run lifecycle hook timed out) and, if the runtime never answered, says it was a provider/VM boot failure with no shell to offer; a VM the provider reports terminated before it started (transient right after a build) is replaced once by a fresh boot of the same build, with a notice. A runtime-reported failure prints the retained failure record (stage, command, exit code, redacted log tail, build and repo identity) and the recovery steps. A setup or readiness failure that left the VM and control runtime healthy (failure.diagnosable) is kept for diagnosis, within the recipe's idle-suspend and lifetime bounds, with its down command; --terminate-failed terminates it instead (the behaviour before rerun, for scripts). If the recipe uses hooks.setupRun, startRun, readyRun or a non-default hooks.ready and the image's runtime predates them, --wait warns that they were not run and that the app is unchecked. Any other definite boot failure (the runtime reports failed, or the provider reports the VM failed) is terminated and verified as down does, with a line on stderr; the exit code stays the wait's own, and a termination that is not verified, or fails, prints the pylot devbox down <id> to rerun. --keep-failed keeps a failed box that is not diagnosable for debugging and prints its down command and, with lambda-microvm, its estimated hourly cost. A box that only ran out of time, one already terminating or gone, and a wait interrupted with Ctrl-C are never terminated: the first gets the same down hint. --keep-failed without --wait is a usage error (2); without --wait, up never terminates. --region <name> refuses (exit 2) a harness the profile's harnesses lacks; --region auto boots in the first region of regionOrder that has the harness, a succeeded build and headroom, and prints its choice and why it skipped the others on stderr (none passes: exit 1 with a reason per region, or 2 when no region offers the harness). --restore-from always boots in the predecessor's region: auto is pinned there, and another --region is refused. A --harness the build does not install (its recorded harness set; see harness.install under Recipes) exits 2 before any quota read, broker session or boot; auto skips such a region as unsupported. Builds without a recorded set count as having both. |
| prompt <id> <text...> [--model M] [--cwd repo] [--wait] [--timeout s] [--out path [--force]] [--resume] | Starts a turn and prints its id. --wait streams it, with the same reconnects and exit codes as wait. --out implies --wait and writes a turn file. |
| wait <id> [turnId] [--timeout s] [--out path [--force]] [--resume] | Streams a turn (by default the latest) until it finishes. Text prints as-is, tool calls as › tool Name. Reconnects after a dropped or silent (45 s without data or keep-alive) stream with Last-Event-ID, backing off 1 s doubling to 30 s for up to 5 attempts per gap, and re-mints the endpoint token when it expires. Exits 0 done, 1 failed, 2 bad option or unsupported (--resume without suspend support), 3 devbox suspended or gone (outcome unknown), 4 runtime unreachable after retries, 5 still running at --timeout, 130 aborted. A suspended devbox is never woken unless --resume is passed. --out prints no events: it writes a turn file and one summary line, also when the turn is still running at --timeout. |
| watch <id> [--follow] [--timeout s] | Prints the devbox's turns, execs and state as JSONL and never wakes it. The first line is {"schema":"pylot.devbox.watch","version":1,"devbox","region"}; then one record per line: devbox.state (state, at observed, source: "provider"), turn.start, turn.event (index, event), turn.end (status, error?), each with devbox, turn (an exec id too), kind (turn, exec or auth-renew), seq (the runtime stream's sequence id) and at (server time), and gap (after, oldest, turns) when the stream's replay had dropped frames, or the runtime restarted and its sequence started over (the watch then resumes from the new sequence), whose events are then read back from the stored turns with seq: null. (turn, index) never repeats. Without --follow it exits at the next turn or exec end with wait's codes (0 done, 1 failed, 130 aborted); a suspended or gone devbox exits 3. With --follow it keeps going across turns and suspend/resume until Ctrl-C (exit 0) or the devbox is gone (3). Either way, --timeout exits 5 and an unreachable runtime 4. An open stream keeps a Lambda MicroVM awake, so the runtime stream is held only while something runs; otherwise only provider state is polled (every 15 s), and the runtime is checked once per idle-suspend window (the recipe's idleSuspendSeconds plus 30 s), at once after a resume, and never while it sleeps. A turn that started and ended between checks is reported late, from the replay, with its server timestamps. A runtime without the stream capability falls back to per-turn SSE and a capped /v1/turns poll, with the same records and seq: null. Use wait for one known turn; watch when you don't know what will run next. |
| exec <id> [--cwd repo] [--timeout s] [--json] -- <cmd...> | Runs a command in the devbox as the agent user, outside the agent and without a model, beside any running turn. Streams the command's stdout and stderr live and exits with its exit code. --timeout (default 600, at most 3600) kills its whole process group: exit 124. Exits 130 when aborted (also by a suspend or terminate), 2 when the runtime has no exec (an image built before exec, or a runtime sharing the agent's uid: rebuild the image), 3 when the devbox is suspended or gone (never woken), 4 when the runtime is unreachable. --json prints {exit, command_exit, stdout, stderr, truncated, durationMs, execId, devbox, region} at the end instead: command_exit is the command's own code, null unless the exec ran to completion, so a command's 4 and an unreachable box's 4 differ. Output past 1 MiB is dropped and marked truncated. Refused while a checkpoint runs. The exec is a turn of kind exec, so turns, turn and wait show it. It shares the agent's uid, which could tamper with what it runs: diagnostics only, never security or acceptance evidence. |
| rerun <id> <setup\|start\|ready> [--wait] | After a setup or readiness failure that left the box diagnosable, and after repairing it (exec, prompt), reruns the failed stage in place, without a rebuild: setup (services, setup hook even when it already ran, start hook), start (services, start hook) or ready (the readiness check alone). The readiness check always runs last and the box reports ready only once it passes. Starts in the background; --wait waits like up --wait and prints the rerun's own failure. Never wakes a suspended box. Exit 0 ready, 1 failed or refused (another rerun running, a checkpoint, or a failure before the repos were in place), 2 a runtime without rerun, 3 gone or asleep. See Setup and readiness failures. |
| abort <id> [turnId] [--json] | Aborts the running agent turn, or the queued or running turn or exec named (id or unique prefix), and waits up to 20 s until it has stopped (the runtime sends SIGTERM, then SIGKILL after 5 s, and persists native auth before the turn reports aborted). Never wakes a suspended box. Suspending a box does not abort its turn: the turn pauses with the box and finishes after resume. Exit 0 aborted, 1 nothing running (it says what is queued) or it finished first, 3 gone or asleep (also when the box is suspended or terminated while the turn stops: the poll checks provider state first and never wakes it), 4 unreachable, 5 accepted but still stopping (check it with turn). --json prints {exit, result, status, requested, kind, turn, devbox, region}. |
| logs <id> [-f\|--follow] | Runtime logs from CloudWatch: boot phases, hooks, services, runtime errors. Reads the image's log group (/aws/lambda-microvms/<namespace>-<recipe>) and the streams of this devbox's MicroVM. --follow polls until Ctrl-C. |
| usage [--since 7d] [--by recipe\|devbox\|model\|day] [--json] | Turns, failed turns, tokens (input, cache read, cache write, output), agent time and cost, aggregated from the broker's turn records in the bucket (usage/<day>/<devbox>/<turn>.json, ADR 0008). --since takes 30m, 12h, 7d, 2w or a date. Tokens are always counted. Cost appears only where the harness reported it: $1.20* means only some turns did, - means none (Codex reports no cost). Claude Code cost is per turn: the runtime subtracts the session's previous running total. Exception, kept as an overcount rather than dropped: the first resumed turn after restoring a checkpoint saved before this fix (no saved total), or after the session id changed without a saved total, reports the session's whole running total once. Records stored before this fix carry running totals. Unverified: turns on one box with different --cwd repos share one Claude session, and the per-turn cost assumes that session has one running total. --json prints the rows and total. There is no database: the command lists each day's prefix and GETs the records (8 in parallel). Needs bucket, region and the operator policy's s3:GetObject/s3:ListBucket on usage/*. |
| turns <id> | Lists the devbox's turns with their status and duration. |
| turn <id> <turnId> [--tools] [--json \| --out path [--force]] | Shows one turn with its events. --tools also shows each tool call's input (the command, for shell tools) and its output. --json prints the stored turn; --out writes a turn file. Exits 1 for a failed or aborted turn, else 0. |
| status <id> | Provider info (state, age, expiry, endpoint host) and runtime status (boot phase, busy, turn count, a boot row with each boot phase's duration, app readiness (verified, unchecked when the recipe has no readiness check, failed), the latest rerun, the retained failure with its recovery steps while the box is failed, or a recovered row once a rerun fixed it, and the lifecycle hook authentication mode when the runtime reports it, with a warning on stderr when it is degraded), and disk use: one disk row per device (/ and /workspace usually share one, so it prints once), or unknown for a runtime built before disk reporting. Above 85% used it warns on stderr and suggests freeing space (find what is large with exec ... -- du) or a sparse clone (repos[].clone.sparse); a bigger tier is never the advice. --json passes the runtime's disk array through under runtime.disk. |
| capture [--json] | The effective capture settings and where each goes: execution metadata (on by default), private task context (off), debug capture (off, per boot) and maintainer analytics (off, no recipient), each with its value, source, destination, scope and retention. Nothing is changed. Guide. |
| export (--day <date> \| --from <t> --to <t>) [--regions all\|a,b] [--limit n] [--cursor c] [--all] [--format json\|ndjson] [--include-context [--include-debug]] [--allow-partial] [--timeout s] [--max-calls n] [--max-records n] | Durable, paginated export of skill observations and turn summaries from CloudWatch (and, with --include-context, your bucket's context/), with coverage per source. [from, to) in UTC; the window may not reach into the current UTC day without --allow-partial (which ends it at now); a window that closed less than 30 minutes ago is exported as a snapshot (request.asOf, settled: false, complete: false, a stderr warning), because late events can still arrive. Events are attributed to this stack by their own namespace, so a stack whose namespace starts with yours is not mixed in (older lines in an ambiguous group are reported as ambiguous_source, not counted). Never contacts a devbox. stdout is one JSON document (or NDJSON); diagnostics go to stderr. Exit 0: produced, every source readable (a page.nextCursor may remain); 1: a source could not be read or the export was capped (the document is still printed); 2: usage error. Records have a stable recordId (<region>/<namespace>/<devboxId>/<eventId>; turn ids alone can repeat across devboxes) and a monotonic revision. Needs logs:FilterLogEvents and logs:DescribeLogGroups (the operator policy) and, for context, the separate OperatorContextPolicy. Schemas and semantics: guide. |
| analytics status \| preview [--since 7d] \| enable [--accept] \| disable \| send [--since 7d] | Optional aggregate analytics for the Pylot maintainers, with consent of its own that capture settings never grant. No recipient is designated in this release: enable is refused (exit 2), send reports consent_off or no_recipient and makes no request, and preview prints the exact allowlisted payload built from your own turn records so you can inspect it. Guide. |
| ls [--all] [--json] | Lists live devboxes: short id, recipe, harness, tier (lambda-microvm: 4G/16G ~$0.25 is baseline/disk and the estimated $/h of the local recipe of that name, - when there is none), state, age, time to expiry and endpoint host. --all adds terminated ones (metadata history) and unmanaged substrate instances; without it, a count of hidden unmanaged ones goes to stderr. --json prints the same selection as an array of provider records with unmanaged (and tier, or null, with lambda-microvm). With several region profiles and no --region, every region is listed in parallel with a REGION column; a region that fails only warns. |
| forward <id> <port> [--local-port N] | Serves a devbox port on http://127.0.0.1:<N> (default: the same port, else a free one) with the provider's auth injected. See Forwarding. |
| suspend <id> | Native suspend (memory and disk kept), where the provider supports it. A running turn is not aborted: it pauses with the box and finishes after resume (use abort to stop it). |
| resume <id> [--wait [--keep-failed]] [--force] | Resumes natively while there is headroom before the lifetime cap. Past it, restores into a new devbox with the same recipe, harness and model, continuing the agent session and the devbox/<id> WIP branch (ADR 0004). Prints the id to use. With --wait, a restored box that definitely fails to boot is terminated as with up --wait; --keep-failed keeps it. |
| down <id> / down --orphans [--yes] | Terminates the devbox. Safe to repeat. Exit 0 once termination is verified (or the box is already gone), 5 when requested but not verified within the deadline (--timeout <s>). When the devbox cannot be looked up (a throttled or failed provider read, not "no such devbox"), nothing is terminated and it exits 4 when retries ran out or 1 otherwise, saying the box may still be running; it never reports such a box as gone. --orphans lists live unmanaged VMs from our images (no metadata, e.g. a crashed up) and terminates them only with --yes. |
| shell <id> | Shell access instructions, where the provider offers them. Otherwise, how to run commands with exec or through a prompt. Never promises a shell into a VM that is gone (exit 3, with the provider's reason and the logs command) or a runtime whose boot failed before the repos were in place (exit 1, with the logs and down). |
| init [--region R] [--deploy [--yes]] [--claude\|--claude-token-file <path>] [--codex] [--github [--github-org <org>]\|--github-token-file <path>] [--overwrite] [--no-input] [--namespace <ns>] [--dry-run] | Sets up or repairs a region and writes its profile to the config file. It always prints a checklist: AWS identity, Lambda MicroVMs, the stack (and whether it is older than this CLI), the operator policy ARN (right after a deploy, a warning with the aws iam attach-user-policy command), the MicroVM memory quota (a warning under 16 GB with the exact request-service-quota-increase command), the GitHub and harness secrets, and a warning when a region profile's harnesses omits a harness whose login is now stored. On a terminal with no step flags it then offers each missing step (deploy, Claude login, Codex login, GitHub App) with y/N prompts and reruns the checklist with the accepted ones; without a terminal, or with --no-input, it never prompts. Steps, all with the deploy identity (the operator identity cannot touch secrets): --deploy deploys or upgrades the stack bundled in this package (a synthesized CDK cloud assembly, through npx aws-cdk@<pinned>), running cdk bootstrap first when the region has none; CDK asks to approve IAM changes on a terminal, --yes approves them. Only the default namespace pylot-devboxes deploys this way; another deploys from a clone (cd packages/infra && npx cdk deploy -c namespace=<ns>). Upgrading is rerunning --deploy with a newer CLI. --claude runs claude setup-token and reads the token from a hidden prompt (stdin without a terminal; --claude-token-file reads a file), rejects anything but an sk-ant-oat… token (API keys: issue #6), and stores <ns>/harness/claude. --codex runs codex login --device-auth in a fresh private home with file-backed ChatGPT auth (never your desktop login; works without a terminal, a human approves the printed link and code), stores <ns>/harness/codex, then deletes the home so the secret is the only copy (a failed login keeps it for diagnosis). Logins are per region: make a separate --codex login in each, never copy one. --github / --github-token-file store GitHub access. --overwrite replaces any credential already set; replacing the Codex login warns to down every Codex devbox in that region first, since a live box writes its refreshed old login back. |
| config set <key> <value> | Sets one shared key (telemetry, captureContext, logRetentionDays, maxConcurrent, namespace, recipesDir, runtimeDir, provider) in the config file, keeping its comments and region profiles. The new file is validated before it is written. Region identifiers are written by init. |
| config [--json] | Prints the effective configuration (of the selected region) and where each value came from, plus the region profiles when the file has them. --json: {region, file, fileFound, values: {key: {value, source}}}, with null for unset keys, plus profiles and defaultRegion with profiles, and selectionError when no region can be selected. |
| recipe ls [--json] / recipe validate <file> | Lists the recipes directory / validates one recipe file. Both exit 1 on an invalid recipe. --json prints {file, name, valid, recipe, error} per file. With lambda-microvm the table adds BASELINE, DISK and EST $/H columns and --json a tier object (baselineMiB, vcpu, diskGiB, estUsdPerHour, estUsdPerHourBurst, estUsdPerHourSuspended, priceBasis); see Tiers and cost. |
Ids and build ids go to stdout and progress goes to stderr, so commands compose:
id=$(pylot devbox up web --wait)--json on ls, builds, recipe ls and config prints one JSON document on stdout: an array for lists ([] when empty), and every record carries region (the record's own region: from its metadata, else the region it was listed in; null when none is set, as with microsandbox). Warnings and hints stay on stderr, so stdout always parses.
Tiers and cost
Lambda sizes a MicroVM by baseline memory alone: 0.5, 1, 2, 4 or 8 GiB, with half a vCPU per GiB and up to 4x burst. The disk follows the tier (8 GB up to 2 GiB, 16 GB at 4 GiB, 32 GB at 8 GiB), so a recipe's resources.diskGiB can raise its tier. Estimated running cost is about $0.063 per GiB of baseline per hour (2 GiB: ~$0.13/h; 4 GiB: ~$0.25/h), up to 4x that at full burst; a suspended box costs about $0.0001/h per GiB of snapshot. These are us-east-1 list prices (Arm), read 2026-10-17; no other regional table was published, so every region shows the same estimate. The constants live in packages/cli/src/pricing.ts. Image storage ($0.08/GB-month per kept version) is not included.
Turn files
--out <path> on prompt, wait and turn writes the complete stored turn (read back from the runtime, so events a dropped stream missed are included) and prints one summary line:
turn <turnId> done exit=0 tokens=54.5k/300 cost=$0.1500 duration=3m out=/abs/path/turn.jsonltokens is input (uncached plus cache read and write) / output; - marks a value the harness did not report (Codex reports no cost). The file is JSONL unless the path ends in .md, which writes Markdown (a field table, the prompt, the final message, a numbered tool-call digest and errors). JSONL lines are told apart by kind:
| kind | Fields |
|---|---|
| header (first) | schema (1), devbox, turn, region, turnKind, prompt, createdAt |
| event | seq, event (the stored harness event, in order) |
| result (last) | status, exit (0 done, 1 failed, 130 aborted, 5 still queued or running), usage (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd), durationMs, startedAt, finishedAt, sessionId, finalMessage, error |
A field the runtime did not report is null. The file is created with mode 0600 through a temporary file and a rename, with missing parent directories created. An existing file is refused before any remote call (exit 2) and left untouched unless --force is given. The process exit code is the verb's own: wait's table, or turn's 0/1.
MCP server
pylot mcp serves the devbox lifecycle to a coding agent as a local stdio MCP server. The host launches it; it uses the same config file and operator AWS identity as the CLI, calls the devbox operations in process (one cached endpoint per devbox, no CLI subprocesses), writes only the protocol to stdout and logs to stderr.
| Tool | Annotations | What it does |
|---|---|---|
| up | open-world | Boots a recipe's latest successful build and returns the devbox id at once; it does not wait, so it never terminates a box. A wait for "ready" that sees the boot fail returns failed (exit 1) with a failure object (kind control, setup or app; stage; exit_code; diagnosable; rerun_stage) and leaves the box running (wait is read-only). Its next_step is exec, then rerun, when the box is diagnosable, else logs then down. A VM the provider stopped is exit 3 with the provider's reason. Records the MCP client (clientInfo name/version) and an optional label in the devbox metadata; ls and status (CLI and MCP) show them. |
| prompt | open-world | Starts a turn; returns its id and queue position at once. |
| exec | destructive, open-world | Starts a diagnostic command (argv, cwd, timeout_s); returns its exec id at once. Same trust boundary as pylot devbox exec. |
| rerun | destructive, open-world | After a diagnosable setup or readiness failure and a repair, reruns stage (setup, start or ready; failure.rerun_stage names it) in place without a rebuild; returns at once, then wait with target "ready". Same as pylot devbox rerun. |
| abort | destructive, idempotent | Aborts the running agent turn, or the queued or running turn (full turn or exec id) given, and waits until it has stopped (exit 0); exit 1 when nothing is running or it finished first, 5 when still stopping. Suspending does not abort a turn. Same as pylot devbox abort. |
| wait | read-only | Blocks until target (required: "ready", or a turn or exec id) finishes, sending progress about every 20 s when the call carries a progress token. Returns running with next_poll_after_s: 0 before the host's cap: 1500 s for Claude Code, 250 s for Codex, 50 s for other hosts, or PYLOT_MCP_WAIT_MAX_S. Besides duration_ms (the turn's or exec's own duration), a result carries wait_elapsed_ms (this call), wait_budget_s (the shorter of timeout_s and the cap) and wait_return (ready, boot_failed, turn_ended, exec_ended, cached, cap_reached, timeout_reached, cancelled, gone, unreachable, refused or error). |
| status, ls, turns, turn, logs | read-only | Provider state and runtime status; pylot devboxes; a devbox's turns; one turn without waiting; the last log lines. |
| wake_check | read-only | Tests this session's wake paths with no devbox and no AWS call. mode: "push" returns at once and sends one channel push after seconds (default 30; one pending at a time). The push arrives only in a session started with the channel flag, which the server cannot detect, so channel_push reads needs_launch_flag, not a promise of delivery. mode: "wait" blocks for seconds (default 10 s past the background threshold: 130 s), like wait on a turn. Each result carries wake (client, entrypoint, auto_background, background_after_s, channel_push) and a next_step that says how to fix a missing path. |
| suspend, resume, persist | idempotent | resume returns at once; then wait with target "ready". |
| down | destructive, idempotent | Terminates. On a running devbox whose runtime reports work since the last checkpoint (dirtySincePersist on /v1/status; a runtime that does not report it counts as dirty), it runs persist first and refuses (exit 1) when that fails, unless force: true. A suspended devbox is not woken: terminating it fires no terminate hook, so the checkpoint its suspend hook made is the last one, and the result says so. |
Every tool takes an optional region: a profile name or AWS region. Without it, a call naming a devbox runs in that devbox's region (found as in the CLI; a prefix matching in two regions is refused, exit 2), ls lists every region, and other calls use the server's region (pylot mcp --region, else defaultRegion). up also takes "auto". With a flat config the server has one region, and another region is refused. prompt, exec, suspend, resume, persist and down take full devbox ids only: a prefix is refused (exit 2) with the matching ids in candidates, and a VM without pylot namespace metadata is refused outright. No tool wakes a suspended devbox except resume (exit 3 otherwise). PYLOT_MCP_TOOLS=read serves only the read-only tools.
Results have an outputSchema and structuredContent with devbox, region, status, exit (the exit-code table; for an exec, the command's code, with command_exit), turn / exec, usage (cost_usd null for Codex), duration_ms, failure_reason, next_step, next_poll_after_s and out_path, plus per-tool fields; the text content is the one-line summary. isError is set only for exits 2 and 4. A finished turn is written in full to <stateDir>/turns/<devbox>/<turn>.jsonl (a turn file) and reused from there afterwards, even once the devbox sleeps. stateDir is $PYLOT_DEVBOXES_STATE_DIR, else $XDG_STATE_HOME/pylot-devboxes, else ~/.local/state/pylot-devboxes. Text produced inside the devbox (the final message, capped at about 4 KB head and tail; exec stdout and stderr and log lines, the last 8 KB each; a turn's, exec's or checkpoint's error, a runtime HTTP error body and the runtime's status error, as untrusted.failure_reason and untrusted.runtime_error) appears only under untrusted; the top-level failure_reason is pylot's own wording. status reports hook_auth (uid, degraded or bearer; null on older runtimes), runtime_reachable, phases (each boot phase with duration_ms), app readiness, failure and the latest rerun. A boot failure's own text (its message, command, redacted log tail and build/repo identity) is under untrusted.failure_reason, untrusted.failure_command, untrusted.log_tail and untrusted.failure_source.
Wake-ups
A turn's end reaches the agent in one of two ways:
- L1, channel push. In Claude Code the server sends
notifications/claude/channelwith only the devbox id, turn id and status to the process that started the turn (or whosewaitreturnedrunning), unless awaitalready delivered it. - L2, blocking
wait. Claude Code moves awaitstill running after 120 s to the background and wakes the agent when it returns.
Which one works depends on how the session was started, not on pylot:
| Session | L2: wait in the background | L1: push to an idle agent |
|---|---|---|
| claude in a terminal (interactive) | yes, after 120 s | only when started with --dangerously-load-development-channels server:pylot |
| claude -p, the Agent SDK | no: wait blocks until the turn ends (fine for scripts) | no |
| A subagent's or an IDE server's call | no: blocks | no |
| Codex, other hosts | the host's own background support, else loop on wait | no |
Channels are a Claude Code research preview. Pushes are delivered only when the session is started with that flag: you confirm a dialog each launch, and there is no settings switch. Make it a shell alias if you want pushes every time:
alias claude-pylot='claude --dangerously-load-development-channels server:pylot'Without the flag nothing is pushed and nothing fails, so an agent should call wait rather than end its turn expecting a push. The server says this in its instructions and in the next_step of prompt and of a wait that returns running. It reads CLAUDE_CODE_ENTRYPOINT (cli interactive, sdk-cli under -p) and the background switches (CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS, CLAUDE_AUTO_BACKGROUND_TASKS) to say which case applies. It cannot see whether channels are on. Outside MCP, pylot devbox wait and watch cover background shells.
First run: ask the agent to call wake_check with mode: "wait", then with mode: "push" and end its turn. The wait passes when its result arrives as a background task notification. The push passes when a "channel push reached this session" message wakes the idle agent.
Register and check
pylot mcp install codex|claude-code [--dry-run] [--update] [--region <profile>]
pylot mcp doctor codex|claude-code [--read-call] [--json]install writes the running Node executable and CLI entry, by absolute path, into the client's user-level config, with a backup, an atomic replace and no change when it is already registered; --dry-run writes nothing. The Node path is the node on PATH when that is the same binary (Homebrew's stable /usr/local/bin/node, not the versioned Cellar path). A different existing pylot entry is refused unless --update is given: it then replaces only the managed fields (command, args and, for Codex, the timeout, wait cap, enabled and approvals), keeps the rest of the entry and any registered --region, and with --dry-run lists each changed field. Run install <client> --update after upgrading or moving Node or pylot-cli. For Codex it sets tool_timeout_sec = 1800, PYLOT_MCP_WAIT_MAX_S = "1500", approve by default and a prompt for up, exec, rerun and down (an install from before rerun existed approves it by default: run pylot mcp install codex --update to add the prompt; doctor reports it as stale). No region is written unless --region is given. doctor starts the registered command through the MCP SDK and reports registration, executable, startup, protocol and tools separately, with --read-call adding one ls. Neither reads the operational config, and neither prints config text, child stderr or tool data. Details: MCP guide, Codex, Claude Code.
The manual forms follow.
Claude Code
claude mcp add --scope user pylot -- pylot mcp # every project on this machine
claude mcp add --scope project pylot -- pylot mcp # writes .mcp.json for the repo
claude mcp add pylot -- pylot mcp # default (local): this project only, not sharedor .mcp.json (add "env": {"AWS_PROFILE": "..."} when the operator identity is not the default profile):
{
"mcpServers": {
"pylot": { "command": "pylot", "args": ["mcp"] }
}
}Annotations are hints, so set the approvals explicitly. In .claude/settings.json, reads, prompt and abort run without asking; up, exec, rerun and down ask:
{
"permissions": {
"allow": [
"mcp__pylot__status", "mcp__pylot__ls", "mcp__pylot__turns", "mcp__pylot__turn", "mcp__pylot__logs", "mcp__pylot__wait", "mcp__pylot__wake_check",
"mcp__pylot__prompt", "mcp__pylot__abort", "mcp__pylot__suspend", "mcp__pylot__resume", "mcp__pylot__persist"
],
"ask": ["mcp__pylot__up", "mcp__pylot__exec", "mcp__pylot__rerun", "mcp__pylot__down"]
}
}Codex
~/.codex/config.toml (or codex mcp add pylot -- pylot mcp, then edit). Unset, Codex's tool_timeout_sec is a 300 s wall-clock limit per call (measured with Codex CLI 0.147.0; Codex's docs say 60). Progress notifications do not extend it, and startup_timeout_sec does not affect it. The 250 s wait cap fits under it; it applies to codex-mcp-client 0.147.0 and later, and older or other codex-named clients keep the 50 s cap. If you set tool_timeout_sec, set PYLOT_MCP_WAIT_MAX_S about 17% below it: lower it when the timeout is lower (tool_timeout_sec = 60 needs PYLOT_MCP_WAIT_MAX_S = "50"), and raise both together to block longer per call:
[mcp_servers.pylot]
command = "pylot"
args = ["mcp"]
tool_timeout_sec = 1800
env = { PYLOT_MCP_WAIT_MAX_S = "1500" }
# Reads, prompt and abort run without asking; up, exec, rerun and down ask.
default_tools_approval_mode = "approve"
tools.up.approval_mode = "prompt"
tools.exec.approval_mode = "prompt"
tools.rerun.approval_mode = "prompt"
tools.down.approval_mode = "prompt"The approval keys and the timeout behaviour are the ones measured with codex-cli 0.147.0.
Agent skills
The package carries the repository's skills/ (pylot-cli, devbox-recipe) at its own version. pylot skills copies them into an agent's skills directory. Like pylot mcp install, it never reads the operational config.
pylot skills path # where the bundled skills are
pylot skills ls [--target claude|codex|agents] [--project] [--json]
pylot skills install [skill...] --target claude|codex|agents [--project] [--dry-run] [--force]| Target | Directory | With --project |
|---|---|---|
| claude | $CLAUDE_CONFIG_DIR/skills, default ~/.claude/skills | ./.claude/skills |
| codex, agents | ~/.agents/skills (Codex and other agents that read the shared location) | ./.agents/skills |
These are the directories npx skills -g -a claude-code|codex uses. ls shows each bundled skill's state per target (default: claude and codex): absent, current (exactly the bundled files, whoever wrote them), outdated (pylot's own copy from another version, unedited), modified (pylot's copy, edited since) or foreign (no pylot marker, a symlink or a file). install installs every bundled skill unless named. It creates absent, leaves current untouched, updates outdated and refuses modified and foreign unless --force; one refusal writes nothing for any skill (exit 1). --force moves the old copy (a symlink stays a symlink; its target is never touched) to $XDG_STATE_HOME/pylot-devboxes/skill-backups/ (default ~/.local/state/..., or PYLOT_DEVBOXES_STATE_DIR). Each skill is staged beside its destination and swapped in by rename. Each copy gets a .pylot-skill.json marker: the CLI version and a hash of every file written, which is how edits are told apart from pylot's own copies. --dry-run prints the same plan (files to add, change or remove) and writes nothing. Restart the agent, or start a new session, to load the result.
Configuration
~/.config/pylot-devboxes/config.yaml (or $XDG_CONFIG_HOME/pylot-devboxes/config.yaml, or the path in $PYLOT_DEVBOXES_CONFIG):
# `pylot devbox init` writes these from the infra stack outputs.
provider: lambda-microvm # or: local
region: us-east-1
namespace: pylot-devboxes # infra namespace; images are <namespace>-<recipe>
bucket: <BucketName> # image build artifacts (builds/)
executionRoleArn: <ExecutionRoleArn>
buildRoleArn: <BuildRoleArn> # default: executionRoleArn
brokerOperatorUrl: <OperatorUrl> # operator API, SigV4 (AWS_IAM)
brokerDevboxUrl: <DevboxUrl> # what devboxes call with their session tokens
recipesDir: ~/src/devbox-recipes # default: ./recipes
# runtimeDir: path/to/packages/runtime # default: the installed @pylot-devboxes/runtime
# maxConcurrent: 5 # up refuses past this many live devboxes; 0 disables
# logRetentionDays: 30 # days CloudWatch keeps each recipe's MicroVM logs; 0 leaves retention alone
# telemetry: false # execution metadata in your CloudWatch logs is ON by default; false turns it off (see Telemetry below)
# captureContext: true # opt in to storing task context (request, responses, skill descriptions) in your bucketRegion profiles
One file can hold several regions (KTD11). Deploying the stack in another region end to end: second region guide. Region-specific keys move under regions.<name>; the rest stay on top and are shared:
provider: lambda-microvm
namespace: pylot-devboxes
recipesDir: ~/src/devbox-recipes
defaultRegion: us-east-1 # used when nothing names a region
regionOrder: [us-east-1, us-east-2] # --region auto preference; unlisted profiles follow
regions:
us-east-1: # the profile name; region defaults to it
bucket: <BucketName>
executionRoleArn: <ExecutionRoleArn>
buildRoleArn: <BuildRoleArn>
brokerOperatorUrl: <OperatorUrl>
brokerDevboxUrl: <DevboxUrl>
harnesses: [claude, codex] # what this region's broker can log in; default: all
memoryQuotaMiB: 8192 # quota fallback for the headroom check
us-east-2:
bucket: <BucketName>
executionRoleArn: <ExecutionRoleArn>
buildRoleArn: <BuildRoleArn>
brokerOperatorUrl: <OperatorUrl>
brokerDevboxUrl: <DevboxUrl>
harnesses: [claude]A flat file is one implicit profile and behaves as before. pylot devbox init --region <r> on a flat file of another region rewrites it as profiles (the existing region becomes defaultRegion) and on a profile file adds or updates regions.<r>; the previous file is backed up. With profiles, the region is chosen by --region, else PYLOT_DEVBOXES_REGION, else AWS_REGION/AWS_DEFAULT_REGION, else defaultRegion; an environment region that matches no profile is an error, and the per-region environment variables (PYLOT_DEVBOXES_BUCKET, role ARNs, broker URLs) are refused because they would point one region at another's resources. --region and PYLOT_DEVBOXES_REGION also keep id lookups in that region.
New devboxes record their region in their metadata. An id is found through a local cache (<stateDir>/devbox-regions.json, refreshed by ls, up and every lookup), then by reading every region in parallel; an AccessDenied or outage in one region makes only that region unavailable. auto's headroom check is the quota preflight below: a region without headroom is skipped with its numbers, and the chosen one prints its usage (region auto: us-east-2 (4096 of 8192 MiB in use, needs 4096)).
Memory quota
Lambda MicroVMs caps the baseline memory allocated across a region's VMs (Service Quotas lambda / L-CD1C0CC4, "Max allocated ARM_64 MicroVM memory", in Gigabytes; AWS's default is 1,024 GB in us-east-1, but new accounts can start much lower, so check yours). up checks it before minting a session:
- In use is the sum of each RUNNING, SUSPENDED, PENDING or SUSPENDING VM's image version
minimumMemoryInMiB. Suspended boxes still count, sosuspendfrees nothing;downdoes. A VM whose image is outside this stack's namespace, or unreadable, counts as 8192 MiB. Needed is the build's ownminimumMemoryInMiB. - The limit comes from
servicequotas:GetServiceQuota(the operator policy grants it). When that is denied, missing, or not the expected quota in Gigabytes, the profile'smemoryQuotaMiB(default 8192) stands in, with a one-line note on stderr. - Caching: the limit for 24 h (
up --refreshre-reads it) and image-version memory for good (versions are immutable), in<stateDir>/quota.json. - A refusal names the region,
used + needed > limit, and a table of consumers: box, recipe, state, MiB. An explicit region is refused;--region autoskips to the next region. - At boot, RunMicrovm can still refuse (another
upraced the check). If a VM terminated in the last 2 minutes,upwaits ~30 s and retries once; otherwise, or on a second refusal,automoves to the next region and an explicit region exits 1.--restore-fromnever changes region. MCPupruns the same preflight and failover.
Environment
Environment variables override the file: PYLOT_DEVBOXES_PROVIDER, PYLOT_DEVBOXES_REGION (then AWS_REGION, AWS_DEFAULT_REGION), PYLOT_DEVBOXES_BUCKET, PYLOT_DEVBOXES_EXECUTION_ROLE_ARN, PYLOT_DEVBOXES_BUILD_ROLE_ARN, PYLOT_DEVBOXES_NAMESPACE, PYLOT_DEVBOXES_BROKER_OPERATOR_URL, PYLOT_DEVBOXES_BROKER_DEVBOX_URL, PYLOT_DEVBOXES_RECIPES, PYLOT_DEVBOXES_RUNTIME, PYLOT_DEVBOXES_MAX_CONCURRENT, PYLOT_DEVBOXES_TELEMETRY (true/false), PYLOT_DEVBOXES_CAPTURE_CONTEXT (true/false) and PYLOT_DEVBOXES_LOG_RETENTION_DAYS. A flat file whose bucket, roles or broker URLs belong to its region is refused when AWS_REGION (or another region variable) names a different region, instead of mixing the two.
Guardrails without state. up counts live devboxes (provider.list(), so suspended ones count) and refuses at maxConcurrent (default 5). Nothing is stored; the provider's listing is the truth. Logs need the operator policy's logs:FilterLogEvents/DescribeLogStreams on /aws/lambda-microvms/<namespace>-*, and log retention its logs:PutRetentionPolicy there.
The config holds no secrets by design. AWS credentials come from the standard AWS provider chain (environment, SSO, profiles). Every devbox credential is minted by the broker at runtime, and the broker owns session persistence.
Telemetry
Two different things, both kept in your own AWS account:
- Cost accounting, always on. The broker meters every model call (
UsageRecord) and stores oneTurnRecordper turn underusage/in your bucket;usagereads them. They hold token counts, cost, timing, status, model, a coarse error class and your own devbox, recipe and harness names. They never hold prompts, responses, paths, repository or branch names, URLs, session ids or error text (ADR 0008). Stack proptelemetrySinks: nonestops the sinks; the stored turn records expire afterusageRetentionDays(default 90). Records written before 2026-10-20 can contain prompt text: deletes3://<bucket>/usage/to purge them early. - Execution metadata, on by default. Devboxes booted with this release write trace events (
trace.start,tool.call,tool.result,skill.load,llm.call,capture.status,trace.end) to their own CloudWatch log stream, with the actual tool and skill names, stable ids (devbox, turn, native session, tool invocation), outcomes, timings and token counts; never task text. Turn it off withpylot devbox config set telemetry false(orPYLOT_DEVBOXES_TELEMETRY=false); running devboxes keep the setting they booted with. Schema, skill-use evidence and per-harness coverage: guide; runtime side: runtime README. Before this release the default was off; the key is unchanged. Traces need images built with this release's runtime:up --waitwarns when the image's runtime predates them or predates capture settings, andpylot devbox build <recipe>fixes it (an older runtime still writes the older v1 traces). - Private task context, off by default.
pylot devbox config set captureContext truestores, per turn, the request, the previous request, up to five bounded agent replies and the loaded skills' description and version in your bucket undercontext/(credential-filtered on a best-effort basis; task text can still hold confidential data). It never goes to CloudWatch or a telemetry sink, and it needs the redeployed stack (route, prefix, 30-day lifecycle rulecontextRetentionDays, and the optionalOperatorContextPolicythatexport --include-contextneeds). Targeted debug capture of listed tools' input and output is a separate per-boot flag:up --capture-debug Bash,Read --capture-debug-hours 2. - Maintainer analytics, off, and not deliverable yet. See
analyticsabove. Nothing is ever sent to the Pylot maintainers by capture settings.
Retention and deletion. Trace lines live in the MicroVM log groups /aws/lambda-microvms/<namespace>-<recipe>, with the build and runtime logs. Lambda creates those groups with no expiry, so the CLI sets one after build (once the build is done with --wait; without it, the group may not exist yet) and on every up. With logRetentionDays unset, it reads the group (DescribeLogGroups) and puts the default 30 days only when the group has no retention: a retention you already set is kept. With logRetentionDays set (pylot devbox config set logRetentionDays 90, or PYLOT_DEVBOXES_LOG_RETENTION_DAYS), it puts that value every time (PutRetentionPolicy is idempotent), replacing whatever the group had. The value must be one CloudWatch accepts (1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653). logRetentionDays 0 leaves retention alone. It is best-effort: a failure prints one warning line and never fails the command. With an operator policy from before this release (no logs:DescribeLogGroups or logs:PutRetentionPolicy; redeploy the stack), the default is not applied and says so, and an explicit value is still attempted. MCP up applies it too, silently. A CLI from before this release refuses a config file with a logRetentionDays key (unknown key). Delete existing data with aws logs delete-log-group (it also removes runtime logs).
Logs Insights (log group /aws/lambda-microvms/<namespace>-*):
Each turn ends with one trace.end, whether it completed, failed or was aborted, so the first queries count turns.
# Turns, duration and status, per harness (done, failed and aborted rows)
filter telemetry = "trace" and event = "trace.end"
| stats count(*) as turns, avg(durationMs) as avgMs, pct(durationMs, 95) as p95Ms by harness, status
# Error classes of failed and aborted turns
filter telemetry = "trace" and event = "trace.end" and status != "done"
| stats count(*) as turns by harness, status, errorClass
# Event counts across turns: per turn (from trace.end) and per event type
filter telemetry = "trace" and event = "trace.end"
| stats count(*) as turns, avg(toolCalls) as tools, avg(toolErrors) as toolErrors, avg(llmCalls) as modelCalls,
avg(skillLoads) as skills, sum(dropped) as dropped by harness
filter telemetry = "trace" | stats count(*) as events by harness, event
# Tool timing and error rate
filter telemetry = "trace" and event = "tool.result"
| stats count(*) as calls, avg(durationMs) as avgMs, sum(status = "error") as errors by harness, tool
| sort calls desc
# Per-model-call timing and tokens (Claude Code; durationMs is approximate, see the runtime README)
filter telemetry = "trace" and event = "llm.call" and scope = "call"
| stats count(*) as calls, avg(durationMs) as avgMs, pct(durationMs, 95) as p95Ms,
avg(inputTokens) as input, avg(cacheReadTokens) as cacheRead by harness
# Token totals: scope "turn" only. scope "call" is a breakdown of the same tokens; never sum both.
filter telemetry = "trace" and event = "llm.call" and scope = "turn"
| stats sum(inputTokens), sum(outputTokens), sum(cacheReadTokens), sum(costUsd) by harness
# Skill events by outcome. Every loaded event is counted here, repeats included; the counted uses
# (one per agent, session, turn and skill) are the `counted` records of `pylot devbox export`.
filter telemetry = "trace" and event = "skill.load" | stats count(*) by skill, outcome
# One turn, step by step (take a traceId from any line above)
filter telemetry = "trace" and traceId = "<traceId>"
| fields ts, seq, event, tool, skill, outcome, status, durationMs, errorClass
| sort ts ascRecipes
A recipe is <recipesDir>/<name>.yaml, and its name must match the file name. The schema is RecipeSchema in @pylot-devboxes/core.
# recipes/web.yaml
name: web
description: Example web app with Postgres
repos:
- repo: example-org/web
- repo: example-org/shared-config
ref: main
path: config
- repo: example-org/monorepo
clone: # optional; default is a blobless partial clone (filter: blob:none)
sparse: [apps/web, packages/ui] # cone mode; or { patterns: ["/*", "!/archive/"] }
# filter: tree:0 # or none (legacy full clone)
# depth: 1 # shallow; throwaway boxes only (merge-base, rebase and some pushes break)
services: [postgres]
image:
steps: # system packages only; with source: clone, repos are not in the image
- RUN dnf install -y postgresql15-server && dnf clean all
hooks:
setup: .devbox/setup.sh # first boot, after cloning, in the first repo
start: .devbox/start.sh # every boot and resume
# startRun: "bin/rails s -d" # inline bash after the start script (also setupRun); for repos you can't add files to
ready: .devbox/ready.sh # readiness check after the start hook; exit 0 = healthy (default path; a missing file is no check)
# readyRun: "curl -fsS localhost:3000/up" # inline readiness check
# readyTimeoutSeconds: 120 # retried every 2 s until it passes or this runs out
secrets: [DATABASE_URL, PAYMENTS_TEST_KEY] # names only; values come from the broker. A map form marks a secret `proxy` so the agent never sees it (see the devbox-recipe skill)
harness:
default: claude
models: { codex: gpt-5-codex } # naming codex here also installs it
# install: [claude, codex] # CLIs in the image; default: `default` plus harnesses named in models/versions (an explicit codexBackend names codex)
git:
author: { name: Example Devbox, email: [email protected] }
resources: { vcpu: 2, memoryMiB: 4096, diskGiB: 8 } # disk default 8; 16/32 raise the Lambda baseline tier
maxLifetimeSeconds: 28800
idleSuspendSeconds: 900repos[].clone controls how much of a repo is fetched; see "Cloning large repos" in the devbox-recipe skill. With source: bake, a cone-mode sparse list also trims the archive shipped into the image (git archive pathspecs for --source-dir; the GitHub zipball is downloaded whole, then filtered); sparse.patterns do not trim it, so the image holds the full tree.
image.dockerfile (a path relative to the recipe file) replaces base and steps; its FROM must be a glibc Linux for the provider's arch. The provider always appends its own devbox layer.
Setup and readiness failures
A boot can fail in four places, and status, up --wait, wait (MCP) and logs tell them apart:
| Where | What you see | Diagnosis |
|---|---|---|
| Provider/VM boot | The provider's state (failed, terminated) and its reason, e.g. Run lifecycle hook timed out; no runtime status | No shell. pylot devbox logs <id> has what the VM logged |
| Control runtime | failure.kind: control (broker credentials, harness access, credential proxy) | No agent or exec; logs, then down |
| Repository setup | failure.kind: setup: clone, services, setup or start hook | After the clone: diagnosable. A clone failure is not |
| Application readiness | failure.kind: app: the recipe's readiness check (hooks.ready / readyRun) never passed within readyTimeoutSeconds | Diagnosable |
The runtime keeps the first failure in /v1/status and in the log: the stage, the command, its exit code, the last 4 KB of its output with secret values and token-shaped strings redacted, and the build, image and repo refs/commits it ran on. A failed rerun's own failure is shown as the current one; once a rerun succeeds, the first is shown as recovered (MCP: failure.recovered: true).
Redaction is best effort. It replaces the exact values of claimed secrets of 6 or more characters and the runtime's tokens, plus common token shapes (GitHub, OpenAI, Slack, AWS key ids, JWTs, Authorization: values, URL credentials). It does not catch shorter secrets, or a secret printed in another form (base64, URL-encoded, split across lines, or derived from it). Bake output is written to the build's CloudWatch log on every build, failed or not, and stays there for the log group's retention: keep secrets out of bake output. A box with a readiness check is ready only once the check passes; without one, app is unchecked and ready means the start hook exited 0.
When failure.diagnosable is true, the VM and control runtime are healthy, prompt and exec keep working and up --wait keeps the box. The manual recovery path, without a rebuild:
- Inspect:
pylot devbox status <id>(failure, log tail), thenpylot devbox exec <id> -- <cmd>(or a prompt) to look around:exec <id> -- bin/rails db:migrate:status,exec <id> -- tail -n 100 log/development.log. - Repair in place, deliberately: run the migration, install the missing package, fix the config. Nothing resets a database or migrates data for you.
- Rerun the failed stage:
pylot devbox rerun <id> setup|start|ready --wait. The stage, the stages after it and the readiness check run again; the box turnsreadyonly after the check passes, andstatuskeeps the original failure asrecovered. - Or stop:
pylot devbox down <id>. A kept box idle-suspends and expires on the recipe's normal schedule; nothing extends its lifetime.
Fix the recipe or the bake afterwards so the next boot does not need the repair.
Baked images
With source: bake (ADR 0007) the build ships a zip of each repo at its recorded SHA. During the build the runtime starts the services and runs bake.script, and the image snapshot captures the running services and data. Boot then only fast-forwards each repo to its ref and runs the start hook.
Sources come from GitHub's zipball API with GITHUB_TOKEN/GH_TOKEN or gh auth token (never printed), or from a local checkout with --source-dir owner/name=path (git archive; the commit must exist there, so fetch first). A repo whose SHA cannot be resolved fails the build. bake.secrets: true hands the recipe's secrets to the bake script; the values then live in the image, and builds shows bake+secrets. Rebuild when dependencies or the schema change.
Environment and ports
env is plain, non-secret configuration. It reaches the runtime unexpanded, and the runtime substitutes ${DEVBOX_ID}, ${DEVBOX_HOST} (this devbox's endpoint host) and ${DEVBOX_HOST_SUFFIX} (shared by all endpoints of the provider and region). ports lists application ports forward may reach, beyond the runtime's 8080.
A generic Node application with app-owned setup scripts:
# recipes/web.yaml
name: web
repos: [{ repo: example-org/web }]
image: { dockerfile: docker/web.Dockerfile } # recipes/docker/web.Dockerfile
source: bake
bake: { script: .devbox/bake.sh }
services: [postgres, redis]
env:
NODE_ENV: development
APP_ALLOWED_HOSTS: ${DEVBOX_HOST_SUFFIX}
ports: [3000]
secrets: [APP_CONFIG_KEY]# recipes/docker/web.Dockerfile
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential git curl ca-certificates libpq-dev postgresql redis-server \
&& rm -rf /var/lib/apt/lists/*
# The built-in service starts Debian's registered cluster, not a custom PGDATA.
# Trust only local sockets/loopback inside this isolated development VM.
RUN printf 'local all all trust\nhost all all 127.0.0.1/32 trust\nhost all all ::1/128 trust\n' \
> /etc/postgresql/15/main/pg_hba.confThe example app must supply .devbox/install.sh, .devbox/prepare.sh and
.devbox/start-app.sh; these are app-owned scripts, not built-in framework
commands. The install script uses the app's lockfile. Preparation creates or
migrates development and test databases and loads repeatable seeds. The start
script starts missing app/worker processes in the background, binds the app to
0.0.0.0:3000 and returns after checking the app's health route.
.devbox/bake.sh can call the install and preparation scripts when those steps
need no secrets and cause no external effects. Baked images skip the setup hook,
so per-boot secret configuration belongs in .devbox/start.sh. That hook calls
the preparation and start scripts idempotently. APP_ALLOWED_HOSTS is an example
configuration convention that the app must implement; it is not a built-in
runtime or framework setting. The database example uses local development trust
authentication; adapt it to the application's actual database setup.
Forwarding
pylot devbox forward "$id" 3000 # http://127.0.0.1:3000, open it in a browserforward proxies every request to the devbox endpoint for that port (endpointForPort on providers that offer it; otherwise only the runtime port 8080). It injects the provider's auth headers and refreshes them before they expire (Lambda tokens last at most 60 minutes). The upstream sees its own endpoint host, so the application's host allowlist must accept it. Origin and Referer are rewritten to the endpoint origin (for CSRF checks). Forwarded, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port tell the app the request arrived over the endpoint's scheme (HTTPS on Lambda) at that host, so frameworks such as Rails compute the same origin and accept the login POST; any forwarding headers the client sent are dropped first. Redirects to the endpoint host, over http or https, and cookie domains are rewritten back. Cookie flags such as Secure are kept: Chrome stores Secure cookies on http://127.0.0.1 (checked live), Firefox should too, and Safari is untested. Cookies on 127.0.0.1 are shared across ports, so every forward and local listener on 127.0.0.1 receives them: run one authenticated forward at a time, or use separate browser profiles. Bodies stream both ways, so SSE works, and WebSocket upgrades (for example Vite HMR) are piped through. The port must be in the recipe's ports or be 8080. Ctrl-C stops it.
Issue to PR, step by step
pylot devbox build web --wait # once per dependency change
id=$(pylot devbox up web --harness claude --wait)
pylot devbox prompt "$id" --wait "Read issue example-org/web#42 and summarize what has to change."
pylot devbox prompt "$id" --wait "Write a failing test for it, then make it pass."
pylot devbox prompt "$id" --wait "Run the full test suite and fix anything you broke."
pylot devbox prompt "$id" --wait "Push a branch and open a PR that closes #42 (devbox-pr create)."
pylot devbox suspend "$id" # step away
pylot devbox resume "$id" # same VM, or a restored one past the cap: use the printed id
pylot devbox down "$id"Inside the devbox the agent opens the PR with devbox-pr create --repo owner/name --head <branch> --title "…" [--body-file f] [--base b] [--draft], which prints the PR URL. The broker creates it with the session's Gi
