# Building Cowork apps

Cowork Studio is an IDE (VS Code-based) where a workspace can carry its own **apps**. An app
is a folder `.cowork/apps/<name>/` 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.md) ·
[widgets + SDK](/docs/guides/widgets-and-sdk.md) · [routines](/docs/guides/routines.md) ·
[publish / install / audit](/docs/guides/publish-and-install.md) · [agents and grants](/docs/guides/permissions.md) ·
[courses](/docs/guides/courses.md). Read the one you need before writing code.

## 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

1. **Widgets show, routines do.** Files, keys, the network → a routine; the widget calls it.
2. **Data in `ctx.data`** (`.cowork/data/<app>/`): `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 <id>`).
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.md)).

### From scratch

1. Create `.cowork/apps/<name>/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 <team-server>`.

## cowork.json 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 <https url>` or the browser's install icon; desktop app only). See [manifest](/docs/guides/manifest.md). |
| `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` | `{ "<app>-<verb>": "file.ts" }` or `{ "<app>-<verb>": { "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.md). |
| `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.md));
it never travels. **What an app is lives only in its `cowork.json`.** Under `.cowork/`: `apps/<name>/` (a Keep-both copy
beside it: `<name>-v1-2-3`), `data/<app>/` (`ctx.data`; a table is `data/<name>.db`), `courses/<name>/`, `lessons/<id>/`, git-ignored `local/`. Never create `Cowork/`, `Webapps/`, `Routines/` or `Courses/` in the workspace root.

## 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.md)):

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

```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.md).
- **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 <name> --field token=…` ("Connecting" in [routines](/docs/guides/routines.md)).
  Names under `cowork.` are reserved; a capability reaches its own connection and `<name>.*`, 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 <id>`). `ctx.credential.*` already throws `NotConnected`.
- Run it: `cowork routine run <id> --args '{"k":"v"}' [--workspace <dir>]` (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 '<value>'`.
  See [routines](/docs/guides/routines.md) ("Without the IDE").

## 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 <name>`, 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.md).

## Lifecycle

```bash
cowork team-server list                     # where you can publish / install from
cowork push [dir] [--to <ts> [--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 <ts>/<app>[@X.Y.Z]       # a version PINS it; without, it follows the store
cowork app update [<app>] [--replace|--keep-both|--ai-merge|--skip]   # a name unpins
cowork app versions <ts>/<app>
cowork app enable|disable <app> | remove <app>
cowork app unpublish <ts>/<app>[@X.Y.Z] --yes   # the whole app, or one older version
cowork routine list | run <id> [--args '<json>']   # every verb: [--workspace <dir>]
cowork routine runs [show <run-id>] [--team-server <ts>]   # 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/<workspace path, non-alphanumerics → "-">-<8-hex hash>/audit/<UTC date>.jsonl`
  (Windows: under `%LOCALAPPDATA%\Cowork`; who, which app + version, device, org, agent, inputs; kept 7
  days). See [lifecycle](/docs/guides/publish-and-install.md).

## Courses

A course is `.cowork/courses/<name>/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/<id>/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/<name> --dry-run`, publish with `--team-server <ts>`. Full format:
[courses](/docs/guides/courses.md).

## Test loop

1. `cowork routine run <id> --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/<name>/<file>.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/<name>/*.test.ts`): `bun test` skips dot-folders, so name the path: `bun test ./.cowork/apps/<name>`.

## 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.md)).
