Sign in

Fork a computer-use agent's desktop

Updated

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.

On this page8

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), and ANTHROPIC_API_KEY set to a key from your own Anthropic account.

1. Create a sandbox

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 it is 2 vCPU and 4 GB, and copies keep the parent's deletion time.

2. Install the desktop

Save apt-home.sh from 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:

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

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

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:

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:

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

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:

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:

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

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).

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?).

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).

6. Give each copy its own instruction

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

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:

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

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).