# Fork a computer-use agent's desktop

> Run Chrome on a Linux desktop in a sandbox, let Claude's computer use tool drive it, then fork the sandbox mid-task into live copies that carry on from the same screen.

URL: https://prized.dev/docs/sandboxes-fork-computer-use-desktop
Updated: 2026-11-06

A fork copies a sandbox's memory and every process, so a desktop forks with it: the display, Chrome with its open tab and a half-filled form, and the agent with its conversation. Sandboxes have no desktop template, so you set one up yourself, without root: packages unpacked in your home, Chrome's `.deb`, and a one-path patch to Xvfb. The agent is a short Python loop on Claude's computer use tool.

The steps use the `prized` CLI, signed in or with `PRIZED_API_KEY` set ([CLI](https://prized.dev/docs/cli#sandboxes)), and `ANTHROPIC_API_KEY` set to a key from your own Anthropic account.

## 1. Create a sandbox [#1-create-a-sandbox]

```bash
ID=$(prized sandbox create --max-runtime 1h)
```

It gets the default shape, 4 vCPU and 8 GB, the one the timings below were measured on. On the [free tier](https://prized.dev/docs/sandboxes#free-tier) it is 2 vCPU and 4 GB, and copies keep the parent's deletion time.

## 2. Install the desktop [#2-install-the-desktop]

Save `apt-home.sh` from [System packages without root](https://prized.dev/docs/sandboxes-own-template#2-add-system-packages-without-root): it downloads packages with apt and unpacks them into `~/sysroot`. Install Xvfb (the display), xdotool (mouse and keyboard), ffmpeg (screenshots), Chrome's libraries and fonts, then Chrome, and the Anthropic SDK in a venv:

```bash
prized sandbox cp apt-home.sh ${ID}:apt-home.sh
prized sandbox exec $ID --timeout 15m -- 'bash apt-home.sh xvfb x11-xkb-utils xdotool x11-utils ffmpeg libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 libatspi2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libvulkan1 libxshmfence1 fonts-liberation fonts-dejavu-core fonts-noto-color-emoji libgtk-3-0t64'
prized sandbox exec $ID --timeout 10m -- 'curl -fsSLo chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && mkdir -p ~/chrome && dpkg-deb --fsys-tarfile chrome.deb | tar -x -C ~/chrome && rm chrome.deb'
prized sandbox exec $ID --timeout 10m -- 'python3 -m venv venv && venv/bin/pip install -q anthropic'
```

Chrome is unpacked with `tar` because `dpkg -x` stops at its setuid helper, which needs root; Chrome does not need that helper: it still sandboxes its renderers.

## 3. Start the desktop [#3-start-the-desktop]

Save this script. It starts a 1280x800 display on `:1` and Chrome on a test page:

```bash title="desk.sh"
set -e
export LD_LIBRARY_PATH=$HOME/sysroot/usr/lib/x86_64-linux-gnu DISPLAY=:1
mkdir -p /tmp/xkb ~/desk && ln -sf ~/sysroot/usr/bin/xkbcomp /tmp/xkb/xkbcomp
python3 -c "import os; p = os.path.expanduser('~/sysroot/usr/bin/Xvfb'); b = open(p, 'rb').read(); assert b.count(b'/usr/bin\0') == 1; open(os.path.expanduser('~/desk/Xvfb'), 'wb').write(b.replace(b'/usr/bin\0', b'/tmp/xkb\0'))"
chmod +x ~/desk/Xvfb
~/desk/Xvfb :1 -screen 0 1280x800x24 -nolisten tcp -dpi 96 > ~/desk/xvfb.log 2>&1 &
for i in $(seq 50); do [ -e /tmp/.X11-unix/X1 ] && break; sleep 0.1; done
[ -e /tmp/.X11-unix/X1 ] || { cat ~/desk/xvfb.log; exit 1; }
~/chrome/opt/google/chrome/chrome --user-data-dir=$HOME/.chrome --no-first-run --no-default-browser-check --disable-sync --password-store=basic --test-type --window-position=0,0 --window-size=1280,800 --force-device-scale-factor=1 --disable-features=Translate,MediaRouter,OptimizationHints,PrivacySandboxSettings4 --disable-background-networking --disable-component-update --no-pings file://$HOME/work/form.html > ~/desk/chrome.log 2>&1 &
wait
```

The patch is the line that runs `python3`. Xvfb runs `xkbcomp`, its keyboard-map compiler, from `/usr/bin`, a path built into the binary, and without root nothing can be added there. So the script copies Xvfb, changes that one path to `/tmp/xkb`, which has the same length, and links the unpacked `xkbcomp` there.

The test page is a form with a name and three plans:

```html title="form.html"
<!doctype html>
<title>Create a workspace</title>
<body style="font: 22px sans-serif; margin: 60px">
<h1>Create a workspace</h1>
<form id="f">
  <p><label>Name <input id="ws" size="24"></label></p>
  <p>Plan:
    <label><input type="radio" name="plan" value="Starter" required> Starter</label>
    <label><input type="radio" name="plan" value="Pro"> Pro</label>
    <label><input type="radio" name="plan" value="Enterprise"> Enterprise</label></p>
  <p><button>Create</button></p>
</form>
<script>
  f.onsubmit = (e) => { e.preventDefault(); document.querySelector('h1').textContent = `${ws.value} is live on the ${f.plan.value} plan`; f.remove(); };
</script>
</body>
```

Copy both in and start the desktop:

```bash
prized sandbox cp desk.sh ${ID}:desk.sh
prized sandbox cp form.html ${ID}:form.html
prized sandbox exec $ID --detach -- 'bash desk.sh'
```

## 4. Start the agent [#4-start-the-agent]

The agent sends Claude screenshots of the display and carries out its clicks and typing with xdotool. It runs the task it is given, then waits for a file, `next.txt`, and carries on with what it says. That wait is where you fork:

```python title="cu.py"
import base64, os, ssl, subprocess, sys, time
import anthropic

SYS = os.path.expanduser("~/sysroot/usr")
ENV = dict(os.environ, DISPLAY=":1", LD_LIBRARY_PATH=SYS + "/lib/x86_64-linux-gnu")
KEYS = {"enter": "Return", "return": "Return", "esc": "Escape", "escape": "Escape", "backspace": "BackSpace", "tab": "Tab", "space": "space", "delete": "Delete", "pageup": "Page_Up", "pagedown": "Page_Down", "up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", "cmd": "super", "meta": "super"}
BUTTONS = {"left_click": (1, 1), "right_click": (3, 1), "middle_click": (2, 1), "double_click": (1, 2), "triple_click": (1, 3)}
SYSTEM = "You operate a Linux desktop (1280x800) with the computer tools. Chrome shows the page you work on. End each group of actions with a screenshot. Keep replies to a few words."
TOOLS = [{"type": "computer_toolset_20260801", "configs": {"zoom": {"enabled": False}}}]

def xdo(*args):
    return subprocess.run([SYS + "/bin/xdotool", *map(str, args)], env=ENV, check=True, capture_output=True, text=True).stdout

def keys(k):
    return "+".join(KEYS.get(p.lower(), p) for p in k.split("+"))

def hold(mods, down):
    for m in filter(None, (mods or "").split("+")):
        xdo("keydown" if down else "keyup", keys(m))

def screenshot():
    png = subprocess.run([SYS + "/bin/ffmpeg", "-loglevel", "error", "-f", "x11grab", "-video_size", "1280x800", "-i", ":1", "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"], env=ENV, check=True, capture_output=True).stdout
    open("screen.png", "wb").write(png)  # the last screen Claude saw
    return [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": base64.b64encode(png).decode()}}]

def act(name, inp):
    c, mods = inp.get("coordinate"), inp.get("text")
    if name == "screenshot":
        return screenshot()
    if name == "cursor_position":
        return [{"type": "text", "text": ", ".join(xdo("getmouselocation", "--shell").split()[:2])}]
    if name in BUTTONS or name in ("left_click_drag", "scroll"):
        hold(mods, True)  # modifier keys, such as shift, held for this one action
        try:
            if name == "left_click_drag":
                xdo("mousemove", *inp["start_coordinate"], "mousedown", 1, "sleep", 0.1, "mousemove", *c, "sleep", 0.1, "mouseup", 1)
            elif name == "scroll":
                if c: xdo("mousemove", *c)
                xdo("click", "--repeat", inp.get("scroll_amount", 3), {"up": 4, "down": 5, "left": 6, "right": 7}[inp["scroll_direction"]])
            else:
                if c: xdo("mousemove", *c)
                xdo("click", "--repeat", BUTTONS[name][1], BUTTONS[name][0])
        finally:
            hold(mods, False)
    elif name == "mouse_move":
        xdo("mousemove", *c)
    elif name in ("left_mouse_down", "left_mouse_up"):
        xdo(name.removeprefix("left_").replace("_", ""), 1)
    elif name == "type":
        xdo("type", "--delay", 20, "--", inp["text"])
    elif name == "key":
        for _ in range(int(inp.get("repeat", 1))): xdo("key", "--clearmodifiers", keys(inp["text"]))
    elif name == "hold_key":
        xdo("keydown", keys(inp["text"])); time.sleep(float(inp["duration"])); xdo("keyup", keys(inp["text"]))
    elif name == "wait":
        time.sleep(float(inp["duration"]))
    else:
        raise ValueError(f"{name} is not supported here")
    return [{"type": "text", "text": "OK"}]

def run(client, messages):
    for _ in range(30):  # a cap on turns, so a stuck task cannot run up a bill
        ssl.RAND_add(os.urandom(64), 64.0)  # a fork copy starts with its parent's random state: mix in its own
        reply = client.messages.create(model="claude-opus-5-5", max_tokens=8000, system=SYSTEM, tools=TOOLS, messages=messages, output_config={"effort": "low"}, cache_control={"type": "ephemeral"})
        messages.append({"role": "assistant", "content": reply.content})
        results, failed = [], False
        for block in reply.content:
            if block.type == "text":
                print("claude:", block.text, flush=True)
            elif block.type == "tool_use":
                print("act:", block.name, block.input, flush=True)
                result = {"type": "tool_result", "tool_use_id": block.id, "toolset_name": "computer"}
                try:
                    if failed: raise RuntimeError("Not executed: an earlier computer action in this turn failed.")
                    result["content"] = act(block.name, block.input)
                except Exception as e:
                    failed = True
                    result.update(content=[{"type": "text", "text": str(e)}], is_error=True)
                results.append(result)
        if not results:
            return
        messages.append({"role": "user", "content": results})
    raise SystemExit("stopped after 30 turns")

messages = [{"role": "user", "content": sys.argv[1]}]
run(anthropic.Anthropic(), messages)
print("waiting for next.txt", flush=True)
while not os.path.exists("next.txt"):
    time.sleep(0.2)
messages.append({"role": "user", "content": open("next.txt").read()})
run(anthropic.Anthropic(), messages)  # a new client: a fork closes the copy's connections to other machines
print("done", flush=True)
```

Copy it in and start it with the first half of the task. Your key goes only to this process, as an environment variable; the copies have it in memory, like everything else:

```bash
prized sandbox cp cu.py ${ID}:cu.py
P=$(prized sandbox exec $ID --detach --env ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY -- 'venv/bin/python cu.py "In Chrome, type Lovelace Labs in the Name field. Do not pick a plan. Then reply READY."')
until prized sandbox exec $ID --logs $P | grep -q 'waiting for next.txt'; do sleep 2; done
prized sandbox exec $ID --logs $P
# act: screenshot {}
# act: left_click {'coordinate': [233, 241]}
# act: type {'text': 'Lovelace Labs'}
# act: screenshot {}
# claude: READY. The Name field shows "Lovelace Labs"; no plan was picked and nothing was submitted.
# waiting for next.txt
```

If the agent fails, a wrong key say, the loop never ends: press Ctrl-C and read the error with `prized sandbox exec $ID --logs $P`.

## 5. Fork it mid-task [#5-fork-it-mid-task]

```bash
COPIES=($(prized sandbox fork $ID --count 3))
```

Each copy starts with the same screen, the same tab with the name typed in, and the agent's conversation in memory, so it carries on with no replay. In our runs on this shape, the command returned three running copies in 2 to 3.5 seconds. A running parent pauses for each fork: here it stopped for 1.5 to 3 seconds each time, then carried on ([Fork](https://prized.dev/docs/sandboxes#fork)).

Fork while the agent waits between turns, as here: a copy's connections to other machines are closed, so a request in flight at the fork is lost in the copies. And restart or reseed anything that makes keys or IDs, as `cu.py` reseeds OpenSSL before each request ([Do copies share randomness or identity?](https://prized.dev/docs/sandboxes-faq#do-copies-share-randomness-or-identity)).

From code, `Sandbox.connect(id).fork(3)` in Python or `await (await Sandbox.connect(id)).fork({ count: 3 })` in TypeScript makes the same copies ([Use Sandboxes from Python or TypeScript](https://prized.dev/docs/sandboxes-python-typescript#pause-fork-and-long-commands)).

## 6. Give each copy its own instruction [#6-give-each-copy-its-own-instruction]

```bash
set -- Starter Pro Enterprise
for C in "${COPIES[@]}"; do prized sandbox exec $C -- "echo 'Pick the $1 plan, click Create, and reply DONE.' > n.tmp && mv n.tmp next.txt"; shift; done
```

Each instruction is written under another name, then renamed, so the agent never reads it half-written.

## 7. Watch the copies [#7-watch-the-copies]

Each copy has its own log, which starts with the parent's, and its own `screen.png`, the last screenshot Claude took there. This waits for each copy's agent to finish, then prints the end of its log and copies its screenshot out:

```bash
for C in "${COPIES[@]}"; do until prized sandbox exec $C --logs $P | grep -qx done; do sleep 2; done; prized sandbox exec $C --logs $P | tail -2; prized sandbox cp ${C}:screen.png $C.png; done
# claude: DONE. The page now says "Lovelace Labs is live on the Starter plan."
# done
# claude: DONE. The page now says "Lovelace Labs is live on the Pro plan."
# done
# claude: DONE. The page now says "Lovelace Labs is live on the Enterprise plan."
# done
```

Each `.png` shows its own plan: "Lovelace Labs is live on the Starter plan", then Pro, then Enterprise. The parent's agent is still waiting at the plan step.

## 8. Delete them [#8-delete-them]

```bash
prized sandbox rm $ID "${COPIES[@]}"
```

Each copy bills per second at the parent's shape until you delete it or its run time ends ([Billing: Sandboxes](https://prized.dev/docs/billing#sandboxes)).
