This is the abridged developer documentation for Cowork Studio
# Overview
> Build apps for Cowork Studio — widgets that use the window.cowork SDK, routines run by the Cowork daemon, the cowork.json manifest, and the cowork CLI.
A Cowork workspace carries its own **apps**. An app is a folder `.cowork/apps//` with a `cowork.json` manifest, React **widgets** (`.jsx` / `.tsx`, the UI, drawn in the IDE) that call the `window.cowork` SDK, and **routines** (`.ts`, privileged backend code run by the local Cowork daemon: secrets, files, signed-in APIs). Copy the folder and you have copied the app. [Build your first app](/docs/guides/building-apps/)The pattern every app follows, starting from the CRUD template. [cowork.json](/docs/reference/cowork-json/)Every manifest field: routines, file routing, accounts, capabilities, the status bar. [The window.cowork SDK](/docs/reference/sdk/interfaces/coworksdk/)What a widget can call: routines, dialogs, accounts, notifications, capture, state. [The CLI](/docs/reference/cli/)Every cowork command: routines, apps, publishing, secrets, team servers. ## I want to… [Section titled “I want to…”](#i-want-to) | … | In a widget (`window.cowork`) | In a routine (`ctx`) | Also | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | Ask the user (a dialog, a confirm) | [`ui.dialog` / `ui.confirm`](/docs/reference/sdk/interfaces/coworksdk/#ui), options in [`CoworkDialog`](/docs/reference/sdk/interfaces/coworkdialog/) | [`consent.request`](/docs/reference/sdk/interfaces/coworkroutinecontext/#consent) | | | Use an API key, or sign in to Google / Microsoft | [`credentials.get` / `connect`](/docs/reference/sdk/interfaces/coworksdk/#credentials) — widgets never see the key | [`credential.fetch` / `fields`](/docs/reference/sdk/interfaces/coworkroutinecontext/#credential) | [`credential` in cowork.json](/docs/reference/cowork-json/#credential), [`cowork credential`](/docs/reference/cli/#cowork-credential) | | Run backend code | [`routines.run`](/docs/reference/sdk/interfaces/coworksdk/#routines) | [Writing routines](/docs/guides/routines/) | [`cowork routine run`](/docs/reference/cli/#cowork-routine) | | Keep data fresh | [`routines.subscribe`](/docs/reference/sdk/interfaces/coworksdk/#routines) — never `setInterval` | [`unchanged()`](/docs/reference/sdk/interfaces/coworkroutinecontext/#unchanged) for reads | | | Store the app's data | [`state`](/docs/reference/sdk/interfaces/coworksdk/#state) (per view, small) | [`data`](/docs/reference/sdk/interfaces/coworkroutinecontext/#data), [`db`](/docs/reference/sdk/interfaces/coworkroutinecontext/#db) | | | Notify the user | [`notify`](/docs/reference/sdk/interfaces/coworksdk/#notify), `badge` | [`notify`](/docs/reference/sdk/interfaces/coworkroutinecontext/#notify), `badge` | | | Record the screen or audio | [`capture`](/docs/reference/sdk/interfaces/coworksdk/#capture) | — panes only | [`capture` in cowork.json](/docs/reference/cowork-json/#capture) | | Hand work to an agent | | [`queue`](/docs/reference/sdk/interfaces/coworkroutinecontext/#queue) | [`cowork queue`](/docs/reference/cli/#cowork-queue) | | Open a file type in my app | [`openedFile`](/docs/reference/sdk/interfaces/coworksdk/#openedfile) | | [`fileRouting`](/docs/reference/cowork-json/#filerouting) | | Let AI agents call my routines | | mark each routine `readOnly` / `destructive` / `reachesOthers` ([`routines` in cowork.json](/docs/reference/cowork-json/#routines)) | [Agents and grants](/docs/guides/permissions/), [`cowork agent`](/docs/reference/cli/#cowork-agent) | | Publish or install an app | | | [Publish and install](/docs/guides/publish-and-install/), [`cowork push`](/docs/reference/cli/#cowork-push) | ## How these docs are made [Section titled “How these docs are made”](#how-these-docs-are-made) Nothing under **Guides** or **Reference** is edited by hand. Every Cowork build regenerates them from what ships: * **Guides**: the `cowork-studio` Agent Skill that Cowork installs for Claude Code and Codex. * **SDK types**: the `.cowork/local/cowork.d.ts` every workspace gets, rendered by TypeDoc. * **cowork.json** and **workspace.json**: `cowork.schema.json` and `workspace.schema.json`, the schemas the editor validates them against. * **CLI**: the CLI's usage table — every command, including the group verbs only `cowork --help` prints. * **Commands**: the `contributes.commands` of Cowork's extensions. ## For AI agents [Section titled “For AI agents”](#for-ai-agents) * [`/docs/llms.txt`](/docs/llms.txt), [`llms-full.txt`](/docs/llms-full.txt) (everything) and [`llms-small.txt`](/docs/llms-small.txt) follow [llmstxt.org](https://llmstxt.org). * On a machine with Cowork, the same guides are the `cowork-studio` skill (`~/.claude/skills/cowork-studio/`), matched to the installed version, and every workspace has the types at `.cowork/local/cowork.d.ts`.
# Building Cowork apps
> Cowork Studio is an IDE (VS Code-based) where a workspace can carry its own apps.
Cowork Studio is an IDE (VS Code-based) where a workspace can carry its own **apps**. An app is a folder `.cowork/apps//` holding a **`cowork.json`** manifest plus its files: `.jsx` widgets (UI, rendered in the IDE) and `.ts` routines (privileged backend code run by the local Cowork daemon). Copying the folder copies the app — there is nothing to register elsewhere. The details, one page each: [manifest](/docs/guides/manifest/) · [widgets + SDK](/docs/guides/widgets-and-sdk/) · [routines](/docs/guides/routines/) · [publish / install / audit](/docs/guides/publish-and-install/) · [agents and grants](/docs/guides/permissions/) · [courses](/docs/guides/courses/). Read the one you need before writing code. ## Make an app — start from the CRUD template [Section titled “Make an app — start from the CRUD template”](#make-an-app--start-from-the-crud-template)
```bash
cowork template install cowork/app-sidebar-workbench --name clients # → .cowork/apps/clients/
```
That is the reference app every Cowork app follows: a list in the sidebar, a page per record (`fileRouting`), and `clients-list` / `-read` / `-save` / `-remove` routines over one file per record. Its `README.md` walks through the pattern below. Change what a record is (or point the routines at a service) instead of starting from a blank file. ### The pattern — follow it in every app [Section titled “The pattern — follow it in every app”](#the-pattern--follow-it-in-every-app) 1. **Widgets show, routines do.** Files, keys, the network → a routine; the widget calls it. 2. **Data in `ctx.data`** (`.cowork/data//`): `read(name, fallback)`, `write(name, value)`, `list(subfolder)`, `remove(name)`, `path(name)`. Never `fs` + hand-built paths. 3. **Fresh with `cowork.routines.subscribe`, never `setInterval`**: one loop per app, paused while no pane is visible, stopped by `NotConnected` until `refresh()`. 4. **Fail with a name**: `throw ctx.error("NotFound", "…")`; the widget checks `e.name`, never the message. Show `e.runId` with the error (`cowork routine runs show `). 5. **Mark reads** with `ctx.unchanged()`; **log changes** with `ctx.log(...)`. 6. **The app's panes talk** with `cowork.app.post(msg)` / `cowork.app.onMessage(cb)`. 7. **Credentials belong to a capability**: its cowork.json declares `"credential"` (a sign-in, a key, or either); its routines call the service with `ctx.credential.fetch(url)` (bearer, refresh, retries) or read a key with `ctx.credential.fields()`. An app declares `"uses"`, calls those routines and touches no credential (`NotGranted`); its panes connect with `cowork.credentials.get/connect(name)` (one sign-in per provider, Manage › Components › Credentials). A package declaring neither `kind` nor `uses` predates capabilities and keeps its old access until it declares one. 8. **Paint at once**: last answer in `cowork.state`, skeleton rows until the first one. A failed ACTION keeps its own error (cleared by the next action or Dismiss) — the next good read clears only a failed read. 9. **Mark what each capability routine changes** (`readOnly`, `destructive`, `reachesOthers`, `description`, `input`): agents get a capability's routines as tools — reads on by default, the rest only when granted ([agents and grants](/docs/guides/permissions/)). ### From scratch [Section titled “From scratch”](#from-scratch) 1. Create `.cowork/apps//cowork.json` (the folder name follows the same lowercase-kebab rule as `name`, and is normally the same; `name` = the id):
```json
{ "name": "weather", "version": "0.1.0", "title": "Weather", "description": "Today's forecast.",
"icon": "cloud", "color": "#1A73E8", "entry": "weather-sidemenu.jsx", "display": "sidebar",
"routines": { "weather-forecast": "forecast.ts" } }
```
2. Write the widget `weather-sidemenu.jsx` (a default-exported React component calling `cowork.routines.subscribe("weather-forecast", …)`) and the routine `forecast.ts` (`export default async (args, ctx) => {...}` returning JSON). 3. Test: `cowork routine run weather-forecast --args '{"city":"Lisbon"}'`, then click the new icon on the activity bar (saving a `.jsx` hot-reloads its pane). 4. Share: `cowork push .cowork/apps/weather --to `. ## cowork.json in one screen [Section titled “cowork.json in one screen”](#coworkjson-in-one-screen) | key | notes | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` **req.** | `^[a-z0-9][a-z0-9-]{0,62}$` (lowercase letters, digits, `-`) — store id + activity-bar id; not `apps`, `data`, `courses`, `local` or a Windows device name (`con`, `nul`…). Never rename a published app. | | `version` **req.** | semver `X.Y.Z`. `cowork push` publishes it (and bumps the patch when needed). | | `title`, `description` | Display name (default `name`); store blurb. | | `icon` | A **codicon** id (`mail`, `graph`, `database`, `rocket` …) **or** an image file in the app folder (`icon.png`; .png/.svg/.webp/.jpg ≤ 256 KB) — the image fills the rail tile, full color, and `cowork push` sends it to the store with each version. Never a URL. | | `color` | Hex tile color `#rrggbb`; the glyph turns white. | | `entry` | `.jsx`/`.tsx` UI. Present → an activity-bar icon. Omit for a routines-only app — or a **capability** (`"kind": "capability"`: routines + a `SKILL.md`, no UI) that apps name in `"uses": {"gmail": "0.2.0"}` (name → lowest version, same major); a template install pulls it. Or `"web": {"index": "web/index.html"}` instead of `entry`: a **web app** — a prebuilt static site opened unmodified in an editor tab, files routed to its index (desktop app or browser). Or `"web": {"url": "https://…"}`: a **site app**, a web site installed as an app (`cowork app install ` or the browser's install icon; desktop app only). See [manifest](/docs/guides/manifest/). | | `display` | `sidebar` (default: panel under the icon) · `tab` (editor tab) · `modal` (dialog) · `right` (a pane of the right side bar, beside Chat — no rail icon). | | `open` | `sidebar` only: an app file or `{"routine": id, "args": {}}` returning `{path}` — opened in the editor on first click. | | `routines` | `{ "-": "file.ts" }` or `{ "-": { "file": "file.ts", "readOnly": true, "description": "…", "input": {…}, "timeout": 120 } }` — ids are workspace-global, **prefix them with the app name**. In a capability each routine is a tool for agents: `readOnly` (changes nothing), `destructive`, `reachesOthers`, a `description` and its arguments as a JSON Schema `input`. | | `fileRouting` | `{ "**/*.ext": "viewer.jsx" }` — workspace-relative glob → the widget that opens matching files. A glob ending in `/` routes a folder instead (`"Notebook/": "notebook.jsx"`): clicking it in the Explorer opens the widget. | | `statusBar` | `{"routine": id, "args"?: {}, "interval"?: 60}` — one footer item drawn from what one of the app's routines returns (`{text, icon?, tooltip?, tone?, click?: {args, confirm?}, menu?, data?}` or null); a click re-runs it with `click.args`. See "Footer item" in [manifest](/docs/guides/manifest/). | | `credential` | **Capabilities only.** How its routines reach their service: `{"oauth": {"provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.modify"]}}` (a sign-in, shared by every capability of that provider — connecting asks for what they all need), `{"key": {"fields": ["token"], "url": "https://…", "hint": "…"}}` (fields the person pastes), or both. An app's own `credential` is ignored (reported). | | `requires` | `["desktop"]` if it needs the desktop app (e.g. `ctx.browser`); `["web"]`; or omit. | | `tags` | Store filters. | All manifest paths are relative to the app folder and must stay inside it. A bad `name` or `version` = not an app; any other bad key is dropped and the app still works. The IDE validates the file as you type. **`.cowork/workspace.json` is workspace STATE only** — `startup.home` (the window's start app; default: the app named `home`), `disabled` switches (`{"apps": [".cowork/apps/weather"], "routines": ["weather-forecast"]}`), `publishTo`, `rail.folders`, `deployments` (routines' schedules / webhooks: `cowork deploy add`, see [routines](/docs/guides/routines/)); it never travels. **What an app is lives only in its `cowork.json`.** Under `.cowork/`: `apps//` (a Keep-both copy beside it: `-v1-2-3`), `data//` (`ctx.data`; a table is `data/.db`), `courses//`, `lessons//`, git-ignored `local/`. Never create `Cowork/`, `Webapps/`, `Routines/` or `Courses/` in the workspace root. ## Widgets (.jsx) — the hard rules [Section titled “Widgets (.jsx) — the hard rules”](#widgets-jsx--the-hard-rules) * One file, one module, `export default` a React component. * **No relative imports** (`./util` fails). Each widget is standalone; duplicate small helpers, move shared/privileged logic into a routine. * **Only allowlisted imports**: `react`, `react-dom`, `@/components/ui/*` (stock shadcn/ui), `@/lib/utils` (`cn`), `lucide-react`, `recharts`, `@xyflow/react`, `@xterm/xterm` (+ fit / web-links addons), `radix-ui`, `sonner`, `cmdk`, `vaul`, `react-resizable-panels`, `react-day-picker`, `embla-carousel-react`, `input-otp`, `clsx`, `tailwind-merge`, `class-variance-authority`. Nothing else — no npm installs, no CDN. * **No secrets, no filesystem, no credentialed network in a widget.** It runs in a webview. Anything needing a key, a file or an authenticated API → a routine. * **Theme with tokens**: `bg-background text-foreground`, `bg-card`, `text-muted-foreground`, `border`, `bg-primary text-primary-foreground`. They follow the workbench light/dark theme; `dark:` variants follow it too. Don't hardcode colors. * `alert`/`confirm`/`prompt` don't work — use `cowork.ui.dialog(...)` / `cowork.ui.confirm(...)` or inline UI. `window.cowork` essentials (full table in [sdk](/docs/guides/widgets-and-sdk/)):
```js
await cowork.routines.run(id, args, { timeoutMs }) // → the routine's return value; rejects with e.name / e.runId
const s = cowork.routines.subscribe(id, args, { interval: 60, onData, onError }) // keep it fresh — s.refresh(), s.close()
cowork.app.post(msg) / cowork.app.onMessage(cb) // this app's other panes (sidebar ↔ tabs)
await cowork.credentials.get("google") / .connect("google") // { state, account?, missingScopes, … } — the shared sign-in
cowork.state.get() / cowork.state.set(value) // persisted per view — cache for instant paint
cowork.openFile("path/in/workspace") // opens through fileRouting
cowork.openedFile?.workspaceRelativePath // when opened for a routed file (path only — read it in a routine)
cowork.log(...) // "Cowork" Output panel
cowork.visible / cowork.onVisibility(cb) // on screen? (subscribe already gates on it)
cowork.tables.query({ db, sql }) // SQLite .db files (read-only query, exec for writes)
cowork.queue.send("tasks", { body }) / cowork.journal.add({ skill, text }) // agent task queues + the skill journal
cowork.agent.setContext(v) / cowork.agent.select(t) // feed the IDE's AI agent
cowork.ui.dialog(opts) / cowork.ui.confirm({ title, body }) / cowork.chat.ask(text) / cowork.openExternal(url) / cowork.capture.record(opts) // capture: after Cowork's dialog
cowork.browser.open(url) / cowork.browser.status() // the page beside the IDE (desktop app); else openExternal
cowork.statusBar.get(app)?.data / .onChange(cb) / .refresh(app) / .click(app) // footer items
await cowork.identity.currentUser() // { uid, email } or null
```
## Routines (.ts) — the rules [Section titled “Routines (.ts) — the rules”](#routines-ts--the-rules)
```ts
export default async (args: { city: string }, ctx: CoworkRoutineContext) => {
ctx.unchanged(); // a read: the audit keeps only that it ran
// the `weather` capability ("credential": {"key": {"fields": ["key"]}}): its fields, or NotConnected naming where to connect
const { key } = ctx.credential.fields();
const res = await fetch(`https://api.example.com/forecast?q=${encodeURIComponent(args.city)}&key=${key}`);
if (res.status === 429) throw ctx.error("RateLimited", "The forecast service is busy — try again in a minute.");
if (!res.ok) throw new Error(`forecast ${res.status}`);
const forecast = await res.json();
ctx.data.write("last-forecast.json", forecast); // .cowork/data/weather/ — never inside the app folder
return forecast; // JSON-serializable, or the caller gets null
};
```
* A fresh Bun process per run (full Node/Bun APIs). Default timeout 30 s (widgets may pass up to 300 s). Throw to fail. * `ctx`: `workspaceRoot`, `unchanged()`, `error(name, message)`, `data` (the app's data folder), `identity` (who it runs for), `credential.fetch/token/fields/status/start/disconnect` (the capability's connection), `secrets.get/list/set` (its cached values), `log`, `db(path)`, `queue(name)`, `journal.add`, `auth.getState/getIdToken`, `command.run` (consent-gated shell), `consent.request`, `browser.*` (desktop only), `notify` / `badge` (an OS notification + badge count; widgets: `cowork.notify/badge`). Details: [routines](/docs/guides/routines/). * **Every run is audit-logged** (7 days, on this computer): its inputs, and — for a run that failed or changed something — a 200-character result preview and its last log lines. Call `ctx.unchanged()` in a read so it records only that it ran. Never pass a secret as an argument: a key is connected by the person — `cowork.credentials.connect(name, { token })` from the app's pane, Manage › Components › Credentials, or `cowork credential connect --field token=…` ("Connecting" in [routines](/docs/guides/routines/)). Names under `cowork.` are reserved; a capability reaches its own connection and `.*`, an app none (`NotGranted`). * **Fail with a name**: `throw ctx.error("NotConnected", "Paste your token in the sidebar.")` — the widget's promise rejects with `e.name === "NotConnected"` (and `e.runId`, for `cowork routine runs show `). `ctx.credential.*` already throws `NotConnected`. * Run it: `cowork routine run --args '{"k":"v"}' [--workspace ]` (prints the result). * On a server (no IDE): `cowork start --headless` runs them — schedules and webhooks (`cowork deploy add`) too; a command a routine runs is approved ahead of time with `cowork consent allow ''`. See [routines](/docs/guides/routines/) ("Without the IDE"). ## Agents and grants [Section titled “Agents and grants”](#agents-and-grants) Agents (Claude Code, Codex, any MCP client) reach a workspace's **capabilities** through one door on the Cowork daemon, each with its own login (`cowork agent create `, or Manage Workspace › Permissions › Agent access, which prints the `claude mcp add` line to run in the workspace folder). A capability's routines are its tools; mark each one in its cowork.json:
```json
"routines": {
"invoices-list": { "file": "list.ts", "readOnly": true, "description": "Lists open invoices" },
"invoices-approve": { "file": "approve.ts", "reachesOthers": true, "description": "Approves an invoice and emails the supplier",
"input": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }
}
```
* A `readOnly` routine is granted to every agent by default (and its client runs it without asking); everything else is off until the owner grants it — per workspace, a default per routine and an override per agent (`cowork agent grant invoices/invoices-approve allow --agent claude`). * Only capabilities are offered to agents; the owner (panes, `cowork routine run`, schedules, webhooks) is never gated by grants. Every agent call is audited with the agent and its task. * A routine needing a connection that is not connected is unavailable to agents whatever is granted. Details, commands and examples: [agents and grants](/docs/guides/permissions/). ## Lifecycle [Section titled “Lifecycle”](#lifecycle)
```bash
cowork team-server list # where you can publish / install from
cowork push [dir] [--to [--remember]] [--dry-run] # publish the manifest version (bumps the patch if needed); --remember = default target
cowork app list [--json] # every app here: version, source, pinned|auto, on/off
cowork app install /[@X.Y.Z] # a version PINS it; without, it follows the store
cowork app update [] [--replace|--keep-both|--ai-merge|--skip] # a name unpins
cowork app versions /
cowork app enable|disable | remove
cowork app unpublish /[@X.Y.Z] --yes # the whole app, or one older version
cowork routine list | run [--args ''] # every verb: [--workspace ]
cowork routine runs [show ] [--team-server ] # executions, newest first
```
* Versions only move forward; a store newer than your copy refuses the push ("update first"). * Clean, unpinned installs **auto-update**; an edited app is never overwritten (the store asks). Keep user data outside the app folder so the app stays clean. * Every routine run and lifecycle action is appended to the local audit log `~/.cowork-studio/workspaces/-<8-hex hash>/audit/.jsonl` (Windows: under `%LOCALAPPDATA%\Cowork`; who, which app + version, device, org, agent, inputs; kept 7 days). See [lifecycle](/docs/guides/publish-and-install/). ## Courses [Section titled “Courses”](#courses) A course is `.cowork/courses//course.md` (front matter `title`, `level`, `lessons:` — lesson ids, in order; the body is its description). A lesson, in any number of courses, is a folder: `.cowork/lessons//lesson.md` (front matter `title`, `minutes`, `video` = a YouTube/Vimeo/Loom link; the body is markdown) plus the pictures it shows, referenced relatively. Check it with `cowork publish .cowork/courses/ --dry-run`, publish with `--team-server `. Full format: [courses](/docs/guides/courses/). ## Test loop [Section titled “Test loop”](#test-loop) 1. `cowork routine run --args '…'` for each routine — fix errors here first. 2. Save the widget → its pane/tab hot-reloads. New app or changed `cowork.json` → the icon appears/updates on the activity bar (reload the window if it doesn't). 3. `cowork app open .cowork/apps//.jsx [--as tab|dialog|sidemenu]` opens a widget directly. 4. Logs: the "Cowork" Output panel (`cowork.log`, `ctx.log`, routine stdout/stderr). Check the rail: icon, color, `title`. 5. Tests inside an app (`.cowork/apps//*.test.ts`): `bun test` skips dot-folders, so name the path: `bun test ./.cowork/apps/`. ## Checklist before you finish [Section titled “Checklist before you finish”](#checklist-before-you-finish) * [ ] `cowork.json` has a valid `name` + semver `version`; paths are inside the folder. * [ ] Routine ids are prefixed with the app name; every routine returns JSON. * [ ] No relative imports and only allowlisted imports in `.jsx`. * [ ] No `setInterval` polling — `cowork.routines.subscribe`; no `/Not connected/` matching — `e.name`. * [ ] App data through `ctx.data`; reads call `ctx.unchanged()`; sign-ins and keys declared in the capability's `credential`, never the app's. * [ ] Every capability routine says what it changes (`readOnly` / `destructive` / `reachesOthers`) and has a `description`; a routine taking arguments has an `input` schema. * [ ] No secrets/keys in widgets, in `cowork.json`, in routine args, or committed files. * [ ] Colors via theme tokens; the UI fills its pane and scrolls inside. * [ ] Nothing new added to `.cowork/workspace.json` except state (`disabled`, `publishTo`, `rail`, `startup`; `deployments` via `cowork deploy add`). * [ ] Ran each routine once from the CLI; opened the app once. * [ ] A short `SKILL.md` says how an agent USES the app (see "Folder layout" in [manifest](/docs/guides/manifest/)).
# Courses — markdown files
> A course is course.md plus the lessons it lists: people and agents write them as files, the team server publishes the course, and subscribers take it in the Academy (lessons in order, video on top, progress kept).
A course is **course.md** plus the **lessons** it lists: people and agents write them as files, the team server publishes the course, and subscribers take it in the **Academy** (lessons in order, video on top, progress kept). A lesson is a workspace's own folder, so one lesson can be in several courses.
```plaintext
.cowork/courses/onboarding/ the folder name is the course's store name (lowercase, digits, -)
course.md front matter (its lessons: line) + the description people read before subscribing
assets.json optional, like any published folder: {"item": {"tags": ["sales"]}}
.cowork/lessons/
welcome/ a lesson: the folder name is its id ("welcome")
lesson.md front matter + the lesson in markdown
home.png a picture it shows: 
first-steps/
lesson.md
shots/editor.webp subfolders are fine: 
```
## course.md [Section titled “course.md”](#coursemd)
```markdown
---
title: Sales Onboarding
level: Beginner
lessons: welcome, first-steps
---
Everything a new rep needs in their first week — the tools, the pipeline, the first call.
```
* `title` **required**; `level` = `Beginner` | `Intermediate` | `Advanced` (optional). * `lessons` — the lesson ids it teaches, comma-separated, **in order**: the line is the course. Each id is a folder in `.cowork/lessons/`. Add a lesson to a course by adding its id; another course may list the same one. * The body is the course's description (markdown, ≤ 2000 characters) — the listing shows it. ## lesson.md [Section titled “lesson.md”](#lessonmd)
```markdown
---
title: Your first call
minutes: 8
video: https://www.youtube.com/watch?v=M7lc1UVf-VE
---
What to say in the first two minutes, and what to log after.
## Before the call
- Read the account notes.

## After the call
Log it in the CRM. See [the call guide](https://example.com/guide).
```
* `title` **required**; `minutes` a whole number (optional); `video` optional — a **YouTube, Vimeo or Loom** link, played above the lesson. * The body is the lesson. `##` headings are its sections; lists, tables, `code`, quotes all render. * Front matter is plain `key: value` lines between `---` lines (quotes optional). ## Rules (publish checks every one and names the file) [Section titled “Rules (publish checks every one and names the file)”](#rules-publish-checks-every-one-and-names-the-file) * Every id on the `lessons:` line is a folder `.cowork/lessons//` holding `lesson.md`; ids are lowercase letters, digits and `-`, unique in the line. Reordering the line reorders the course without losing anyone's progress; renaming a lesson's folder makes it a new lesson (fix the line of every course that lists it). * Published, the course is one folder: each lesson travels as `NN-/` beside course.md (NN = its place). A course folder may also hold its own `NN-/` lesson folders — then the line must list each of them too. * Pictures are `.png .jpg .jpeg .gif .webp .svg` files **in the lesson's own folder** (or its subfolders), referenced relatively — or an `https://` image. Never `../`, never another lesson's. * Links go to `https://` pages or `#headings` — a relative link can't open from the Academy. * At most 40 lessons; the whole course ≤ 25 MB (keep video out of it — link it). * No scripts, no iframes: the Academy sanitizes the markdown. ## Make, check, publish [Section titled “Make, check, publish”](#make-check-publish)
```bash
# Create → Courses → New course in the IDE writes .cowork/courses//course.md and a first lesson,
# .cowork/lessons/-welcome/ — or write the files yourself.
cowork publish .cowork/courses/onboarding --dry-run # checks it: prints the outline, or the problem
cowork publish .cowork/courses/onboarding --team-server acme # publish (admins of that team server)
cowork team-server store acme # it is listed as a course
```
In the IDE: **Manage Workspace → Components → Courses & Lessons** lists both; a course's page adds and removes its lessons, a lesson's page puts it in courses or deletes it (from every course). **Publish… → A course** publishes one. Publishing again makes the next version; subscribers get it within a minute. A course is **subscribed**, never installed: on the team server's page → **Subscribe** → it appears in the Academy. If a course was unpublished and is published again under the same name, it comes back as the same course: subscriptions and progress resume.
# cowork.json — the app manifest (full reference)
> A folder .cowork/apps// containing a cowork.json is a Cowork app.
A folder `.cowork/apps//` containing a `cowork.json` is a Cowork app. Apps are discovered ONE level deep under `.cowork/apps/`, in folder-name order. A folder counts only when its own name is lowercase kebab (`^[a-z0-9][a-z0-9-]{0,62}$`, not `apps`/`data`/`courses`/`local`, not a Windows device name such as `con` or `nul`) — dotted, uppercase, symlinked and dot-prefixed folders are skipped. A Keep-both copy of an update sits beside yours as `-v1-2-3`. Every path inside the manifest is RELATIVE TO THE APP FOLDER, uses forward slashes, and may not escape it (no leading `/`, no `..`, no `\`, no drive letters). The IDE validates `.cowork/apps/*/cowork.json` against the manifest schema as you type — no `$schema` key is needed (unknown keys, `$schema` included, are ignored). ## Keys [Section titled “Keys”](#keys) | key | type | rules | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string, **required** | `^[a-z0-9][a-z0-9-]{0,62}$`. Store id AND activity-bar id. Unique per workspace (a second folder claiming it is reported broken) and per team-server store. Never rename a published app — a new name is a new app. | | `version` | string, **required** | semver `X.Y.Z` (optional `-pre` / `+build`), no leading `v`. | | `title` | string ≤ 200 | Display name (rail tooltip, pane title, store card). Default: `name`. | | `description` | string ≤ 2000 | One or two sentences for the store card. | | `icon` | string | A codicon id (`mail`, `calendar`, `rocket`, `graph`, `database`, `terminal` … — ) or `$(mail)`, **or** an image file inside the app folder (`icon.png`, `assets/logo.svg`; `.png`/`.svg`/`.webp`/`.jpg`, ≤ 256 KB, not a symlink). An image fills the rail tile in full color (centered, never cropped; `color` shows behind transparent parts) — square art with its own padding looks best. Manage Workspace and the Team Servers store show it too (`cowork push` puts it on the store listing; a codicon clears an old image there). Never a URL. Absent → a letter tile. | | `color` | string | Hex tile color `#rgb` / `#rrggbb` (e.g. `#EA4335`); the glyph turns white. | | `entry` | string | The UI, `.jsx` or `.tsx`. Present → the app gets its own activity-bar icon. Absent → a routines-/routes-only app (no icon). | | `web` | `{ "index": "<.html file>", "open"?: "drop:", "download"?: { "url", "sha256" } }` | Makes the folder a **web app** instead of a JSX one — see "Web apps" below. Never together with `entry` (the `entry` wins, `web` is dropped with an error). | | `display` | `sidebar` \| `tab` \| `modal` | What clicking the icon does with `entry`. Default `sidebar`; a web app is always `tab` (another value is dropped with an error). | | `open` | string \| `{ "routine": id, "args"?: {…} }` | **`sidebar` only.** What to open in the EDITOR the first time the icon is clicked in a session: an app file, or a routine that returns `{ "path": "" }` (for content that must be fetched/materialized first). Ignored (with an error) for `tab`/`modal`. | | `routines` | `{ id: file }` or `{ id: { "file": file, "timeout"?: s, "readOnly"?, "destructive"?, "reachesOthers"?, "description"?, "input"? } }` | id `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$` → `.ts` / `.js` / `.mjs` file. ≤ 100. `timeout` (optional): how long one run may take, whole seconds 1–300 (default 30). The rest says what the routine is as a **tool** — what an agent sees and is granted ([agents and grants](/docs/guides/permissions/)): `readOnly: true` it changes nothing (on for every agent by default; never together with the next two), `destructive: true` what it changes cannot be undone, `reachesOthers: true` it reaches people beyond the account (sends, posts, pays); `description` one line (≤ 400 chars); `input` its arguments as a JSON Schema `{"type": "object", "properties": {…}, "required": […]}` (≤ 8 KB). A bad tool key is reported and left at its safe default (a write, no description); the routine stays. A bare `"id": "file.ts"` is a write with no description. A `permission` key is ignored (reported). | | `fileRouting` | `{ glob: appFile }` | A WORKSPACE-relative glob (`**/*.maillist.json`) → an app file in this folder that renders matching files. ≤ 50. A glob ending in `/` routes FOLDERS (`"Notebook/"`, `"Projects/*/"`): clicking the folder in the Explorer opens the app, and a folder route never matches a file (nor a file route a folder); its value must be a `.jsx`/`.tsx`. An `.html` value opens the file in the app's web app, so it must be its `web.index` (any other `.html` value is dropped with an error). | | `credential` | `{ "oauth"?: { "provider", "scopes", "ownClient"? }, "key"?: { "fields", "url"?, "hint"? } }` | **A capability's** connection to its service — a sign-in, a pasted key, or either (one at least). `oauth`: `provider` an OAuth preset (`google`, `microsoft`), the `scopes` its routines need (≤ 32); every capability declaring a provider shares ONE sign-in, named after it (`google`), and connecting asks for what they all need together; `ownClient: true` signs in through the user's own client instead, under the capability's own name. `key`: the `fields` the person pastes (≤ 8, lowercase letters and `_`, e.g. `["token"]`), stored as `.`; `url` (https) where to get them, `hint` (≤ 200 chars) one line on how. The person connects once — Manage Workspace › Components › Credentials, an app's pane (`cowork.credentials.connect`), or `cowork credential connect`; routines use `ctx.credential.*`. An app declares none (ignored, with a problem line); the old `accounts` / `secrets` keys are ignored (reported). | | `capture` | `["screen" \| "system-audio" \| "mic" \| "screenshot", …]` | What the app's panes may capture with `cowork.capture` (any subset; an unknown entry drops the key with a problem line). A recording with video needs `screen`, its sound `system-audio` and/or `mic`; `capture.shot` needs `screenshot`. Declaring is not consent: every capture still asks the person in Cowork's dialog. Manage Workspace › the app's page lists it. An app's only (a capability has no panes). | | `kind` | `app` (default) \| `capability` | A `capability` is a package of routines with no UI that apps use — and agents, through Cowork's MCP door, as granted — a Gmail capability is the `gmail-*` routines + its `SKILL.md`. It carries no `entry` or `fileRouting` and no `uses` of its own (reported if it does; `kind` never changes what the folder registers). Manage Workspace lists it under Components › Capabilities instead of Apps. Credentials belong to a capability: its routines reach its own connection (`credential`) and `.*` secrets; an app's reach none (`NotGranted`). A package that declares neither `kind` nor `uses` predates capabilities and keeps its old access until it declares one. | | `uses` | `{ "": "" }` | The capabilities an app needs, each with the lowest version it works with; met by a switched-on package of that name at the same major and that version or above. ≤ 8, never the app's own name. A template install of an app brings the capabilities it `uses` along from the same team server; a missing, off or too-old one is listed in Components › Capabilities, and a routine call it would have answered fails with an error naming it. Declaring `uses` puts an app under the capability model (as `"kind": "app"` does): its widgets and routines reach another package's routines only through a capability named here (`Forbidden` otherwise, naming the line to add). | | `requires` | `[]` \| `["web"]` \| `["desktop"]` | Where it can run. `["desktop"]` = the Cowork Studio desktop app only (e.g. it uses `ctx.browser`). Empty = anywhere. | | `tags` | string\[] ≤ 20, each ≤ 40 chars | Store search/filters. | | `banner` | string | The store listing's wide picture: an image file in the app folder (`.png`/`.svg`/`.webp`/`.jpg`, ≤ 1 MB, not a symlink). `cowork push` uploads it when it changed. | | `statusBar` | `{ "routine": id, "args"?: {…}, "interval"?: s }` | One item in the footer (status bar), drawn from what `routine` — one of THIS app's routines — returns. `interval`: seconds between refreshes while the window has focus, 15–3600, default 60. See "Footer item" below. | | `screenshots` | string\[] ≤ 5 | The store listing's screenshots, in order — image files in the app folder (≤ 1 MB each). `cowork push` uploads the changed ones and removes any the listing holds beyond them; leave the key out to manage screenshots in the Team Servers store instead. | Validation: an invalid `name` or `version` makes the folder "not an app" (shown as broken, never registered). Any other bad key is dropped with an error and the rest of the app keeps working. Unknown keys are ignored. ## Display modes [Section titled “Display modes”](#display-modes) * **`sidebar`** — `entry` renders in a side panel under the app's icon. Optional `open` puts something in the editor area too (once per session, so the user isn't yanked around). * **`tab`** — clicking the icon opens `entry` in an editor tab (no side panel), every click. * **`modal`** — clicking opens `entry` in a resizable in-workbench dialog. ## Web apps [Section titled “Web apps”](#web-apps) A web app is a prebuilt static site — an `index.html` with its scripts, styles, `.wasm` and fonts, e.g. a Rust/WASM editor's release build — that Cowork serves **unmodified** on its own origin and draws in an editor tab of the normal editor groups (no URL bar, no browser history):
```json
{ "name": "wordcraft", "version": "0.10.0", "title": "WordCraft", "icon": "icon.png",
"web": { "index": "web/index.html" },
"fileRouting": { "**/*.docx": "web/index.html" } }
```
* `web.index` is the page, inside the folder; the folder that holds it is what gets served (dotfiles and anything outside it never are). Unzip the site's release into `web/` as it ships. * Or keep the build out of the app: `"download": {"url": "https://…/site.zip", "sha256": ""}` fetches the pinned release on the first open (checked against the sha256 before it is unpacked, cached per version). `index` is then relative to the BUILD — a zip whose files sit in one top folder has it stripped — so it is usually `"index.html"`, and routes name `"index.html"` too. A build copied into the folder as `web/` still wins. * `display` is always `tab`; there is no `entry`, no widget and no `window.cowork` SDK — the page is the app. Routines and `SKILL.md` work as in any app. * A routed file (`**/*.docx` above) opens in the web app's tab. Open the app directly with `cowork app open .cowork/apps//web/index.html`. * The app gets the routed file the web's standard ways — its open picker, a file ``, `launchQueue`; saving under the file's own name writes it back, any other name asks where. An app that opens files only by a drop sets `"open": "drop:"` (e.g. `"drop:#canvas"`): the file is dropped on that element. * It opens in the Cowork Studio desktop app and in Cowork in a browser tab or the PWA alike: the daemon serves it on its own `http://-.app.localhost:/` origin. Like a JSX app it gets an activity-bar icon. In a browser tab, closing it with unsaved changes does not ask yet. ## Site apps [Section titled “Site apps”](#site-apps) A site app is a **web site installed as an app**: `web.whatsapp.com`, a dashboard, any https page. It draws in an editor tab (no address bar) on your signed-in browser accounts, so the site is already logged in, and its notifications and unread count become the app's. It is a normal workspace app, a folder `.cowork/apps//` with a `cowork.json`, so it can be pushed like any other:
```json
{ "name": "whatsapp", "version": "1.0.0", "title": "WhatsApp", "icon": "icon.png",
"web": { "url": "https://web.whatsapp.com/", "scope": "https://web.whatsapp.com/" } }
```
* `web.url` is an https page (http only on localhost) with no credentials in it. `scope` is an https prefix on the same origin; it defaults to `url` up to its last `/`. A manifest has `web.index` (a web app) or `web.url` (a site app), not both, and `fileRouting` cannot point at a site app. * **Install** from the in-IDE browser: the address bar shows an install icon when the page has a Web App Manifest, and the menu offers "Install \…" (or "Install page as app…" for any https page). Or run `cowork app install `: the name, icon and color come from the site's own web app manifest (the page's title and icon when it has none). `cowork app remove ` deletes the files the install wrote; if cowork.json no longer points at the installed url, or a file changed, it keeps it and says so. Open one with `cowork app open .cowork/apps//cowork.json`. * **In the right side bar**: `"display": "right"` puts the site in a tab of its own in the right side bar, beside Chat and Claude Code — ChatGPT, Claude, Gemini next to your files. `"display": "sidebar"` puts it in a pane of the left side bar, opened from its activity-bar icon; with neither it opens in an editor tab. `cowork app install https://chatgpt.com/ --title ChatGPT --display right` installs it there (chatgpt.com and claude.ai turn scripts away, so the CLI installs them from the address with no icon — `--title` names them); for an installed one, set the key in its cowork.json. The page keeps running while its pane is hidden (another tab of the side bar, the side bar closed), so a long reply keeps streaming. In the right side bar any title works (ChatGPT, whose name starts with Chat, gets no rail icon as a tab app). A JSX app can be `"display": "right"` too: its sidebar UI then lives in the right side bar (`open` is a sidebar app's only — it runs from a rail click).
```json
{ "name": "chatgpt", "version": "1.0.0", "title": "ChatGPT", "display": "right",
"web": { "url": "https://chatgpt.com/" } }
```
* **Notifications** the site raises reach Cowork like an app's: a banner (muted per app), the bell, routines started with `cowork deploy add --notifications `, and `cowork.onNotification`. The site's unread count (`navigator.setAppBadge`) shows on the dock or taskbar icon. An OS banner click goes to the chat or page; the bell's Open shows the app but does not click for you. * Agents can look at and use a site app's tab (`apps list|screenshot|click|type|press`) with the person's consent, which names the site. The WhatsApp template tells agents never to read or send chats. **Limits** * Desktop app only — sites refuse to be shown inside another page. In Cowork in Chrome, its installed web app or on a phone, opening a site app shows "\ is part of the desktop app" (Get the Desktop App / Not Now) and the tab closes; a site app in a side bar says so in its pane. * In the right side bar the app's tab shows its title, not its icon or color. Dragging the tab to another side bar is remembered for you and wins over `display`. * A site notifies only while its tab is open: closing the tab stops it (a window hidden to the tray keeps it running), and there is no Web Push. Only the active workspace's site apps run; switching workspaces closes the others. At most 6 banners per app per minute. * A notification a site's own background worker raises is shown by the system without the app's name or mute. The page's own notifications are the ones Cowork attributes. * The unread count is on the dock or taskbar, not on the app's rail icon, and a site that does not set a badge gets the `(N)` from its page title. The rail icon itself arrives with the next engine release; until then open a site app right after installing it, from Manage Workspace, with `cowork app open` or from its notifications. * No address bar on a page outside `scope`. Switching accounts or a crash reload loses the page's state. * Ignored from the site's manifest: shortcuts, file and protocol handlers, share target, window-controls overlay. A site that checks the browser's user agent may need a fix from Cowork's side. * Install refuses `localhost` and private-network addresses when the start page is public. ## Examples [Section titled “Examples”](#examples) A mail client — sidebar list + a routine-materialized editor view + a routed file type:
```json
{
"name": "gmail",
"version": "1.0.0",
"title": "Gmail",
"description": "Read and triage your inbox without leaving Cowork.",
"icon": "mail",
"color": "#EA4335",
"entry": "gmail-sidemenu.jsx",
"display": "sidebar",
"open": { "routine": "gmail-open-inbox", "args": { "folder": "INBOX" } },
"routines": {
"gmail-open-inbox": "open-inbox.ts",
"gmail-fetch": "fetch.ts",
"gmail-send": "send.ts"
},
"fileRouting": { "**/*.maillist.json": "mail-list.jsx" },
"tags": ["email", "google"]
}
```
A dashboard in a tab:
```json
{ "name": "sales-dashboard", "version": "0.1.0", "title": "Sales", "icon": "graph", "color": "#0F9D58",
"entry": "dashboard.jsx", "display": "tab", "routines": { "sales-load": "load.ts" } }
```
A quick-capture dialog:
```json
{ "name": "quick-note", "version": "0.1.0", "icon": "note", "entry": "quick-note-modal.jsx", "display": "modal" }
```
A capability — a key to connect, and routines marked as tools for agents:
```json
{ "name": "invoices", "version": "1.0.0", "title": "Invoices", "kind": "capability",
"credential": { "key": { "fields": ["token"], "url": "https://billing.example.com/settings/api", "hint": "Settings › API › New token" } },
"routines": {
"invoices-list": { "file": "list.ts", "readOnly": true, "description": "Lists open invoices" },
"invoices-approve": { "file": "approve.ts", "reachesOthers": true, "description": "Approves an invoice and emails the supplier",
"input": { "type": "object", "properties": { "id": { "type": "string", "description": "The invoice id" } }, "required": ["id"] } }
} }
```
A headless app (routines + a viewer for a file type, no icon):
```json
{ "name": "csv-tools", "version": "1.2.0", "routines": { "csv-summarize": "summarize.ts" },
"fileRouting": { "**/*.csv": "csv-viewer.jsx" } }
```
## Footer item [Section titled “Footer item”](#footer-item) `statusBar` puts one item in the footer, left, after `Workspace: `. Its routine returns what to show — or `null` to hide it:
```ts
export default async (args, ctx) => args.action === "disconnect"
? (await disconnect(), null) // a click's work; the item refreshes after
: (await isConnected())
? { text: "jotas@34.135.78.28", icon: "remote", tooltip: "Connected over SSH",
click: { args: { action: "disconnect" }, confirm: "Disconnect from the VM?" },
data: { connected: true } }
: { text: "Connect to VM", icon: "plug", click: { args: { action: "connect" } },
data: { connected: false } };
```
* `text` (required), `icon` (a codicon; `sync~spin` spins), `tooltip`, `tone`: `"warning"` | `"error"` (colors the item). * `click: { args, confirm? }` — clicking runs the SAME routine with `args` (after a modal when `confirm` is set) while the item spins; a thrown error shows as a notification. `menu: [{ label, args, confirm? }]` instead lets the user pick one action. Either way the item refreshes after. * `data` — free-form (≤ 16 KB) for the app's widgets: `cowork.statusBar.get("").data`. * It refreshes when it appears, every `interval` seconds while the window has focus, when focus returns, after a click, and on `cowork.statusBar.refresh(name)`. A failing refresh shows the app's title as a warning (the error in its tooltip; a click retries). * Each refresh is a routine run (audited, tagged `status-bar`): keep the routine cheap — a local check, not a network round trip — and the interval as long as you can live with. ## Folder layout [Section titled “Folder layout”](#folder-layout)
```plaintext
.cowork/apps/gmail/
cowork.json
gmail-sidemenu.jsx ← entry (naming: *-sidemenu.jsx / *-modal.jsx is convention only)
mail-list.jsx ← fileRouting target
open-inbox.ts fetch.ts send.ts ← routines
README.md ← optional; travels with the app
SKILL.md ← optional; how an agent uses the app (linked as .claude/skills/)
```
`SKILL.md` tells an agent how to USE the app: * its files and data; * setup and environment (test vs production); * the routines to run; * the rules that bite. Its front matter is `name` (the app's `name`) and a one-line `description` ending in "Use when …". Cowork links every enabled app that has one as the project skill `.claude/skills/`, pointing at the app folder, so Claude Code knows the app from the start of a session. Keep it short: the README stays the full reference. Everything in the folder travels when the app is pushed/installed. Keep generated data (caches, exports, `.db` files that hold user data) OUT of the app folder — write them elsewhere in the workspace (`ctx.data` writes to `.cowork/data//`; `cowork table create ` makes `.cowork/data/.db`), so an update never collides with user data and an edited app stays "clean". ## Collisions [Section titled “Collisions”](#collisions) * Routine ids are ONE namespace per workspace: first app folder wins. A fileRouting glob two apps claim (Docs and WordCraft both `**/*.docx`) asks the user once which app opens it and remembers the answer in `.cowork/workspace.json` `openWith`; until then the first switched-on app is the route. **Prefix routine ids with the app name** (`gmail-fetch`, not `fetch`). ## `.cowork/workspace.json` — workspace STATE, not registrations [Section titled “.cowork/workspace.json — workspace STATE, not registrations”](#coworkworkspacejson--workspace-state-not-registrations) The file holds state only (it never travels with an app or a published workspace); the keys:
```json
{
"publishTo": "",
"startup": { "home": "gmail" },
"disabled": { "apps": [".cowork/apps/gmail-v1-2-3"], "routines": ["gmail-send"] },
"rail": { "folders": [{ "id": "google", "title": "Google", "color": "#0090ff",
"members": ["workbench.view.cowork.app.gmail", "workbench.view.cowork.app.calendar"] }] },
"deployments": { "gmail-sync": { "target": "cloudflare-worker", "mode": "local", "schedule": "0 * * * *" } }
}
```
* `disabled.apps` takes an app FOLDER (switches off its icon, routes and routines — including files a later update adds) or a single file; `disabled.routines` takes routine ids (listed but refuse to run). Off-only switches; safe to commit. * `rail.folders` groups activity-bar icons into folders (members are view-container ids; an app's is `workbench.view.cowork.app.`). Users make them by dragging one icon onto another. A folder holds icons of one rail group — app icons and the system icons never share one. * `publishTo` is the default team server for `cowork push` — set it with `cowork push --to --remember` (or "Use as this workspace's default" when publishing from Manage Workspace). * `startup.home` names the app the window opens on (default: the app named `home`); `startup.left` / `right` (booleans) open or close the side bars at start. * Nothing else registers anything: containers, file routes, routines and open modes come from the app's `cowork.json`, so the app is self-contained and updates re-register it automatically.
# Permissions — agents and grants
> An agent (Claude Code, Codex, any MCP client) reaches a workspace's capabilities through one door on the Cowork daemon, signed in with its own login.
An **agent** (Claude Code, Codex, any MCP client) reaches a workspace's **capabilities** through one door on the Cowork daemon, signed in with its own login. What it may run is **granted**, one routine at a time, by the person who owns the workspace. Everything else — the app's panes, a terminal's `cowork routine run`, schedules, webhooks, `ctx.routines.run` — is the owner, and grants never gate it. ## What a capability author marks [Section titled “What a capability author marks”](#what-a-capability-author-marks) A capability's routines ARE its tools: each entry in `routines` says what it does and what it changes, as an MCP tool says it.
```json
{ "name": "clickup", "version": "1.1.0", "kind": "capability",
"credential": { "key": { "fields": ["token"], "url": "https://app.clickup.com/settings/apps", "hint": "Settings › Apps › API Token" } },
"routines": {
"clickup-tasks": { "file": "tasks.ts", "readOnly": true, "description": "Lists the tasks of a list",
"input": { "type": "object", "properties": { "list": { "type": "string", "description": "The list id" } }, "required": ["list"] } },
"clickup-comment": { "file": "comment.ts", "reachesOthers": true, "description": "Comments on a task (its watchers are notified)" },
"clickup-delete": { "file": "delete.ts", "destructive": true, "description": "Deletes a task" }
} }
```
* `readOnly: true` — it changes nothing: no write at the service, no money spent, no user or project code run, nothing written in the workspace but its own cache (no view state, no export, no file a person could have edited). Granted to every agent by default, and an agent's client may run it without asking — so when in doubt it is not a read. A read cannot also be `destructive` or `reachesOthers` (then `readOnly` is ignored, with a line). * `destructive: true` — what it changes cannot be undone. `reachesOthers: true` — it reaches people beyond the account (sends, posts, pays). * `description` (≤ 400 characters) and `input` (a JSON Schema `{"type": "object", …}` of its arguments, ≤ 8 KB) are what the agent reads before calling it — write them for an agent. * A bare `"id": "file.ts"` is a write tool with no description: off for agents until granted. * Risk is derived, never stored: a read is low; destructive or reaching others is high; the rest medium. Manage Workspace shows it beside each routine. * Only `"kind": "capability"` packages are offered to agents — an app's own routines and loose routines are not. The capability's `SKILL.md` is the guide an agent gets with its tools. ## How the owner grants [Section titled “How the owner grants”](#how-the-owner-grants) Per workspace: a **default** for each `/`, and an **override** per agent. The rule: the agent's own setting, else the workspace default, else on only for a read. A routine whose connection is not connected (its `credential`) is unavailable whatever is granted. * **Manage Workspace › Permissions › Agent access** — Add agent (the token is shown once, with the lines that connect a client), and per agent: each capability's routines with Default / Allow / Deny. **Capability defaults** — the workspace default, one switch per routine. * **The CLI** (a headless server has no page):
```bash
cowork agent create claude # prints the token ONCE + the connect lines
cowork agent list # name, id, last seen, revoked
cowork agent grants [--agent claude] # every routine: the default and each agent's answer
cowork agent grant clickup/clickup-comment allow --agent claude
cowork agent grant clickup/* deny # the default for every agent without its own setting
cowork agent grant clickup/clickup-comment default --agent claude # back to the rule
cowork agent token claude # a new token; the old one stops working
cowork agent revoke claude --yes
```
Connect the agent from the workspace folder (Claude Code's local scope: private to that folder, never in git — the URL names the workspace):
```bash
claude mcp add --transport http cowork 'http://127.0.0.1:47913/api/mcp?ws=%2Fpath%2Fto%2Fws' --header "Authorization: Bearer cwa_…"
```
(Codex: the `[mcp_servers.cowork]` block `cowork agent create` prints, in `~/.codex/config.toml`.) The agent then starts a task, lists the capabilities it may use, and calls `read` (a granted read) or `run` (anything else granted); a call that is not granted says so and names who to ask. ## What gets recorded [Section titled “What gets recorded”](#what-gets-recorded) Every agent call is a routine run like any other — the same off switch, the same audit record — naming the agent and its task: Manage Workspace › Observability › Executions shows **Who** (you, or the agent) and the task; `cowork routine runs` lists them. A call that is not granted is recorded `denied` and never starts. ## Who is this run for [Section titled “Who is this run for”](#who-is-this-run-for) `ctx.identity` (routines) is `{ user: {uid, email} | null, organization: {id, slug, name} | null }` — who is signed into Cowork, frozen at the start of the run; use it to stamp who did what. `cowork.identity.currentUser()` is the same user in a widget. `cowork status` and `cowork org` show the organization. ## Known limit [Section titled “Known limit”](#known-limit) Grants are policy on the owner's own machine: a process holding the daemon token is the owner and can make agents or grants. They keep agents to what you allowed; they are not a wall against the machine's owner.
# Publish, install, update, pin, remove — and the audit log
> Apps are shared through a team server: members + a store.
Apps are shared through a **team server**: members + a store. Publishing and installing need a signed-in Cowork account (sign in inside Cowork; `cowork status` shows the account). Pushing needs an admin role on that team server.
```bash
cowork team-server list # your team servers (slug, your role)
cowork team-server store # what it publishes
```
## Push (publish a version) [Section titled “Push (publish a version)”](#push-publish-a-version)
```bash
cowork push # the current folder: an app folder, or a workspace (every .cowork/apps//)
cowork push .cowork/apps/gmail # one app
cowork push .cowork/apps/gmail --to acme # an explicit team server
cowork push --to acme --remember # …and make it this workspace's default (workspace.json#publishTo)
cowork push --dry-run # what would be published / bumped; nothing uploaded or written
cowork push --json # machine-readable rows
```
Each app prints one row: `published vX`, `bumped vA → vB`, `up to date vX`, or `failed: …` (exit 1; the other apps still push). * **Target**: `--to ` > the store the folder came from (its install receipt) > `.cowork/workspace.json#publishTo` > an error listing your team servers. `--to --remember` stores that team server as `publishTo` (machine-local, never packaged) once it resolves — even if the push itself then fails; an unknown team server is never stored and `--dry-run` writes nothing. * **Version** comes from `cowork.json`. Unchanged files → "up to date" (nothing published). Changed files but that version already published → the patch is bumped (`1.2.3` → `1.2.4`) and written back to `cowork.json`. Store newer than your copy → refused: update first. Versions only move forward — never lower `version` by hand. * After a push the folder is tracked like an install, so a collaborator's later push reaches your copy automatically while you haven't edited it. * The team server keeps the newest 10 versions of each app. An admin can withdraw a bad older one (`cowork app unpublish /@`); the latest can't be withdrawn — push a fixed version first. Copies already installed (even pinned to it) keep working. ## Install and manage (in a workspace) [Section titled “Install and manage (in a workspace)”](#install-and-manage-in-a-workspace)
```bash
cowork app list [--json] # every app here: version, local | @ (pinned or auto), on/off, newer version out
cowork app install acme/gmail # the store's latest → .cowork/apps/gmail/, follows the store
cowork app install acme/gmail@1.2.0 # a version the store still keeps, PINNED (auto-update leaves it)
cowork app versions acme/gmail # the versions the store keeps (yours marked)
cowork app update # every clean, unpinned update that verifies
cowork app update gmail # take the latest now (unpins)
cowork app update gmail --keep-both # an EDITED app needs one choice (below)
cowork app disable gmail # switch the whole app folder off (workspace.json#disabled); files kept
cowork app enable gmail
cowork app remove gmail # uninstall: files still exactly as installed are deleted, edited ones kept
cowork app unpublish acme/gmail --yes # delete it from the store (admins)
cowork app unpublish acme/gmail@1.2.0 --yes # withdraw ONE older version (admins; never the latest)
# `app versions` then notes a copy pinned here to a withdrawn version
cowork app open .cowork/apps/gmail/mail-list.jsx --as tab # open a widget in the running workbench (tab|dialog|sidemenu)
```
Every verb takes `--workspace ` (default: the current folder). `` is the folder (`.cowork/apps/gmail`), its basename, or the manifest `name` (the folder that owns it). An updated app you edited needs one of: `--replace` (the store wins; files you ADDED are never touched), `--keep-both` (yours stays exactly as it is; the new version lands WHOLE in `.cowork/apps/-v/` (`gmail-v1-2-0`), switched off — compare the two, then delete the one you don't want), `--ai-merge` (Claude merges; needs the `claude` CLI; if it can't, it keeps both) or `--skip` (dismiss that version). Keep both and skip dismiss the version: the next one asks again, and `--replace` can still take it later. Two folders with one `name` (Keep both's copy beside yours): the name belongs to the first folder that is switched ON, and the other is listed but registers nothing. To try the new version, switch yours off and the copy on (`cowork app disable .cowork/apps/gmail` + `cowork app enable .cowork/apps/gmail-v1-2-0`) — its routines, rail icon and skill take over; both share `.cowork/data//`. `remove` only handles installed apps — a local app (one you made) is yours: delete its folder. `install` / `update` refuse unverified content unless you pass `--allow-unverified` — don't, unless the user explicitly asks. The same actions exist in the IDE (Manage Workspace and the Team Servers store page). ## Auto-update [Section titled “Auto-update”](#auto-update) Cowork checks installed apps every few minutes (and at start, after sign-in, after a push). A newer store version is applied automatically only when the installed app is **clean** (every file still matches what was installed), **verified**, and **not pinned**. An edited app is never overwritten — the Manage Workspace icon counts it and a toast names it; you choose in Manage Workspace or on the store page. Kill switch: `COWORK_NO_AUTO_UPDATE=1`. Consequences for authors and agents: * Editing an installed app's files turns its auto-update off (until you `cowork app update \ --replace|--keep-both|--ai-merge|--skip`). To change someone else's app, prefer pushing a new version from the source. * Keep user data outside the app folder, or the app is "edited" forever. ## The audit log [Section titled “The audit log”](#the-audit-log) Every routine run (from a widget, the CLI or an agent, a webhook, a schedule) and every app lifecycle action (push / install / update / remove) appends one JSON line to
```plaintext
/workspaces/-<8-hex hash>/audit/.jsonl
```
(`` is `~/.cowork-studio` on macOS/Linux and `%LOCALAPPDATA%\Cowork` on Windows; e.g. `~/.cowork-studio/workspaces/-home-ana-work-fa27a359/audit/2026-09-26.jsonl`, dates in UTC; the hash keeps paths like `/a/b-c` and `/a-b/c` apart — find the folder with `ls ~/.cowork-studio/workspaces/ | grep `.) A line:
```json
{"ts":"2026-09-26T14:03:11.412Z","capability":"gmail-fetch","input":{"folder":"INBOX"},
"status":"ok","durationMs":812,
"context":{"executionId":"…","userId":"…","organizationId":"…","deviceId":"…",
"workspace":"/home/ana/work","source":"cli","agent":"claude-code",
"appId":"gmail","appVersion":"1.2.3","kind":"capability"}}
```
* `capability` is the routine id for a run (`gmail-fetch`) and `"cowork.app.push"` / `"cowork.app.install"` / `"cowork.app.update"` / `"cowork.app.remove"` for a lifecycle action (an automatic update is `"cowork.app.update"` with source `system`). * `source`: `app` (a widget), `cli` (the CLI — `agent` says if Claude Code / Codex ran it), `mcp` (an agent through Cowork's MCP door — `principal` names its login, `taskId` its task), `webhook`, `schedule`, `system`, `deploy`. * `status` is `ok`, `error`, or `denied` (refused before it started: an agent the routine is not granted to — see [agents and grants](/docs/guides/permissions/)). * `input` is the routine's arguments (capped at 16 KB). Values under credential-looking keys (`authorization`, `cookie`, hook/capability tokens, `*password`, `*secret`, `*api_key`, `*token` fields) are replaced by `"[redacted]"` — but never pass secrets as args anyway. * `context.caller` (when present) is what the caller CLAIMED (app / source / agent); `appId`/`appVersion`/`kind` (`app` | `capability`) are derived by Cowork from the routine file that actually ran; `context.caller.chain` lists the routines that called it. `cowork routine runs --app ` lists one package's runs; `show ` prints the routines that called it (`via daily → send-report`). * `agent` is filled when the run came from Claude Code or Codex. * Local only, files are private to the user (0600), deleted after 7 days. A run that failed or changed something also keeps a result preview (200 characters, credential-named keys redacted) and its last log lines (up to 2,000 characters); a read that called `ctx.unchanged()` keeps neither. Nothing else of the result or the log is recorded. * To answer "what ran here recently?", read the day files with `jq`, e.g. `jq -c 'select(.status!="ok")' ~/.cowork-studio/workspaces//audit/*.jsonl`.
# Routines (.ts) — the privileged half of an app
> A routine is a .ts (or .js / .mjs) file whose default export is called with (args, ctx).
A routine is a `.ts` (or `.js` / `.mjs`) file whose default export is called with `(args, ctx)`. It runs as its own Bun process spawned by the Cowork daemon — full Node/Bun runtime (`fetch`, `node:fs`, `node:path`, `Bun.*`), started fresh for every run (so an edit applies on the next run). It is the ONLY place that can read secrets, touch the filesystem or call authenticated APIs. Widgets call it; you run it from the CLI; agents call a capability's routines as tools, as granted ([agents and grants](/docs/guides/permissions/)).
```ts
// .cowork/apps/gcal/events.ts — in the `gcal` CAPABILITY, whose cowork.json is
// { "name": "gcal", "version": "0.1.0", "kind": "capability",
// "routines": { "gcal-events": { "file": "events.ts", "readOnly": true, "description": "Lists upcoming events" } },
// "credential": { "oauth": { "provider": "google", "scopes": ["https://www.googleapis.com/auth/calendar.readonly"] } } }
export default async (args: { max?: number }, ctx: CoworkRoutineContext) => {
ctx.unchanged(); // a read
const res = await ctx.credential.fetch( // the shared Google sign-in: bearer,
// refresh on 401, retries on 429 / 5xx
`https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=${args.max ?? 10}&orderBy=startTime&singleEvents=true&timeMin=${new Date().toISOString()}`,
);
if (!res.ok) throw new Error(`Calendar ${res.status}`); // the caller's promise rejects with this
const { items } = await res.json();
ctx.log("fetched", items.length, "events"); // → Output panel only
return items.map((e: any) => ({ id: e.id, title: e.summary, start: e.start?.dateTime ?? e.start?.date }));
}; // must be JSON-serializable
```
(Credentials belong to a capability: only its routines reach the connection its cowork.json `credential` declares — an app's routine gets `NotGranted`, and an app's own `credential` is ignored. The app declares `"uses": {"gcal": "0.1.0"}`, calls `cowork.routines.run("gcal-events")` and signs in once with `cowork.credentials.connect("google")` (or Manage Workspace › Components › Credentials) — until then the routine throws `NotConnected`, with a message that says so.) ## Rules [Section titled “Rules”](#rules) * `export default` a function (sync or async). The return value must be JSON-serializable (else the caller gets `null` + a warning). Throw to fail — the caller's promise rejects with the message. Throw `ctx.error(name, message)` to give the failure a kind the widget can branch on (`e.name === "NotConnected"`, `"RateLimited"` …); every rejection also carries `e.runId`. * Timeout: 30 s by default. A routine that needs longer declares it in `cowork.json` (`{"file": "x.ts", "timeout": 120}`, seconds, ≤ 300) — apps, `cowork routine run`, schedules and webhooks all honor it; a widget can still pass `{ timeoutMs }` per call. * **Each run leaves one record**, kept 7 days in the local audit log: status, duration, error, and — when it failed or changed something — a result preview (200 chars, credential-named keys redacted) and the last lines it logged (`console.log` / `ctx.log` also stream to the "Cowork" Output panel). A read that calls `ctx.unchanged()` records only that it ran. `cowork routine runs` pages through them. A run of an app installed from a team server is also reported to that server — never its input or result; a failed run's last lines, with credential-looking strings cut out. * **An execution is a run that changed something, or failed** — like an n8n execution. A run that only read (a list, a status, a poll that found nothing) calls `ctx.unchanged()`: it stays in the local record but is never reported and is left out of the run lists (`cowork routine runs --unchanged` shows it). A widget that polls a read routine then adds no noise. Call it per run, from the branch that knows: `if (action === "list") ctx.unchanged()`. * **Inputs ARE recorded** in the local audit log (7 days). Never pass a secret as an argument — read it inside the routine (`ctx.credential.fields()`, `ctx.secrets.get`). * Routine ids are one namespace per workspace — prefix with the app name. * A routine may import sibling files inside its own app folder (normal module resolution). Keep them inside the folder so they travel with the app. No duplicate top-level declarations (the runner refuses the file). * Read files relative to `ctx.workspaceRoot`; refuse paths that escape it (example below). * A disabled routine (`.cowork/workspace.json#disabled.routines`, or its app folder disabled) refuses to run. * In a capability, say what each routine changes (`readOnly`, `destructive`, `reachesOthers`, `description`, `input` — [manifest](/docs/guides/manifest/)): agents run it only as granted, reads on by default. A `readOnly` routine must really change nothing (it may cache what it read in its own `.*` secrets or `ctx.data`). ## `ctx` [Section titled “ctx”](#ctx) | member | what | | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceRoot` | Absolute workspace path. | | `identity` | Who this run is for: `{ user: {uid, email} \| null, organization: {id, slug, name} \| null }`, frozen at the start of the run. Use it to act on WHO (stamp an approver). | | `credential.fetch(url, init?)` → `Response` | The capability's connection — the one its cowork.json `credential` declares. A sign-in sends its bearer; a 401 refreshes once and retries; only to the service's API hosts (`googleapis.com`, `graph.microsoft.com`), https — a pre-authenticated upload URL takes plain `fetch`. A key sends nothing by itself: put its header in `init.headers` (from `fields()`). A 429 / 5xx waits and retries `retries` times (0–4, default 2; `retries: 0` for a write that must never repeat); `json` sends a JSON body. Resolves with the Response whatever its status — name the failure yourself (`if (!res.ok) throw ctx.error(…)`). Throws `NotConnected` naming where the person connects it; a capability declaring no `credential` gets `NotConnected` saying so, an app's routine `NotGranted`. | | `credential.token({ force? })` / `credential.fields()` | A sign-in's access token, refreshed when needed (`force` after the service answered 401) — for an SDK that wants one; prefer `fetch` — or a key's first field. `fields()` → the key's fields by name (`{ token }`), or `NotConnected`. | | `credential.status()` / `credential.start()` / `credential.disconnect()` | `{ connected, account?: { email } }` — never a token; begin a sign-in yourself → `{ authUrl }`; forget the connection, revoked at the service when it can be — for every capability that shares it. Connecting is the person's: Manage Workspace › Components › Credentials, an app's pane (`cowork.credentials.connect`), or `cowork credential connect`. | | `secrets.get(name)` / `list()` / `set(name, value)` | The signed-in Cowork account's encrypted secret store, within the package's grants: a capability's routine reaches its own `.*` (and reads `cowork.openrouter`), an app's routine nothing — anything else throws `NotGranted` naming the package and the name asked (a package that declares neither `kind` nor `uses` keeps its old, unrestricted access). For what a routine caches beside its connection (`clickup.user_id`): `get` → `undefined` if unset; `list` → the names it may reach; `set` stores one. Names `^[A-Za-z_][A-Za-z0-9_.-]*$`; the `cowork.` prefix is reserved — of it a routine reads only `cowork.openrouter`, a preprovisioned OpenRouter key (OpenAI-compatible, `https://openrouter.ai/api/v1`) present when signed in. | | `log(...parts)` | A line in the Output panel (and, for a run that changed something, in its audit record). | | `error(name, message)` | An `Error` with that name, to throw: `throw ctx.error("NotFound", "…")`. The widget's promise rejects with `e.name === "NotFound"` — branch on it, never on the message. Your error carries `e.runId`; Cowork's own `NotFound` (no such routine) comes before any run and has none. | | `data` | The app's own data folder, `.cowork/data//` (the app is the `.cowork/apps/` folder whose cowork.json names it; a `.gitignore` keeps it out of git): `read(name, fallback)` (JSON; `fallback` when missing or broken), `write(name, value)` (atomic, creates subfolders) → the workspace-relative path, `list(subfolder?)`, `remove(name)` → bool, `path(name)`, `dir`, `fileName(title, fallback)`. Names stay inside the folder (`..` and absolute paths throw). Keep user data here, never in the app folder. | | `unchanged()` | This run changed nothing (a read, an empty poll): not an execution — kept locally, never reported, hidden from the run lists. Ignored if the run then throws. | | `db(path)` → `{ query, exec, transaction }` | A workspace SQLite `.db` (WAL). `query` returns rows, `exec` changes them (`{ changes, lastInsertRowid }`) — `query` does not refuse a write, so keep writes in `exec`; `transaction(fn)` must be SYNCHRONOUS. | | `queue(name)` → `{ send, receive, ack, nack, approve, reject, reply, retry, peek, list, counts }` | A task queue in the workspace store (`.cowork/local/queues.db`), synchronous — the same tasks as `cowork queue …` and the Tasks view. A task has a `subject`, a `status` (`todo` · `in_progress` · `needs_review` · `done` · `failed`), `from`, `to` (an agent's name, `owner`, or `anyone`), `review`, the request (`body`, `files`) and a thread of `entries`. `send(body, { subject?, to?, as?, review?, ref?, meta?, delayS?, files? })` (files `{ path }` copied, or `{ name, text \| base64 }`; a repeated `ref` answers the first task) creates the queue; `receive({ as?, mine?, leaseS? })` → the oldest task for `as` (or for anyone) with a `receipt`, or `null`; finish with `ack(id, receipt, { result?, files?, review?, to? })` (the result and files — a `{ path }` only inside the workspace — become your entry; `review` asks the owner, `to` hands it on) or give it back with `nack(id, receipt, { delayS?, error? })`; `approve(id)`, `reject(id, comment)` (Send back), `reply(id, body, { to?, as?, files? })`, `retry(id)`; `list({ view?, agent?, limit?, before? })`; `counts()` per view. Use it to hand work to an agent, never to start one. | | `journal.add({ skill, kind?, text, source? })` | Leave a learning / error / note for a skill (`source` defaults to `routine:`); an agent later folds open entries into the skill (`cowork journal entries --open`). | | `routines.run(id, args?, { timeoutMs? })` | Run another routine of this workspace and await its result — see [Calling another routine](#calling-another-routine). | | `notify({ title, body?, tag?, urgent? })` | An OS notification from this routine's app — what `cowork.notify` does in a widget (same fields, cuts and click), and the way to notify with **no pane open**: the routine that polls keeps what it saw last in `ctx.data`, and on something new calls `await ctx.notify({ title: "New email", body: from, tag: "inbox" })`. **The desktop app notifies reliably** — even with its window closed (tray); **a browser tab is best effort** — only while a tab on this workspace is open, and a backgrounded tab is throttled to about one check a minute. Only the **active** workspace's notifications show. One nobody showed within a minute is dropped (the next poll repeats it); muted or past 6 a minute → dropped, still resolves. A **capability's** notifications go to the one app that uses it: a click opens that app, and either one's Notifications switch (the app's or the capability's, in Manage Workspace) mutes them. A routine outside a `.cowork/apps//` package gets `BadRequest`. Absent in deployed routines (see below). | | `badge(count)` | This app's count on the OS badge — as `cowork.badge`: the latest call wins, `0` clears; report it on every poll (`await ctx.badge(unread)`). The desktop app's dock / taskbar shows the **active** workspace's count (a browser tab: a `(N) `title prefix); another workspace's waits until it is active. | | `auth.getState()` / `auth.getIdToken()` | Who is signed into Cowork → `{ authenticated, user: { uid, email, displayName } \| null }`; a short-lived Firebase ID token (or null). | | `command.run(cmd, { title, description, value, timeoutMs })` | Run a shell command after a user consent modal (login shell PATH, in the workspace folder) → `{ code, stdout, stderr }`. `value` = a stable consent label. | | `consent.request({ value, title, description, command })` | Consent only → `{ granted, ticket? }` (hand the ticket to a widget's `stream.open`). | | `browser.*` | Drive the Cowork Studio desktop app's embedded browser: `open`, `navigate`, `snapshot`, `find`, `click`, `fill`, `type`, `press`, `waitFor`, `read`, `screenshot`, `tabs`, `evaluate(js, { tabIndex })` (runs in the page — uses its logged-in session). `open(url)` opens a new tab; `open()` with no URL shows the browser (its last-active tab). `toggle()` is deprecated — it does what `open()` does; use `open()`. Desktop only → set `"requires": ["desktop"]`. | ### Calling another routine [Section titled “Calling another routine”](#calling-another-routine) `await ctx.routines.run("gmail-send", { to, subject, body })` runs `gmail-send` like any other caller would — the same off switch, a concurrency slot, its own run record — and resolves with what it returns. An id your app declares is your app's own routine; a capability's routines answer by their ids while it is installed and on (name it in `uses` so installing your app brings it). Calls nest two deep (a → b → c; c calling another throws `RoutineDepth`), a routine already in the chain throws `RoutineCycle`, and the nested run never outlives yours (`timeoutMs` is capped by what is left). A routine that threw rejects with its own `e.name` (`NotConnected` …) and `e.runId`; a refusal rejects as `NotFound`, `RoutineDisabled` or `TooManyRoutines` — at once, never waited out (a `NotFound` refusal has no `e.runId`: nothing ran). The called run's audit record names the chain (`caller.chain`). A routine reached this way is **on demand**, not active: only an armed schedule or webhook makes one active. Write the id as a literal (`ctx.routines.run("gmail-send", …)`) where the step runs it: Manage › Workflows draws each one as a chip under its step, on the owning capability's tile (a click opens that routine's board). Only calls written inside a step's `run` (a `defineRoutine` flow) or anywhere in a plain routine's file are drawn — not a helper elsewhere in a flow's file, not one in another file, not a computed id (`ctx.routines.run(id)`) and not a call through a destructured or renamed ctx (`const { routines } = ctx`). A skill's step that runs `cowork routine run ` draws the same chip, and makes the skill an agent workflow — when it lives outside every package (`.claude/skills/…`): an app's or a capability's own SKILL.md says how to use the package, and is never listed as one. **Deployed routines** (`cowork deploy`, a container or a Cloudflare Worker) get a smaller `ctx`: `workspaceRoot`, `secrets.get/list` (from env), `log`, `unchanged` (does nothing), a signed-out `auth`, an anonymous `identity` (`user: null`) — and `db`: SQLite under the container's `/data` volume, or Cloudflare D1 on a Worker deployed with `--db ` (`cowork deploy run` creates that database when your Cloudflare account has none by that name — no `wrangler d1 create`; deployments may share one), where `query`/`exec` return Promises and `transaction()` throws. **`await` every `ctx.db` call** (harmless on the synchronous local runner) and one routine runs everywhere. Nothing else is there — no `credential`, `command`, `consent`, `browser`, `routines`, `notify` / `badge`, `data`, `queue`, `journal`, `error`, `secrets.set`; `cowork deploy build` names any the routine uses. ## Connecting [Section titled “Connecting”](#connecting) A capability declares what it connects with in its cowork.json `credential` — a sign-in (`"oauth": {"provider": "google", "scopes": […]}`), a key (`"key": {"fields": ["token"], "url", "hint"}`), or both. The person connects once, and every capability sharing the connection is connected: * **Manage Workspace › Components › Credentials** — Connect (a sign-in opens the service's consent; a key shows a form from `fields`, with `hint` and a link to `url`), Disconnect. * **From the app's pane** — on `NotConnected`, its Connect form calls `cowork.credentials.connect("acme", { token })` (a key) or `cowork.credentials.connect("google")` (a sign-in), then `refresh()`es its subscription. `cowork.credentials.get(name)` says whether it is connected — the key never reaches the pane again. * **From a terminal** (a headless server): `cowork credential list`, `cowork credential connect acme --field token=…` (a sign-in prints the address to open in a browser), `cowork credential disconnect acme`. The routine reads it — `ctx.credential.fields()` / `ctx.credential.fetch(url)` — and throws `NotConnected` naming where to connect while it is not:
```ts
// acme-me.ts — in the `acme` capability ("credential": {"key": {"fields": ["token"]}})
export default async (_args: {}, ctx: CoworkRoutineContext) => {
ctx.unchanged();
const { token } = ctx.credential.fields(); // NotConnected until the person connects it
const res = await ctx.credential.fetch("https://api.acme.example/v1/me", { headers: { authorization: `Bearer ${token}` } });
if (!res.ok) throw ctx.error("BadKey", `Acme refused this key (${res.status}).`);
return await res.json();
};
```
**Other secrets** — your own, by name, for a routine that is not a capability's connection:
```bash
cowork secrets set openai.key --stdin <<< "$KEY" # stdin keeps it out of shell history
cowork secrets list # names only
```
Values are readable only by routines and `cowork secrets get` — never by widgets or daemon routes. ## Running [Section titled “Running”](#running)
```bash
cowork routine run gmail-fetch --args '{"folder":"INBOX","limit":5}' # prints the JSON result
cowork routine run gmail-fetch --workspace /path/to/ws # from outside the workspace
cowork routine list
cowork deploy add nightly-sync --schedule "0 6 * * *" # give it a trigger (below)
cowork routine active nightly-sync on # run it on its own: arms its schedule or webhook
cowork routine active nightly-sync off # stop it (the workspace stays armed for the others)
```
`cowork routine run` runs a routine **as the owner** — outside any agent's grants. An agent calls a capability's routines through Cowork's MCP door (`read` / `run` with `capability`, `tool`, `params`), as granted ([agents and grants](/docs/guides/permissions/)). **A trigger makes a routine run on its own**, and it is the workspace's, not the app's — there is no cowork.json key, and a pushed app carries none. `cowork deploy add --schedule "*/5 * * * *"` (a 5-field cron) or `--webhook` writes it to `.cowork/workspace.json#deployments` (names only — commit it). `--mode local`, the default, means this computer's daemon fires the schedule (in local time); `--mode deployed` leaves the schedule to the copy `cowork deploy run` puts on a Worker or container (in UTC) — nothing fires it here. A webhook is answered here in either mode (`cowork deploy hooks` prints its URL). `--hook-token` makes it ask for a token: `deploy add` mints it and prints it, and a caller sends it as the `x-cowork-hook-token` header (missing or wrong → 403). A deployed copy can also accept `Authorization: Bearer` tokens (`cowork deploy run --consumer-tokens-file `). A trigger fires only while this workspace's triggers are armed — `cowork routine active \ on` arms them. **A notifications trigger** starts the routine when an app raises a notification: `cowork deploy add --notifications [,|*]` (app names as `cowork app list` prints them; `*` = every app's). The routine receives the notification as its args — `{ app, appTitle, title, body?, tag?, urgent?, at }` — and it runs here only, in either mode. It needs the Active switch (`cowork routine active on`, or Manage › Workflows), which is also the person's consent to hand those apps' titles and bodies to the routine; adding apps to the list later switches it off until the switch is used again. A muted app still triggers. A routine that a notification started cannot trigger another by its own `ctx.notify` (it reaches the person, starts nothing). Treat the body as untrusted text. `routine list` ends each row with its state: `active · ` / `active · webhook` run on their own; `on demand` runs only when something calls it; `… · not armed` has a trigger that is declared but not armed; `off` is switched off. `routine active on|off` is the Active switch (Manage › Workflows and the Monitor draw the same one): `on` needs a trigger (`cowork deploy add`, above) and arms that routine plus this workspace's local triggers; `off` stops that routine only (a webhook keeps its URL). Arming the workspace also fires ITS OTHER opted-in schedules and webhooks — the first switch in a workspace whose triggers were off turns them all on (`cowork deploy triggers off` stops them all). A switched-off routine (`cowork app disable`, the switch above its board) is refused until it is switched on, and so is one your organization does not let you run. An app does not switch routines on: the SDK gives an app's pane no `setActive` — arming triggers is the user's click in Manage › Workflows or the Monitor, or this command. (The daemon route is protected by the same capability token as the secret store, so this is a rule an app follows, not a wall: don't call `/api/deploy/active` from app code.) `--json` prints `{"ok":true,"id","active","state"}`. ### An agent on a queue [Section titled “An agent on a queue”](#an-agent-on-a-queue) Cowork starts no agent for a queue. An agent — a Claude Code session, a script — takes work by looping `cowork queue receive --as --wait 600 --json` (exit 3 = nothing came), doing the task, then `cowork queue ack --receipt --result ""` (`nack` gives it back). A widget or routine addresses it with `queue.send(q, { body, to: "" })` (`owner` = you, `anyone`, the default, = the first taker); `cowork queue receivers` shows who took a task lately. ### Without the IDE (a server) [Section titled “Without the IDE (a server)”](#without-the-ide-a-server) The same routines run on a box with no IDE — a VM, a container — where an agent (Claude Code) with a shell runs them as the owner (grants gate only the MCP door):
```bash
cowork start --headless # the runner, in the foreground (systemd / Docker)
cowork login # once; paste the address the browser ends on
cowork app install / --workspace /srv/work # then it follows the store
cowork consent allow '' --workspace /srv/work # each command a routine runs
cowork routine run --args '{…}' --workspace /srv/work
cowork routine runs --workspace /srv/work # what ran, newest first (show = its log)
```
Pull the executions where the code went: `docker exec cowork routine runs` (or over ssh). With a team server, every runner reports its apps' runs to it on its own: owners and admins read them on the team server's page (Executions) or with `cowork routine runs --team-server `. A routine that calls a Python (or any other) tool runs it with `ctx.command.run`, so the runner needs that tool: the Docker runner image adds Python with `--build-arg PYTHON=1`, and `docker exec pip install -r /work/requirements.txt` installs the packages once onto the `/data` volume (they survive a new image). No window can show the approval prompt there, so an unapproved `ctx.command.run` / `ctx.consent.request` is refused at once with the exact `cowork consent allow …` line to run. `browser.*` needs the desktop app. A routine used as a sidebar app's `open` returns `{ "path": "" }`; Cowork opens that file (through fileRouting). This is how an app materializes content first (fetch → write `INBOX.maillist.json` → route it to the list widget). A routine that renders data for a routed file type reads the file itself — widgets only get `cowork.openedFile.path`:
```ts
import { readFileSync } from "node:fs";
import { resolve, sep } from "node:path";
export default async ({ path }: { path: string }, ctx: CoworkRoutineContext) => {
const abs = resolve(ctx.workspaceRoot, path);
if (!abs.startsWith(ctx.workspaceRoot + sep)) throw new Error("path escapes the workspace");
return JSON.parse(readFileSync(abs, "utf8"));
};
```
# Widgets (.jsx / .tsx) and the window.cowork SDK
> Types for everything below are scaffolded into .cowork/local/cowork.d.ts in a Cowork workspace (ambient — nothing to import).
Types for everything below are scaffolded into `.cowork/local/cowork.d.ts` in a Cowork workspace (ambient — nothing to import). Read that file when you need an exact signature. ## The module rules [Section titled “The module rules”](#the-module-rules) * One file = one module. `export default` a React component (function). Transpiled by the daemon (Bun), evaluated in a webview with a `require` shim. * **No relative imports.** `import x from './helpers'` fails ("not in the cowork webapp allowlist"). Every widget is standalone: duplicate tiny helpers, and put shared or privileged logic in a ROUTINE (`cowork.routines.run`). * **Only these imports resolve** (anything else throws at load): * `react`, `react/jsx-runtime`, `react-dom`, `react-dom/client` * `@/components/ui/` — the stock shadcn/ui set: accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, button, calendar, card, carousel, chart, checkbox, collapsible, command, context-menu, dialog, drawer, dropdown-menu, hover-card, input, input-group, input-otp, label, menubar, native-select, navigation-menu, pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner, switch, table, tabs, textarea, toggle, toggle-group, tooltip * `@/lib/utils` (`cn`), `clsx`, `tailwind-merge`, `class-variance-authority` * `lucide-react` (icons), `recharts` (charts), `@xyflow/react` (node/edge canvases, CSS included), `@xterm/xterm` + `@xterm/addon-fit` + `@xterm/addon-web-links` (terminals, CSS included) * `radix-ui`, `react-day-picker`, `sonner`, `vaul`, `cmdk`, `react-resizable-panels`, `embla-carousel-react`, `next-themes`, `input-otp` * No npm installs, no CDN scripts, no `fetch` to third-party APIs with credentials — the webview has no secrets and no filesystem. Network + secrets + files → a routine. * Tailwind classes work (compiled at runtime). Copy-pasted shadcn block JSX works unchanged. ## Theming [Section titled “Theming”](#theming) * Use the shadcn semantic tokens — `bg-background`, `text-foreground`, `bg-card`, `text-muted-foreground`, `border`, `bg-primary text-primary-foreground`, `bg-accent` … They follow the workbench theme (light/dark) live with zero code. * `dark:` variants follow the WORKBENCH theme (not the OS). * Don't hardcode colors (`#fff`, `bg-white`, `text-black`). A hardcoded-light UI gets an automatic dark filter on dark themes, which looks worse than tokens. * Fill the pane: the root element is sized by the host; use `h-full` / flex layouts and let inner regions scroll. ## `window.cowork` (also the global `cowork`) [Section titled “window.cowork (also the global cowork)”](#windowcowork-also-the-global-cowork) | member | what | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `routines.run(id, args?, { timeoutMs? })` | Run a routine; resolves its return value, rejects with its error — `e.name` is the kind the routine threw (`ctx.error("NotFound", …)`, `NotConnected` from `ctx.credential`), `e.runId` the run (`cowork routine runs show `). Cowork's own `NotFound` (no such routine, or its capability is not installed) comes before any run and has no `e.runId`; a `NotFound` the routine threw always has one. Identical calls in flight share one run; a busy daemon (`TooManyRoutines`) is waited out. Default timeout 30 s, max 300 s. | | `routines.subscribe(id, args, { interval?, timeoutMs?, onData?, onError? })` → `{ refresh(): Promise, close() }` | Keep a routine's answer fresh — **use this instead of `setInterval`.** Runs now, then every `interval` seconds (min 5, default 60) while a pane of this app is on screen. Every pane of the app asking the same routine with the same args shares ONE loop (the sidebar and its tab poll once); identical answers are not re-sent; a pane opening later gets the last answer at once. `NotConnected` pauses the loop until `refresh()` (call it after the user connects) or (inside the IDE) until a pane of the app is shown again; other errors back off. `refresh()` always answers — from a run that starts after the call, so it sees a change you just made — even when nothing changed, and resolves once the answer reached `onData`/`onError` — await it for a spinner. Subscribe at mount and keep it open; `close()` on unmount. | | `app.post(msg)` / `app.onMessage(cb)` | This app's own panes (sidebar, tabs, dialog) talking: `post` reaches every OTHER open pane of the same app in this workspace; `onMessage` returns the unsubscribe. E.g. a page posts `{ type: "changed", id }` after a save and the sidebar calls its subscription's `refresh()`. | | `credentials.get(name)` / `credentials.connect(name, fields?)` | A connection of the capabilities this app `uses` — declared in their cowork.json `credential` (an app declares none); a sign-in is named after its service (`google`, shared by every Google capability), a key after its capability. `get` → `{ name, kind: "oauth" \| "key" \| "both", state: "connected" \| "needs-scopes" \| "not-connected", account?: { email }, missingScopes, usedBy, fields?, url?, hint? }` — ready when `state === "connected"`; never a token or a key. `connect` signs in (opens the service's consent for what every capability using it needs; the sign-in finishes in the browser — then `refresh()`) or, with `fields` (`{ token }`), saves a key; one that takes either signs in unless `fields` are given. A sign-in rejects `NotHere` in a machine's (remote) window. | | `routines.runs(id?, {app?})` | Bounded run history (metadata only); `app` narrows to one package's runs. Each run names its package (`app`, `kind`) and, when nested, `chain` — the routines that called it. | | `state.get()` / `state.set(v)` | Per-view persisted state (survives hide/show + reload). Use it as a stale-while-revalidate cache: paint the cached value, refresh underneath. Keep it BOUNDED. | | `openFile(path)` | Open a workspace file (routes through fileRouting). | | `openExternal(url)` | Programmatic external open (trust prompt). Plain `` is already routed — no need to call it for links. | | `browser.open(url)` / `browser.status()` | The desktop app's in-IDE browser: `open` shows an http(s) page **beside the IDE**, in a browser tab of the desktop app's browser group (a tab already on that site is reused, else a new tab) → `{ embedded: true }`; without it (web, code-server) it falls back to `openExternal` → `{ embedded: false }`. `status()` → `{ open, tabs: [{ url, title, active }] }` or `null` — poll it (only while `visible`) to follow the user, e.g. pick an id out of the URL they reached. For setup guides: put the guide in a TAB (a routed file) — it sits in the files group beside the browser group where its pages open. | | `notify({ title, body?, tag?, urgent? })` | An OS notification from this app — a banner from the desktop app (even with its window closed), or from a browser tab on this workspace (best effort). Only the **active** workspace's notifications show. Rejects in a machine's (remote) window. `title` is required and shows exactly as sent — put the app's name in it if you want it there (≤ 100 characters; `body` ≤ 300, `tag` ≤ 64 — longer is cut). A `tag` replaces this app's not-yet-shown notification with the same tag; `urgent: true` keeps the banner up and asks for attention (dock bounce, flashing taskbar). A click opens the app. Muted by the user (Manage Workspace › the app's Notifications switch) or past 6 a minute → dropped, and it still resolves; rejects `BadRequest` without a title. To notify while no pane is open (new mail), call `ctx.notify` from the routine that polls — a routine outside a `.cowork/apps//` package gets `BadRequest`, and a capability's notifications go to the one app that uses it (the click opens that app; its switch or the capability's mutes them). Every notification also appears in the workbench's Notifications center (the bell) as `: — `; the banner shows only while the window is not focused. | | `onNotification(cb)` | Subscribe to THIS app's notifications — its own `cowork.notify` and its routines' / capabilities' `ctx.notify`, as they are raised: `cb({ app, appTitle, title, body?, tag?, urgent?, at })`; returns an unsubscribe function. Live in every pane of the app; a muted app delivers none. An event, not a poll. `n.app` says which app it was posted as, not who posted it. Another app's notifications are for a routine ([notifications trigger](/docs/guides/routines/)). | | `capture.record({ video?, audio?, live?, file? })` | Records the screen (`video: "screen"`, the default, or `"window"`; `false` = sound only) with the sound the computer plays (`audio.system`) and/or the microphone (`audio.mic`). Declare what you use in cowork.json `"capture"`. Every recording first asks the person in **Cowork's dialog**, which names your app, what it asks for and what it gets; nothing starts without that Allow, and a recording shows a pill with **Stop** (the desktop app's own window, or a bar in a browser tab). Resolves once recording with a session `{ id, file, onAudio(cb), onStop(cb), stop() }`; the file lands at `file` (default `Recordings/-.webm`, inside the workspace) when it stops — Stop, `stop()`, the system ending the share, or the window closing (what was recorded is kept). `live: true` also streams the mix to `onAudio` as 16 kHz mono `Int16Array` frames of 20 ms — **the app hears what the person hears**; say so in your UI. One capture per workspace. Rejects by name: `Denied` (the person said no, or the OS refused), `NotDeclared`, `Busy`, `DiskFull` (under 2 GB free), `Unsupported` (a page that cannot capture — `cowork app serve`, plain http on another machine). Panes only: a routine cannot capture. | | `capture.shot({ source?, file?, image? })` | One picture of a screen or window, after the same dialog. `image: true` hands the PNG back as `image` (a `Blob`); otherwise (or also, with `file`) it is saved and `path` says where (default `Recordings/-.png`). Needs `"capture": ["screenshot"]`. | | `capture.current()` | This app's recording in progress, or `null` — re-attach `onAudio` / `onStop` after the pane remounts. | | `badge(count)` | This app's count on the OS badge, summed over the apps: on the desktop app's dock / taskbar icon for the **active** workspace only; in a browser tab, a `(N) `prefix on its title. Another workspace's count waits until that workspace is active. State, not an event: the latest call wins, `0` clears, nothing expires — report it on every poll (a restart clears it). A muted app's count leaves the sum. From a routine: `ctx.badge(n)`. | | `openedFile` | Set when this widget was opened for a routed file or folder: `{path, workspaceRelativePath, basename, ext, matchedGlob}` (a folder: `matchedGlob` ends in `/` — tell it by that; its `ext` is `""`). The widget gets the PATH, not the content — read/parse (or list) it in a routine. | | `workspaceRoot` | Absolute workspace path. | | `log(...parts)` | A line in the "Cowork" Output panel. | | `visible` / `onVisibility(cb)` | Whether the pane/tab is on screen (the Page Visibility API lies inside webviews). `routines.subscribe` already gates on it; use it for anything else you do while visible. | | `tables.schema/query/exec({ db, … })` | SQLite `.db` files (workspace-relative); `query` is read-only, ≤ 500 rows per page. | | `queue.send(q, { body, subject?, to?, as?, review?, ref?, meta?, delayS?, files? })` / `receive(q, { as?, mine?, leaseS? })` / `ack(q, id, receipt, { result?, files?, review?, to? })` / `nack(q, id, receipt, { delayS?, error? })` / `approve(q, id)` / `reject(q, id, comment)` / `reply(q, id, body, { to?, as?, files? })` / `retry(q, id)` / `tasks({ queue?, view?, agent?, q? })` / `items(q, { view?, agent? })` / `peek` / `list()` / `summary()` / `receivers(q?)` / `remove` / `purge` | The workspace's task queues (`.cowork/local/queues.db`, the Tasks view) — work for agents and scripts (`cowork queue receive --as ` in a `/loop`). A task: `subject`, `status` (`todo` · `in_progress` · `needs_review` · `done` · `failed`), `from`, `to` (an agent, `owner` or `anyone`), `review`, the request (`body`, `files`) and `entries` (its thread). Body ≤ 1 MiB; files as `{ name, text \| base64 }`; `receive` → the oldest task for you + its `receipt`, or `null`. | | `journal.skills()` / `entries(skill, { status?, limit? })` / `add({ skill, kind?, text, source? })` / `resolve(skill, ids?)` | The skill journal: learnings / errors per skill, `open` until an agent folds them into the SKILL.md and resolves them. `entries` lists oldest first, the open ones unless `status` says otherwise, up to `limit` (1–200, default 200). | | `agent.setContext(v)` / `agent.select(text)` / `clearContext()` / `clearSelection()` / `setAutoSelection(bool)` | Feed the IDE's AI agent. Text the user selects in the app already reaches it automatically. | | `ui.dialog({ message, detail?, type?, fields?, buttons?, alert?, scope? })` | Cowork's dialog → `{ label, values }` — see [Dialogs](#dialogs). (`alert`/`confirm`/`prompt` don't work in webviews.) | | `ui.confirm({ title?, body?, confirmLabel?, cancelLabel?, destructive? })` | `ui.dialog`'s yes/no shorthand → `true` on confirm, `false` on Cancel or Esc. `destructive: true` for a delete. | | `chat.ask(text)` | Open the workbench chat and submit `text`. | | `consent.request({ value, title, body, detail })` | Ask the user to approve a command → `{granted, ticket?}`. | | `stream.open({ argv, consent, onData, onEnd })` | One long-lived process with raw stdio (terminals, `tail -f`). Needs a consent grant/ticket; max 2 open per view. | | `identity.currentUser()` | The signed-in user `{uid, email}` (or null). | | `viewSource()` | Open this app's source in the text editor. | | `setTitleActions([...])` / `onTitleAction(cb)` | Title-bar buttons on an app's pane in the standalone runner (`cowork app serve`) only; the IDE ignores them, so put an app's buttons in its UI. | | `closeTab()` | Close this app's editor tab. | | `statusBar.get(app)` / `statusBar.onChange(cb)` | An app's footer item (`statusBar` in its manifest) as last drawn, with its `data` — or null. `onChange((app, item) => …)` is called with each app's current item right away, then on every change — subscribe instead of reading `get` at mount. | | `statusBar.refresh(app?)` / `statusBar.click(app)` | Re-run an app's footer routine now (after your widget changed what it reports) / act as if the user clicked it — a widget's "Connect" button can simply be the footer item's click. | ## Dialogs [Section titled “Dialogs”](#dialogs) `cowork.ui.dialog(opts)` resolves `{ label, values }`: the button's label and the fields' values (by field `id`) plus the button's own `values`. Cancel or Esc → `label: null`, with `values` as the fields were left — test `label`, never `values`. * Fields: `text` · `textarea` · `number` · `checkbox` · `select` · `color` · `section` (read-only: `label` + `placeholder` text, `monospace`). `values[id]` is a string (a `number`: a number, `""` while empty; a `checkbox`: a boolean). `select` and `color` take `options: [{ label, value }]` — a swatch's `value` is a hex color (`#rgb` … `#rrggbbaa`) or `""` for no color. * Buttons: `{ label, role: "submit" | "cancel", primary?, destructive?, card?, sublabel?, values? }`; a `card` is an option row in the body. With no buttons, one OK. * To refuse an answer, ask again with `alert: { variant: "destructive", text }` and the typed `value`s. * **Scope:** leave the default `window` (dims the whole IDE) for anything that creates, changes or deletes data; `scope: "pane"` (dims only this pane) is ONLY for a choice that changes how things look, like a theme.
```js
const { label, values } = await cowork.ui.dialog({
type: "none", message: "Rename the report?",
fields: [{ id: "name", type: "text", label: "Name", value: report.name }],
buttons: [{ label: "Cancel", role: "cancel" }, { label: "Rename", primary: true, role: "submit" }],
});
if (label === "Rename") await cowork.routines.run("rename-report", { name: values.name });
if (await cowork.ui.confirm({ title: "Delete the report?", confirmLabel: "Delete", destructive: true })) { /* … */ }
```
## A complete widget [Section titled “A complete widget”](#a-complete-widget) The CRUD template (`cowork template install cowork/app-sidebar-workbench --name `) is the full reference; this is the same pattern for a connected app's sidebar:
```jsx
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
export default function Inbox() {
const cowork = window.cowork
const [data, setData] = useState(() => cowork.state.get() ?? null) // instant paint
const [error, setError] = useState(null)
const [connecting, setConnecting] = useState(false)
const inbox = useRef(null)
useEffect(() => {
// One loop for the app, while a pane is on screen — never setInterval.
inbox.current = cowork.routines.subscribe('mail-inbox', { limit: 25 }, {
interval: 60,
onData: (result) => { setData(result); setError(null); cowork.state.set(result) },
onError: setError, // NotConnected pauses the loop
})
return () => inbox.current.close()
}, [])
async function connect() {
await cowork.credentials.connect('google') // the sign-in of the capability this app "uses"
setConnecting(true) // the sign-in finishes in the browser
}
// NotConnected paused the loop: it resumes on refresh() — or, in the IDE, by itself when a pane
// of the app is shown again (the user coming back from the browser).
if (error?.name === 'NotConnected') {
return (