Add computer use to your product
How to give each of your users a sandbox that Claude works on, show them its screen live beside a log of each step, let them take control to sign in, and stop the sandbox when the task is done.
This guide shows how to build computer use into your own product with the TypeScript SDK. Claude works a sandbox’s browser on your server. Your user watches the screen live in your page, next to a list of what Claude did, and takes control when a site asks them to sign in.
Your API key stays on your server. The browser gets a sandbox token, which opens one sandbox’s screen and nothing else.
Before you start
@divergentlabs/sandbox, installed withnpm install @divergentlabs/sandbox.- On your server: an API key with the
sandboxes:writescope inDIVERGENT_API_KEY, and an Anthropic API key inANTHROPIC_API_KEY. Install@anthropic-ai/sdk. - In your page:
@novnc/novnc, andreactif you use the React component.
1. Create a sandbox for the user
Create one sandbox per user or per task, and tag it with your own user id in metadata. Do not give it secrets: a sandbox that has secrets cannot have a token, in any environment, because the user with control, and any page Claude reads, could read them. For a sandbox that needs your customer’s credentials, see Keep each customer’s credentials in its own environment.
// server/computer.ts
import Anthropic from '@anthropic-ai/sdk'
import { Divergent, type Sandbox } from '@divergentlabs/sandbox'
import { computerToolset, type ComputerStep } from '@divergentlabs/sandbox/anthropic'
const divergent = new Divergent({ apiKey: process.env.DIVERGENT_API_KEY! })
const anthropic = new Anthropic()
export async function createComputer(userId: string) {
const sandbox = await divergent.sandboxes.create({
size: 'standard',
metadata: { user: userId },
idleTimeoutSeconds: 900,
})
await sandbox.desktop.start()
return sandbox
}
standard has 4 GiB of memory, enough for several tabs, at $0.10 an hour. idleTimeoutSeconds: 900 stops the sandbox after 15 minutes without use.
2. Run the model on your server
Keep one computerToolset and one message list per conversation. onAction is called after each action, so pass each step to your user’s page over the connection your app already has, such as server-sent events or a WebSocket.
const SYSTEM =
'You use a Linux desktop with Chromium. When a site asks you to sign in, stop and say so. ' +
'The user signs in themselves, then asks you to continue.'
export function createSession(sandbox: Sandbox, send: (step: ComputerStep) => void) {
const messages: Anthropic.Beta.BetaMessageParam[] = []
const toolset = computerToolset(sandbox.desktop, {
// Leave out step.screenshot unless the page shows thumbnails. It is a base64 PNG.
onAction: ({ name, input, error }) => send({ name, input, error }),
})
async function prompt(text: string) {
messages.push({ role: 'user', content: text })
for (;;) {
const response = await anthropic.beta.messages
.stream({
model: 'claude-opus-5-5',
max_tokens: 64000,
thinking: { type: 'adaptive' },
output_config: { effort: 'medium' },
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
system: SYSTEM,
tools: [toolset.tool],
messages,
})
.finalMessage()
messages.push({ role: 'assistant', content: response.content })
if (response.stop_reason !== 'tool_use') return response
messages.push({ role: 'user', content: await toolset.run(response.content) })
}
}
return { prompt }
}
prompt returns when Claude stops calling tools: the task is done, or it is waiting for the user to sign in. Show its last text block to the user.
The screen is 1280x800, so the positions Claude sends are screen pixels and need no scaling.
3. Mint a token for the user’s browser
Add a route that mints a token for the signed-in user’s sandbox. Check that the sandbox is theirs first.
export async function screenToken(userId: string, sandboxId: string, ability: 'view' | 'control') {
const sandbox = await divergent.sandboxes.get(sandboxId)
if (sandbox.metadata.user !== userId) throw new Error('Not your sandbox')
const grant = await sandbox.createToken({
abilities: [ability],
expiresIn: ability === 'control' ? 300 : 3600,
origins: ['https://app.example.com'],
})
return { token: grant.token, sandbox: grant.sandbox, expiresAt: grant.expires_at }
}
originslists the pages that may use the token. Addhttp://localhost:5173, or your development origin, while you build.- A token works for 60 to 3600 seconds. Mint a new one before
expiresAt. A new token reconnects the screen. - A
viewtoken shows the screen, and the API ignores its keyboard and mouse input.
4. Show the screen and the steps
Mount <SandboxScreen> with the token, and list the steps beside it.
// app/ComputerPanel.tsx
import { SandboxScreen } from '@divergentlabs/sandbox/react'
type Step = { name: string; input: Record<string, unknown>; error?: string }
type Grant = { token: string; sandbox: string }
export function ComputerPanel(props: {
grant: Grant
mode: 'view' | 'control'
steps: Step[]
onTakeControl: () => void
onHandBack: () => void
}) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
<SandboxScreen
token={props.grant.token}
sandbox={props.grant.sandbox}
mode={props.mode}
style={{ aspectRatio: '16 / 10' }}
/>
<div>
<ol>
{props.steps.map((step, i) => (
<li key={i}>
{describe(step)}
{step.error ? ` (failed: ${step.error})` : ''}
</li>
))}
</ol>
{props.mode === 'view' ? (
<button onClick={props.onTakeControl}>Take control</button>
) : (
<button onClick={props.onHandBack}>Hand back</button>
)}
</div>
</div>
)
}
function describe({ name, input }: Step) {
const at = Array.isArray(input.coordinate) ? ` at ${input.coordinate.join(', ')}` : ''
switch (name) {
case 'screenshot': return 'Looked at the screen'
case 'zoom': return 'Looked closer at part of the screen'
case 'left_click': return `Clicked${at}`
case 'double_click': return `Double-clicked${at}`
case 'type': return `Typed "${input.text}"`
case 'key': return `Pressed ${input.text}`
case 'scroll': return `Scrolled ${input.scroll_direction}${at}`
case 'wait': return `Waited ${input.duration} s`
default: return name.replaceAll('_', ' ')
}
}
The step names are the members of Claude’s computer toolset. See Claude for the full list.
5. Let the user take control to sign in
When Claude stops because a site asks for a sign-in, let the user do it in the screen. Their password goes from their keyboard to the sandbox, and never through Claude or your server.
-
When the user clicks Take control, ask your server for a
controltoken. Pass it to<SandboxScreen>and setmodetocontrol. The user’s keyboard and mouse now work the desktop. -
When the user clicks Hand back, ask your server for a
viewtoken. Pass it to<SandboxScreen>and setmodetoview.Setting
modetoviewstops the page from sending input, but acontroltoken allows input until it expires. Replace it with aviewtoken, and keepcontroltokens short. Step 3 gives them 300 seconds. -
Continue the task on your server:
await session.prompt('I have signed in. Continue the task.')
Don’t call prompt while the user has control. Claude and the user would both move the pointer.
6. Stop or delete the sandbox
When the task is done, stop the sandbox or delete it.
await sandbox.stop() // keeps files and sign-ins; billed for storage only
await sandbox.resume() // the next task starts where this one ended
await sandbox.destroy() // deletes the sandbox and its files
A stopped sandbox keeps the browser’s cookies, so the user stays signed in for the next task. To find a user’s sandboxes, filter by the metadata from step 1:
const sandboxes = await divergent.sandboxes.list({ metadata: { user: userId } })
A running sandbox is billed by the minute. A stopped sandbox is billed for the data it changed, with the first 25 GiB a month included. See Sandbox pricing and plans.
Troubleshooting
| Code | Cause | Fix |
|---|---|---|
origin_refused |
The page’s origin is not in the token’s origins. |
Add the page’s origin, such as https://app.example.com, to origins when you mint. |
sandbox_has_secrets |
The sandbox was created with secrets. | Create a sandbox without secrets. |
insufficient_ability |
The page sent input with a view token. |
Mint a control token for the user. |
no_desktop |
The desktop is not running. | Call sandbox.desktop.start(). |
not_running |
The sandbox stopped, for example after its idle timeout. | Call sandbox.resume(), then sandbox.desktop.start(). |
If the screen disconnects, onDisconnect is called. The token may have expired: mint a new one.
For every method, option and error code, see the TypeScript SDK reference.