Skip to content

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 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): 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).
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"].

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.

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:

// 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:

Terminal window
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.

Terminal window
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).

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"}.

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.

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):

Terminal window
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:

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"));
};