# Remote Workspaces

URL: https://cogitator.app/docs/tools/remote-workspaces

> Run an agent's workspace in a cloud sandbox on E2B, Daytona or Vercel Sandbox, with the same file tools, commands, trash and snapshots as a local workspace.

## Overview

A remote workspace is a [workspace](/docs/tools/workspace) whose files and commands live in a sandbox of a cloud provider instead of on your machine. The agent's tools work the same, the commands it writes run in a machine you can throw away, and nothing it does reaches your files, your network or your keys.

Three ship in `@cogitator-ai/sandbox`, each on its provider's SDK, an optional peer dependency you install next to it:

| Workspace                | Install           | Credentials                                                                   | Default root      |
| ------------------------ | ----------------- | ----------------------------------------------------------------------------- | ----------------- |
| `E2BWorkspace`           | `e2b`             | `E2B_API_KEY`                                                                 | `/home/user`      |
| `DaytonaWorkspace`       | `@daytona/sdk`    | `DAYTONA_API_KEY` (`DAYTONA_API_URL`, `DAYTONA_TARGET`)                       | `/home/daytona`   |
| `VercelSandboxWorkspace` | `@vercel/sandbox` | Vercel's OIDC token, or `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID` | `/vercel/sandbox` |

```typescript
import { Agent, Cogitator, workspaceTools } from '@cogitator-ai/core';
import { E2BWorkspace } from '@cogitator-ai/sandbox';

const workspace = new E2BWorkspace({ root: 'project', template: 'base' });

const coder = new Agent({
  name: 'coder',
  model: 'anthropic/claude-sonnet-5-5',
  instructions: 'You work on the project in your workspace. Run the tests before you finish.',
  tools: workspaceTools(),
  workspace,
});

const cog = new Cogitator();
try {
  await cog.run(coder, { input: 'Clone github.com/acme/parser and make its tests pass' });
} finally {
  await workspace.dispose();
}
```

## Lifecycle

The sandbox is created on first use, or in `start()`, which also checks the sandbox has the tools the workspace needs. `dispose()` kills what commands left running and then releases the sandbox as `onDispose` says: `destroy` deletes it, `stop` stops it so its files survive (Daytona, Vercel), `keep` leaves it running. A sandbox the workspace created is destroyed by default, one it was pointed at (`sandboxId`, `sandbox`, `name`) is kept, or stopped for a named Vercel sandbox.

```typescript
const workspace = new DaytonaWorkspace({ sandbox: 'agent-box', onDispose: 'stop' });
```

Without the SDK, without credentials, or when the provider cannot create the sandbox, `start()` (or the first operation) fails with a `WorkspaceError` of code `UNAVAILABLE` that says which. An SDK that is installed but fails to load (a Node.js version it does not support, a broken install) is reported with its own error. `e2b` needs 2.28.1 or later, and its releases from 2.59 need Node.js 22.19 or later, newer than the 22.12 Cogitator itself needs.

E2B and Vercel end a sandbox after a set time. `lifetime` (30 minutes by default on both) sets it, and the workspace pushes the end back while it is used: before an operation once half of it has passed, and every minute while a command runs. A connected E2B sandbox gets the same lifetime. Your plan caps how long a sandbox may live. Daytona stops an idle sandbox after `autoStopInterval` minutes instead.

## Same behavior on every provider

The drivers do only what the providers do best: create or connect to a sandbox, move bytes in and out, and run a program. Everything else runs as small shell scripts inside the sandbox through `RemoteShellWorkspace`, so the three behave the same:

