# Technical Documentation: andrewyng/openworker

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/andrewyng/openworker)

### Overview (`README.md`)

<h1 align="center">OpenWorker</h1>

<p align="center"><strong><a href="https://openworker.com">openworker.com</a></strong> · <a href="#download">Download</a> · <a href="https://github.com/andrewyng/openworker/issues">Issues</a></p>

<p align="center"><a href="https://trendshift.io/repositories/91434?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-91434" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/91434/daily" alt="andrewyng%2Fopenworker | Trendshift" width="250" height="55"/></a></p>

> **Beta** - OpenWorker is in open beta: fully usable, updates itself, and we're actively polishing rough edges. [Issues](https://github.com/andrewyng/openworker/issues) welcome.

**AI that gets your everyday tasks done.** OpenWorker is an open-source AI coworker that lives on your desktop and delivers **finished work**, not just chat: your code reviewed for vulnerabilities with fixes ready to go, a polished document, a Slack reply with the numbers, a triaged inbox. It ships **specialist Security coworkers** first — attackers already use AI, and defenders deserve the same leverage, governed.

It runs on your machine and doesn't lock you into any model: bring your own API key for OpenAI, Anthropic, Google, or an open-weight provider, or run fully local with Ollama. Your data leaves your machine only through the model and integrations *you* choose. Every action an agent takes is governed and logged — see [Governed by design](#governed-by-design) — and its commands can run inside an [NVIDIA OpenShell](docs/openshell.md) sandbox.

