How to keep a coding agent’s environment warm between jobs
· 10 min read · Mert Ozgun
A coding agent that runs on a fresh machine spends the start of every job rebuilding it: clone the repository, install dependencies, pull container images, warm the build cache. On a sandbox that is thrown away after each job, that work repeats every time, and every time it can fail on a slow mirror or a registry rate limit.
This guide does that work once and keeps it. You set an environment up, keep it in a Snapshot, give each task its own copy, and rebuild it only when something it is built from changes. The pattern works on any VM whose disk outlives it. The code uses Ortam’s API, where a Sandbox is a Linux VM whose disk is saved, and it is written to be run as it stands: every step waits for the one before it and stops the script if it fails.
One Sandbox per repository. Every job for the repository runs in the same Sandbox, one after another, and finds the last job’s dependencies, build outputs and agent session files in place. This is the simplest setup and the warmest one, but jobs share one working tree and one set of services, so running them at the same time needs a separate worktree and service state for each.
One Snapshot per repository, and a new Sandbox per task. You prepare the environment once and take a Snapshot. Each task gets its own Sandbox created from it, so tasks can run in parallel without sharing a working tree, and a task that breaks its machine breaks only its own copy.
Most of this guide builds the second, because it covers the first: for one Sandbox per repository, skip the Snapshot and stop the Sandbox between jobs.
Set up onceClone, install, pull images, run the tests
SnapshotStored under the key of its inputs
Task 412: its own Sandbox
Task 413: its own Sandbox
Task 414: its own Sandbox
Set up once, take a Snapshot, and give each task its own Sandbox from it.
Commands in a Sandbox run in the background. The API accepts a command, returns 202 with its ID, and the command keeps running after the request ends. So a script that sends git clone and then npm ci without waiting starts both at once, and npm ci runs before the repository exists.
These helpers wait. Save them as ortam.sh and source them from your scripts. They need curl, jq and uuidgen, and two variables: ORTAM_API, the API address, and ORTAM_API_KEY.
ortam.sh
set -euo pipefail# Sends a request and prints the response. A request that changes something gets one idempotency key, reused on# every retry, so a retry after a lost response never acts twice. Retries network errors, 429 and 5xx.ortam() { local method=$1 path=$2 body=${3:-} status attempt out key out=$(mktemp) || return 1 local args=(--silent --show-error --output "$out" --write-out '%{http_code}' -X "$method" -H "Authorization: Bearer $ORTAM_API_KEY") if [[ $method != GET ]]; then key=$(uuidgen) || return 1 args+=(-H "Idempotency-Key: $key") fi if [[ -n $body ]]; then args+=(-H "Content-Type: application/json" -d "$body"); fi for attempt in 1 2 3 4 5 6; do status=$(curl "${args[@]}" "$ORTAM_API$path") || status=000 case $status in 2??) cat "$out" || return 1; rm -f "$out"; return 0 ;; 000 | 429 | 5??) sleep $((attempt * 2)) ;; *) echo "$method $path: HTTP $status $(cat "$out")" >&2; rm -f "$out"; return 1 ;; esac done echo "$method $path: no answer after $attempt tries" >&2 rm -f "$out" return 1}# Reads a Snapshot or an operation until it's done, and prints it. Succeeds only if it completed.wait_for() { local path=$1 record state while :; do record=$(ortam GET "$path") || return 1 state=$(jq -r .state <<<"$record") if [[ $state != pending && $state != running ]]; then break; fi sleep 2 done if [[ $state != completed ]]; then echo "$path ended $state" >&2; return 1; fi echo "$record"}# Runs a shell command in a Sandbox and waits for it. Succeeds only if it exited with code 0. The optional third# argument is a JSON object of environment variables for this command.run() { local sandbox=$1 command=$2 env=${3:-"{}"} body id record state body=$(jq -n --arg c "$command" --argjson env "$env" '{argv: ["/bin/bash", "-lc", $c], env: $env}') || return 1 id=$(ortam POST "/v1/sandboxes/$sandbox/executions" "$body" | jq -er .resource.id) || return 1 while :; do record=$(ortam GET "/v1/executions/$id") || return 1 state=$(jq -r .state <<<"$record") if [[ $state != accepted && $state != running ]]; then break; fi sleep 2 done if [[ $state == exited && $(jq -r .exit_code <<<"$record") == 0 ]]; then return 0; fi echo "\"$command\" ended $state, exit code $(jq -r '.exit_code // "none"' <<<"$record")" >&2 return 1}# Stops a Sandbox and waits. Succeeds only if its final save completed, so the next start finds this run's files.stop_sandbox() { local op record op=$(ortam POST "/v1/sandboxes/$1/stop" | jq -er .operation.id) || return 1 record=$(wait_for "/v1/operations/$op") || return 1 jq -e '.result.final_save == "completed"' <<<"$record" >/dev/null || { echo "final save: $(jq -r .result.final_save <<<"$record"); the next start uses the save before it" >&2 return 1 }}
A command’s output can’t be read through the API yet: that route is to be published. Until it is, these helpers judge each step by its state and exit code, and you read output in a terminal on the Sandbox.
A private repository needs a credential to clone and fetch, and anything written to the Sandbox’s disk is saved and copied into every Snapshot. So don’t write the token to a file, a remote URL or the git config. Pass it to each git command as an environment variable instead: git reads configuration from GIT_CONFIG_COUNT and its numbered keys, and sends the header without writing it anywhere.
Use a token that expires, such as a GitHub App installation token, which lasts an hour. Ortam doesn’t return a command’s environment when you read the command back, and git doesn’t write the header to any file, so the token isn’t in the Sandbox’s saves or Snapshots unless a program writes it out. It is still visible to every process that command starts.
Create a Sandbox and run the setup an agent would otherwise repeat. Docker isn’t preinstalled on the base image, so install it first. Replace the repository and the commands with your project’s own:
Each run returns only when its command has ended, and the script stops at the first command that fails. Running the tests is part of the setup: it fills the build and test caches, and it proves the environment works before you copy it. The last line removes dangling images, the untagged leftovers of earlier builds, created more than a week ago, so they aren’t copied into every task (Keep the disk from growing).
You don’t save anything yourself. A running Sandbox is saved automatically, and a save covers the whole disk, including installed packages, Docker images and volumes (Saves and recovery).
Take a Snapshot of the prepared Sandbox and wait for it to complete. On a running Sandbox it captures the files at some point after the request is accepted, so take it only once setup has finished. Then stop the Sandbox, so it isn’t billed while it waits for the next rebuild:
Shell
snapshot=$(ortam POST "/v1/sandboxes/$golden/snapshots" | jq -er .resource.id)wait_for "/v1/snapshots/$snapshot" >/dev/nullstop_sandbox "$golden"
For each task, create a Sandbox from the Snapshot and check out the task’s commit. The checkout in the Snapshot is the one setup cloned, so the task’s first command fetches:
Images built from your own source, by a build: entry in the Compose file, are as old as the Snapshot, so each task builds them again from its commit. Docker reuses the cached layers, so only the layers after the first changed one are rebuilt.
Before the agent starts, check that the copy is warm. npm ci --offline fails if a package isn’t in npm’s cache, and --pull never makes Compose fail if an image it pulls isn’t on the disk:
Shell
run "$task" "cd app && npm ci --offline"run "$task" "cd app && sudo docker compose up -d --wait --pull never --build"
If npm ci --offline fails, the cache isn’t where npm looks for it, or the lockfile changed since the Snapshot and the key should have caught it. If Compose fails, look for a service with pull_policy: always or an image that isn’t pinned. These checks cover npm’s cache and the pulled images. The image builds, install scripts and running services can still use the network.
The new Sandbox gets a copy of the files and changes independently from then on. It doesn’t inherit the source’s settings or Connection grants, so set those on each new Sandbox before the agent starts.
When the task is done and you’ve collected what it produced, delete its Sandbox. Deleting it doesn’t affect the Snapshot:
For one Sandbox per repository, the end of each job is stop_sandbox instead. Check that it succeeds: if the final save fails, the next start uses the save before it, and the job’s last changes aren’t there.
A Snapshot goes stale when something it was built from changes: the lockfile, a Dockerfile, the Compose file, the images it pulls, or the base image. Rebuilding on a schedule wastes work when nothing changed and keeps a stale copy when something did. Key each Snapshot on its inputs instead.
Compute the key in your own system from a clone of the repository, at the task’s commit. Include package.json, whose install scripts can change without the lockfile changing. Pin images by digest in the Compose file (postgres:16@sha256:…), so a new image changes the file; a tag such as postgres:16 can point at a new image without any file changing.
Shell
# Prints a key for the environment at a commit. Fails if an input file is missing.env_key() { local commit=$1 file sums="ubuntu-24.04-v1" for file in package.json package-lock.json Dockerfile docker-compose.yml; do sums+=" $file:$(git -C "$CLONE" show "$commit:$file" | sha256sum | cut -c1-64)" || return 1 done printf '%s' "$sums" | sha256sum | cut -c1-16}
Add any other file that changes what gets installed, such as .npmrc, and use the base image your Sandboxes are created with.
The key doesn’t include your application’s source, so a new commit doesn’t make a new Snapshot. The images built from that source are rebuilt in each task instead, as shown above.
Store the key next to the Snapshot ID. When a task arrives, compute the key at the task’s commit. If a Snapshot has that key, create the task’s Sandbox from it. If not, build a new one: create a Sandbox from the newest Snapshot, check out the task’s commit, run the install and build steps again, and take a Snapshot under the new key. The package manager’s cache is on the disk, so the install downloads mostly what changed. If the base image itself changed, run the whole setup on a new Sandbox instead, because a Snapshot keeps the operating system it was taken with.
A task arrivesFrom your queue, with its commit
Compute the keyLockfile, package.json, Dockerfile, Compose file, base image
Is there a Snapshot with this key?
Yes
Create the task’s Sandbox from itThen check out the task’s commit
No, same base image
Rebuild from the newest SnapshotCheck out, install, take a new Snapshot under the new key
No, new base image
Set up from scratch on the new baseA Snapshot keeps the system it was taken with
Refresh the environment only when its inputs change.
Two tasks can arrive with the same new key at once. Build each key once, under a lock in your own system, and have the other task wait for it or start from the previous Snapshot and install what changed.
A save keeps files, not running processes, so memory, open sessions and anything in /tmp or /run are gone after a stop. Services start again at boot if they’re enabled, as on any Linux server. Packages such as docker.io and postgresql enable their service when they’re installed; enable your own units with sudo systemctl enable.
Containers come back if they have a restart policy and existed when the Sandbox stopped, because Docker starts them again when its daemon starts. The setup above created them with docker compose up -d:
A database that was writing when a save happened is in the same state as after a power cut. Postgres and most other databases recover from that when they start. Test data you can’t recreate belongs in a dump file, not only in the database’s own files.
Every file on the disk is saved, and saved data is billed per GB a month (Pricing and limits). Old images and caches add up across many Snapshots. In a terminal on the Sandbox, df -h / and sudo docker system df show what takes the space. The setup’s last step prunes images before the Snapshot is taken; add your own caches there if they grow.
Keep the images and caches the tests need. Pruning those makes every task download them again, which is the work this setup avoids.
Files are copied as they are. A Sandbox created from a Snapshot has the same SSH keys and tokens as the source, and a file you delete is gone from later saves but stays in earlier ones and in Snapshots taken before you deleted it.
Pass secrets to the commands that need them as environment variables, as with the git token above. For a model account, grant the task’s Sandbox a Connection: the credentials it delivers live in temporary storage that is never saved. If a credential does end up on a disk you copied, rotate it.
What a hardware failure can lose in an agent’s sandbox, how Ortam picks the save it recovers from, why it never reruns a command, and what your system should do to bound the loss.
A coding agent’s sandbox can start fresh, from a prebuilt image, or on the disk the last job left. What each one costs in waiting, failures, drift, parallel work and money, and how to measure it on your own jobs.