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

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

- `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.md)): 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
  `<name>.*` secrets or `ctx.data`).

## `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 `<name>.*` (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/<app name>/` (the app is the `.cowork/apps/<dir>` 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:<file>`); an agent later folds open entries into the skill (`cowork journal entries <skill> --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/<name>/` 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

`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 <id>` 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 <name>` (`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

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

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

**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 <id> --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 <id> --consumer-tokens-file <path>`). A trigger fires only while this workspace's triggers are armed — `cowork routine
active \<id> on` arms them.

**A notifications trigger** starts the routine when an app raises a notification: `cowork deploy add <id> --notifications <app>[,<app>|*]` (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 <id> 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 · <cron>` / `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 <id> 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

Cowork starts no agent for a queue. An agent — a Claude Code session, a script — takes work by looping
`cowork queue receive <queue> --as <name> --wait 600 --json` (exit 3 = nothing came), doing the task,
then `cowork queue ack <queue> <id> --receipt <r> --result "<what it did>"` (`nack` gives it back).
A widget or routine addresses it with `queue.send(q, { body, to: "<name>" })` (`owner` = you,
`anyone`, the default, = the first taker); `cowork queue receivers` shows who took a task lately.

### 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 <team-server>/<app> --workspace /srv/work   # then it follows the store
cowork consent allow '<value>' --workspace /srv/work           # each command a routine runs
cowork routine run <id> --args '{…}' --workspace /srv/work
cowork routine runs --workspace /srv/work           # what ran, newest first (show <run-id> = its log)
```

Pull the executions where the code went: `docker exec <runner> 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 <ts>`.

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 <runner> 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": "<workspace-relative file>" }`;
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"));
};
```