* **Paths** are normalized against the root, then resolved in the sandbox with every symlink followed (`realpath -m`), and refused with `OUTSIDE_ROOT` when they land outside. A symlink a command made that points out of the root is refused like any other path.
* **Reads and writes** are bounded. A read copies at most one byte over the limit to a private file (mode 0600) of the sandbox's temporary directory before it is downloaded, so a file a command grows meanwhile never moves more than that, and reads never write the root. A write uploads to a fresh file of the temporary directory, copies it next to the file and moves it over the file in one step, keeping its mode, once the upload is checked to be the size sent. `maxFileBytes` (10 MiB by default) caps both with code `TOO_LARGE`. Nothing goes through `.cogitator`, which a command could swap for a symlink, and the scripts that keep snapshots in `.cogitator/snapshots` check it is a real directory of the root every time. What an interrupted operation leaves in the temporary directory is removed an hour later.
* **Listing** follows the local rules: entries sorted by name with each directory's contents right after it, hidden entries left out unless `includeHidden`, `skipDirs` listed but not entered, `glob` and `limit` applied after sorting. The sandbox sorts the entries and cuts them at `limit` itself, and with a `glob` it sends only the entries whose name matches its last segment, so a big tree is not shipped whole. A listing over 64 MiB is refused with `TOO_LARGE`: narrow it with `skipDirs`, `glob` or `limit`.
* **`remove` and `rename`** have the local rules for `recursive`, `trash` (into `.cogitator/trash`), `overwrite`, `EXISTS`, `NOT_EMPTY` and nesting. Like `LocalWorkspace` they are refused with `BUSY` while a command of the workspace runs or left processes in the background.
* **Commands** run under `setsid` as the leader of their own process group, reported with marker lines whose token arrives on stdin, so a command cannot forge them. A timeout returns exit code 124 and kills the group (SIGTERM, then SIGKILL after `killGrace`), an abort rejects with `ABORTED`. `background: 'kill'` (default) ends what a command left running when it exits, `keep` leaves it until `dispose()`. A process that starts a session of its own (`setsid`) leaves the group and lives on until the sandbox goes, as with `DockerWorkspace`. Before it signals a group, the workspace checks the group still leads the session its command started, so an id the system gave to another process since is left alone. Output is streamed through `onStdout` and `onStderr` and capped at `maxOutputBytes` with `truncated` set. The providers' SDKs keep a command's whole output in memory (E2B's even while it streams it), so a command that prints more than `maxStreamBytes` (64 MiB by default) in all is stopped like on a timeout, with `truncated` set, exit code 143 and a note on stderr.
* **Snapshots** are tar archives in `.cogitator/snapshots` inside the sandbox. `snapshot({ label, metadata, exclude, maxFileBytes })` always leaves out `.cogitator`, `node_modules` and `.git` at any depth, `exclude` adds to them, and a file over `maxFileBytes` (10 MiB by default) is skipped and listed in `skipped`. `restore(id, { dryRun })` removes what was made since, brings back what changed and leaves alone the excluded and skipped paths, files made since over the snapshot's size limit (`maxFileBytes` of the record) and directories that hold any of them, and reports what it `restored` (every file of the archive, as a tar restore rewrites them all), `deleted` and `kept`. They work with [file checkpoints](/docs/core/file-checkpoints) like a local workspace's. Names that are not UTF-8 are kept as they are. A snapshot over `maxSnapshotBytes` (1 GiB by default) is refused with `TOO_LARGE`: exclude what can be rebuilt, such as `node_modules/**`. Excludes of the form `dir/**`, `dir` and `**/name/**` are left out by the sandbox itself, so their files are never listed. They live and die with the sandbox.

The sandbox is the security boundary. A command can read every file of its sandbox anyway, so the path checks keep the agent's file tools in the root rather than guard the machine. The image needs `sh`, GNU coreutils, findutils and tar, and util-linux `setsid`, which the default images of all three providers have. `start()` names whatever is missing.

## Options

Every remote workspace takes these:

| Option             | Default                  | What it does                                              |
| ------------------ | ------------------------ | --------------------------------------------------------- |
| `root`             | the provider's directory | Absolute, or relative to the provider's working directory |
| `createRoot`       | `true`                   | Create the root when it is missing                        |
| `cwd`              | `.`                      | Where commands run, relative to the root                  |
| `env`              | none                     | Environment variables every command gets                  |
| `timeout`          | 120000                   | Milliseconds before a command is stopped                  |
| `killGrace`        | 2000                     | Milliseconds between SIGTERM and SIGKILL                  |
| `maxOutputBytes`   | 1 MiB                    | Bytes of stdout and of stderr a result keeps              |
| `maxFileBytes`     | 10 MiB                   | Largest file `read` and `write` accept                    |
| `background`       | `kill`                   | What happens to background processes by default           |
| `maxStreamBytes`   | 64 MiB                   | Output a command may print in all before it is stopped    |
| `maxSnapshotBytes` | 1 GiB                    | Bytes of files a snapshot may hold                        |

### E2BWorkspace

| Option           | Default                            | What it does                                   |
| ---------------- | ---------------------------------- | ---------------------------------------------- |
| `apiKey`         | `E2B_API_KEY`                      | API key                                        |
| `template`       | `base`                             | Template the sandbox is created from           |
| `sandboxId`      | none                               | Connect to a running or paused sandbox instead |
| `lifetime`       | 30 minutes                         | Milliseconds of disuse before E2B kills it     |
| `metadata`       | none                               | Metadata of a created sandbox                  |
| `onDispose`      | `destroy`, `keep` with `sandboxId` | `destroy` or `keep`                            |
| `domain`         | E2B's cloud                        | Domain of a self-hosted deployment             |
| `requestTimeout` | the SDK's                          | Milliseconds each request may take             |

### DaytonaWorkspace

| Option                         | Default                                                | What it does                                        |
| ------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
| `apiKey`, `apiUrl`, `target`   | `DAYTONA_API_KEY`, `DAYTONA_API_URL`, `DAYTONA_TARGET` | Credentials and region                              |
| `sandbox`                      | none                                                   | Reuse a sandbox by id or name, started when stopped |
| `image` or `snapshot`          | Daytona's default                                      | What a created sandbox runs                         |
| `resources`                    | Daytona's                                              | `cpu`, `memory` and `disk` of an image sandbox      |
| `name`, `labels`, `sandboxEnv` | none                                                   | Name, labels and environment of a created sandbox   |
| `autoStopInterval`             | 15                                                     | Idle minutes before Daytona stops the sandbox       |
| `startTimeout`                 | 60                                                     | Seconds to wait for the sandbox to start            |
| `onDispose`                    | `destroy`, `keep` with `sandbox`                       | `destroy`, `stop` or `keep`                         |

Commands run in Daytona sessions, one per command (removed when it ends, kept while it has background processes to keep), and files go through the file API as bytes. Daytona records the command line of every session command, so the workspace passes `env` in a file of a private directory (mode 0700) of the temporary directory, which the command sources and removes before anything else, and the values never show in that record. Output of a kept command that arrives after `exec` returned is dropped, its log stream ends with the command.

### VercelSandboxWorkspace

| Option                         | Default                                               | What it does                                            |
| ------------------------------ | ----------------------------------------------------- | ------------------------------------------------------- |
| `token`, `teamId`, `projectId` | `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID` | Access token triple, else the OIDC token                |
| `name`                         | none                                                  | Reuse the sandbox of that name (resumed), or create it  |
| `image`                        | `vercel/sandbox/universal`                            | Image, e.g. `vercel/sandbox/node:24` or your own        |
| `runtime`                      | none                                                  | Legacy runtime such as `node24`, deprecated for `image` |
| `lifetime`                     | 30 minutes                                            | Milliseconds the sandbox runs, extended while used      |
| `vcpus`, `ports`               | Vercel's                                              | Size and exposed ports of a created sandbox             |
| `onDispose`                    | `destroy`, `stop` with `name`                         | `destroy`, `stop` or `keep`                             |

Without the token triple the SDK finds the OIDC token itself: from the `x-vercel-oidc-token` header in a Vercel Function, or from `VERCEL_OIDC_TOKEN`, which `vercel env pull` writes to `.env.local`. A sandbox created without a `name` is not persistent, so it leaves no snapshot behind, and `destroy` also deletes the snapshots only that sandbox used.

## Your own provider

`RemoteShellWorkspace` takes any `RemoteShellDriver`: connect to a sandbox, read and write a file by absolute path, run a program to its end, start one with streamed output and stdin, and release the sandbox. Implement those five on SSH, Modal or your own runner and the workspace brings the rest.

```typescript
import { RemoteShellWorkspace, type RemoteShellDriver } from '@cogitator-ai/sandbox';

const driver: RemoteShellDriver = {
  provider: 'my-runner',
  defaultRoot: '/srv/work',
  connect: async () => connectToMyRunner(),
};

const workspace = new RemoteShellWorkspace({ driver, root: 'project' });
```

`shellQuote` and `shellCommand` turn an argv into a shell line for providers that only take one.
