# 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](https://docs.divergentlabs.xyz/sandboxes/sdk.md). 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 with `npm install @divergentlabs/sandbox`.
- On your server: an API key with the `sandboxes:write` scope in `DIVERGENT_API_KEY`, and an Anthropic API key in `ANTHROPIC_API_KEY`. Install `@anthropic-ai/sdk`.
- In your page: `@novnc/novnc`, and `react` if 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](https://docs.divergentlabs.xyz/sandboxes/environments.md).

```ts
// 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.

```ts
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.

```ts
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 }
}
```

- `origins` lists the pages that may use the token. Add `http://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 `view` token 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.

```tsx
// 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](https://docs.divergentlabs.xyz/sandboxes/sdk.md#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.

1. When the user clicks **Take control**, ask your server for a `control` token. Pass it to `<SandboxScreen>` and set `mode` to `control`. The user's keyboard and mouse now work the desktop.

2. When the user clicks **Hand back**, ask your server for a `view` token. Pass it to `<SandboxScreen>` and set `mode` to `view`.

   Setting `mode` to `view` stops the page from sending input, but a `control` token allows input until it expires. Replace it with a `view` token, and keep `control` tokens short. Step 3 gives them 300 seconds.

3. Continue the task on your server:

   ```ts
   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.

```ts
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:

```ts
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](https://docs.divergentlabs.xyz/sandboxes/pricing.md).

## 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](https://docs.divergentlabs.xyz/sandboxes/sdk.md).
