OpenHands agent-server · design · rev 3 · 5 Oct 2026

Reclaiming storage for local and remote sandboxes

Every conversation leaves something behind: caches and dependencies inside its sandbox, and for remote providers the sandbox itself, which keeps billing while paused. The design gives each piece one owner. The agent-server cleans the files in its own box. Whoever created the box owns the box. The provider pauses and destroys. The agent-server's HTTP API is the seam between them.

What changed since rev 1

Rev 1 (2 Oct) proposed one reclaimer inside the SDK that also drove remote providers through backend adapters. Review discussion and a real remote integration changed that.

The architecture

Frontend Canvas UI · shows state · asks the user no tokens · no provider calls · no timers same origin / IPC Orchestrator · created the box Canvas · sandbox-server · automations credentials, provider client, tunnels sets idle timeout / TTL at creation export → confirm → delete the box optional: delete long-paused boxes (only when always-on, never on desktop) reads activity from the agent-server Provider · the box's lifecycle DigitalOcean · Daytona · E2B · OpenHands Cloud pause on idle · resume · destroy billing · ideally a TTL for paused boxes provider API: create(idle, TTL) · delete sandbox (the box) pauses on idle (snapshot) Agent-server · everything is local HTTP API · the seam GET storage status POST reclaim (dry run) GET download-trajectory + retention per conversation Reclaimer (#5263–#5433) drop caches on conversation stop shed deps above a disk budget archive after N days (Docker) paths · Trash · under idle() HTTP via tunnel
Files are cleaned where they live. The box is managed by whoever made it, through the provider. The only thing that crosses the box boundary is HTTP to the agent-server: it reports activity and sizes, runs a reclaim pass on demand, and exports history. No provider knowledge enters the SDK.

Five principles

  1. Everything is local to the agent-server. It cleans its own disk with plain paths, whether the box is a laptop, a Docker container or a DigitalOcean microVM. That is why the path-based code in the PR stack is right, not a limitation; new conversation layouts get a new storage adapter.
  2. Whoever created the box owns its lifecycle. Pausing, deleting and paying for a sandbox happen outside it: Canvas for DigitalOcean sessions, sandbox-server and runtime-api for Cloud and evals, the outer agent-server for its inner Docker containers.
  3. The remote extension point is HTTP, not a Python protocol. Orchestrators already reach the agent-server over HTTP. A few endpoints serve every future provider; a RuntimeBackend hierarchy in the SDK would serve none of them better.
  4. Native first. When the provider has idle and expiry settings, set them at creation and let the provider enforce them. Only build a loop for what it can't do.
  5. Timers need an always-on owner. A policy like "delete boxes paused for 30 days" can run in the provider or on a server, never in a desktop app that may be closed, and never in a box that is paused.

Who owns what

Agent-server (SDK)

  • Caches, dependencies, archived runtimes, worktrees
  • Activity: per conversation (from state-file times) and for the whole box (idle_time)
  • History export

Doesn't own: the box, provider credentials, other boxes.

Orchestrator

  • Provider credentials, client, tunnels
  • Policies set when a box is created
  • Export, then delete; server-side retention when always-on

Doesn't own: files inside a box (it asks over HTTP).

Provider

  • Pause on idle, resume, destroy
  • Snapshots and billing
  • TTL for paused boxes (to ask for)

Doesn't own: which conversations matter to the user.

Frontend

  • Status, paused age, sizes
  • Connect, pause, delete
  • The "history will be lost" confirmation

Doesn't own: tokens, provider calls, anything on a timer.

One word, two levels: "archived"

The user sees the same thing at both levels: history readable, no more prompting. The difference is only what gets freed.

