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).
// .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.)
export defaulta function (sync or async). The return value must be JSON-serializable (else the caller getsnull+ a warning). Throw to fail — the caller's promise rejects with the message. Throwctx.error(name, message)to give the failure a kind the widget can branch on (e.name === "NotConnected","RateLimited"…); every rejection also carriese.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.logalso stream to the "Cowork" Output panel). A read that callsctx.unchanged()records only that it ran.cowork routine runspages 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 --unchangedshows 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): agents run it only as granted, reads on by default. AreadOnlyroutine must really change nothing (it may cache what it read in its own<name>.*secrets orctx.data).
| 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. |
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
Section titled “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
Section titled “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, withhintand a link tourl), Disconnect. - From the app's pane — on
NotConnected, its Connect form callscowork.credentials.connect("acme", { token })(a key) orcowork.credentials.connect("google")(a sign-in), thenrefresh()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:
// 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:
cowork secrets set openai.key --stdin <<< "$KEY" # stdin keeps it out of shell historycowork secrets list # names onlyValues are readable only by routines and cowork secrets get — never by widgets or daemon
routes.
Running
Section titled “Running”cowork routine run gmail-fetch --args '{"folder":"INBOX","limit":5}' # prints the JSON resultcowork routine run gmail-fetch --workspace /path/to/ws # from outside the workspacecowork routine listcowork 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 webhookcowork 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).
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
Section titled “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)
Section titled “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):
cowork start --headless # the runner, in the foreground (systemd / Docker)cowork login # once; paste the address the browser ends oncowork app install <team-server>/<app> --workspace /srv/work # then it follows the storecowork consent allow '<value>' --workspace /srv/work # each command a routine runscowork routine run <id> --args '{…}' --workspace /srv/workcowork 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:
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"));};