Skip to content

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 · widgets + SDK · routines · publish / install / audit · agents and grants · 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”
Terminal window
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.

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

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

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.
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.
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); 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.

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

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
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.
  • 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). 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 ("Without the IDE").

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:

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

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

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.

  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>.
  • 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).