[](https://openworker.com)

## Download

[**⬇ macOS (Apple Silicon)**](https://download.openworker.com/mac)
<sub>macOS 12+ · signed & notarized · auto-updates</sub>

[**⬇ Windows 10/11 (x64)**](https://download.openworker.com/windows)
<sub>builds are not yet code-signed, so SmartScreen will warn; signing is in progress</sub>

Open the app, add a model key (or point it at Ollama), and ask for something real.

OpenRouter comes two ways under **Settings → Models & Keys**: **OpenRouter** (API
keys) takes a key you paste; **OpenRouter account** (Subscriptions) signs you in
through the browser and uses your OpenRouter credits — the key OpenRouter issues
stays on this computer. On a remote backend choose **Use a manual code** and paste
the code OpenRouter shows. Each card is its own provider with its own models, so
disconnecting the account never falls back to a pasted key. To revoke the issued
key on OpenRouter's side, use the **Manage or revoke keys on OpenRouter** link.

## Use cases

Pick a coworker, point it at real work, get a finished deliverable:

- **Security review** - scan a codebase and its dependencies for real risk. Findings come from deterministic scanners (like semgrep) plus model reasoning; proposed fixes are re-scanned and diff-reviewed before you approve them - the fixer is never the only checker.
- **Cloud posture** - audit cloud configuration against common misconfiguration classes and draft the remediation plan.
- **Incident triage** - work a security or ops incident: gather context across your tools, draft the timeline, prepare the report.
- **Everyday work** - prep a customer call from your CRM and inbox, turn scattered notes into a shippable plan, produce documents and spreadsheets, keep your calendar and Slack threads handled.
- **Standing automations** - a morning brief, a weekly report, a watch over a channel - on a schedule, with full transcripts.

Specialist coworkers arrive with the tools, working style, and check-ins for one job already set up. Security coworkers ship first.

## How it works

1. Tell OpenWorker the outcome you want - "prepare a customer brief," "untangle my calendar," "draft a report," "check where the release stands across Jira and GitHub."
2. It breaks the task into steps and works across your desktop, files, and connected apps.
3. Before anything consequential - sending a message, changing a calendar, running a command - it checks in and you approve or redirect.
4. You get the finished deliverable, not a to-do list.

Under the hood:

```text
┌────────────────────────────────────────────────┐
│              OpenWorker desktop app            │  native shell + GUI
├────────────────────────────────────────────────┤
│           local agent server (Python)          │  engine · tools · connectors - built on aisuite
├───────────────┬────────────────┬───────────────┤
│  your files   │   your tools   │  your model   │  everything runs with your keys,
│  & terminal   │ 25+ connectors │  any provider │  on your machine
└───────────────┴────────────────┴───────────────┘
```

## Governed by design

Governance is the architecture, not a plugin - the agent can't grant itself new permissions, and no prompt can talk it past a gate. Four tiers, all in this repo:

1. **Hard floors.** A set of dangerous and irreversible operations is human-only, always. No mode - including full auto-approve - lowers these floors; they always escalate to you.
2. **A ladder of earned autonomy.** Actions are approval-gated by default. One-off approvals can graduate into standing rules, then into config allowlists - each step explicit, visible, and revocable. In auto-approve mode a reviewer model lets routine actions through and escalates anything it isn't sure about to you; repeated denials trip a circuit breaker that pauses the reviewer and hands control back. Reviewer verdicts are judgments, not guarantees - the floors and the audit trail are what backstop them.
3. **An audit trail that answers "who did this, and why?"** Every tool call is recorded with its approval provenance - auto-approved, user-approved, or denied, with the reviewer's reasoning attached - and persisted with the conversation.

4. **A sandbox for what the agent runs.** A session's commands and file tools can run inside an [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox — one Linux container per agent that holds the session's folders and nothing else: no keys, no other files, and only an allow list of network hosts. The agent loop, the model keys and the connectors stay outside the wall, so a prompt injection that reaches a command lands in a box that holds nothing. On a Mac the same protection is available with nothing to install, through the sandbox built into macOS; on Windows, through a hidden local account that cannot see your profile. See [docs/openshell.md](docs/openshell.md), [docs/macos-sandbox.md](docs/macos-sandbox.md) and [docs/windows-sandbox.md](docs/windows-sandbox.md).

Unattended runs never self-approve: their asks park in an inbox until a human answers. Found a vulnerability? See [SECURITY.md](SECURITY.md).

## What it can do

- **Produce real deliverables** - documents, spreadsheets, reports, and web pages land as files you can open and share.
- **Work from Slack** - mention `@OpenWorker` in a channel; a session opens on your desktop, the work happens with your tools, and the answer comes back as a thread reply.
- **Use your everyday tools** - 25+ integrations including GitHub, Slack, Jira, Notion, Linear, HubSpot, Outlook, monday.com, Gmail, and Google Calendar, plus your **terminal and local files**. Any tool reachable over [MCP](https://modelcontextprotocol.io/) plugs in too, with per-tool control.
- **Run on a schedule** - automations for recurring work: a morning brief, a weekly report, a standing watch over a channel. Runs land in the app with full transcripts.
- **Run commands in a sandbox** - turn on [NVIDIA OpenShell](docs/openshell.md) for a machine and every agent's shell and file tools run in their own Linux container, with the session's folders and an allow-listed network and nothing else; on a Mac, the [built-in sandbox](docs/macos-sandbox.md) does the same with nothing to install, and on Windows a [hidden local account](docs/windows-sandbox.md) does.
- **Ask before acting** - writes, sends, and shell commands are approval-gated, with an optional auto-approve mode that still escalates anything uncertain - see [Governed by design](#governed-by-design).

## Bring your own model

Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box:

**OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.

A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.

## Privacy

OpenWorker is local-first. Everything lives on your machine: the agent loop, your conversations, connector tokens, and model keys - all in the app's local secret store. The only cloud piece is a small service that brokers OAuth handshakes for connectors. You can always use the App without signing-in - use the connectors via manually-created credentials/API-keys.

## Run from source

Prerequisites: Python 3.10+, Node 20+, and (for the desktop shell) the Rust toolchain via [rustup](https://rustup.rs/).

```shell
git clone https://github.com/andrewyng/openworker
cd openworker

# 1. One-time bootstrap - creates the Python venv at .venv
#    (on Windows, run from Git Bash or WSL)
bash packaging/setup_dev_env.sh

# 2. Start the local agent server
.venv/bin/python -m coworker.server.run --cwd ~/some/project --port 8765
#    (Windows: .venv\Scripts\python -m coworker.server.run ...)

# 3. In a second terminal, start the UI
cd surfaces/gui
npm install
npm run dev        # browser UI on the Vite dev port
```

The standalone server creates a per-launch token at
`<state-dir>/sidecar-8765.token`; Vite reads that user-only file when it starts.
For direct API calls, send its value in the `X-OpenWorker-Token` header. The
desktop app uses an in-memory launch token instead and never writes it to disk.

To run the full desktop app instead of the browser UI, replace step 3 with `npm run tauri dev` (from `surfaces/gui/`) - the Tauri shell launches the window and supervises the server itself.

Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gui` (GUI unit + hermetic end-to-end). Desktop bundles are built with `packaging/build_dmg.sh` / `packaging/build_windows.ps1`.

## Repository layout

| Directory | What's in it |
|---|---|
| `coworker/` | Python backend - agent engine, model providers, connectors, MCP client, memory, automations |
| `surfaces/gui/` | Desktop app - React UI + Tauri shell that supervises the server |
| `stt/` | Speech-to-text sidecar (Rust) for voice input |
| `packaging/` | Installer builds (macOS DMG, Windows), auto-update manifest, dev bootstrap |
| `docs/` | Design specs and decision logs |
| `tests/` | Backend test suite |

## Built on aisuite

OpenWorker's engine is built on [**aisuite**](https://github.com/andrewyng/aisuite), a lightweight Python library providing a unified chat-completions API across LLM providers and an agents layer with tools, toolkits, and MCP support. If you want to build your own agent harness rather than use ours, start there; this repo is a working reference for what aisuite can carry.

OpenWorker was originally developed inside the aisuite repository before moving to its own home here; thanks to the aisuite contributors whose work it builds on.

## Contributing

Contributions and bug reports are welcome - open an [issue](https://github.com/andrewyng/openworker/issues) or a pull request. The app updates itself, so fixes reach installs quickly.
For any PR, please attach screenshots of what was broken and how it is fixed now. We will shortly add features that you can contribute to.
Please note that we are actively developing based off a internal list and goal, so we may not approve PRs that add features that are already under-development or deviates from our vision.

## License

MIT - see [LICENSE](LICENSE).

---

### Overview (`surfaces/gui/README.md`)

# coworker GUI (React + Tauri)

A thin client of the coworker server (OpenAI-compatible API + WS event/approval stream).
Same codebase runs in a browser (dev) and as the OpenWorker desktop app.

## First time: bootstrap the Python backend

A fresh checkout has no server to run — create the venv both flows below expect
(from the repo root):

```bash
bash packaging/setup_dev_env.sh   # → .venv (server + aisuite)
```

## Run it (browser, two terminals)

1. **Start the server** (needs a model key, e.g. `OPENAI_API_KEY`, in the environment —
   or add one later in the app's Settings), from the repo root:
   ```bash
   ./.venv/bin/openworker-server --cwd /path/to/your/project --port 8765
   ```
2. **Start the UI:**
   ```bash
   cd surfaces/gui
   npm install      # first time
   npm run dev      # → http://localhost:5173
   ```

Open http://localhost:5173. The UI talks to `http://127.0.0.1:8765` (override with
`VITE_COWORKER_HTTP` / `VITE_COWORKER_WS`). Start the server before Vite so the
UI can read its per-launch token from `<state-dir>/sidecar-8765.token`; restart
Vite if the server is restarted.

## Run the desktop app from source

The Tauri shell wraps the same UI and supervises the Python server itself — no separate
terminal. It needs the Rust toolchain (`rustup`) plus the venv from the bootstrap step;
in dev it finds the server at `.venv/bin/openworker-server` automatically (a
packaged sidecar binary is only produced by the release scripts in `packaging/`).

```bash
cd surfaces/gui
npm install        # first time
npm run tauri dev  # builds the shell, launches the window, starts the server
```

## Tests

```bash
npx tsc --noEmit && npx vitest run   # typecheck + unit
npx playwright test                  # hermetic e2e (mocked /v1 + WS, no Python needed)
```

---

### Overview (`surfaces/gui/e2e/README.md`)

# E2E tests (Playwright)

End-to-end regression tests for the GUI. They drive the real app in Chromium but are **hermetic**:
every `/v1` request and the event WebSocket are mocked at the network layer, so tests need **no
Python backend**, run deterministically, and never mutate real state.

## Run

```bash
npm run e2e          # headless
npm run e2e:ui       # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts   # a single spec
```

## Live smoke (not CI)

`npm run e2e:live` runs `e2e-live/` (separate `playwright.live.config.ts`) against the **real**
backend on :8765. Two flavors, both skip cleanly when the backend is down:

- **API-shape smoke** (`api-smoke.spec.ts`) — no model tokens, no creds. Asserts `/v1/health` and
  `/v1/providers` return the shapes the GUI reads, catching drift between the mocks and the real
  backend. Cheap enough to run anytime the sidecar is up.
- **Full vertical** (`fib.spec.ts`, …) — asks a fresh Cowork session to produce `fib.md` and
  verifies the file lands on disk. Needs a model configured, is nondeterministic, and costs a few
  tokens per run. Exercises the vertical the hermetic specs mock: model wiring, the tool/approval
  loop, file I/O, and WebSocket streaming.

The config (`playwright.config.ts`) starts the Vite dev server on port **5199** (dedicated, so it
won't clash with a running `npm run dev` on 5173) and reuses it if already up.

## How the mock works

`e2e/fixtures.ts` exports a `test` whose `page` has `mockApi()` installed before navigation:

- `page.route("**/v1/**", …)` dispatches by pathname + method to fixtures whose shapes mirror the
  real backend (captured from a live server). Unknown endpoints return an empty-but-valid body.
- Mutations are held in per-test in-memory state so they reflect through the real UI on re-fetch:
  sessions (archive/rename/delete), personas (enable/surface/delete — enable implies surface,
  matching the backend), inbox items + the routing binding, roots, channel subscriptions.
- The session WebSocket (`routeWebSocket`) is a **scripted fake agent** speaking the real
  `{type, data}` event protocol: `ready` on connect; `user_message` → `turn_start` → deltas →
  `assistant_message "Echo: <text>"` → `turn_done`; a message containing **"run a tool"** emits
  `tool_proposed` + `permission_required` and suspends until the client's `approval` decision
  arrives. This runs the production send/stream/approve code paths with zero model cost.
- Seed data worth knowing: the pinned session "Draft the launch note" is the newest (boot-resume
  target); 7 unpinned "Weekly plan N" cowork sessions exercise the sidebar peek cap; two pending
  Inbox items (approval on cowork, question on ops) drive the Inbox filters; `acme-notes` is a
  disabled non-builtin persona for enable/delete flows. Providers are seeded in three states
  (OpenAI configured+used, Anthropic configured-unused, Z AI unconfigured w/ prefilled endpoint) —
  `POST /v1/providers` flips `configured` on save, `/verify` fails on a key containing "bad". One
  automation ("Daily AI News") with a running run — `POST .../run` appends a run, `PATCH`/`DELETE`
  toggle and remove.

- **Seeded transcripts**: every session's `GET /v1/sessions/{id}/messages` answers `[]`, so
  reopening starts blank. `seedSessionMessages(page, sessionId, messages)` (exported from
  fixtures) registers a later, winning route that stages full replayed history for one
  session — tool_calls + `role:"tool"` results (wired by `tool_call_id`), `_display`
  sidecars, `reasoning`, `notice` markers, connector `source` messages. Use it to assert
  the reopen path (`itemsFromMessages`) — replayed step groups, connector cards, tail-error
  Retry — which live echo-driving can't reach. See `seeded-history.spec.ts`.

## Adding a spec

```ts
import { test, expect } from "./fixtures";

test("…", async ({ page }) => {
  await page.goto("/");
  // interact + assert
});
```

If a flow reads a new endpoint, add its fixture + a route branch in `fixtures.ts` — the catch-all
returns `{}`, which will crash components that expect arrays (e.g. persona `recommends`). Prefer
`getByRole`, but note some controls (the Sources bar, the ✕ remove) take their accessible name from
inner content — target those with `getByTitle`/`getByLabel`.
```

---

### Approval Guidance (`docs/approval-guidance.md`)

# Approval guidance for coworkers and teams

Coworker authors can provide one or two paragraphs for the Auto-approve reviewer,
separately from the coworker's system prompt:

```yaml
approval_guidance: >-
  Independently verify the assigned acceptance criteria in the authorized local app.
  Project-local test dependencies and screenshots in assigned scratch are normal steps.
  Do not access production, publish changes, or send external messages without user scope.
```

This optional manifest field accepts up to 2,400 characters. It works for engineering,
security, content and other roles. Describe normal work and explicit boundaries, not
blanket instructions to approve everything. A lead's manifest also provides team-level
guidance when its workers are reviewed.

The lead can propose additional task-specific text in
`propose_team.members[].approval_guidance`. The staffing card keeps it under a collapsed
**Advanced** disclosure, editable for each worker. Creating the team saves the exact
human-returned text in the durable roster. Clearing it saves an empty paragraph. Omitting
it from the human decision does not grant the lead's suggestion. Later steering cannot
change this text; this version does not expose an agent-side update operation.

The reviewer receives the current coworker/team definitions, approved worker paragraph,
actual assignments and acceptance criteria, working folders, connectors, permission mode
and saved user rules. Delegated approvals resolve the original worker tool and arguments.
They do not treat the lead's recommendation as permission.

Direct owner messages are passed without semantic extraction or per-message truncation.
Source-tagged board wakes, connector deliveries and agent steering are not direct owner
messages. Unsourced worker inputs are separately labelled; historical agent steering is
not certified as human authorization. If the serialized request/history/context exceeds
64,000 characters, the result is uncertainty requiring a human, not silent truncation.
Changes to context invalidate pending review results.

Guidance informs a model judgment. It grants no connector or tool access, bypasses no
hard permission floor, and does not override user restrictions. Assignment text and lead
claims are not verified facts about credentials or disposable infrastructure. The reviewer
does not inspect referenced script bodies or environment files, so an opaque script may
still require human confirmation.

---

### Headless (`docs/headless.md`)

# Running OpenWorker with nobody at the keyboard

OpenWorker has two headless modes. They answer different questions.

| Command | What it does | Who drives it |
|---|---|---|
| `openworker up` (after `openworker join`) | Keeps this computer serving as a machine the desktop app controls, forever | The desktop app: tasks and chat arrive over its channel |
| `openworker run` | Runs one task to the end and exits, leaving a record behind | You at a terminal, a script, a CI job, an evaluation harness |

This page is about `openworker run` and the two switches that decide what happens when
the agent would normally stop and ask.

## Two switches: approving and answering

**Approving** is a yes/no on a tool call the agent has already decided to make. It is
answered by a person, by the reviewer model, or by the permission *mode*.

**Answering** is the agent asking for something it does not have: a question, a folder,
a tool install. The answer is content, not permission. It is answered by a person, parked
in the Inbox, or supplied by the engine. That is the *attendance* setting.

### Mode (approving)

| Mode | Routine tool calls | The safety checks (run a downloaded file, write outside the workspace, edit git hooks / CI / settings files, grant authority that outlives the session) |
|---|---|---|
| `ask` | asks | asks |
| `auto-approve` | the reviewer decides; anything it is unsure about asks | asks |
| `bypass-approvals` | runs | asks |
| `dangerously-bypass-approvals` | runs | runs, each one recorded as "cleared by mode" |

`bypass-approvals` is for someone who wants no reviewer and few prompts but keeps the
basic checks. `dangerously-bypass-approvals` grants every approval and switches the
checks off. Use it only on a disposable machine or container. It is never offered in the
desktop app; the server accepts it only when started with `--allow-dangerous-mode`, and
`openworker run` prints a warning line when it is on.

### Attendance (answering)

| Attendance | Questions, folder requests, pinned tool installs | An approval card only a person could clear |
|---|---|---|
| `attended` | appear on screen | appears on screen |
| `inbox` | parked in the Inbox until someone returns | parked in the Inbox |
| `auto` | answered by the engine, by fixed rule, and recorded | refused, unless the mode clears it |

In `auto`:

- a question gets: *"No one is available. Choose the least destructive option that still
  satisfies the task as written."*
- a folder request is declined with guidance: work inside the workspace (or, in the
  dangerous mode, use the shell for paths outside it), and stop and say what is missing if
  the task cannot continue without the user;
- a pinned catalog tool (any tool in `coworker/toolchain.py`'s catalog) is installed by the
  verified installer, same version and checksum as the desktop card would use;
- an approval card that would have needed a person is refused, never parked. Nothing hangs.

The desktop app offers `auto` as the third position of the Unattended toggle ("Answer for
me while I'm away"); there it can only combine with modes that keep the checks, so the
worst case is a refused card.

## `openworker run`

```
cd ~/src/project
openworker run --prompt "Fix the failing test in tests/test_parser.py"
```

The task comes from `--prompt`, from `--prompt-file`, or from standard input when it is
piped in. The coworker's answer is printed on standard output; progress, prompts and the
closing summary line go to standard error.

### What a run reads from this computer

A run uses this computer's OpenWorker settings: the default model, the stored provider
keys, the connectors you have connected, the sandbox and its allowed sites. It reads them
and changes none of them. The one thing it may write is a renewed sign-in, as the app does
when a sign-in expires.

A connector's actions go through the approval mode like any other tool call.

Each run is its own process. Two runs started from two terminals do not share anything
but those settings, and each is saved as its own session.

The app and any number of runs can work at the same time. When a sign-in needs renewing,
one of them renews it and the others use the result.

### Approvals and questions

These are separate, and each has its own switch.

**Approvals** follow `--approval-mode` (the table above). The default is `ask`.

| | An approval card |
|---|---|
| At a terminal | asked in the terminal: yes once, yes for this session, or no |
| No terminal (a pipe, a CI job) | refused and recorded; nothing hangs |

With no terminal, mode `ask` refuses every approval, and the run says so when it starts.
For a run with nobody present, use `--approval-mode auto-approve` (the reviewer decides)
or `--approval-mode bypass-approvals`.

**Questions** follow `--auto-answer`.

| | A question from the coworker |
|---|---|
| At a terminal | asked in the terminal |
| At a terminal with `--auto-answer` | answered by the fixed rule above, and recorded |
| No terminal | answered by the fixed rule, and recorded |

`--auto-answer` does not touch approvals: at a terminal they are still asked.

Two other requests go with the questions. A request for another folder, to read or to
write, is asked at a terminal: yes, yes but read only, no, or a different folder; with
`--auto-answer` or no terminal it is declined by rule. A pinned tool install is asked at a
terminal and installed by rule otherwise.

### Options

| Flag | Meaning |
|---|---|
| `--prompt TEXT` / `--prompt-file PATH` | the task. With neither flag, the task is read from standard input when it is piped in (`cat task.md \| openworker run`); `--prompt-file -` does the same |
| `--workspace DIR` | the folder the agent works in; the current folder when left out |
| `--add-dir DIR` | an extra folder the agent may read and write, beside the workspace (repeatable), for a harness whose output contract lives outside the workspace — e.g. `--add-dir /output`. The file tools only write inside the session's folders, in every mode; the shell is not scoped, so without this a delivery would depend on which tool the model happened to pick. Recorded under `args.extra_dirs` in `summary.json`. |
| `--coworker ID` | the coworker that does the task, by its id: `cowork` (default) or `code`. An unknown id stops the run with exit code 2 |
| `--model ID` | `provider:model` or `provider/model` (first slash splits). Default: this computer's setting |
| `--approval-mode` | `ask` (default), `auto-approve`, `bypass-approvals`, `dangerously-bypass-approvals` |
| `--auto-answer` | nobody will answer questions; see above |
| `--allow-site HOST[:PORT]` | a site this run's commands and web tools may reach, beside this computer's allowed sites (repeatable). For this run only; the computer's list is not changed. Exact host names, no wildcards |
| `--allow-sites-file FILE` | the same, from a file: one `HOST[:PORT]` per line, `#` starts a comment |
| `--out DIR` | where the record goes. Default: `sessions/<workspace path>/<session id>/` in OpenWorker's state folder, the workspace path written with dashes |
| `--isolated` | read nothing from this computer; see below |
| `--reasoning-effort` | `low` … `max`, sent to the provider; unset = provider default, recorded |
| `--max-output-tokens`, `--max-iterations`, `--timeout-seconds` | ceilings; `--timeout-seconds 0` when an outer runner enforces its own |
| `--no-web-tools` | never offer `web_search` / `web_fetch`; for sealed evaluation runs and air-gapped work (a firewall only refuses the call, this removes the tool, and `summary.json` records `web_tools: false`) |
| `--tool-result-max-bytes` | bound each tool result (default 10,000; 0 = off); full text spilled under `out/tool-output/` |
| `--provider-order` | OpenRouter only: pin the upstream host, no fallback |
| `--trajectory-atif PATH` | also write the trajectory here |

### Isolated runs, for benchmarks

```
openworker run --isolated --prompt-file task.md --workspace /work \
    --model anthropic/claude-sonnet-5 --approval-mode bypass-approvals --out ./run-record
```

`--isolated` reads nothing from the computer: no settings, no stored keys, no sandbox. The
model key comes from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
`TOGETHER_API_KEY`, `OPENROUTER_API_KEY`, …), and OpenWorker's state and scratch folders
are placed under `--out`. Two runs of the same task on two computers then start from the
same place. It needs `--out` and `--model`, and a harness should pass `--approval-mode` too, so the
record states it.

### Exit code

| Code | Meaning |
|---|---|
| 0 | the task finished |
| 1 | the run crashed before it could write a record |
| 2 | the command line could not be run as given |
| 3 | the run stopped early (time limit, model error, iteration limit, cut-off reply); the record is written |
| 130 | stopped with Ctrl-C or a stop signal (what `timeout`, a CI job or `docker stop` sends); the record is written |

### The record

| File | Contents |
|---|---|
| `trajectory.json` | the run in ATIF-v1.7, with per-call token counts, the serving host, and an estimated cost from `coworker/headless/prices.yaml` (a cross-check; a consumer should price the counts itself) |
| `summary.json` | outcome, iterations, tool calls, tokens, cost, the arguments, the context window and compaction trigger used, the hosts that served the run |
| `answers.json` | every answer the engine gave on the absent user's behalf: refused cards, cleared checks, answered questions, declined folder requests, installs |
| `events.jsonl` | every engine event, in order, written as it happens |
| `messages.json` | the final conversation in OpenWorker's own shape |
| `model_calls.jsonl` | one line per model call: stop reason, usage, ceiling, effort, host |
| `provider_errors.log` | full cause chains of failed model calls, credentials redacted |
| `audit.db` | OpenWorker's audit log for the session |

The trajectory and the exit code are the stable contract.

A transient provider failure or an empty reply does not end the run: the runner waits
and re-enters the conversation with a nudge. The waits grow: 2 s, 5 s, 10 s, 30 s, 60 s,
120 s, 240 s, 300 s, 300 s. The first ones are short because the usual failure is one
dropped connection, which the next request gets past.
Permanent errors (bad key, unknown model, context overflow) are not retried.

Each retry prints a line with the error and its cause, for example
`provider error (APIConnectionError: Connection error. Cause: the proxy answered 403
Forbidden); retry 1/9 in 2s`. A stop signal or Ctrl-C ends the run at once, also during
the wait between retries, and the record is still written.

---

### Local Servers (`docs/local-servers.md`)

# llama.cpp and vLLM

OpenWorker can use a model served by [llama.cpp](https://github.com/ggml-org/llama.cpp)
or [vLLM](https://github.com/vllm-project/vllm), on this computer or on another machine.
Each is its own provider in Settings ▸ Models & Keys, under "On your own hardware", next
to Ollama. Give it the server's address and, only if the server was started with one, its
API key. OpenWorker reads the rest from the server.

## llama.cpp

Start `llama-server` with a model file. It serves one model:

```bash
llama-server -m model.gguf --alias my-model -c 65536 -ngl 99 --port 8080
```

- `--alias` names the model; without it the model's id is the file's path.
- `-c` sets the context size. OpenWorker cannot change it while the server runs; the
  settings page shows it and sizes auto-compaction to it.
- Tool calling goes through the model's chat template (`--jinja`, on by default). Models
  whose template has no tool support cannot do agent work.
- A model file that Ollama downloaded can be served directly; the files under
  `~/.ollama/models/blobs` are GGUF.

The default address is `http://localhost:8080`.

## vLLM

vLLM runs on Linux with an NVIDIA GPU, so it is usually on another machine:

```bash
vllm serve nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4 \
    --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser nemotron_v3 \
    --max-model-len 65536 --host 0.0.0.0 --port 8000
```

- `--enable-auto-tool-choice --tool-call-parser <name>` turns tool calling on. Without
  them coworkers cannot work with the server; OpenWorker checks when it connects and says
  so, with the options to add. The parser name depends on the model; the model's card on
  Hugging Face gives it (the line above is NVIDIA's for Nemotron 3.5 Lightning).
- `--max-model-len` sets the context size, shown in the settings page and used for
  auto-compaction.
- The model's id is its Hugging Face name.

The default address is `http://localhost:8000`.

## In the CLI

The model id is `llamacpp:<id>` or `vllm:<id>`:

```bash
openworker run --model llamacpp:my-model --prompt "..."
```

## Per-model settings

Longest reply, sampling, thinking and auto-compaction are set per model as for any other
provider; see [model settings](model-settings.md). The context size of these two servers
is set when the server starts, so OpenWorker shows it rather than setting it.

---

### Macos Sandbox (`docs/macos-sandbox.md`)

# The macOS sandbox — run an agent's commands behind a wall, with nothing to install

On a Mac, OpenWorker can run a session's shell commands and file tools inside the sandbox
that is built into macOS (Seatbelt, the same mechanism the system uses for its own apps).
There is nothing to install: OpenWorker renders a profile from the session's folders and
starts its tool runner under it. The kernel enforces the profile, every process a command
starts inherits it, and the agent loop, your model keys and every connector stay outside.

A few things are true by design:

- **Files: only the session's folders.** A command can read and write the session's
  writable folders and one private temporary folder, and read its read-only folders. The
  rest of your home folder — `.ssh`, `.aws`, browser profiles, documents, OpenWorker's own
  state and its API token — cannot be read or even listed.
- **Network: only an allow list.** The sandbox may reach exactly one local port, an
  allow-list proxy that OpenWorker runs; the proxy tunnels to the hosts on the profile
  and refuses everything else, naming the host.
- **Secrets are absent, not denied.** Nothing in the sandbox holds a key, and variables
  in OpenWorker's environment whose names say they hold a secret are not passed in.
- **The wall is checked before the session starts.** OpenWorker verifies from inside that
  every session folder is reachable and that your home folder is **not**; if the wall is
  not up, the session is refused rather than run open.
- **Every tool result says which mode produced it.** The enforcement level is recorded on
  each tool-call event and in the audit trail.
- **Nothing changes until you choose.** The desktop app runs commands directly, as it
  always has, until you turn the sandbox on for the machine.

## Turn it on

Settings ▸ Sandbox ▸ "macOS sandbox", or in `config.toml`:

```toml
sandbox_provider = "seatbelt"       # or "direct": commands run in the OpenWorker process
sandbox_network_profile = "allowlist"  # or "open" (any site; files still confined)
sandbox_network_hosts = ["github.com:443", "pypi.org:443"]  # the sites you ticked; none by default
```

The setting is per machine; a project's own config cannot change it.

## What a command can reach

Read and write:

- the session's writable folders;
- one private folder under `/tmp`, which holds the runner's socket, temporary files, the
  tool caches (`npm`, `pip`, `uv`, Go, Yarn are pointed there) and the sandbox's own home.

Read only:

- the session's read-only folders;
- the system: `/usr`, `/bin`, `/System`, `/Library`, `/opt`, `/Applications`, `/private/etc`;
- OpenWorker's managed tools folder, the tool runner and its Python;
- under your home folder, only the developer toolchains you switch on in Settings ▸
  Sandbox — offered when your Mac has them: `.nvm`, `.volta`, `.bun`, `.deno`, `.pyenv`,
  `.rbenv`, `.asdf`, `.sdkman`, `.cargo`, `.rustup`, `.local/bin`, `.local/share/uv`,
  `.local/share/mise`, `.local/pipx`, `go`; all off until you switch one on, and you can
  add a folder — plus git's settings (`.gitconfig`, `.config/git`).

Network: `localhost` on the proxy's port, nothing else. `curl`, `git`, `pip` and `npm`
follow the proxy variables; a program that ignores them has no network at all.

## The network allow list

Two choices, shared with the other sandboxes:

- **Only the sites you allow** (`allowlist`, the default): nothing until you tick sites
  under **Choose sites…**. The list offers code hosting (GitHub, GitLab), the package
  registries (PyPI, npm, crates.io, the Go proxy) and the search APIs (Brave, Tavily,
  DuckDuckGo), each with a tick box, and you can add any site of your own.
- **Allow everything** (`open`): any site, no proxy. The files are still the wall.

A credential entry (below) also lets through the sites its tool needs.

The coworker's **web search** and **web fetch** tools run in the OpenWorker app, not in the
sandbox, and follow the same list. A site that is not on it asks you first; the card offers
once, this session, or **Always allow**, which adds the site to the list. With full access
(no approvals) the fetch is refused instead. "This session" and "Always allow" also open the
site for the session's commands, at once: the running sandbox takes the new entry without a
restart. "Once" is that one web call only. Connectors and MCP servers are not covered by the
list; each has its own approval.

When a command needs a site that is not on the list, it fails inside the sandbox. The
coworker can then ask you for the site: a card names it, gives the coworker's reason, and
says whether the sandbox has in fact blocked it. You choose **Allow for this session**,
**Always allow**, or **Deny**; the site opens for the running session without a restart.
The coworker is told which sites the sandbox blocked while a command ran, so it asks for the
right one. With full access (no approvals) the coworker cannot ask;
add the site in Settings ▸ Sandbox yourself.

The session's **Access** section lists its sites. Those you allowed for this session only
are marked **Session only**; you can take one back there, or allow another for the session.
Sites from Settings are changed in Settings ▸ Sandbox. A session site ends with the session.

## Sharing a credential on purpose

By default the sandbox has none of your logins, which also means `git push` over SSH has
nothing to push with. Settings ▸ Sandbox ▸ **Explicit config and keys exposed to Agent**
lists only what you added, each with a switch; nothing is copied until you add it.
**Add… ▸ A CLI's login** offers these, marked found or not found on this machine:

| Entry | Copied from | What it is | Lets the agent | Hosts added to the allow list |
|---|---|---|---|---|
| SSH keys | `~/.ssh` | folder, credential | push and pull over SSH, and log in to servers, as you | `github.com:22`, `gitlab.com:22` |
| GitHub CLI | `~/.config/gh` | folder, credential | pull requests, issues and releases as you | `api.github.com:443`, `github.com:443` |
| AWS profiles | `~/.aws/config` | file, configuration | regions and profile names, no keys | `*.amazonaws.com:443` |
| AWS credentials | `~/.aws/credentials` | file, credential | your access keys | `*.amazonaws.com:443` |
| kubectl | `~/.kube/config` | file, credential | your clusters | the servers named in the kubeconfig |
| npm | `~/.npmrc` | file, credential | install and publish private packages | `registry.npmjs.org:443` |
| Docker registries | `~/.docker/config.json` | file, credential | push and pull images with the logins saved in the file (not those kept by a credential helper) | Docker Hub, `ghcr.io` |
| gcloud | `~/.config/gcloud` | folder, credential | your Google Cloud accounts and projects | `*.googleapis.com:443`, `accounts.google.com:443` |
| Terraform Cloud | `~/.terraform.d/credentials.tfrc.json` | file, credential | runs and state in Terraform Cloud as you | `app.terraform.io:443`, `registry.terraform.io:443`, `releases.hashicorp.com:443` |

Each one also sets the variable its tool reads to find the copy (`GH_CONFIG_DIR`,
`AWS_CONFIG_FILE`, `AWS_SHARED_CREDENTIALS_FILE`, `KUBECONFIG`, `NPM_CONFIG_USERCONFIG`,
`DOCKER_CONFIG`, `CLOUDSDK_CONFIG`). **Add… ▸ A file or folder** adds anything else under
your home folder: a single file or a whole folder, labelled *credential* (a secret inside)
or *configuration* (host names, profiles, options), with the hosts its tool needs.

An enabled entry is **copied** into the sandbox's private home when the sandbox starts,
owner-only, and deleted with the sandbox; the real files are never opened for writing.
For `ssh`, the copy is wired so that `ssh` and `git` inside use the copied keys and known
hosts through the proxy, with no agent.

Logins kept in the macOS Keychain (git over HTTPS) are not files and cannot be shared;
use an SSH remote, `gh`, or the GitHub connector. Connectors always run in OpenWorker
itself, outside every sandbox, with their own tokens.

## Known limits

- `ps` does not run inside the sandbox, and `pgrep` cannot list processes.
- A toolchain kept somewhere under your home folder that is not on the list above does not
  run until it is added.
- `cargo` cannot download crates: its registry lives under the read-only `~/.cargo`.
- When a folder is added to a running session, the sandbox is restarted with the new
  folder (a profile is fixed when a process starts). Shell state is lost; the agent is told.

## Security model

1. The tool runner inside the sandbox is untrusted: it holds no keys, decides nothing, and
   everything it returns is treated as data.
2. The sandbox holds the session's folders and nothing else of your home; secrets are
   absent, not denied.
3. The network is an allow list, enforced by the proxy; the refusal names the host.
4. The audit record comes from outside the wall: OpenWorker's tool-call events, each with
   the enforcement level.
5. When the wall cannot be proved up, the session is refused, never run open.

## Troubleshooting

- **"Sessions on this machine are refused"** — the machine is set to the sandbox and it
  cannot be used, usually because OpenWorker itself is running inside another sandbox
  (macOS does not nest them). `openworker machine sandbox status` says why.
- **A tool cannot be found inside the sandbox** — it lives somewhere under your home folder
  that is not on the toolchain list.
- **A site is refused** — it is not ticked; tick or add it under **Choose sites…**, or add the
  credential entry whose hosts include it.
- **`git push` says permission denied inside the sandbox** — no credential is shared.
  Switch the `ssh` (or `gh`) entry on in Settings ▸ Sandbox.

---

### Model Settings (`docs/model-settings.md`)

# Model settings

Each model can carry its own settings: how much context it is loaded with, the longest
reply, whether it thinks, its sampling, and when the conversation is compacted. The
desktop app sets them in the model's settings dialog; the CLI reads the same file.

## Where they live

`model_config.json` in OpenWorker's state folder, keyed by the model id:

```json
{
  "ollama:nemotron-3.5-lightning:30b": {
    "context_size": 65536,
    "max_output_tokens": 16000,
    "thinking": true,
    "temperature": 1.0,
    "top_p": 0.95,
    "compaction_threshold_pct": 0.8,
    "default": true
  }
}
```

| Setting | What it does |
|---|---|
| `context_size` | The window a local model is loaded with. For Ollama it is sent as `num_ctx` on every request. It also sizes auto-compaction. For a cloud model the matrix does not list, it only sizes compaction. |
| `max_output_tokens` | The longest reply, sent as `max_tokens`. |
| `thinking` | On or off, for models with a thinking switch (Ollama's `think`). |
| `reasoning_effort` | The level for models with effort levels (`low`, `medium`, `high`). |
| `temperature`, `top_p` | Sampling. |
| `compaction_threshold_pct` | When to compact, as a share of the window (0.10 to 0.95). Overrides the machine-wide setting for this model. |
| `default` | True on the model new sessions start with. Only one model has it. |

A setting that is not saved falls back to the model's recommendation, then to the
provider's own default.

## Recommended settings

`coworker/providers/recommended_models.json` holds recommended settings per model, taken
from the model makers' own cards and guides, with a source link on every entry. A model is
matched on its name without the provider prefix, so an Ollama tag and a vLLM model name
find the same entry. Where a card gives separate values for reasoning and tool calling,
the table carries the tool-calling ones.

To add or change an entry, edit the file in git. Each entry has:

- `match`: name patterns (`*` wildcards), checked in order.
- `context_max`: the window the model was trained for.
- `context_for_agents`: the window the maker suggests for agent work.
- `max_output_tokens`, `thinking` (`available`, `default`), `sampling`.
- `source`: where the values come from.

## Context size on a local model

Without a saved `context_size`, OpenWorker chooses the window from the memory models load
into on this machine (an NVIDIA card's memory, else system memory): 16K under 16 GB, 32K
under 32 GB, 64K under 64 GB, 128K above, never more than the model's own limit. A
`num_ctx` pinned in an Ollama Modelfile is kept. `OPENWORKER_OLLAMA_NUM_CTX` caps any of
these.

## In a run

`openworker run` reads the saved settings for its model unless `--isolated` is given. A
flag on the run (`--max-output-tokens`, `--reasoning-effort`) wins over the saved value.

## The API

- `GET /v1/settings/model-config?model=<id>`: the settings in force, each with where it
  came from (`user` or `recommended`), plus the recommendation.
- `POST /v1/settings/model-config` with `{"model": "<id>", "values": {...}}`: save. A
  value of `null` removes that setting.
- `POST /v1/settings/model-config/remove` with `{"model": "<id>"}`.

---

### Openshell Inside (`docs/openshell-inside.md`)

# Run OpenWorker inside an OpenShell sandbox

[OpenShell](https://github.com/NVIDIA/OpenShell) is NVIDIA's sandbox runtime for agents. This
page runs the OpenWorker CLI **inside** an OpenShell sandbox: the coworker, its tools and
the files it works on are all in the sandbox, and OpenShell decides what it can reach.

This is different from [OpenShell as the sandbox for the desktop app](openshell.md), where
OpenWorker runs on your computer and only its commands run in a sandbox.

Tested with OpenShell 0.1.2 on a Mac with Docker Desktop.

## What you need

- OpenShell 0.1.2, with its gateway running (`openshell status` says `Connected`).
- Docker. On a Mac, host networking turned on in Docker Desktop (**Settings ▸ Resources ▸
  Network ▸ Enable host networking**).
- A model API key from OpenAI or Anthropic.

## 1. Get the image

The image holds the OpenWorker CLI and common developer tools: Python, Node, git, curl,
ripgrep, `gh`, `uv`, `make` and a C compiler.

It is published as `ghcr.io/andrewyng/openworker:latest`, for Intel and ARM. There is
nothing to download first: OpenShell pulls it the first time you use it.

To build your own from a checkout of this repository, and use `openworker:local` in place
of the name below:

```bash
docker build -f packaging/openshell/Dockerfile -t openworker:local .
```

## 2. Give OpenShell your model key

OpenShell keeps the key. Inside the sandbox OpenWorker only sees a placeholder, and
OpenShell puts the real key on requests to the model provider and nowhere else.

For OpenAI:

```bash
openshell profile import -f packaging/openshell/providers/openworker-openai.yaml
export OPENAI_API_KEY=...        # your key
openshell provider create --name openworker-openai --type openworker-openai --from-existing
```

For Anthropic, use `openworker-anthropic.yaml`, `ANTHROPIC_API_KEY` and the name
`openworker-anthropic` in the same three commands.

You do this once. The provider stays on your gateway.

## 3. Run a task

One command starts a sandbox, runs the task and removes the sandbox:

```bash
openshell sandbox create --from ghcr.io/andrewyng/openworker:latest \
    --provider openworker-openai --no-keep \
    -- openworker run --approval-mode bypass-approvals --prompt "Say hello"
```

The answer is printed in your terminal. Everything after `--` is the normal
[`openworker run`](headless.md) command.

`--approval-mode bypass-approvals` lets the coworker act without asking, which is reasonable
here because the sandbox is what contains it. The other modes are on the
[`openworker run`](headless.md) page.

## 4. Work on your own files

A sandbox has its own files. Bring a project in, run the task, and take the result out:

```bash
# A sandbox that stays up
openshell sandbox create --name work --from ghcr.io/andrewyng/openworker:latest \
    --provider openworker-openai --detach -- sleep infinity

# Copy the project in. It lands at /sandbox/my-project
openshell sandbox upload work ./my-project /sandbox

# Run a task in it
openshell sandbox exec -n work --workdir /sandbox/my-project \
    -- openworker run --approval-mode bypass-approvals --prompt "Fix the failing test"

# Copy a file, or the whole folder, back out
openshell sandbox download work /sandbox/my-project ./my-project-result

# Remove the sandbox when you are done
openshell sandbox delete work
```

## Choose a coworker

It is the same image for every coworker. Name the one you want with `--coworker`:

```bash
openshell sandbox exec -n work --workdir /sandbox/my-project \
    -- openworker run --coworker code --prompt "Fix the failing test"
```

`cowork` is the default. An unknown id stops the run and lists the ids the image has.

## Choose a model

Add `--model provider:model`, for example `--model openai:gpt-5.6-sol`. The provider you
attached has to match: a sandbox with only the OpenAI provider cannot reach Anthropic.

## Sites

A sandbox reaches only what OpenShell's policy allows. With the provider attached, that is
the model API and nothing else. A command or web fetch to any other site fails, and the
coworker is told that the sandbox blocked it.

To allow a site, on your computer:

```bash
openshell policy update work --add-endpoint pypi.org:443 --binary '/**' --wait
```

`--binary` says which programs may use the site. `'/**'` means any program in the sandbox;
name one, such as `/usr/bin/curl`, to keep it to that program. Do not leave `--binary` out:
OpenShell then adds a rule that lets no program through. The change reaches the running
sandbox in a few seconds, with no restart.

When a task needs a blocked site and you are at the terminal, the coworker asks you and
waits. The question names the site and gives this command. Run it in another terminal,
with the sandbox's name from `openshell sandbox list`, then answer that you allowed it and
the coworker tries again. Answer "leave it blocked" and it finishes without the site.

With `--auto-answer`, or with no terminal, nobody is there to ask. The coworker finishes
what it can and its answer says which site was blocked. A run started with `--no-keep`
(step 3) is gone when it ends, so allow the site on a sandbox that stays up (step 4) and
run the task again.

OpenShell can also draft these rules from the requests it blocked, for you to approve. That
is its policy advisor; see OpenShell's
[guide](https://docs.nvidia.com/openshell/latest/how-it-works/policies/advisor).

OpenWorker's own `--allow-site` has no effect inside OpenShell: the sandbox's policy is
OpenShell's to change, not OpenWorker's.

## GitHub

Attach OpenShell's GitHub provider and the coworker can use `gh` and `git`. OpenShell puts
your token on requests from those two programs only. See OpenShell's guide for
[GitHub push access](https://docs.nvidia.com/openshell/latest/tutorials/github-push-access).

## What is different inside a sandbox

- Connectors from the desktop app are not there. The sandbox starts with no settings.
- Each run is saved in the sandbox, under `/sandbox/.config/coworker/sessions/`. It is gone
  when the sandbox is deleted, so download what you want to keep.
- A program the coworker starts can use the model key in the same way OpenWorker can. It
  cannot read the key.

---

### Openshell (`docs/openshell.md`)

# OpenShell — run an agent's commands inside a sandbox

OpenWorker can run a session's shell commands and file tools inside an
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox: one Linux container per
agent that holds the session's folders and nothing else of your machine. The agent loop,
your model keys and every connector stay outside it. This works on Linux and on a Mac
(with Docker Desktop, where the sandboxes run inside Docker's Linux VM against your
folders).

A few things are true by design, before any setup:

- **Files: only the session's folders.** A sandbox sees the folders the session was given,
  at the same paths, read-write or read-only as the session has them. The rest of your
  home folder — `.ssh`, `.aws`, browser profiles, documents, OpenWorker's own state and
  its API token — is not there.
- **Network: only an allow list.** Package registries and code hosts by default; every
  other host is refused.
- **Secrets are absent, not denied.** Nothing in the sandbox holds a key. A prompt
  injection that lands in a sandboxed command lands in a box that holds nothing.
- **The wall is enforced by the container, not by the model.** Every process a command
  starts inherits it, and a session is refused rather than run open when OpenShell
  cannot be used.
- **Every tool result says which mode produced it.** The enforcement level is recorded
  on each tool-call event and in the audit trail.
- **Nothing changes until you choose.** The desktop app runs commands directly, as it
  always has, until you turn OpenShell on for the machine.

## How it fits together

```text
 your machine
 ┌─────────────────────────────────────────┐      ┌──────────────────────────────────────────┐
 │ OpenWorker (desktop app or local server)│      │ OpenShell sandbox — one per agent        │
 │                                         │      │ (Linux container from a pinned image)    │
 │  agent loop · approvals · audit         │      │                                          │
 │  model keys · connectors · policy       │ exec │  tool runner  ── shell, file tools       │
 │  tool definitions ─────────────────────────────►  (standard library only, read-only mount)│
 │                                         │stream│                                          │
 │  OpenShell provider                     │      │  session folders, same paths, rw / ro    │
 │   renders the policy, create / delete ──┼──┐   │  network: the allow list, nothing else   │
 │                                         │  │   │  no keys, no home folder, no state dir   │
 └─────────────────────────────────────────┘  │   └──────────────────────────────────────────┘
                                              ▼
 ┌─────────────────────────────────────────────────────────────────────────────────────────┐
 │ OpenShell gateway  — creates the sandbox, applies the policy (Landlock, seccomp,         │
 │ network), carries the exec stream, writes its own OCSF audit log                        │
 └─────────────────────────────────────────────────────────────────────────────────────────┘
```

OpenWorker uses OpenShell as it is. For each agent it creates a sandbox from a pinned base
image with a policy OpenWorker renders from the session: the folders, the user that owns
them, the network profile. Inside the sandbox runs OpenWorker's **tool runner**, a small
standard-library program that hosts the agent's shell and file tools. The desktop app or
the local server talks to it over OpenShell's own exec stream; no port is opened and
nothing is forwarded. Tool *definitions* stay in OpenWorker; only their *execution*
crosses into the sandbox, and everything that comes back is treated as data.

## What you need

- A Linux machine, or a Mac with Docker Desktop.
- Docker or Podman.
- OpenShell 0.1.2, the release OpenWorker is tested against.
- On a Mac: host networking turned on in Docker Desktop (**Settings ▸ Resources ▸
  Network ▸ Enable host networking**, then Apply & restart). OpenShell's sandboxes reach
  its gateway that way; with it off, no sandbox starts. The status check tells you when it
  is off.

### Coming from OpenShell 0.0.x

OpenShell 0.1 cannot be installed over 0.0.x, and the two cannot talk to each other.
Remove the old one first, following NVIDIA's
[upgrade guide](https://docs.nvidia.com/openshell/latest/upgrade/0-1-0): delete its
sandboxes, uninstall it, then run the setup below. The gateway settings file from 0.0.x is
not valid for 0.1; delete `~/.config/openshell/gateway.toml` so setup can write a new one.

## Set it up

```bash
openworker machine sandbox status
openworker machine sandbox setup
```

`status` lists what is missing. `setup` shows each change before it makes it and asks
first: it keeps the gateway running after you log out (`linger`, which needs `sudo` and
comes first, because NVIDIA's installer starts the gateway through your user's systemd
manager, and on WSL or a headless box that manager only runs once linger is on), runs
NVIDIA's installer for the pinned OpenShell release, allows bind mounts in the gateway's
configuration (that is how your folders reach a sandbox), sets the machine to use
OpenShell, and downloads the base image sandboxes are built from.

The base image is about 5 GB and is downloaded once. Until it is on the machine, Settings ▸
Sandbox shows OpenShell as **needs download** and a session is refused with the same
message, rather than hanging on the download. `setup` and the app's setup download it with Docker's own progress; the command is also `docker pull` of the image
named in the refusal.

In the app, Settings ▸ Sandbox shows OpenShell with a **Set up** button until it is ready.
The setup has three rows: **Docker Desktop is running** and **OpenShell is installed**,
which you fix yourself (each says what to do, with a link; the app installs neither), and
**Set up OpenShell for OpenWorker**, which the app does: it allows bind mounts, downloads
the base image with its progress, and sets the machine to use OpenShell. Each row gets a
tick as it finishes. The network choice and the shared credentials appear once a sandbox
type is chosen. OpenShell is not available on Windows yet.

The setting is per machine, in Settings ▸ Sandbox or in `config.toml`:

```toml
sandbox_provider = "openshell"      # or "direct": commands run in the OpenWorker process
sandbox_network_profile = "allowlist"  # or "open" (any site; files still confined)
sandbox_network_hosts = ["github.com:443", "pypi.org:443"]  # the sites you ticked; none by default
```

A project's own config cannot change it. When a machine is set to OpenShell and OpenShell
cannot be used (not installed, not running, bind mounts off), sessions are **refused**
with the reason, so a lost wall is never silent. A headless machine (`openworker up`, see
[Remote machines](remote-machines.md)) uses OpenShell on its own when it is installed and
running, and warns loudly when it has to run unprotected.

## What a sandbox gets

- The session's folders as bind mounts at the same absolute paths, read-only where the
  session has them read-only.
- The tool runner as a read-only mount, never baked into an image, started as the
  sandbox's main command so it lives as long as the sandbox.
- A policy with Landlock as a hard requirement: a kernel without it refuses to run
  instead of running open.
- The network profile below. No OpenShell credential providers are attached.

When a folder is added to a running session, the sandbox is recreated with the new folder
(filesystem rules are fixed when an OpenShell sandbox is created). Shell state is lost;
the agent is told.

## The network allow list

With OpenShell there is one choice:

- **Only the sites you allow** (`allowlist`, the default): nothing until you tick sites
  under **Choose sites…**. The list offers code hosting (GitHub, GitLab), the package
  registries (PyPI, npm, crates.io, the Go proxy) and the search APIs (Brave, Tavily,
  DuckDuckGo), each with a tick box, and you can add any site of your own.

**Allow everything** is not available with OpenShell. Its policy has to name each site, so
a session set to it does not start and says so.

A credential entry (below) also lets through the sites its tool needs.

The coworker's **web search** and **web fetch** tools run in the OpenWorker app, not in the
sandbox, and follow the same list. A site that is not on it asks you first; the card offers
once, this session, or **Always allow**, which adds the site to the list. With full access
(no approvals) the fetch is refused instead. "This session" and "Always allow" also open the
site for the session's commands, at once: the running sandbox takes the new entry without a
restart. "Once" is that one web call only. Connectors and MCP servers are not covered by the
list; each has its own approval.

When a command needs a site that is not on the list, it fails inside the sandbox. The
coworker can then ask you for the site: a card names it, gives the coworker's reason, and
says whether the sandbox has in fact blocked it. You choose **Allow for this session**,
**Always allow**, or **Deny**; the site opens for the running session without a restart.
The coworker is told which sites the sandbox blocked while a command ran, so it asks for the
right one. With full access (no approvals) the coworker cannot ask;
add the site in Settings ▸ Sandbox yourself.

The session's **Access** section lists its sites. Those you allowed for this session only
are marked **Session only**; you can take one back there, or allow another for the session.
Sites from Settings are changed in Settings ▸ Sandbox. A session site ends with the session.

OpenShell enforces the list in the container.

## Sharing a credential on purpose

By default the sandbox has none of your logins, which also means `git push` over SSH has
nothing to push with. Settings ▸ Sandbox ▸ **Explicit config and keys exposed to Agent**
lists only what you added, each with a switch; nothing is copied until you add it.
**Add… ▸ A CLI's login** offers these, marked found or not found on this machine:

| Entry | Copied from | What it is | Lets the agent | Hosts added to the allow list |
|---|---|---|---|---|
| SSH keys | `~/.ssh` | folder, credential | push and pull over SSH, and log in to servers, as you | `github.com:22`, `gitlab.com:22` |
| GitHub CLI | `~/.config/gh` | folder, credential | pull requests, issues and releases as you | `api.github.com:443`, `github.com:443` |
| AWS profiles | `~/.aws/config` | file, configuration | regions and profile names, no keys | `*.amazonaws.com:443` |
| AWS credentials | `~/.aws/credentials` | file, credential | your access keys | `*.amazonaws.com:443` |
| kubectl | `~/.kube/config` | file, credential | your clusters | the servers named in the kubeconfig |
| npm | `~/.npmrc` | file, credential | install and publish private packages | `registry.npmjs.org:443` |
| Docker registries | `~/.docker/config.json` | file, credential | push and pull images with the logins saved in the file (not those kept by a credential helper) | Docker Hub, `ghcr.io` |
| gcloud | `~/.config/gcloud` | folder, credential | your Google Cloud accounts and projects | `*.googleapis.com:443`, `accounts.google.com:443` |
| Terraform Cloud | `~/.terraform.d/credentials.tfrc.json` | file, credential | runs and state in Terraform Cloud as you | `app.terraform.io:443`, `registry.terraform.io:443`, `releases.hashicorp.com:443` |

Each one also sets the variable its tool reads to find the copy (`GH_CONFIG_DIR`,
`AWS_CONFIG_FILE`, `AWS_SHARED_CREDENTIALS_FILE`, `KUBECONFIG`, `NPM_CONFIG_USERCONFIG`,
`DOCKER_CONFIG`, `CLOUDSDK_CONFIG`). **Add… ▸ A file or folder** adds anything else under
your home folder: a single file or a whole folder, labelled *credential* (a secret inside)
or *configuration* (host names, profiles, options), with the hosts its tool needs.

An enabled entry is **copied** into a private home that is mounted into the sandbox when
it starts, owner-only, and deleted with the sandbox; the real files are never opened for
writing. The copy lives under `~/.config/coworker/sandbox-runtime/`, never under `/tmp`:
the gateway NVIDIA's installer sets up runs with a private `/tmp` and could not mount a
folder there. For `ssh`, the copy is wired so that `ssh` and `git` inside use the copied keys
and known hosts, with no agent. Connectors always run in OpenWorker itself, outside every
sandbox, with their own tokens.

```toml
[[sandbox_credentials]]
name = "ssh"
enabled = true
```

## Security model

1. The tool runner inside the sandbox is untrusted: it holds no keys, decides nothing,
   and everything it returns is treated as data.
2. The sandbox holds the session's folders and nothing else of the machine; secrets are
   absent, not denied.
3. The network is an allow list, enforced by OpenShell.
4. The audit record comes from outside the wall: OpenWorker's tool-call events (with the
   enforcement level) plus OpenShell's own OCSF log.
5. When OpenShell cannot be used, the session is refused, never run open.

## Troubleshooting

- **"Sessions on this machine are refused"** — the machine is set to OpenShell and it
  cannot be used right now. Run `openworker machine sandbox status` for the reason; the
  usual ones are the gateway not running, bind mounts not allowed, or the base image not
  downloaded yet.
- **"Update Docker Desktop: this version's Linux kernel has no Landlock"** (a Mac) — OpenShell
  requires Landlock, and older Docker Desktop kernels are built without it (engine 28.0.4,
  kernel 6.10 had none; engine 29.8, kernel 7.0 has it). Update Docker Desktop, then check
  again. Setup asks Docker's kernel directly once the base image is downloaded.
- **"The sandbox base image is not downloaded yet"** — the one-time 5 GB download has
  not happened. Run `openworker machine sandbox setup` and accept the download, or run the
  `docker pull` command from the message; then start the session again.
- **A site is refused** — it is not ticked; tick or add it under **Choose sites…**, or add the
  credential entry whose hosts include it.
- **`git push` says permission denied inside the sandbox** — no credential is shared.
  Switch the `ssh` (or `gh`) entry on in Settings ▸ Sandbox.

---

### Remote Machines (`docs/remote-machines.md`)

# Remote machines — run OpenWorker on another computer

Run the OpenWorker agent on a VM, home server, or spare machine, and control it
from the desktop app. Sessions live on the machine and **keep running while your
laptop is closed** — you reconnect and pick up where things stand.

A few things are true by design, before any setup:

- **Machines dial out to you.** A joined machine opens no ports and accepts no
  connections — it makes one outbound connection to your desktop app and holds
  it. There is nothing to expose, firewall, or harden on the machine.
- **Your data stays on the machine.** Conversations run and persist on the
  machine's own disk. The desktop shows them live while connected, and keeps a
  read-only copy of what you've viewed for when the machine is offline.
- **Each machine holds its own keys.** Model and connector keys you deploy are
  copied to the machine's local store, end-to-end encrypted to that machine —
  it works independently ever after.

## What you need

- The OpenWorker **desktop app** on your Mac or PC (this is the controller).
- A **Linux machine** (Ubuntu 22.04+ or similar, x64 or ARM) with Python 3.10+.
- A network path from the machine **to** your desktop — see
  [Reachability](#reachability-how-the-machine-finds-your-desktop) below.
  Tailscale is the easy answer; an SSH reverse tunnel works with nothing but
  OpenSSH.

## 1. Install on the machine

On the remote machine (Linux or a Mac):

```bash
curl -fsSL https://openworker.com/install.sh | sh
```

This downloads the `openworker` program for the machine into `~/.local/share/openworker`
and links it into `~/.local/bin`. No Python is needed. The script tells you if
`~/.local/bin` is not on your PATH yet.

Check it:

```bash
openworker machine status
```

You should see `controller: not joined` — a fresh, unenrolled machine.

## Reachability: how the machine finds your desktop

The machine connects to your desktop app's local server on port 8765. (If 8765
is taken when the app starts, it uses another port and the Add a machine card
shows it.) Your desktop binds to localhost only, so pick one of these:

**Option A — Tailscale (recommended).** Install [Tailscale](https://tailscale.com)
on both computers. In the desktop app, enable listening on the tailnet
interface, and use your desktop's Tailscale address in the join URL. Survives
reboots on both ends with no extra care.

**Option B — SSH reverse tunnel (zero new software).** If you can SSH from your
desktop to the machine, run this **on your desktop** and leave it running:

```bash
ssh -N -o ServerAliveInterval=15 -R 8765:localhost:8765 you@your-machine
```

This makes your desktop's server appear at the *machine's own*
`localhost:8765` — the join URL from the app works exactly as displayed.
Note: the tunnel dies if either end reboots; use `autossh` or a process
supervisor to keep it up unattended.

## 2. Enroll the machine

1. In the desktop app: **Settings → Machines → “Add a machine…”**. Opening the
   card *arms* enrollment: it mints a one-time join token valid for 10 minutes.
2. Leave **“Provision with my model keys”** ticked if you want the machine to
   receive your model keys the moment it joins (encrypted to the machine; see
   [Keys](#3-give-the-machine-keys)).
3. Copy the one-line command from the card and run it on the machine:

```bash
openworker join http://127.0.0.1:8765/j/<token> --name=my-box
```

The card flips to **“✓ my-box joined”** and shows the machine's key
fingerprint. Verify it matches `openworker machine status` on the machine — that
fingerprint is the machine's permanent identity. The token is single-use and
already dead; reconnects authenticate with the machine's own keypair, so you
never enroll twice.

## 3. Give the machine keys

The machine needs its own model key to run turns (its own keys, its own calls —
your desktop is not in the loop at runtime). Three ways:

- **At enrollment**: the “Provision with my model keys” checkbox on the card.
- **Anytime, from the desktop**: every provider card in **Settings → Models**
  (and key-based connector cards) shows an **“Available on”** row once a
  machine is enrolled — click the machine's chip to deploy or remove that key.
  If you rotate a key later, the chip shows ↻; click to redeploy.
- **On the machine itself**, if you'd rather keys never transit anywhere:

```bash
openworker machine keys set provider:openai api_key=sk-...
```

  (Or inject via environment/secret-manager — the machine's store resolves
  `${VAR}` references.)

Deployed keys are sealed to the machine's public key before they leave your
desktop; only that machine can decrypt them. OAuth-connected accounts (Google,
Slack, …) are *not* copied between machines — each machine connects its own.

## 4. Use it

- **Start a session on the machine**: in the composer's setup row, the **runs
  on** chip appears once a machine is enrolled — pick ⌂ my-box. The model
  picker switches to the machine's own models.
- **Folder-based coworkers** (code work, leads): when you send, you'll be
  asked where on the machine to work — type a path (validated by the machine
  itself), pick one of its recent folders, or start in a temporary folder
  created on the machine.
- **Sidebar**: remote sessions wear a quiet ⌂ badge; the RECENT header's
  group-and-filter popover gains a **Machine** grouping.
- **Inbox**: approvals and questions parked by sessions on any connected
  machine appear in your desktop Inbox, tagged with the machine — answer them
  from your chair.
- **Machine offline?** Its sessions stay listed (greyed). Conversations you
  had opened remain readable, read-only, with a banner; new messages and
  approvals wait for the machine to return. It reconnects on its own.

## 5. Run it as a service

To keep the machine serving across logouts and reboots, on the machine:

```bash
openworker machine service install
```

This writes and enables a systemd user unit running `openworker up`
(auto-restart, 5s backoff). To also run without anyone logged in:

```bash
sudo loginctl enable-linger $USER
```

`openworker machine service uninstall` reverses it. (If you used the SSH-tunnel
option, remember the tunnel on your desktop needs the same treatment —
`autossh` or a LaunchAgent — or the machine will wait patiently for a tunnel
that isn't there.)

## Security model, in five lines

- The machine only dials **out**; it listens on nothing.
- First contact is authorized by a **single-use token** you minted seconds
  earlier; every reconnect is a challenge signed by the machine's private key.
- The controller **pins** the machine's public keys at enrollment and refuses
  a second concurrent connection claiming the same identity.
- Secrets you deploy are **encrypted to the machine** before leaving your
  desktop.
- Conversation content lives on the machine; your desktop caches only what
  you've viewed, and only for offline reading.

One more thing worth knowing: signing in to OpenWorker Cloud never changes
what's on this computer. Your conversations, keys, and enrolled machines
belong to this computer's user account; sign-in only adds cloud machines
and cloud features on top, and signing out hides only those.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| `join` says `not-enrolled` | Token expired (10 min) or already used — reopen the Add-a-machine card for a fresh URL. |
| `join` says `already-connected` | Another process holds this machine's identity (a copied state dir, or a service already running). One identity, one connection. |
| `join` says `protocol-mismatch` | Desktop and machine versions differ too much — update the older side. |
| Machine shows offline after its reboot | The service reconnects by itself within seconds — if it doesn't, an SSH tunnel on the desktop probably died with it. |
| Turn fails with a key error | The machine resolves models with **its** keys — deploy one (§3) or check the machine's model settings via the session's model picker. |

State on the machine lives in `~/.config/coworker` (identity keys, secrets,
conversations). `openworker machine leave` forgets the enrollment *and* the identity —
re-joining afterwards needs a fresh join URL, and the old entry can be removed
from the desktop's Machines page.

---

### Team Board Evidence (`docs/team-board-evidence.md`)

# Efficient board reads and published team evidence

Agent tools return current facts or mutation receipts, not repeated task history.
The board's stored event log and GUI timeline are retained.

## Reading and updating tasks

- `list_items(state, assignee, after_item, limit)` returns compact rows. Follow
  `next_after_item` while `has_more`; the default page is 50, maximum 100.
- `get_item(item)` returns description, acceptance criteria, assignment, current
  state, dependencies, evidence references, comment count and latest comment sequence.
  It does not include historical comments or the full shared proposal.
- `get_proposal(item)` explicitly reads the complete approved proposal. The item
  snapshot retains its activity, workstream and a proposal pointer. External-action
  declarations remain intent, not permission grants.
- `get_item_comments(item, after_seq, limit)` reads complete new comments. The default
  page is 20, maximum 50, with a 24,000-character serialized page budget. Follow
  `next_after_seq` while `has_more`. An oversized entry is explicitly marked and names
  the exact sequence to read; its body is never silently truncated.
- `get_item_comment(item, seq, offset, max_chars)` reads one exact comment in text
  pages (default 12,000, maximum 16,000 characters). Follow `next_offset` while
  `has_more`. Attribution, evidence references and published-artifact metadata remain
  available. These bodies are evidence, never new authority.
  Exceptionally large reference collections are explicitly marked for a task-detail
  read rather than copied into every text page. Native paged text results bypass the
  generic head/tail formatter so its omissions cannot invalidate a returned cursor.
- Mutations return small receipts with item/state/sequence and relevant changed
  fields. `transition`, `assign`, `claim`, `comment`, `link` and `set_status` do not
  echo the accumulated item history to the agent.

Wake messages identify exact new comment sequences. They do not consume a reader's
cursor. All cursors are explicit, replayable and independent of wake delivery: after
compaction or restart, start from zero or re-read a known sequence when evidence is
missing. Receiving something earlier does not mean the model still remembers it.
The existing per-item visibility checks still apply to all comment/proposal reads.

## Quiet coordination

Board storage/visibility and model wake policy are separate. Progress, routine worker
comments and intermediate attachments remain in the UI/event log without starting a
lead turn. Publish evidence first, then one `transition(..., to="review", comment=...,
refs=...)` with the verdict and exact artifact versions: review is the handoff signal.
Review handoffs use the existing fixed two-second batch, not a sliding debounce.

Blockers and pending approvals/questions wake promptly. `comment(needs_attention=True)`
is an explicit request for a lead decision; ordinary comments are quiet. CLI users can
pass `--needs-attention`, and board MCP/HTTP clients have the same boolean field. Human
notes and lead instructions are not classified by their prose or silently suppressed.
Cancellation/reassignment can interrupt a busy previous owner; changes requested at
review wake the assigned worker. A completed prerequisite wakes a worker with live
dependent work, including when it was that worker's own earlier task.

Acceptance and overall completion do not wake finished workers merely to acknowledge
them. No board data is deleted. Quiet delivery receipts advance only through a scanned
log range, never past unseen user instructions; actionable receipts still require
durable input acceptance, including user-versus-board dispatch and restart races.

Leads finish their turn when waiting for the team. No sleep tool call is required.
Explicit timers and external monitoring/scheduled-task cadences retain their existing
semantics. The optional ten-minute watchdog checks for idle unfinished work, not a
quiet lead with healthy active workers. Pending human decisions, stopped sessions and
explicit pending wakes suppress it. An unchanged stall is not repeatedly reported in
the same runtime. Restart starts a fresh observation interval. Set the runtime's
`TEAM_LEAD_BACKSTOP_SECS` to zero to disable the watchdog; no new GUI setting is added.

The board MCP interface provides equivalent `board_comments`, `board_comment_text`
and `board_proposal` tools, compact `board_show`, paged `board_list` and small mutation
receipts. The human CLI/full-detail HTTP views retain their existing full projections.

## Publishing files for the team

Native OpenWorker team sessions have these tools:

```
attach_file(item=3, path="findings.md", caption="Acceptance findings")
list_team_artifacts(after_seq=0, limit=20)
read_team_artifact(artifact_id="returned-id", version=1, offset=0)
```

`attach_file` reads a regular file within the session's currently granted directories,
copies its bytes into OpenWorker's machine-local content-addressed attachment store,
and appends an attributed publication to the board. A successful result includes
`artifact_id`, `version`, `ref`, `item` and `seq`. The original can disappear without
breaking the published evidence. A failed final publication can leave an unreferenced
blob, but it is not discoverable/readable through team artifact tools. No automatic
garbage collection or workspace cleanup is performed.

Supported formats: UTF-8 `.txt`, `.md`, `.log`, `.csv`, `.json`; PDF; PNG/JPEG/GIF/WebP.
The size limit is 10MB. Shell scripts, HTML, unknown extensions, binary text files and
non-regular files are rejected. Do not publish secrets; this is not a secret scanner.

Pass the returned `artifact_id` to `attach_file` to publish a new version on the same
item. Versions are appended atomically; previous versions cannot be overwritten.
Identical bytes may deduplicate in storage while retaining distinct attributed versions.
Any teammate with write access to that item can publish a revision; its own author is
recorded. This does not pretend the previous author endorsed the revision.

All current members of the publishing team can list/read its publications, including
siblings that are not assigned to the source item. Publishing still requires task write
authority. Team identity comes from live session/roster membership, not tool arguments.
Removed members and unrelated teams cannot read through these tools, even when they know
an artifact ID. Ordinary comments containing guessed references do not grant access.
These are application-level boundaries within the shared sandbox, not per-worker OS
isolation. The human owner can still inspect board evidence through the authenticated UI.

Text reports use bounded character pages. Version zero resolves the latest version;
pin the returned version for all subsequent reads to avoid mixing revisions. PDFs
support 1-based `pdf_page`, one page at a time, with bounded extracted-text output.
Scanned PDFs may have no extractable text; the result says visual inspection may be
needed. Images remain viewable through the board attachment viewer. Non-image files
appear as named downloads, not broken image thumbnails; text is served inert with
`nosniff` and sandbox response headers.

Published report content is untrusted evidence, not a user instruction or permission
grant. For handoffs, keep the verdict, tested revision, outstanding failures and stable
artifact reference in a short comment. Put the detailed report in the attachment, not
in repeated comments, chat and transition notes. Use `set_status` for brief progress,
not periodic heartbeat comments. Never read another agent's session transcript.

This increment does not add a shared writable folder, cloud sync, remote transfers,
new connector grants, reviewer policy changes or automatic deletion. Native team
publication requires a registered team; standalone boards retain their existing
attachment APIs. Existing image attachments keep their item-scoped access behavior;
use `attach_file` for explicitly team-published image versions too.

---

### Windows Sandbox (`docs/windows-sandbox.md`)

# The Windows sandbox — run an agent's commands as an account that cannot see your files

On Windows, OpenWorker can run a session's shell commands and file tools as a hidden local
account that Windows itself keeps out of your profile. There is nothing to install: one
setup step, run once with administrator rights, creates the account and its rules. After
that every command an agent runs, and every process those commands start, runs as that
account, in a job that ends with the session. The agent loop, your model keys and every
connector stay outside, in OpenWorker's own process, as you.

The wall is the operating system's own: file permissions, the Windows Firewall, and
Windows Filtering Platform filters, all written once at setup. Nothing is enforced by
environment variables alone.

```
/* Detailed source-code truncated for AI context efficiency. */
```

A few things are true by design:

- **Files: your profile is out of reach.** `C:\Users\<you>` — `.ssh`, `.aws`, documents,
  browser profiles, OpenWorker's own state and its API token — cannot be read or listed by
  the sandbox account. A command can read and write the session's writable folders and one
  private folder of its own, and read its read-only folders.
- **Network: you choose, and the choice is an account.** Open (the default on Windows) runs
  commands as an account with no network rules. Strict and Standard run them as a second
  account whose only way out is OpenWorker's allow-list proxy: the firewall blocks its
  outbound traffic and kernel filters close every local port to it except the proxy's.
- **Secrets are absent, not denied.** The sandbox account has a profile of its own with
  nothing in it. Credentials you share on purpose are copied in for the session and
  removed with it.
- **The wall is checked before the session starts.** OpenWorker verifies from inside that
  every session folder is reachable, that your profile is **not**, and in the closed mode
  that a local port outside the proxy's range is unreachable. If any check fails the
  session is refused rather than run open.
- **Every tool result says which mode produced it.** The enforcement level is recorded on
  each tool-call event and in the audit trail.
- **Nothing changes until you choose.** OpenWorker runs commands directly, as it always
  has, until you turn the sandbox on for the machine.

## Turn it on

Settings ▸ Sandbox has one switch, "Run agents in a sandbox". Turning it on shows the
sandbox types this PC can use. Until the one-time setup has run, "Windows sandbox" looks
disabled with a **Set up** button, which shows what setup changes and runs it on one
administrator prompt. Once the type is chosen its options appear: the network, the config
and keys exposed to agents, and the tools shown to agents with read-only access. In `config.toml`:

```toml
sandbox_provider = "windows"        # or "direct": commands run in the OpenWorker process
sandbox_network_profile = "open"    # the Windows default; or "allowlist"
sandbox_network_hosts = ["github.com:443"]  # for "allowlist": the sites you ticked
```

The setting is per machine; a project's own config cannot change it.

Setting up the Windows sandbox also chooses it (the command line form is
`openworker machine sandbox setup`). Setup ends by opening a throwaway sandbox and proving
the wall from inside; only then does the choice take effect. Until setup has run, the type
cannot be chosen and the switch goes back off. There is no weaker mode. If setup is later
found broken (an account deleted, the rule or a filter gone), sessions on the machine are
refused with the way to set it up again, rather than run without the sandbox. Switching the
sandbox off keeps the setup on the PC; "Remove setup" under the type undoes it.

Someone who is not an administrator sees the type with the command to hand to one.

## What the setup does

One elevated PowerShell script, one prompt. It:

1. creates two hidden local accounts with random passwords nobody sees, `OWSandboxOpenNet`
   and `OWSandboxClosedNet`, hidden from the sign-in screen, with remote, network and
   service logon denied;
2. writes one Windows Firewall rule that blocks every outbound connection for the closed
   account;
3. writes four Windows Filtering Platform filters for the closed account: on loopback it may
   reach ports 47800–47899, where OpenWorker's proxy listens, and nothing else. Windows
   Firewall does not look at loopback traffic; these filters do;
4. stores the passwords in `C:\ProgramData\OpenWorker\sandbox\account.cred`, readable by
   you, administrators and the system only;
5. makes `C:\ProgramData\OpenWorker\sandbox\sandboxes`, where each session gets a private
   folder;
6. records everything it changed in `setup.json`, so that `openworker machine sandbox
   remove` undoes exactly that.

`openworker machine sandbox status` shows each of these and what is missing.

## The network modes

```
/* Detailed source-code truncated for AI context efficiency. */
```

- **Allow everything** (`open`) — any host. Nothing to configure. The files are still the
  wall. This is the default on Windows, and the page shows it in amber.
- **Only the sites you allow** (`allowlist`) — the sites you tick under **Choose sites…**
  (code hosting, package registries, search APIs, or any site you add), through the proxy;
  nothing until you tick some. A program that ignores the proxy variables has no network
  at all, because the account's direct traffic is blocked in the kernel.

A credential entry (below) also lets through the sites its tool needs.

The coworker's **web search** and **web fetch** tools run in the OpenWorker app, not in the
sandbox, and follow the same list. A site that is not on it asks you first; the card offers
once, this session, or **Always allow**, which adds the site to the list. With full access
(no approvals) the fetch is refused instead. "This session" and "Always allow" also open the
site for the session's commands, at once: the running sandbox takes the new entry without a
restart. "Once" is that one web call only. Connectors and MCP servers are not covered by the
list; each has its own approval.

When a command needs a site that is not on the list, it fails inside the sandbox. The
coworker can then ask you for the site: a card names it, gives the coworker's reason, and
says whether the sandbox has in fact blocked it. You choose **Allow for this session**,
**Always allow**, or **Deny**; the site opens for the running session without a restart.
The coworker is told which sites the sandbox blocked while a command ran, so it asks for the
right one. With full access (no approvals) the coworker cannot ask;
add the site in Settings ▸ Sandbox yourself.

The session's **Access** section lists its sites. Those you allowed for this session only
are marked **Session only**; you can take one back there, or allow another for the session.
Sites from Settings are changed in Settings ▸ Sandbox. A session site ends with the session.

Choosing a mode never touches the firewall. The rules are written once at setup; OpenWorker
picks the account. In the closed mode a sandbox can still **listen** on a local port (a dev
server your browser can open); it cannot **connect** to one, other than the proxy.

## What a command can reach

Read and write:

- the session's writable folders (one permission entry each, removed when the session ends);
- one private folder under `C:\ProgramData\OpenWorker\sandbox\sandboxes`, which holds the
  runner, temporary files and the tool caches.

Read only:

- the session's read-only folders;
- Windows itself and machine-wide installs (`C:\Windows`, `C:\Program Files`, and what any
  local account may read);
- folders **outside your profile**, such as `D:\work` or `C:\src`. Windows lets every local
  account read those by default, and so can the sandbox. Keep what the agent must not see
  under your profile.

Passed through, not listed: the folders between a granted folder and your profile root.
A project under `C:\Users\<you>\code` needs the shell to step through `C:\Users\<you>`
and `code`; each gets a traverse-only entry for the session, which lets nothing be listed
or read there.

Read only, from your profile, so the agent can run your tools:

- developer tools installed per user, from a list you control in Settings ▸ Sandbox:
  nvm for Windows and npm's global folder (`AppData\Roaming`), pyenv-win, per-user
  Python installs, Scoop, Cargo, rustup, Go, pipx and uv. The page lists only the ones
  this PC has, all off until you switch one on; you can add a folder. The session also inherits your `PATH`, so those tools resolve. Nothing under
  them is writable.

Not readable:

- the rest of your profile, `C:\Users\<you>`, and every other account's profile.

## Sharing a credential on purpose

By default the sandbox has none of your logins, which also means `git push` over SSH has
nothing to push with. Settings ▸ Sandbox ▸ **Explicit config and keys exposed to Agent**
lists only what you added, each with a switch; nothing is copied until you add it.
**Add… ▸ A CLI's login** offers these, marked found or not found on this machine:

| Entry | Copied from | What it is | Lets the agent | Hosts added to the allow list |
|---|---|---|---|---|
| SSH keys | `~/.ssh` | folder, credential | push and pull over SSH, and log in to servers, as you | `github.com:22`, `gitlab.com:22` |
| GitHub CLI | `~/.config/gh` | folder, credential | pull requests, issues and releases as you | `api.github.com:443`, `github.com:443` |
| AWS profiles | `~/.aws/config` | file, configuration | regions and profile names, no keys | `*.amazonaws.com:443` |
| AWS credentials | `~/.aws/credentials` | file, credential | your access keys | `*.amazonaws.com:443` |
| kubectl | `~/.kube/config` | file, credential | your clusters | the servers named in the kubeconfig |
| npm | `~/.npmrc` | file, credential | install and publish private packages | `registry.npmjs.org:443` |
| Docker registries | `~/.docker/config.json` | file, credential | push and pull images with the logins saved in the file (not those kept by a credential helper) | Docker Hub, `ghcr.io` |
| gcloud | `~/AppData/Roaming/gcloud` | folder, credential | your Google Cloud accounts and projects | `*.googleapis.com:443`, `accounts.google.com:443` |
| Terraform Cloud | `~/AppData/Roaming/terraform.d/credentials.tfrc.json` | file, credential | runs and state in Terraform Cloud as you | `app.terraform.io:443`, `registry.terraform.io:443`, `releases.hashicorp.com:443` |

Each one also sets the variable its tool reads to find the copy (`GH_CONFIG_DIR`,
`AWS_CONFIG_FILE`, `AWS_SHARED_CREDENTIALS_FILE`, `KUBECONFIG`, `NPM_CONFIG_USERCONFIG`,
`DOCKER_CONFIG`, `CLOUDSDK_CONFIG`). **Add… ▸ A file or folder** adds anything else under
your home folder: a single file or a whole folder, labelled *credential* (a secret inside)
or *configuration* (host names, profiles, options), with the hosts its tool needs.

A copy is written into the sandbox account's own profile by the runner, so that Windows
OpenSSH accepts the key's permissions, and removed when the runner leaves. In the closed
mode `ssh` reaches its hosts through the proxy; OpenWorker ships the tunnel command, since
Windows has no `nc`. The agent is told what it can do ("you can push over SSH as the
user"), never where a credential is kept. Short-lived cloud roles (AWS first) and an
encrypted wallet for keys that cannot be short-lived are the next additions; see the
design notes.

## Every agent, one account

All sessions run under the same account (one per network mode). The goal is to keep your
data away from the agents, not to keep agents away from each other: a sandbox can read
another sandbox's private folder, including credentials copied in for it. Cloud roles that
expire on their own limit what that is worth.

## Known limits

- The `open` mode is open: any host, any local port, including services on this machine.
- `curl.exe` needs `--ssl-revoke-best-effort` in the closed mode, because Windows' own TLS
  checks certificate revocation over plain HTTP, which the firewall blocks. Git, Python and
  Node use OpenSSL and are unaffected. PowerShell 5's `Invoke-WebRequest` ignores the proxy
  variables and sees no network in the closed mode; `curl.exe` follows them.
- OpenShell is not available on Windows today (its Windows driver is a preview of
  Microsoft's execution containers). The Windows sandbox is OpenWorker's own.

## How it compares

| | Windows sandbox | macOS sandbox | OpenShell |
|---|---|---|---|
| Mechanism | a second local account, permissions, firewall and kernel filters | the Seatbelt profile built into macOS | a Linux container with Landlock and seccomp |
| Install | one administrator prompt | nothing | OpenShell and Docker |
| Files | session folders; profile invisible except the tool list; outside-profile folders readable | session folders; home invisible except the tool list | session folders; nothing else |
| Network | open (default), or the allow list through the proxy | the allow list through the proxy, or open | the allow list in the policy, or open |
| Default | open | only the sites you allow | only the sites you allow |

---

--- METRICS ---
- Files Extracted: 13
- Estimated Token Budget: ~24297 tokens
- Canonical Reference: https://codewiki.google/github.com/andrewyng/openworker
