> ## Documentation Index
> Fetch the complete documentation index at: https://docs.archil.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Coding agent in a sandbox

> Give an AI agent a persistent Linux sandbox where it can install packages, write code, and run a web app you can open.

In the [quickstart](/getting-started/quickstart), every `bash` call runs in a fresh container. That's ideal for working with files, but a coding agent wants a real machine: packages it installs should still be there on the next command, and the dev server it starts should keep running after the command returns.

This guide gives an agent a [persistent sandbox](/compute/sandboxes/introduction) as its computer. The agent builds a small web app, runs it as a supervised service, and you open it at a public URL. Between sessions you pause the sandbox, and the next session picks up exactly where the last one left off, with the server still running. The project lives on an Archil disk mounted at `/workspace`, so the code outlives the sandbox.

## Prerequisites

Export your Archil API key, a region where sandboxes are available (`aws-us-east-1`, `aws-us-west-2`, or `aws-eu-west-1`), and an [AI Gateway](https://vercel.com/ai-gateway) key for the model:

```bash theme={null}
export ARCHIL_API_KEY=key-...
export ARCHIL_REGION=aws-us-east-1
export AI_GATEWAY_API_KEY=...
```

Then install the dependencies. The TypeScript agent uses the [AI SDK](https://ai-sdk.dev); the Python agent calls the same model through the gateway's OpenAI-compatible API.

<CodeGroup>
  ```bash TypeScript theme={null}
  npm init -y && npm pkg set type=module
  npm install disk ai zod tsx
  ```

  ```bash Python theme={null}
  pip install archil openai httpx
  ```
</CodeGroup>

## Define the agent

The agent has one tool, `bash`, that runs a command in the sandbox with `sandbox.exec`. Because it's the same machine every time, `npm install` in one call is visible to the next, and the instructions tell the agent to run servers with `archil-sandbox services create` so they keep running in the background, restart if they crash, and come back if the sandbox reboots.

<CodeGroup>
  ```typescript agent.ts theme={null}
  import { ToolLoopAgent, isStepCount, tool } from "ai";
  import type { Sandbox } from "disk";
  import { z } from "zod";

  const instructions = `You are a coding agent with a persistent Linux sandbox (Debian, Node.js 24, root).
  Your project is in /workspace. Anything you install stays installed between commands.
  Run servers as services so they keep running after your command returns:
    archil-sandbox services create <name> --tcp-port <port> --dir /workspace -- <command>
  After changing code, run: archil-sandbox services restart <name>
  To debug a service, run: archil-sandbox services logs <name>
  Servers must listen on 0.0.0.0. Keep your final answer to two sentences.`;

  export function createCodingAgent(sandbox: Sandbox) {
    const bash = tool({
      description: "Run a bash command in /workspace. Returns the exit status and output.",
      inputSchema: z.object({ command: z.string() }),
      execute: async ({ command }) => {
        console.log(`\n$ ${command}`);
        // exec runs /bin/sh; passing the command through an env var hands it to bash unmodified
        const result = await sandbox.exec('bash -c "$COMMAND"', {
          cwd: "/workspace",
          env: { COMMAND: command },
          timeoutSeconds: 120,
          onOutput: ({ data }) => process.stdout.write(data),
        });
        const status =
          result.status === "timed_out" ? "timed out after 120s" : `exit ${result.exitCode}`;
        return `${status}\n${result.stdout}${result.stderr}`.slice(-10_000);
      },
    });

    return new ToolLoopAgent({
      model: "anthropic/claude-sonnet-5.5",
      instructions,
      tools: { bash },
      stopWhen: isStepCount(25),
    });
  }
  ```

  ```python agent.py theme={null}
  import json
  import os
  import sys

  from openai import OpenAI

  client = OpenAI(
      base_url="https://ai-gateway.vercel.sh/v1",
      api_key=os.environ["AI_GATEWAY_API_KEY"],
  )

  INSTRUCTIONS = """You are a coding agent with a persistent Linux sandbox (Debian, Node.js 24, root).
  Your project is in /workspace. Anything you install stays installed between commands.
  Run servers as services so they keep running after your command returns:
    archil-sandbox services create <name> --tcp-port <port> --dir /workspace -- <command>
  After changing code, run: archil-sandbox services restart <name>
  To debug a service, run: archil-sandbox services logs <name>
  Servers must listen on 0.0.0.0. Keep your final answer to two sentences."""

  BASH_TOOL = {
      "type": "function",
      "function": {
          "name": "bash",
          "description": "Run a bash command in /workspace. Returns the exit status and output.",
          "parameters": {
              "type": "object",
              "properties": {"command": {"type": "string"}},
              "required": ["command"],
          },
      },
  }


  def bash(sandbox, command):
      print(f"\n$ {command}")
      # exec runs /bin/sh; passing the command through an env var hands it to bash unmodified
      result = sandbox.exec(
          'bash -c "$COMMAND"',
          cwd="/workspace",
          env={"COMMAND": command},
          timeout_seconds=120,
          on_output=lambda out: sys.stdout.write(out.data.decode(errors="replace")),
      )
      status = "timed out after 120s" if result.status == "timed_out" else f"exit {result.exit_code}"
      return f"{status}\n{result.stdout}{result.stderr}"[-10_000:]


  def run_agent(sandbox, prompt, max_steps=25):
      messages = [
          {"role": "system", "content": INSTRUCTIONS},
          {"role": "user", "content": prompt},
      ]
      for _ in range(max_steps):
          reply = client.chat.completions.create(
              model="anthropic/claude-sonnet-5.5",
              messages=messages,
              tools=[BASH_TOOL],
          ).choices[0].message
          messages.append(reply)
          if not reply.tool_calls:
              return reply.content
          for call in reply.tool_calls:
              command = json.loads(call.function.arguments)["command"]
              messages.append(
                  {"role": "tool", "tool_call_id": call.id, "content": bash(sandbox, command)}
              )
      return f"Stopped after {max_steps} steps."
  ```
</CodeGroup>

Two details in the tool keep long commands manageable:

* **`onOutput` streams output as it's produced**, so you can watch a slow `npm install` or test run instead of waiting for it to finish. The full output is still returned when the command exits.
* **`timeoutSeconds` bounds every command.** If the agent runs something that never exits, such as starting a server in the foreground, the command ends with status `timed_out` and the agent is told so, rather than hanging the loop. That's also why the instructions point it at services for anything long-running.

## Build the app

The first session creates the workspace disk and the sandbox, then hands the agent its task. When the agent finishes, the script exposes port 3000 publicly and requests the app from your machine. Finally, it pauses the sandbox, which stops compute and snapshots memory so running processes, including the web server, resume intact.

<CodeGroup>
  ```typescript session-1.ts theme={null}
  import * as archil from "disk";
  import { createCodingAgent } from "./agent.ts";

  const { disk } = await archil.createDisk({ name: "coding-agent-workspace" });
  const sandbox = await archil.createSandbox({
    name: "coding-agent",
    baseImage: "node:24",
    vcpuCount: 2,
    memSizeMiB: 4096,
    mounts: [{ disk, path: "/workspace" }],
  });

  const agent = createCodingAgent(sandbox);
  const { text } = await agent.generate({
    prompt:
      "Build a tiny Express app: GET / serves an HTML page with a Celsius-to-Fahrenheit form, " +
      'and GET /api/convert?c=100 returns JSON like {"c":100,"f":212}. ' +
      "Run it as a service named web on port 3000, then check it with curl.",
  });
  console.log(`\n${text}`);

  const hostname = await sandbox.exposePort(3000);
  const response = await fetch(`https://${hostname}/api/convert?c=100`);
  console.log(response.status, await response.text());
  console.log(`Open https://${hostname}`);

  await sandbox.pause();
  ```

  ```python session-1.py theme={null}
  import archil
  import httpx

  from agent import run_agent

  disk = archil.create_disk(name="coding-agent-workspace").disk
  sandbox = archil.create_sandbox(
      name="coding-agent",
      base_image="node:24",
      vcpu_count=2,
      mem_size_mib=4096,
      mounts=[archil.SandboxMountSpec(disk=disk, path="/workspace")],
  )

  text = run_agent(
      sandbox,
      "Build a tiny Express app: GET / serves an HTML page with a Celsius-to-Fahrenheit form, "
      'and GET /api/convert?c=100 returns JSON like {"c":100,"f":212}. '
      "Run it as a service named web on port 3000, then check it with curl.",
  )
  print(f"\n{text}")

  hostname = sandbox.expose_port(3000)
  response = httpx.get(f"https://{hostname}/api/convert?c=100")
  print(response.status_code, response.text)
  print(f"Open https://{hostname}")

  sandbox.pause()
  ```
</CodeGroup>

<CodeGroup>
  ```bash TypeScript theme={null}
  npx tsx session-1.ts
  ```

  ```bash Python theme={null}
  python session-1.py
  ```
</CodeGroup>

You'll see each command the agent runs, its streamed output, and then the response from your machine. The agent's steps vary from run to run; here is one run, with the long file and JSON output trimmed:

```text theme={null}
$ cd /workspace && ls && npm init -y >/dev/null && npm i express 2>&1 | tail -2

found 0 vulnerabilities

$ cd /workspace && cat > server.js <<'EOF'
const express = require('express');
(output trimmed)
archil-sandbox services create web --tcp-port 3000 --dir /workspace -- node server.js
sleep 2
curl -s localhost:3000/ | head -5; curl -s "localhost:3000/api/convert?c=100"; echo; curl -s "localhost:3000/api/convert?c=abc"
(output trimmed)
{"c":100,"f":212}
{"error":"Query parameter c must be a number"}
The Express app is running as the `web` service on port 3000. I checked it with curl: `GET /` returns the HTML form, `/api/convert?c=100` returns `{"c":100,"f":212}`, and `c=abc` returns a 400 error.
200 {"c":100,"f":212}
Open https://3000-agquucjuor64fnjugxbv2lektq.prod-aws-us-west-2-green.archil.app
```

Open the printed URL in your browser to try the form. The script has already paused the sandbox, but a request to an exposed port resumes a paused sandbox automatically, in about a second. The sandbox then keeps running until you pause or stop it again.

<Warning>
  `exposePort` makes the port reachable by anyone with the URL. Don't expose anything that handles real data without adding
  its own authentication. For programmatic access, use a port token instead; see
  [Hosting network services](/compute/sandboxes/introduction#hosting-network-services).
</Warning>

## Resume in a later session

A paused sandbox keeps its memory, its root filesystem, and its mounts. When you come back, find it by name, resume it, and give the agent its next task. `resume()` does nothing if a request already woke the sandbox. Either way, the web server answers right away because the process never stopped; it was frozen with the rest of the VM.

<CodeGroup>
  ```typescript session-2.ts theme={null}
  import * as archil from "disk";
  import { createCodingAgent } from "./agent.ts";

  const sandbox = (await archil.listSandboxes()).find((s) => s.name === "coding-agent")!;
  await sandbox.resume();

  const [{ hostname }] = await sandbox.listPorts();
  let response = await fetch(`https://${hostname}/api/convert?c=37`);
  console.log("after resume:", response.status, await response.text());

  const agent = createCodingAgent(sandbox);
  const { text } = await agent.generate({
    prompt:
      "In the app in /workspace, make /api/convert also accept f instead of c: " +
      'GET /api/convert?f=212 returns {"f":212,"c":100}. Restart the web service and check it with curl.',
  });
  console.log(`\n${text}`);

  response = await fetch(`https://${hostname}/api/convert?f=212`);
  console.log("after change:", response.status, await response.text());

  await sandbox.pause();
  ```

  ```python session-2.py theme={null}
  import archil
  import httpx

  from agent import run_agent

  sandbox = next(s for s in archil.list_sandboxes() if s.name == "coding-agent")
  sandbox.resume()

  hostname = sandbox.list_ports()[0].hostname
  response = httpx.get(f"https://{hostname}/api/convert?c=37")
  print("after resume:", response.status_code, response.text)

  text = run_agent(
      sandbox,
      "In the app in /workspace, make /api/convert also accept f instead of c: "
      'GET /api/convert?f=212 returns {"f":212,"c":100}. Restart the web service and check it with curl.',
  )
  print(f"\n{text}")

  response = httpx.get(f"https://{hostname}/api/convert?f=212")
  print("after change:", response.status_code, response.text)

  sandbox.pause()
  ```
</CodeGroup>

```text theme={null}
after resume: 200 {"c":37,"f":98.6}

$ cd /workspace && ls && cat package.json && grep -rn "convert" --include=*.js --include=*.ts . --exclude-dir=node_modules | head; archil-sandbox services list
(output trimmed)
`/api/convert` now accepts `f` as well as `c`. After I restarted the web service, `curl 'localhost:3000/api/convert?f=212'` returned `{"f":212,"c":100}`.
after change: 200 {"f":212,"c":100}
```

Pausing is the right default between agent sessions that are minutes or hours apart. If the sandbox will sit idle for longer, `stop()` it instead: memory is discarded, but the root filesystem and `/workspace` are kept, and the `web` service starts again on the next boot because services are supervised. Either way, the project files are on the Archil disk, so you can read them with the [disk APIs](/sdks/typescript) or mount the disk somewhere else even after the sandbox is gone.

## Clean up

A sandbox must be stopped before it can be deleted, and a paused sandbox can be stopped directly. Stop and delete the sandbox, then delete the workspace disk.

<CodeGroup>
  ```typescript cleanup.ts theme={null}
  import * as archil from "disk";

  const sandbox = (await archil.listSandboxes()).find((s) => s.name === "coding-agent")!;
  const diskId = sandbox.mounts[0].diskId;
  await sandbox.stop();
  await sandbox.delete();
  await (await archil.getDisk(diskId)).delete();
  ```

  ```python cleanup.py theme={null}
  import archil

  sandbox = next(s for s in archil.list_sandboxes() if s.name == "coding-agent")
  disk_id = sandbox.mounts[0].disk_id
  sandbox.stop()
  sandbox.delete()
  archil.get_disk(disk_id).delete()
  ```
</CodeGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Parallel agents" icon="code-fork" href="/guides/agents/parallel-agents">
    Fork a prepared sandbox per agent, run them side by side, and keep the best result.
  </Card>

  <Card title="Persistent sandboxes" icon="box" href="/compute/sandboxes/introduction">
    Lifecycle, services, network access, and forking in detail.
  </Card>

  <Card title="Sharing disks" icon="share-nodes" href="/concepts/sharing-disks">
    Mount one disk in several sandboxes at once with read-only and conditional mounts.
  </Card>

  <Card title="TypeScript SDK" icon="js" href="/sdks/typescript">
    Full reference for sandboxes, disks, and `exec` options.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.