LevelWho does itWhat is freedWhat stays
ConversationAgent-server, after N inactive days (#5429)The conversation's whole runtime directory: sandbox home and workspace (Docker runtimes only; local mode has no retention)History, which is stored separately; the API returns 410 "read-only"
BoxOrchestrator, with user confirmationThe whole sandbox and its billed snapshotHistory exported first via /download-trajectory/{id}

#5429 currently calls the conversation level "retired"; rename it to "archived" to match OpenHands Cloud.

The agent-server API that makes remote work

EndpointStatusWhat it gives remote orchestrators
Storage statusnewDisk usage, plus per conversation: state, last_active, size, and what the next pass would free. Seven of fifteen providers expose no last-activity time, so the agent-server becomes the source of truth instead of every orchestrator tracking activity itself.
Reclaim nownewRuns one pass immediately, with a dry-run option, still in-process under the idle locks. Shrinks a box before a manual pause or delete, serves as the "maintenance script" (a curl), and backs real-scenario tests.
Retention per conversationnewSet when a conversation is created: default, N days, or on_stop. PR reviews and CI-style automations drop their runtime early; Slack conversations keep it longer.
GET /server_info → idle_timeexistsSeconds since the agent-server last did real work for the whole box. runtime-api's reaper already pauses pods on it. This is the box-level activity signal; per-conversation last_active in storage status is the conversation-level one.
GET /download-trajectory/{id}existsA zip of the conversation directory, with LLM keys and encrypted tokens in its JSON files redacted. Taken before deleting a box. GET /archive does the same for the workspace (git patch or tarball).

Rule for the new endpoints: storage status and reclaim must not call update_last_execution_time(), or maintenance would keep boxes alive past the idle reaper. Today the idle clock moves only on bash, file and git operations, conversation events, streaming and ACP activity. Endpoint names for the new rows are not decided yet. There is no import endpoint, so reopening an exported history read-only elsewhere is still a follow-up.

Worked example: DigitalOcean sessions in Canvas

  1. Create. Canvas, through its Node layer, creates an agent config from an OpenHands manifest, then sessions from that config. That call takes no idle timeout (only DigitalOcean's legacy create does), so the 15-minute default applies. The SDK reclaim settings belong in the template.
  2. Work. The agent-server inside cleans caches as conversations stop and sheds dependencies above the disk budget. The box stays small.
  3. Idle. After 15 minutes without LLM or tool calls, DigitalOcean pauses the session. Compute billing stops; the snapshot keeps billing for storage, which the in-box reclaim has kept small.
  4. Return. Canvas resumes the session before opening the tunnel. DigitalOcean says processes, memory and files are preserved and resume takes 200–300 ms, yet #17838 still allows 20 s for the agent-server to answer after a wake. Treat resume as fast, not instant.
  5. Done. The user clicks Delete in Canvas. Canvas downloads the history, confirms, and calls DELETE /v2/agents/sessions/{id}.

Gaps in #17838 today: no delete (paused sessions pile up, keep their slot in the team's session limit of up to 100 depending on tier, and bill for storage until deleted), and desktop only, because the DigitalOcean logic sits in Electron's main process. The fix is to move that logic into a Node module shared by Electron and the web ingress (/api/mars/*), and ask DigitalOcean for a TTL on paused sessions.

Fits every backend

The same three layers map onto everything we run or plan to run. OpenHands' own runtime-api, behind evals and staging (and very likely Cloud), already works this way; the design describes it rather than inventing something new.

Local Docker / worktreesruntime-api (evals; likely Cloud)DigitalOceanModal
OrchestratorThe outer agent-server (it creates the inner containers)sandbox-server for users; runtime-api's reaper for boxesCanvas mars moduleCanvas, automations or eval harness (none yet)
Create / deleteDocker container / retire (#5429)/start / /stop (stop deletes)create / DELETEcreate() / terminate()
SuspendStop the container/pause: scale to 0, volume kept; optional snapshot after 1 hPause in place, memory keptnone: snapshot the filesystem, terminate, restore as a new sandbox
Activity signalPer-conversation last_activeAgent-server idle_time (pause after 20 min in the Helm chart)DO's own: no LLM or tool calls for 15 min (sessions also report last_event_at)None native; an open tunnel connection counts
Native retention—Paused runtimes deleted after 1 day (fuse workspaces: 30 days); 12 h session capNone knownSnapshots expire after 30 days (memory: 7)
In-box reclaim givesThe whole jobSmaller volume, snapshot, archiveSmaller billed checkpointSmaller snapshot

runtime-api values from its code and Helm chart (private repo, HEAD d938163); production values for Cloud are not in these repos. Its workspace archive and volume snapshots are opt-in. Modal from its docs and modal-client @2bc73c2.

What the harder backends add

  1. A stable workspace id in the orchestrator. A box's identity can change: on Modal, resume is a new sandbox with a new id and URL; runtime-api rotates the session key on resume; DigitalOcean keeps the id. The orchestrator maps each workspace to its current box (id, url, key). The SDK is unaffected.
  2. Suspend comes in kinds. In place with memory (DigitalOcean, E2B), disk only (runtime-api, local Docker), snapshot-and-recreate (Modal), or none (Cloudflare). An orchestrator adapter exposes suspend() / resume() with two flags: memory kept, same identity. This is the one piece of rev 1's role interfaces that returns, in the orchestrator rather than the SDK.
  3. Reclaim, then archive, then snapshot. Every backend that snapshots (runtime-api's snapshotter when enabled, Modal before terminate, DigitalOcean checkpoints) bills the result as storage, and caches and node_modules are often a large share of it. Calling the reclaim endpoint first is its strongest use.

Two activity rules

Share the contract, not code: there are already three orchestrators in different services (outer agent-server, sandbox-server with runtime-api, Canvas mars). What they share is the agent-server API and the adapter shape above. Extract a provider interface from the mars module when a second Canvas provider (Modal) arrives.

Provider capabilities (for orchestrator adapters)

Providers' official docs and client code, plus runtime-api's source, checked 2–5 Oct 2026. ✓ yes · ~ partly · ✗ no. This is orchestrator knowledge, not SDK knowledge.

ProviderList + last activitySuspend, files keptDeleteNative lifecycle
DigitalOcean~ list; last_event_at (meaning unverified)✓ incl. memory✓~ idle pause, no TTL known
Daytona✓ lastActivityAt✓✓✓ stop, pause, archive, delete
E2B~ list, no activity✓ incl. memory✓~ auto-pause, never deletes
Modal~ list by tags, no activity~ no pause; snapshot + restore as new sandbox✓~ idle_timeout terminates; snapshots expire in 30 days
Cloudflare Sandbox✗ no list✗ sleep wipes files✓~ sleep after idle
OpenHands runtime-api✓ /list; activity from agent-server idle_time✓ volume kept (optional snapshot)✓ /stop✓ idle pause (20 min), paused deleted after 1 day

Kept from rev 1, and dropped

Kept

  • A reconcile pass that re-reads real state every time (Reclaimer.run_pass()).
  • Acting only while a conversation is held idle; select outside the lock, move (and mark) inside it.
  • Plan, then apply, now exposed as the dry run of the reclaim endpoint.
  • Native first: provider settings at creation, our code only for the gaps.
  • Suspend capability flags, moved from the SDK into orchestrator adapters.

Dropped

  • Remote backends and provider adapters in the SDK.
  • Role interfaces (Suspendable, Deletable, …) and a path-free abstraction.
  • An activity tracker in every orchestrator: the agent-server reports last_active.
  • Retention that deletes remote boxes automatically from a desktop app.

Pitfalls

Next steps