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”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
Section titled “The pattern — follow it in every app”- Widgets show, routines do. Files, keys, the network → a routine; the widget calls it.
- Data in
ctx.data(.cowork/data/<app>/):read(name, fallback),write(name, value),list(subfolder),remove(name),path(name). Neverfs+ hand-built paths. - Fresh with
cowork.routines.subscribe, neversetInterval: one loop per app, paused while no pane is visible, stopped byNotConnecteduntilrefresh(). - Fail with a name:
throw ctx.error("NotFound", "…"); the widget checkse.name, never the message. Showe.runIdwith the error (cowork routine runs show <id>). - Mark reads with
ctx.unchanged(); log changes withctx.log(...). - The app's panes talk with
cowork.app.post(msg)/cowork.app.onMessage(cb). - Credentials belong to a capability: its cowork.json declares
"credential"(a sign-in, a key, or either); its routines call the service withctx.credential.fetch(url)(bearer, refresh, retries) or read a key withctx.credential.fields(). An app declares"uses", calls those routines and touches no credential (NotGranted); its panes connect withcowork.credentials.get/connect(name)(one sign-in per provider, Manage › Components › Credentials). A package declaring neitherkindnorusespredates capabilities and keeps its old access until it declares one. - 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. - 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).
From scratch
Section titled “From scratch”-
Create
.cowork/apps/<name>/cowork.json(the folder name follows the same lowercase-kebab rule asname, 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" } } -
Write the widget
weather-sidemenu.jsx(a default-exported React component callingcowork.routines.subscribe("weather-forecast", …)) and the routineforecast.ts(export default async (args, ctx) => {...}returning JSON). -
Test:
cowork routine run weather-forecast --args '{"city":"Lisbon"}', then click the new icon on the activity bar (saving a.jsxhot-reloads its pane). -
Share:
cowork push .cowork/apps/weather --to <team-server>.
cowork.json in one screen
Section titled “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. |
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.
Widgets (.jsx) — the hard rules
Section titled “Widgets (.jsx) — the hard rules”- One file, one module,
export defaulta React component. - No relative imports (
./utilfails). 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/promptdon't work — usecowork.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.runIdconst 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-incowork.state.get() / cowork.state.set(value) // persisted per view — cache for instant paintcowork.openFile("path/in/workspace") // opens through fileRoutingcowork.openedFile?.workspaceRelativePath // when opened for a routed file (path only — read it in a routine)cowork.log(...) // "Cowork" Output panelcowork.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 journalcowork.agent.setContext(v) / cowork.agent.select(t) // feed the IDE's AI agentcowork.ui.dialog(opts) / cowork.ui.confirm({ title, body }) / cowork.chat.ask(text) / cowork.openExternal(url) / cowork.capture.record(opts) // capture: after Cowork's dialogcowork.browser.open(url) / cowork.browser.status() // the page beside the IDE (desktop app); else openExternalcowork.statusBar.get(app)?.data / .onChange(cb) / .refresh(app) / .click(app) // footer itemsawait cowork.identity.currentUser() // { uid, email } or nullRoutines (.ts) — the rules
Section titled “Routines (.ts) — the rules”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, orcowork credential connect <name> --field token=…("Connecting" in routines). Names undercowork.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 withe.name === "NotConnected"(ande.runId, forcowork routine runs show <id>).ctx.credential.*already throwsNotConnected. - Run it:
cowork routine run <id> --args '{"k":"v"}' [--workspace <dir>](prints the result). - On a server (no IDE):
cowork start --headlessruns them — schedules and webhooks (cowork deploy add) too; a command a routine runs is approved ahead of time withcowork consent allow '<value>'. See routines ("Without the IDE").
Agents and grants
Section titled “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:
"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
readOnlyroutine 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.
Lifecycle
Section titled “Lifecycle”cowork team-server list # where you can publish / install fromcowork push [dir] [--to <ts> [--remember]] [--dry-run] # publish the manifest version (bumps the patch if needed); --remember = default targetcowork app list [--json] # every app here: version, source, pinned|auto, on/offcowork app install <ts>/<app>[@X.Y.Z] # a version PINS it; without, it follows the storecowork app update [<app>] [--replace|--keep-both|--ai-merge|--skip] # a name unpinscowork 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 versioncowork 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.
Courses
Section titled “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.
Test loop
Section titled “Test loop”cowork routine run <id> --args '…'for each routine — fix errors here first.- 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). cowork app open .cowork/apps/<name>/<file>.jsx [--as tab|dialog|sidemenu]opens a widget directly.- Logs: the "Cowork" Output panel (
cowork.log,ctx.log, routine stdout/stderr). Check the rail: icon, color,title. - Tests inside an app (
.cowork/apps/<name>/*.test.ts):bun testskips dot-folders, so name the path:bun test ./.cowork/apps/<name>.
Checklist before you finish
Section titled “Checklist before you finish”-
cowork.jsonhas a validname+ semverversion; 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
setIntervalpolling —cowork.routines.subscribe; no/Not connected/matching —e.name. - App data through
ctx.data; reads callctx.unchanged(); sign-ins and keys declared in the capability'scredential, never the app's. - Every capability routine says what it changes (
readOnly/destructive/reachesOthers) and has adescription; a routine taking arguments has aninputschema. - 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.jsonexcept state (disabled,publishTo,rail,startup;deploymentsviacowork deploy add). - Ran each routine once from the CLI; opened the app once.
- A short
SKILL.mdsays how an agent USES the app (see "Folder layout" in manifest).