Agent sandboxes on Modal
Connect your Modal account, add a sandbox runtime, and launch, attach to and stop runs that each get their own sandbox.
Agent sandboxes are not available on any plan yet. This guide describes how they work once they are.
An agent sandbox is a small isolated machine that holds one run. A sandbox runtime gives each run its own sandbox. A run on one is a sandbox run. Today every sandbox runtime starts its sandboxes on your own Modal account, so you need a Modal account before you start.
What each side bills
Modal bills the compute to your Modal account. Oxagen bills no sandbox compute.
Oxagen bills model calls as it does for any run, and draws them from the model credential you add in step 4. The Free plan holds no sandbox runtime. Build, Scale and Enterprise hold as many as you need.
The figures on Spend are estimates. See Read the estimate.
1. Create the Modal environment and service user
Give Oxagen a Modal workspace of its own. The token you create below can start, read and stop every sandbox in that Modal workspace, not only the ones in one environment.
- In Modal, create an environment, such as
main. - In the Modal workspace settings, create a service user.
- Copy its token ID, which starts with
ak-, and its token secret, which starts withas-. Modal shows the secret once.
2. Connect Modal
An organization Owner or a workspace Admin does this on the Runtimes tab of Agents.
- Select Connect Modal.
- Enter the Modal workspace, the environment, the token ID and the token secret.
- Select Connect Modal again. Oxagen checks the token and the environment with Modal before it saves them.
The connection shows one of four states: Connected, Token refused, Environment missing, or Not connected. A refused token is stored, but no sandbox can start until you select Replace token and save a working one. The secret stays in the Oxagen vault. It never enters a sandbox, a response or a log.
Disconnect Modal deletes the stored token. It is refused while a sandbox runs on the account or a sandbox runtime still uses it.
The capabilities are connect_modal_account (docs/capabilities/modal-account.connect.md), get_modal_account (docs/capabilities/modal-account.get.md) and disconnect_modal_account (docs/capabilities/modal-account.disconnect.md).
3. Add a sandbox runtime
On the same tab, select Add sandbox runtime. An organization Owner or a workspace Admin can. The button stays blocked until Modal is connected.
A sandbox runtime holds:
- The template every sandbox starts from. It is a registry image, a Dockerfile in a connected repository, or an image pinned by its hash. A hash is a short code made from the image's content, and any change gives a different code. Oxagen queues a build. No run can start until a build is ready.
- The size of each sandbox: CPU, memory and disk, and a GPU if you want one.
- The hosts a sandbox may reach. A sandbox reaches every host through Oxagen's gateway, its only way out.
- The environment whose variables and secrets enter the sandbox. A runtime that names none uses the workspace's default environment.
- An optional run limit. A run older than it stops.
Containment is always on. The host operator is always your Modal account, and you cannot change it later. See create_sandbox_runtime (docs/capabilities/sandbox-runtime.create.md).
4. Add a model credential
A run calls its model with your own credential. Add one that serves the harness you plan to launch:
- Claude Code: an Anthropic API key, or a Claude subscription login.
- Codex: an OpenAI API key. Oxagen stores a ChatGPT login, but Codex runs don't use one yet.
- Stella: an Anthropic API key or an OpenAI API key.
There are two kinds of credential.
An API key serves a harness through its provider. Oxagen asks for the secret at a hidden prompt, or reads it from stdin.
oxagen credentials add --provider anthropicA subscription login uses your Claude or ChatGPT plan. It names the harness it serves: claude-code or codex.
oxagen credentials add --type subscription --harness claude-codeYou can also add either in the app. Open the Account menu in the top bar and choose Credentials. This page calls that place Account › Credentials. Select Add credential, then choose the harness, the kind and the provider. The gateway attaches the credential to your runs only. It never enters the sandbox. Without a credential for the harness, a launch is refused and says so.
oxagen keys is the same command as oxagen credentials, so oxagen keys add works too.
Model key rotation
Rotating a key replaces the secret behind one of your API keys. The credential keeps its ID, its name, its status and its place in the order your runs try credentials in. Only an API key can be rotated. A subscription login has no key to replace, so add the new login, then remove the old one.
In the app:
- Open Account › Credentials.
- On the key's row, select Rotate key. The panel names the key it replaces, such as "This replaces the key of Work, which ends 1a2b."
- Paste the new key into New API key.
- Select Rotate key. To leave the old key in place, select Keep current key instead.
The tab then says "Rotated. Your runs use the new key from their next model call." The row shows the new key's last four characters and when you rotated it, such as "Key rotated Oct 14, 2026, 4:51 PM".
From the CLI:
oxagen keys rotate <credential><credential> is the credential's ID, which starts with vlt_. Run oxagen keys list to see your IDs. The CLI looks up the credential, names it, and asks before it reads the new key:
Rotate the anthropic key "Work" ending 1a2b? Your runs use the new key from their next model call. [y/N]Answer y, then paste the new key at the hidden prompt. The CLI prints the key's new last four characters. A pipe can't answer the question, so pass --yes to pipe the new key in:
oxagen keys rotate vlt_… --yes < new-key.txtWithout --yes, a piped key is refused and nothing changes. oxagen keys list shows when each key was rotated in its ROTATED column.
Either way, your runs use the new key from their next model call. Oxagen keeps no copy of the old key, so revoke it at your provider once the rotation is done. Rotating also clears an "Out of usage" mark, because that mark described the old key. A rotation made from the CLI sends you an email and a notice in the app. See rotate_model_credential (docs/capabilities/rotate_model_credential.md).
Keys for your own endpoint
You can keep an API key for your own server that answers in OpenAI's format, along with the https address it answers on. Sandbox runs don't use such a key yet, because the gateway doesn't send model calls to your own server. Oxagen stores the key until it does.
In the app:
- Open Account › Credentials and select Add credential.
- Under Harness, choose Codex or Stella. The form doesn't offer your own endpoint for Claude Code.
- Under Kind, choose API key.
- Under Provider, choose OpenAI-compatible.
- Enter your server's address in Base URL, such as
https://llm.example.com/v1. - Paste the key into API key and select Save credential.
The row shows "Endpoint" and the address, and its status reads "Not in use yet".
From the CLI:
oxagen keys add --provider openai_compatible --base-url https://llm.example.com/v1Only --provider openai_compatible takes --base-url. The address must start with https://. Oxagen sends your key to it, so it refuses an address on a private network and an address with a username and password in it.
A launch whose only credential for the harness is a key for your own endpoint is refused. For Codex, add an OpenAI API key. For Stella, add an Anthropic or an OpenAI API key.
5. Launch a run
A run always starts on your own agent. Your first launch on a runtime enrolls one for you.
On the web: on the Runs page, find Your agents and select Launch beside one of them. Choose the harness, the runtime, the repository and the branch. Then choose how to start:
- Prompt: the run starts headless with your task.
- Work order: the run takes its repository and brief from the order.
- Open terminal: the run starts interactive.
Select Launch. The run first shows Starting, then Running.
From the CLI:
oxagen sandbox run --harness claude --workspace-slug <slug> --repo <owner/name> --prompt "Fix the failing test"In the CLI, --harness takes claude, codex or stella. --branch picks the branch, --runtime picks the runtime when the workspace has several, and --work-order <id> runs a work order in place of a prompt.
Oxagen checks a launch before it starts a sandbox. A refused launch says why and names the fix. Troubleshooting lists every reason a launch is refused and every way a run can fail to start. See launch_sandbox_run (docs/capabilities/sandbox-run.launch.md) and the CLI commands.
Send a batch of work items
On the Ready tab of Work, select up to 50 approved work items. Open the Send to agent menu and choose Send to sandbox runtime. The choice stays disabled until an agent in the workspace runs on a sandbox runtime. Pick one of your agents on a sandbox runtime and add steering if you want. Each item gets its own sandbox run on that agent's runtime.
Every item of a batch starts at once, each in its own sandbox on the same agent. Oxagen sends Modal five launches at a time, and no limit holds how many of the batch's sandboxes run together. An item whose launch must wait, such as while the runtime's template build is not ready, starts on a later pass, about 15 seconds later. An item that cannot be sent is listed as refused with its reason, and the rest go out. The batch page lists each item with its state, running time, estimate and an Attach to the run link. See send_work_orders (docs/capabilities/work.orders.send.md).
Items sent to an agent on a runtime that is not a sandbox runtime go as one run instead. See send_work_batch (docs/capabilities/work.batch.send.md). A sandbox run cannot take an order from that kind of batch.
6. Attach a terminal
Only the operator who launched a run can attach to it or stop it.
- Web: open the run and select Attach.
- CLI: run
oxagen sandbox attach <run> --workspace-slug <slug>. You can exportOXAGEN_WORKSPACEinstead of passing the flag. Press Ctrl-] to detach. The run keeps going.
If the connection drops, the terminal reconnects and replays the output it missed. The web terminal stops trying after five minutes. The CLI tries five times, about 15 seconds in all. The run may still be going, so attach again.
To end a run, select Stop the run or run oxagen sandbox stop <run> --workspace-slug <slug>. The sandbox ends at once, and anything the harness has not pushed is lost. A run also ends when the harness exits.
7. Runs longer than 24 hours
Modal ends a sandbox after 24 hours. A sandbox does not pause when it sits idle. It runs until its run ends.
A run with time left continues in a new sandbox. Within 15 minutes of the limit, Oxagen snapshots the sandbox's disk and starts a new sandbox from the snapshot. The harness resumes its session from disk. The run page shows which sandbox you are on. Oxagen keeps each snapshot for at most 48 hours, two sandbox lifetimes, and deletes it when the run continues again or ends. A new sandbox that does not match Oxagen's record stops the run as failed.
An open terminal closes at the switch. Attach again.
Read the estimate
Spend shows a Modal compute panel on its Month tab. It lists, for each sandbox runtime, the runs that ended, their running hours and an estimate.
- Oxagen records each estimate when a sandbox ends. A running sandbox has none yet and counts under Running now.
- The estimate multiplies running time by CPU cores, memory and GPU at Modal's published rates of the day the sandbox started. The panel shows the date of the rates.
- A runtime that pins a region is estimated at the highest regional price, so the figure does not read low.
Modal's bill is the figure to trust. See get_sandbox_spend (docs/capabilities/spend.sandbox.get.md).
Limits of the first release
- Modal is the only host. The other hosts in the contracts are refused.
- A sandbox runtime does not pause. It has no idle stop and no paused retention.
- The feature depends on an entitlement your plan grants. Operators of a self-hosted deployment also set the gateway host a sandbox is pinned to. See the
SANDBOX_REQUIRED_HOSTSentry inENV_REGISTRY(packages/config/src/registry.ts).
Troubleshooting
A harness that won't start shows why in one of four places:
- A refused launch starts nothing, and Modal bills nothing. The CLI prints
✗and the message from the tables below. With--json, it prints the reason ascode. The web shows a banner headed "Launch refused" with a shorter sentence for the same reason and, where one helps, a button to the page that fixes it. - A Modal start failure is refused the same way, and Oxagen also keeps the run, marked Failed.
- A run that started can still end Failed. Open the run. The Sandbox panel shows its state, and its Ended row says why it ended.
- A batch item shows its state on the batch page, with any refusal under it.
Refused launches
| Reason | Message | Cause and fix |
|---|---|---|
agent_sandboxes_not_released | "Agent sandboxes are not released yet, so this workspace cannot use sandbox runtimes. Run agents on a runtime an operator enrolls." | Your workspace doesn't have agent sandboxes yet, and no plan includes them. Run the agent on a runtime an operator enrolls. |
org_role_required | The web says "Your role does not let you launch runs in this workspace." | Only an organization Owner, a workspace Admin or a workspace Member can launch. Ask a workspace Admin for a role. |
gau_exhausted, billing_suspended, budget_exceeded | The web says "This organization has no credit left, so the run did not start. An org Owner adds credit on Billing." | The organization's billing is suspended, its included usage is used up, or a spend budget stopped the call. An organization Owner updates billing on the Billing page or raises the budget. |
budget_spent | "The "workspace monthly" budget is spent, so the run did not start. Ask a workspace admin to raise the budget." | A spend budget for the workspace or the organization is over its limit. Raise it on the Budgets tab of Spend, or wait for its period to reset. |
no_sandbox_runtime | "The product workspace has no sandbox runtime. An org Owner can add one on the Runtimes page." | Add a sandbox runtime, as step 3 says. |
sandbox_runtime_not_found | "The product workspace has no sandbox runtime named gpu. Pass --runtime with one of: default." | --runtime names a runtime the workspace doesn't have. Pass a slug the message lists. |
runtime_required | "The product workspace has 2 sandbox runtimes. Pass --runtime with one of: default, gpu." | Pass --runtime. The web always names a runtime, so only the CLI and the API see this. |
workspace_mismatch | "This request is scoped to the product workspace, not staging. Pass --workspace product, or call the staging workspace's address." | An API call names one workspace in its address and another in its body. Send the same workspace slug in both. |
repo_not_connected | "acme/api is not connected to the product workspace. Connect it on the Repositories page." | Connect the repository on the Repositories page, or check how you spelled it. A repository with the same name on the other forge doesn't count. When you name the forge, the message says which one Oxagen looked on. |
no_model_credential | "You have no Claude credential in Oxagen. Add one under Account, then Credentials." | You hold no credential the harness can use. Step 4 lists what each harness takes. A paused or expired credential doesn't count, and one that is out of usage still does. Add one, or resume a paused one, under Account › Credentials. |
custom_endpoint_not_used | "Sandbox runs don't use keys for your own OpenAI-compatible endpoint yet, and you have no other Codex credential in Oxagen. Add one under Account, then Credentials." | Your only key for the harness is for your own endpoint, which runs don't use yet. Add a key from the list in step 4. The web says "Sandbox runs don't use a key for your own endpoint yet." and offers a button to your credentials. |
launch_rule_denied | "The rule "No Friday runs" denies this launch. Ask a workspace admin to change the rule if the run should start." | A workspace decision rule refuses this harness, repository or runtime. A workspace Admin changes the rule. |
decision_rule_denied | "refused by decision rule "no-fridays": No Friday runs" | A workspace decision rule refuses the launch before the launch checks run. The message names the rule's ID and its description. A workspace Admin changes the rule. |
decision_rule_approval_required | "decision rule "gpu-runs" requires approval: GPU runs need a lead." | A workspace decision rule wants a person to approve the launch first. Oxagen creates no approval for this, so there is nothing for an approver to grant. The web shows it as a rule refusal. A workspace Admin changes the rule. |
pending_approval | The web says "The launch did not go through (pending_approval). Nothing started. Try again in a moment." | An access policy holds the launch until an approver grants your request. Launch again once it is granted. |
decision_rules_unavailable | A message that starts "Current decision rules are unavailable". | Oxagen could not read the workspace's decision rules, so it started nothing. Try again in a moment. If it keeps happening, a workspace Admin checks the workspace's rules. |
template_not_ready | "The default sandbox runtime has no ready template build, so no sandbox can start on it yet. Check the build on the Runtimes page." | The runtime has no ready build yet, because its first build is queued, building or failed. Open the runtime on the Runtimes tab of Agents. Wait for the build, or fix the template and select Rebuild template. The web shows this as soon as you pick the runtime. |
agent_sandboxes_unavailable | "Agent sandboxes are not set up on this deployment, so the run did not start. Contact Oxagen support." | The deployment has no gateway host to pin sandboxes to. On Oxagen's own cloud, contact support. A self-hosted deployment sets the host, as Limits of the first release says. |
invalid_input | Such as "a work order supplies the branch, so leave it out". | The request doesn't fit a launch: an empty prompt, a work order sent with a repository, branch or prompt, or neither a repository nor a work order. The web and the CLI catch most of these before they send. |
A launch that runs a work order can also meet these:
| Reason | Message | Cause and fix |
|---|---|---|
work_order_not_found | "This workspace has no work order wo_01k5te3m8b." | Check the order's ID on the item's page. |
work_order_held_by_another_operator | "Work order wo_01k5te3m8b was sent to an agent another person operates. Only that operator can start it." | Only the person who operates the order's agent can run it. |
work_order_batch_unavailable | "Work order wo_01k5te3m8b went out in a batch that runs in one run, and a sandbox takes one order. Send the items with send_work_orders to give each its own sandbox." | Send the items with Send to sandbox runtime instead. |
work_order_ended | "Work order wo_01k5te3m8b has ended, so no sandbox starts for it." | Send the item again to get a new order. |
work_order_claimed | "Work order wo_01k5te3m8b is already claimed by a runtime or another sandbox run." | Another run already has the order. Open that run instead. |
work_order_not_on_sandbox_runtime | "Work order wo_01k5te3m8b was sent to an agent whose runtime is not this workspace's sandbox runtime." | The order went to an agent on another runtime, or --runtime names a different runtime from the order's. Leave out --runtime, or send the item to an agent on a sandbox runtime. |
work_order_harness_mismatch | "Work order wo_01k5te3m8b was sent to an agent that runs codex. Launch with that harness." | Launch with the harness the message names. |
Three more reasons belong to sandbox hosts that Oxagen places: no_sandbox_capacity, gitlab_repo_needs_provider_runtime and work_order_launch_unavailable. No sandbox runtime uses those hosts in this release, so a launch on Modal never returns them.
Modal start failures
Oxagen records the run before it asks Modal for a sandbox. When that request fails, the launch is refused as above, and the run stays on record as Failed, with the end reason "Failed to start or crashed". When Modal refused the request, it started nothing and bills nothing. When the request timed out or its answer was lost, Modal may have started the sandbox anyway. Oxagen's orphan sweep finds that sandbox and stops it within about 10 minutes, and Modal bills the time it ran. On the web, both reasons show the banner "Modal did not start the sandbox. A workspace Admin checks the Modal account on the Runtimes tab of Agents."
| Reason | Message | Cause and fix |
|---|---|---|
sandbox_start_failed | "Modal did not start the sandbox:" followed by Modal's own words. | Modal refused the request, and its words say why. A token revoked in Modal fails here. The Runtimes tab may still read Connected, because Oxagen checks the token only when you connect it. Make a new service user token in Modal, then select Replace token on the Runtimes tab. If the environment is gone, create it in Modal again. Then launch again. |
modal_account_not_connected | "This workspace has no connected Modal account. Connect one with connect_modal_account first." | Oxagen holds no working Modal token for the runtime. An organization Owner or a workspace Admin selects Connect Modal on the Runtimes tab of Agents. |
modal_account_not_connected | "This sandbox runtime names no Modal environment." | The runtime has no Modal environment saved, so connecting Modal again doesn't fix it. A runtime takes the connected account's environment only when it is added. An organization Owner sets the environment with update_sandbox_runtime, or adds the runtime again. |
Failed and stalled runs
These runs started. Open the run and read the Sandbox panel.
| State | Ended | Cause and fix |
|---|---|---|
| Starting for minutes | Not yet | Oxagen moves a run to Running when Modal reports its sandbox running. It asks Modal about every 15 seconds, and a large image takes longer to start. A launch that an API process left unfinished, as a deploy can, ends Failed after 10 minutes, and Oxagen stops its sandbox at Modal. Launch again if that happens. |
| Failed | Failed to start or crashed | The harness exited with an error, or Oxagen stopped the sandbox before the harness started. Oxagen stops a sandbox whose image or allowed hosts don't match its record, and one whose work order another run claimed first. Launch again. If every run on the runtime ends this way, open the runtime and select Run test build to check that the template starts. |
| Failed | Its host stopped reporting | Modal no longer has the sandbox, such as when someone stopped it in Modal. Launch again. |
| Failed, after 24 hours | Failed to start or crashed | The new sandbox for a long run didn't match Oxagen's record, so Oxagen stopped both sandboxes. Anything the harness had not pushed is lost. Launch again. |
| Stopped | Ran past the runtime's run limit | The run reached the runtime's run limit. Raise the limit on the runtime if runs need longer. |
| Stopped | The harness exited | The harness finished and exited on its own. |
Batch items
The batch page lists each item with its state. A refused item shows why under it.
| State | Cause and fix |
|---|---|
| Waiting | The item's launch must wait. The runtime's template build isn't ready, an approver has yet to grant an access request, Oxagen could not read the decision rules, or the deployment has no gateway host. Oxagen tries again on every sandbox sweep, about every 15 seconds, and starts the item once the cause clears. When one launch waits, Oxagen starts no new launch in that pass. Launches already under way still finish, so some items after the waiting one may run. Only the items the pass had not reached wait. |
| Refused | When you send, the send lists each refused item with why: it changed since you read it, it isn't ready, its brief is out of date, or a duty rule refuses it. Read the item again and send it again. When it launches, the reason reads "The sandbox run did not start:" followed by a message from the tables above. A refusal from Oxagen's own checks, which run before the launch's, reads "Oxagen refused to start the sandbox run" with the reason in parentheses. authz_denied there means an access policy denies you the launch, so ask a workspace Admin. Look up any other reason in the tables above. Fix that cause, then send the item again. "The sandbox run did not start: the sandbox ended before it took the order." means the sandbox failed before it claimed the order, and you can send the item again. |
| Withdrawn | A person stopped the batch, or withdrew the item's order before its sandbox started. |
| Ended | The item's sandbox run ended, or another runtime took its order. |
The whole send is refused when the agent's runtime is not a sandbox runtime, or another person operates the agent. Send the items to an agent you operate on a sandbox runtime.