Claude computer use
Claude sees the screen through screenshots and answers with mouse and keyboard actions. Something has to run that loop: send the task, do each action on a Mandala computer, send back what happened, and repeat until Claude stops asking for actions.
A Mandala computer is a full Linux desktop, so the actions land on a real screen rather than a simulated browser. There are three ways to drive one with Claude:
- Anthropic's tool runner with the Mandala driver. Anthropic's SDK runs the loop, and the Mandala SDK ships the driver that does each action on the computer. This is the shortest route, and the next section shows it.
- Your own loop, further down, when you want every request and result in your hands.
- The built-in computer agent, which runs the loop on the platform with your own Anthropic key or, when enabled for your account, Mandala AI credits.
The built-in agent and AI credits
Call POST /api/v1/computers/{id}/agent with your Mandala API key and a prompt. The computer must already be running. Send X-Model-Key for BYOK: Mandala uses that Anthropic key for the request without storing it, Anthropic bills you directly, and no Mandala AI credits are spent. The SDK equivalent is computer.agent(task, model_key=...) in Python; see the Python and TypeScript references.
Omit X-Model-Key for funded mode. It requires an eligible account with a plan, a supported billing country in Stripe, and at least $0.25 in available credits. Availability is controlled per rollout; a paid plan alone does not enable it. Funded runs send prompts, screenshots and tool results to Anthropic under Mandala's account and use Mandala's fixed system prompt and tools. Sonnet 5.5 is the default, with Opus 5.5 selectable. Anthropic fallback routing can serve other models, each priced at its own rate.
Funded calls debit 1.3× Anthropic's API list price. Solo/Studio/Swarm receive $5/$10/$50 each monthly billing period, expiring at period end. Top-ups of $10/$25/$50 expire 12 months from purchase. Without a plan, existing credits freeze while expiry continues; a purchase paid after plan removal is retained for issuance on resubscription if unexpired. The max_cost_usd admission budget defaults to $2; the final call can exceed it. Usage beyond remaining credits creates debt that survives expiry and is settled first from future grants and top-ups. Review the returned cost and stop reason, and check AI outputs before relying on them. See the API reference, pricing and terms.
Credits fund this built-in loop only. /chat/completions, your own loops and agents running inside a computer still require your own model provider.
Continuing a conversation
Each run that acts on the desktop, or finishes with an answer, is kept as one exchange in a thread on that computer: the prompt, the run's closing text and how it ended. Its thread_id comes back on the result. Send it with the next prompt to continue:
curl https://app.mandala.computer/api/v1/computers/$COMPUTER_ID/agent \
-H "Authorization: Bearer $MANDALA_API_KEY" -H 'Content-Type: application/json' \
-d '{"prompt":"Now do the same for Porto","thread_id":"'$THREAD_ID'","stream":false}'The SDKs (0.16.0 and later) take the same field: pass the result's thread id to the next run.
import os
from mandala_computer import Client
client = Client() # reads MANDALA_API_KEY
computer = client.computers.get(os.environ["COMPUTER_ID"])
lisbon = computer.agent("Find tomorrow's weather forecast for Lisbon")
porto = computer.agent("Now do the same for Porto", thread_id=lisbon.thread_id)
print(porto.stop, porto.text)import { Client } from 'mandala-computer';
const client = new Client(); // reads MANDALA_API_KEY
const computer = await client.computers.get(process.env.COMPUTER_ID!);
const lisbon = await computer.agent({ prompt: "Find tomorrow's weather forecast for Lisbon" });
const porto = await computer.agent({ prompt: 'Now do the same for Porto', threadId: lisbon.threadId });
console.log(porto.stop, porto.text);The model is given the earlier turns (the newest 64,000 characters) and told they are done, so it acts only on the new prompt. It still looks at the screen rather than assuming it is as an earlier run left it. A run that was stopped part way is replayed as one that may have done more than it reported. List and read threads with GET /api/v1/agent/threads and GET /api/v1/agent/threads/{thread}, and delete one with DELETE (client.agent_threads.list(), .get() and .delete() in Python, client.agentThreads in TypeScript). Threads are deleted with their computer.
Mandala keeps each run's prompt and closing text this way unless you send "store": false (store=False in Python, store: false in TypeScript), which keeps nothing and returns no thread_id. If you keep the conversation yourself, send it as messages ([{"role": "user" | "assistant", "content": "…"}], the same list in both SDKs) instead of thread_id.
Long jobs in the background
A run can take many minutes, and a stream: false request is cut at the edge after about 120 seconds. For a long job, start a background run instead: the same body (without stream) on POST /api/v1/computers/{id}/agent/runs answers 202 with a run id as soon as the run holds the computer, and the run goes on without your request.
curl https://app.mandala.computer/api/v1/computers/$COMPUTER_ID/agent/runs \
-H "Authorization: Bearer $MANDALA_API_KEY" -H 'Content-Type: application/json' \
-d '{"prompt":"Download this month’s invoices into ~/invoices","max_steps":60}'Poll GET /api/v1/agent/runs/{run} every few seconds for its status (running, completed, failed, cancelled or interrupted), steps so far, cost and ending, and stop it with POST /api/v1/agent/runs/{run}/cancel. In the SDKs, client.agent_runs (client.agentRuns in TypeScript) starts, reads, lists and cancels runs, and its wait polls one until it ends:
import os
from mandala_computer import Client
client = Client() # reads MANDALA_API_KEY
run = client.agent_runs.start(
os.environ["COMPUTER_ID"], "Download this month’s invoices into ~/invoices", max_steps=60
)
run = client.agent_runs.wait(run)
if run.finished:
print(run.result.text)
else:
print(run.status, run.error or run.result)import { Client } from 'mandala-computer';
const client = new Client(); // reads MANDALA_API_KEY
const run = await client.agentRuns.start(process.env.COMPUTER_ID!, {
prompt: 'Download this month’s invoices into ~/invoices',
maxSteps: 60,
});
const ended = await client.agentRuns.wait(run);
if (ended.finished) console.log(ended.result?.text);
else console.log(ended.status, ended.error?.error ?? ended.result?.stop);wait returns however the run ended, so check finished: only a completed run that ended end_turn did the task. It gives up after 30 minutes by default and raises a timeout error, but the run goes on until it ends or you stop it with cancel(run.id). A background run is always kept, so store: false is refused; it is kept in a thread like any other run, and thread_id on the run continues it. If the platform restarts while a run is going, the run ends interrupted with the steps it had taken; it is not resumed, so look at the desktop before starting another.
Before you start a local loop
- A Mandala API key in
MANDALA_API_KEY(Settings → Credentials → API keys), or a savedmandala loginprofile. - An Anthropic API key in
ANTHROPIC_API_KEY. In the local tool-runner and custom-loop examples below it stays on your machine and is never sent to Mandala. Built-in BYOK runs send it to Mandala as described above. - Python 3.10 or newer, or Node 22 or newer.
pip install 'mandala-computer[anthropic]'
# or
npm install mandala-computer @anthropic-ai/sdkWith Anthropic's tool runner
MandalaComputerToolset is a driver for Claude's computer toolset, computer_toolset_20260801. Pass it to the tool runner as a tools entry, and every action Claude asks for runs on the computer.
import anthropic
from mandala_computer import Client
from mandala_computer.anthropic import MandalaComputerToolset
client = Client() # reads MANDALA_API_KEY
with client.computers.ephemeral(template="base", name="claude-toolset") as computer:
computer.wait_for_guest()
# confirm is asked before every action. Approving all of them is a decision
# for a throwaway computer like this one.
with MandalaComputerToolset(computer, confirm=lambda context: True) as desktop:
runner = anthropic.Anthropic().beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=16000,
tools=[desktop],
messages=[{"role": "user", "content": "Open a terminal and show today's date."}],
)
for message in runner:
for block in message.content:
if block.type == "text":
print(block.text)import Anthropic from '@anthropic-ai/sdk';
import { Client } from 'mandala-computer';
import { MandalaComputerToolset } from 'mandala-computer/anthropic';
const client = new Client(); // reads MANDALA_API_KEY
await client.computers.ephemeral({ template: 'base', name: 'claude-toolset' }, async (computer) => {
await computer.waitForGuest();
// confirm is asked before every action. Approving all of them is a decision
// for a throwaway computer like this one.
const desktop = new MandalaComputerToolset(computer, { confirm: () => true });
try {
const runner = new Anthropic().beta.messages.toolRunner({
model: 'claude-opus-5-5',
max_tokens: 16000,
tools: [desktop],
messages: [{ role: 'user', content: "Open a terminal and show today's date." }],
});
for await (const message of runner) {
for (const block of message.content) if (block.type === 'text') console.log(block.text);
}
} finally {
await desktop.close();
}
});confirm is required by Anthropic's class while Claude can type and press keys. It receives each action's name and input before the action runs, so a real deployment can ask a person before anything with consequences. Turn type, key and hold_key off in configs to run without one.
The driver serves every action, zoom included. It handles what the loop below leaves to you:
- Every screenshot is fresh, so it never predates the action it answers.
- A
zoomis cut from the capture it was measured on, so the close-up is of the pixels Claude's rectangle was worked out in, even if another screenshot of the computer is taken in between. - A screen larger than the model accepts (2576 pixels on the long edge, or about 3.75 megapixels) is photographed smaller, and Claude's coordinates are scaled back up.
- A coordinate outside the screenshot is refused rather than moved to the edge.
- A
keywithrepeat, text longer than 400 characters, and awaitof up to 300 seconds are each spread over the platform's per-call limits. Ahold_keyis at most 30 seconds. - A failed action goes back to Claude as an error result in the platform's own words, and the run carries on.
The async form is AsyncMandalaComputerToolset with an AsyncComputer and anthropic.AsyncAnthropic().
Browser tasks with the tool runner
Use MandalaBrowserToolset for work inside web pages: read the DOM's accessibility tree, find elements, fill forms, read page text, and manage tabs. It implements Anthropic's browser_toolset_20260801 over the computer's authenticated CDP connection. Use MandalaComputerToolset for the whole desktop, native apps, browser chrome, or an existing desktop browser session. The browser driver creates its own 1280 × 720 nonpersistent context, with separate cookies and tabs; it does not take over the desktop's existing tabs or logins.
Use Mandala SDK 0.13.0 or newer, a running Linux computer with Chromium, and member-level or owner API access. The base template below supplies the browser. No local Chromium, Playwright installation or SSH tunnel is needed. Node also needs the optional ws package (8.18 or newer):
pip install 'mandala-computer[anthropic]>=0.13.0'
# or
npm install mandala-computer@^0.13.0 @anthropic-ai/sdk ws@^8.18.0These programs allow only HTTPS requests to example.com, read its page, and delete their temporary computer on exit. The Anthropic key stays on your machine.
from urllib.parse import urlsplit
import anthropic
from anthropic.tools import ToolError
from mandala_computer import BrowserSessionPolicy, Client
from mandala_computer.anthropic import MandalaBrowserToolset
def allow_example(_context, url):
parsed = urlsplit(url if "://" in url else "https://" + url)
if parsed.scheme != "https" or parsed.hostname != "example.com":
raise ToolError("This task may only visit https://example.com.")
client = Client()
with client.computers.ephemeral(template="base", name="claude-browser") as computer:
computer.wait_for_guest()
with MandalaBrowserToolset(
computer,
url_policy=allow_example,
session_policy=BrowserSessionPolicy(lease_seconds=1800, max_duration_seconds=7200),
) as browser:
runner = anthropic.Anthropic().beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=16000,
tools=[browser],
messages=[{"role": "user", "content": "Open https://example.com and describe the page."}],
)
for message in runner:
for block in message.content:
if block.type == "text":
print(block.text)import Anthropic from '@anthropic-ai/sdk';
import { ToolError } from '@anthropic-ai/sdk/helpers/beta/toolsets';
import { Client } from 'mandala-computer';
import { MandalaBrowserToolset } from 'mandala-computer/anthropic';
const client = new Client();
await client.computers.ephemeral({ template: 'base', name: 'claude-browser' }, async (computer) => {
await computer.waitForGuest();
const browser = new MandalaBrowserToolset(computer, {
urlPolicy: (_context, url) => {
const parsed = new URL(url.includes('://') ? url : `https://${url}`);
if (parsed.protocol !== 'https:' || parsed.hostname !== 'example.com') {
throw new ToolError('This task may only visit https://example.com.');
}
},
sessionPolicy: { leaseSeconds: 1800, maxDurationSeconds: 7200 },
});
try {
const runner = new Anthropic().beta.messages.toolRunner({
model: 'claude-opus-5-5',
max_tokens: 16000,
tools: [browser],
messages: [{ role: 'user', content: 'Open https://example.com and describe the page.' }],
});
for await (const message of runner) {
for (const block of message.content) if (block.type === 'text') console.log(block.text);
}
} finally {
await browser.close();
}
});Import the driver and BrowserFilePolicy from mandala_computer.anthropic or mandala-computer/anthropic. The TypeScript package has no mandala-computer/anthropic-browser export. Python also offers AsyncMandalaBrowserToolset for AsyncComputer and anthropic.AsyncAnthropic().
URL and action policy
url_policy / urlPolicy allows a URL by returning no value (None / undefined) and refuses it by raising / throwing ToolError. It checks direct navigation and intercepted HTTP(S) requests, including redirects and subresources. Allow the destinations your task actually needs; a site using a different host for assets will need that host permitted too. Callbacks must finish promptly.
This hook is not network isolation. WebSockets, WebRTC, WebTransport and traffic from frames that start before the driver attaches can escape it; use guest egress controls for network containment. Popups and workers are restricted, so sites depending on them may not work. Page reads return element refs that navigation invalidates; read the page again after a stale-ref error.
The URL hook does not approve form submissions or other consequential actions. Supply confirm to decide whether an action should run. javascript_exec is disabled by default; enabling it in configs requires confirm.
Explicit file access
Uploads and downloads are denied by default. To enable them, construct a BrowserFilePolicy for that exact computer, with a task ID (task_id in Python, taskId in TypeScript), then pass it as remote_file_policy / remoteFilePolicy. It belongs to one toolset and cannot be reused across tasks or computers. A local Anthropic file policy does not authorize guest file reads.
Trusted application code must stage upload bytes first: stage_local_file / stageLocalFile takes bytes, not a local path; stage_guest_file / stageGuestFile snapshots a file under configured guest_upload_roots / guestUploadRoots; stage_document / stageDocument takes bytes already retrieved with your Files API authorization. Give the returned handle's id to the model. Enable configs.file_upload.enabled and supply confirm; model paths and unstaged document IDs cannot cause arbitrary file reads.
Downloads separately require downloads=True / downloads: true and an approve_download / approveDownload callback. Only explicit approval publishes a guest path in download_completed. Copy approved data to durable storage before closing the toolset. The default and maximum limits are 1 MiB per file, 4 MiB of staged uploads and 4 MiB of approved downloads per context; you can lower them. This is a bounded file channel, not malware scanning or isolation from other tools with guest access. See the SDK file-policy examples for staging, approval metadata and cleanup behavior.
Session leases and cleanup
The examples explicitly opt into a renewable 30-minute lease with a two-hour absolute limit. Python takes BrowserSessionPolicy (from mandala_computer); TypeScript takes a sessionPolicy object. Lease and maximum are integer seconds with 60 <= lease <= maximum <= 7200. Renewal continues during actions and human confirmation pauses, using your Mandala account credential. Set auto_renew=False / autoRenew: false to stop at the initial lease. Without a session policy, the legacy connection expires after ten minutes even while active. A server lacking renewable-session support refuses the opt-in.
Read browser.session_status / browser.sessionStatus for deadlines, remaining time and terminal errors. Revocation, disconnect, lifecycle changes, terminal renewal failure or the absolute deadline end the session; the driver never silently reconnects or replays actions. An attached browser prevents automatic idle suspension and continues consuming computer resources. Always close the toolset: that disposes its context and revokes its browser capability, but does not stop Chromium or delete the computer. The examples' outer ephemeral scope deletes the computer too.
For raw CDP clients, authentication headers and the versioned lease contract, see the browser-connection API reference.
Your own loop
The rest of this guide writes the loop by hand. Do that when you want to choose exactly what goes into each request, add tools the tool runner does not run for you, or keep the conversation in your own store.
Keep that conversation append-only. On Claude 5.5 models, changing an earlier turn, for example replacing an old screenshot with a placeholder, invalidates the thinking that came after it, and on newer Anthropic accounts the next request is refused. To bound a long run, use Anthropic's server-side context editing rather than rewriting the history yourself.
Which tool version
Current Claude models take the computer toolset, computer_toolset_20260801. It is one entry in tools with no name and no display size. Claude's calls come back as tool_use blocks whose name is the action (left_click, type, screenshot, …), marked with toolset_name: "computer". One reply can hold several of them. Run them in order, then return one tool_result per call. Every result must also carry toolset_name: "computer".
The older computer_20251124 tool, with its computer-use-2025-11-24 beta header, still works on the models and platforms that offer it. Its calls are one tool_use named computer, with the action in input["action"] and the same input fields. To use the loops below with it, read the action from there instead of from the block's name, and leave toolset_name out of the results.
Coordinates and screen size
A Mandala computer's screen is 1280×800 unless you asked for another resolution when you created it. Screenshots come back at that size. Claude answers in screenshot pixels, so its coordinates can go straight to the SDK with no scaling.
That stops being true if you shrink screenshots before sending them, or if you create a computer larger than the model's image limit (2576 pixels on the long edge for current models). In that case, scale each coordinate back up and round it to a whole pixel before acting: round(x * factor) in Python, Math.round(x * factor) in TypeScript. The SDKs refuse a fractional coordinate. Pick the resolution when you create the computer, since it cannot change afterwards: resolution="1280x800" is a good default for computer use.
Mapping actions to the SDK
| Claude action | Python | TypeScript |
|---|---|---|
screenshot | c.screenshot(fresh=True) | c.screenshot(undefined, { fresh: true }) |
left_click, right_click, middle_click, double_click, triple_click | c.click(x, y, *mods) and friends | c.click(x, y, mods) and friends |
left_click_drag | c.drag(x, y, from_x=sx, from_y=sy) | c.drag(x, y, { x: sx, y: sy }) |
mouse_move | c.move(x, y) | c.move(x, y) |
left_mouse_down, left_mouse_up | c.mouse_down(), c.mouse_up() | c.mouseDown(), c.mouseUp() |
scroll | c.scroll(x, y, direction=d, amount=n) | c.scroll(x, y, { direction, amount }) |
type | c.type(text) | c.type(text) |
key | c.key("ctrl", "s") | c.key("ctrl", "s") |
hold_key | c.hold_key("shift", seconds=s) | c.holdKey(["shift"], s) |
wait | c.wait(seconds) | c.wait(seconds) |
cursor_position | c.cursor_position() | c.cursorPosition() |
Claude writes keys the way xdotool does: Return, ctrl+s, Page_Down. The SDK accepts X11 key names, so splitting a chord on + is the only translation.
Always pass fresh=True for a screenshot that Claude will act on. Without it, the platform may return a frame up to 1.5 seconds old (up to 30 seconds while the computer is busy). Claude then thinks its last click missed and clicks again.
The loops below turn off zoom to stay short. Zoom asks for a close-up of one rectangle of the screen, and the screenshot API crops on the server. Do it in two requests, cut from one capture:
- Measure. Take a fresh, small screenshot and read the capture it names and that capture's own size: in Python
seen = c.screenshot_info(64, fresh=True), thenseen.captureandseen.capture_size; in TypeScriptseen = await c.screenshotWithInfo(64, { fresh: true }), thenseen.captureandseen.captureSize. - Crop. Scale Claude's rectangle from the screenshot it was shown into the capture's pixels, round each value to a whole pixel (the SDKs refuse a fractional region), and ask for it from that capture: in Python
c.screenshot(capture=seen.capture, region=(x, y, width, height)), in TypeScriptc.screenshot(undefined, { capture: seen.capture, region: { x, y, width, height } }). Shrink a large crop with a width orscaleso it fits what the model takes.
Without capture, the crop is cut from whatever capture the platform holds when the second request arrives, which can be a newer one at another size. If the capture you measured is no longer held (a newer one replaced it, or the computer's host restarted or it moved), the crop is refused with a 409 and reason: "stale_capture": measure again. Both values are absent from a platform too old to name its captures; stop there rather than crop without one. Return the crop as the zoom result. Claude's later coordinates are still full-screen ones.
Python
import base64
import sys
import anthropic
import mandala_computer
from mandala_computer import Client
MODEL = "claude-opus-5-5"
TOOLS = [{"type": "computer_toolset_20260801", "configs": {"zoom": {"enabled": False}}}]
HALTED = "Not executed: an earlier computer action in this turn failed."
def keys(chord):
# "ctrl+shift+t" -> ("ctrl", "shift", "t")
return tuple(k for k in chord.split("+") if k)
def act(computer, name, args):
"""Do one computer action. Returns the tool_result content."""
x, y = args.get("coordinate") or (None, None)
mods = keys(args.get("text") or "") # held keys, for clicks and scroll
if name == "screenshot":
png = computer.screenshot(fresh=True)
data = base64.b64encode(png).decode()
return [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": data}}]
if name == "left_click":
computer.click(x, y, *mods)
elif name == "right_click":
computer.right_click(x, y, *mods)
elif name == "middle_click":
computer.middle_click(x, y, *mods)
elif name == "double_click":
computer.double_click(x, y, *mods)
elif name == "triple_click":
computer.triple_click(x, y, *mods)
elif name == "left_click_drag":
sx, sy = args["start_coordinate"]
computer.drag(x, y, from_x=sx, from_y=sy)
elif name == "mouse_move":
computer.move(x, y)
elif name == "left_mouse_down":
computer.mouse_down()
elif name == "left_mouse_up":
computer.mouse_up()
elif name == "scroll":
computer.scroll(
x, y, direction=args["scroll_direction"], amount=args.get("scroll_amount", 3), modifiers=mods
)
elif name == "type":
text = args["text"]
for i in range(0, len(text), 400): # at most 400 characters per call
computer.type(text[i : i + 400])
elif name == "key":
for _ in range(args.get("repeat", 1)):
computer.key(*keys(args["text"]))
elif name == "hold_key":
computer.hold_key(*keys(args["text"]), seconds=args["duration"])
elif name == "wait":
computer.wait(min(args["duration"], 30)) # the platform waits at most 30 s
elif name == "cursor_position":
at = computer.cursor_position()
return f"X={at[0]}, Y={at[1]}" if at else "The cursor position is not known yet."
else:
raise ValueError(f"unsupported action: {name}")
return "OK"
def run(computer, task, max_turns=40):
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
messages = [{"role": "user", "content": task}]
for _ in range(max_turns):
response = client.messages.create(model=MODEL, max_tokens=16000, tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "refusal":
return "Claude declined this task."
calls = [block for block in response.content if block.type == "tool_use"]
if not calls:
return "".join(block.text for block in response.content if block.type == "text")
# Run a batch in order and stop at the first failure.
results, failed = [], False
for call in calls:
result = {"type": "tool_result", "tool_use_id": call.id, "toolset_name": "computer"}
if failed:
result.update(is_error=True, content=HALTED)
else:
try:
result["content"] = act(computer, call.name, call.input)
except (mandala_computer.MandalaError, ValueError, KeyError) as e:
failed = True
result.update(is_error=True, content=f"{type(e).__name__}: {e}")
results.append(result)
messages.append({"role": "user", "content": results})
return "Stopped after max_turns."
if __name__ == "__main__":
task = " ".join(sys.argv[1:]) or "Open a terminal and show today's date."
client = Client() # reads MANDALA_API_KEY
with client.computers.ephemeral(template="base", name="claude-computer-use") as computer:
computer.wait_for_guest()
print(run(computer, task))ephemeral() deletes the computer when the block exits, even after an error. To keep the machine, use client.computers.launch(...) instead, and later call computer.stop() or computer.delete() yourself.
A failed action goes back to Claude as an is_error result carrying the SDK's message, which usually says what went wrong ("start the computer first", "the guest agent is busy"). Claude can then adjust. Errors from outside the loop, such as a revoked key, are raised as usual.
TypeScript
import Anthropic from '@anthropic-ai/sdk';
import { Client, type Computer } from 'mandala-computer';
const MODEL = 'claude-opus-5-5';
const TOOLS = [{ type: 'computer_toolset_20260801', configs: { zoom: { enabled: false } } }];
const HALTED = 'Not executed: an earlier computer action in this turn failed.';
type Args = Record<string, any>;
const keys = (chord = '') => chord.split('+').filter(Boolean);
/** Do one computer action. Returns the tool_result content. */
async function act(c: Computer, name: string, a: Args): Promise<string | object[]> {
const [x, y] = a.coordinate ?? [];
const mods = keys(a.text); // held keys, for clicks and scroll
switch (name) {
case 'screenshot': {
const png = await c.screenshot(undefined, { fresh: true });
const data = Buffer.from(png).toString('base64');
return [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data } }];
}
case 'left_click':
await c.click(x, y, mods);
break;
case 'right_click':
await c.rightClick(x, y, mods);
break;
case 'middle_click':
await c.middleClick(x, y, mods);
break;
case 'double_click':
await c.doubleClick(x, y, mods);
break;
case 'triple_click':
await c.tripleClick(x, y, mods);
break;
case 'left_click_drag':
await c.drag(x, y, { x: a.start_coordinate[0], y: a.start_coordinate[1] });
break;
case 'mouse_move':
await c.move(x, y);
break;
case 'left_mouse_down':
await c.mouseDown();
break;
case 'left_mouse_up':
await c.mouseUp();
break;
case 'scroll':
await c.scroll(x, y, { direction: a.scroll_direction, amount: a.scroll_amount ?? 3, modifiers: mods });
break;
case 'type': {
// At most 400 characters per call. Split by character, not by UTF-16
// unit, so an emoji is never cut in half.
const chars = Array.from(a.text as string);
for (let i = 0; i < chars.length; i += 400) await c.type(chars.slice(i, i + 400).join(''));
break;
}
case 'key':
for (let i = 0; i < (a.repeat ?? 1); i++) await c.key(keys(a.text));
break;
case 'hold_key':
await c.holdKey(keys(a.text), a.duration);
break;
case 'wait':
await c.wait(Math.min(a.duration, 30)); // the platform waits at most 30 s
break;
case 'cursor_position': {
const at = await c.cursorPosition();
return at ? `X=${at.x}, Y=${at.y}` : 'The cursor position is not known yet.';
}
default:
throw new Error(`unsupported action: ${name}`);
}
return 'OK';
}
export async function run(c: Computer, task: string, maxTurns = 40): Promise<string> {
const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY
const messages: any[] = [{ role: 'user', content: task }];
for (let turn = 0; turn < maxTurns; turn++) {
const response = await anthropic.messages.create({
model: MODEL,
max_tokens: 16000,
tools: TOOLS as any,
messages,
});
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'refusal') return 'Claude declined this task.';
const calls = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
if (calls.length === 0) {
return response.content.map((b) => (b.type === 'text' ? b.text : '')).join('');
}
// Run a batch in order and stop at the first failure.
const results = [];
let failed = false;
for (const call of calls) {
const result: Args = { type: 'tool_result', tool_use_id: call.id, toolset_name: 'computer' };
if (failed) {
Object.assign(result, { is_error: true, content: HALTED });
} else {
try {
result.content = await act(c, call.name, call.input as Args);
} catch (e) {
if (!(e instanceof Error)) throw e;
failed = true;
Object.assign(result, { is_error: true, content: `${e.name}: ${e.message}` });
}
}
results.push(result);
}
messages.push({ role: 'user', content: results });
}
return 'Stopped after maxTurns.';
}
const client = new Client(); // reads MANDALA_API_KEY
const task = process.argv.slice(2).join(' ') || "Open a terminal and show today's date.";
await client.computers.ephemeral({ template: 'base', name: 'claude-computer-use' }, async (computer) => {
await computer.waitForGuest();
console.log(await run(computer, task));
});As in Python, ephemeral() deletes the computer when the callback finishes or throws, including when waitForGuest() times out. launch() does not: if the computer fails to become ready, it throws and leaves the computer in place for you to inspect.
Making it better
- Say what the computer is. A short system prompt helps: "You are operating an Xfce desktop on Debian. Chromium (the default browser) and Firefox are in the Applications menu at the top left. The desktop user is
user." It saves Claude a few screenshots of exploring. - Use
execfor shell work. Anything that does not need the screen, such as installing a package or reading a file, is faster and cheaper as a command. Add a custom tool that callscomputer.exec(...). Passdesktop=True(Python) or{ desktop: true }(TypeScript) to start a program with a window. - Paste long text.
typesends keystrokes at about 12 ms per character. For a paragraph,computer.paste(text)puts it on the clipboard and presses Ctrl+V. In a terminal, useshift=True(Python) or{ shortcut: 'ctrl+shift+v' }(TypeScript). - Keep the context small. Every screenshot stays in the conversation. For long runs, clear old tool results with Anthropic's context editing, or start a new conversation from a summary.
- Watch it live. Open
computer.vnc.embed_url(embedUrlin TypeScript) in a browser while the loop runs; it is a watch-only desktop page. (view_urlis the same view as a raw WebSocket, for your own noVNC client.) - Bound the run.
max_turnscaps the model calls. A computer is billed while it runs, so delete it, stop it, or let it idle-suspend when you are done.
See also: Troubleshooting and the API reference.