Skip to content

cowork.json — the app manifest (full reference)

A folder .cowork/apps/<name>/ containing a cowork.json is a Cowork app. Apps are discovered ONE level deep under .cowork/apps/, in folder-name order. A folder counts only when its own name is lowercase kebab (^[a-z0-9][a-z0-9-]{0,62}$, not apps/data/courses/local, not a Windows device name such as con or nul) — dotted, uppercase, symlinked and dot-prefixed folders are skipped. A Keep-both copy of an update sits beside yours as <name>-v1-2-3. Every path inside the manifest is RELATIVE TO THE APP FOLDER, uses forward slashes, and may not escape it (no leading /, no .., no \, no drive letters).

The IDE validates .cowork/apps/*/cowork.json against the manifest schema as you type — no $schema key is needed (unknown keys, $schema included, are ignored).

key type rules
name string, required ^[a-z0-9][a-z0-9-]{0,62}$. Store id AND activity-bar id. Unique per workspace (a second folder claiming it is reported broken) and per team-server store. Never rename a published app — a new name is a new app.
version string, required semver X.Y.Z (optional -pre / +build), no leading v.
title string ≤ 200 Display name (rail tooltip, pane title, store card). Default: name.
description string ≤ 2000 One or two sentences for the store card.
icon string A codicon id (mail, calendar, rocket, graph, database, terminal … — https://microsoft.github.io/vscode-codicons/) or $(mail), or an image file inside the app folder (icon.png, assets/logo.svg; .png/.svg/.webp/.jpg, ≤ 256 KB, not a symlink). An image fills the rail tile in full color (centered, never cropped; color shows behind transparent parts) — square art with its own padding looks best. Manage Workspace and the Team Servers store show it too (cowork push puts it on the store listing; a codicon clears an old image there). Never a URL. Absent → a letter tile.
color string Hex tile color #rgb / #rrggbb (e.g. #EA4335); the glyph turns white.
entry string The UI, .jsx or .tsx. Present → the app gets its own activity-bar icon. Absent → a routines-/routes-only app (no icon).
web { "index": "<.html file>", "open"?: "drop:<css>", "download"?: { "url", "sha256" } } Makes the folder a web app instead of a JSX one — see "Web apps" below. Never together with entry (the entry wins, web is dropped with an error).
display sidebar | tab | modal What clicking the icon does with entry. Default sidebar; a web app is always tab (another value is dropped with an error).
open string | { "routine": id, "args"?: {…} } sidebar only. What to open in the EDITOR the first time the icon is clicked in a session: an app file, or a routine that returns { "path": "<workspace-relative file>" } (for content that must be fetched/materialized first). Ignored (with an error) for tab/modal.
routines { id: file } or { id: { "file": file, "timeout"?: s, "readOnly"?, "destructive"?, "reachesOthers"?, "description"?, "input"? } } id ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ → .ts / .js / .mjs file. ≤ 100. timeout (optional): how long one run may take, whole seconds 1–300 (default 30). The rest says what the routine is as a tool — what an agent sees and is granted (agents and grants): readOnly: true it changes nothing (on for every agent by default; never together with the next two), destructive: true what it changes cannot be undone, reachesOthers: true it reaches people beyond the account (sends, posts, pays); description one line (≤ 400 chars); input its arguments as a JSON Schema {"type": "object", "properties": {…}, "required": […]} (≤ 8 KB). A bad tool key is reported and left at its safe default (a write, no description); the routine stays. A bare "id": "file.ts" is a write with no description. A permission key is ignored (reported).
fileRouting { glob: appFile } A WORKSPACE-relative glob (**/*.maillist.json) → an app file in this folder that renders matching files. ≤ 50. A glob ending in / routes FOLDERS ("Notebook/", "Projects/*/"): clicking the folder in the Explorer opens the app, and a folder route never matches a file (nor a file route a folder); its value must be a .jsx/.tsx. An .html value opens the file in the app's web app, so it must be its web.index (any other .html value is dropped with an error).
credential { "oauth"?: { "provider", "scopes", "ownClient"? }, "key"?: { "fields", "url"?, "hint"? } } A capability's connection to its service — a sign-in, a pasted key, or either (one at least). oauth: provider an OAuth preset (google, microsoft), the scopes its routines need (≤ 32); every capability declaring a provider shares ONE sign-in, named after it (google), and connecting asks for what they all need together; ownClient: true signs in through the user's own client instead, under the capability's own name. key: the fields the person pastes (≤ 8, lowercase letters and _, e.g. ["token"]), stored as <capability>.<field>; url (https) where to get them, hint (≤ 200 chars) one line on how. The person connects once — Manage Workspace › Components › Credentials, an app's pane (cowork.credentials.connect), or cowork credential connect; routines use ctx.credential.*. An app declares none (ignored, with a problem line); the old accounts / secrets keys are ignored (reported).
capture ["screen" | "system-audio" | "mic" | "screenshot", …] What the app's panes may capture with cowork.capture (any subset; an unknown entry drops the key with a problem line). A recording with video needs screen, its sound system-audio and/or mic; capture.shot needs screenshot. Declaring is not consent: every capture still asks the person in Cowork's dialog. Manage Workspace › the app's page lists it. An app's only (a capability has no panes).
kind app (default) | capability A capability is a package of routines with no UI that apps use — and agents, through Cowork's MCP door, as granted — a Gmail capability is the gmail-* routines + its SKILL.md. It carries no entry or fileRouting and no uses of its own (reported if it does; kind never changes what the folder registers). Manage Workspace lists it under Components › Capabilities instead of Apps. Credentials belong to a capability: its routines reach its own connection (credential) and <name>.* secrets; an app's reach none (NotGranted). A package that declares neither kind nor uses predates capabilities and keeps its old access until it declares one.
uses { "<capability name>": "<X.Y.Z>" } The capabilities an app needs, each with the lowest version it works with; met by a switched-on package of that name at the same major and that version or above. ≤ 8, never the app's own name. A template install of an app brings the capabilities it uses along from the same team server; a missing, off or too-old one is listed in Components › Capabilities, and a routine call it would have answered fails with an error naming it. Declaring uses puts an app under the capability model (as "kind": "app" does): its widgets and routines reach another package's routines only through a capability named here (Forbidden otherwise, naming the line to add).
requires [] | ["web"] | ["desktop"] Where it can run. ["desktop"] = the Cowork Studio desktop app only (e.g. it uses ctx.browser). Empty = anywhere.
tags string[] ≤ 20, each ≤ 40 chars Store search/filters.
banner string The store listing's wide picture: an image file in the app folder (.png/.svg/.webp/.jpg, ≤ 1 MB, not a symlink). cowork push uploads it when it changed.
statusBar { "routine": id, "args"?: {…}, "interval"?: s } One item in the footer (status bar), drawn from what routine — one of THIS app's routines — returns. interval: seconds between refreshes while the window has focus, 15–3600, default 60. See "Footer item" below.
screenshots string[] ≤ 5 The store listing's screenshots, in order — image files in the app folder (≤ 1 MB each). cowork push uploads the changed ones and removes any the listing holds beyond them; leave the key out to manage screenshots in the Team Servers store instead.

Validation: an invalid name or version makes the folder "not an app" (shown as broken, never registered). Any other bad key is dropped with an error and the rest of the app keeps working. Unknown keys are ignored.

  • sidebar — entry renders in a side panel under the app's icon. Optional open puts something in the editor area too (once per session, so the user isn't yanked around).
  • tab — clicking the icon opens entry in an editor tab (no side panel), every click.
  • modal — clicking opens entry in a resizable in-workbench dialog.

A web app is a prebuilt static site — an index.html with its scripts, styles, .wasm and fonts, e.g. a Rust/WASM editor's release build — that Cowork serves unmodified on its own origin and draws in an editor tab of the normal editor groups (no URL bar, no browser history):

{ "name": "wordcraft", "version": "0.10.0", "title": "WordCraft", "icon": "icon.png",
"web": { "index": "web/index.html" },
"fileRouting": { "**/*.docx": "web/index.html" } }
  • web.index is the page, inside the folder; the folder that holds it is what gets served (dotfiles and anything outside it never are). Unzip the site's release into web/ as it ships.
  • Or keep the build out of the app: "download": {"url": "https://…/site.zip", "sha256": "<hex>"} fetches the pinned release on the first open (checked against the sha256 before it is unpacked, cached per version). index is then relative to the BUILD — a zip whose files sit in one top folder has it stripped — so it is usually "index.html", and routes name "index.html" too. A build copied into the folder as web/<index> still wins.
  • display is always tab; there is no entry, no widget and no window.cowork SDK — the page is the app. Routines and SKILL.md work as in any app.
  • A routed file (**/*.docx above) opens in the web app's tab. Open the app directly with cowork app open .cowork/apps/<name>/web/index.html.
  • The app gets the routed file the web's standard ways — its open picker, a file <input>, launchQueue; saving under the file's own name writes it back, any other name asks where. An app that opens files only by a drop sets "open": "drop:<css selector>" (e.g. "drop:#canvas"): the file is dropped on that element.
  • It opens in the Cowork Studio desktop app and in Cowork in a browser tab or the PWA alike: the daemon serves it on its own http://<name>-<hash>.app.localhost:<port>/ origin. Like a JSX app it gets an activity-bar icon. In a browser tab, closing it with unsaved changes does not ask yet.

A site app is a web site installed as an app: web.whatsapp.com, a dashboard, any https page. It draws in an editor tab (no address bar) on your signed-in browser accounts, so the site is already logged in, and its notifications and unread count become the app's. It is a normal workspace app, a folder .cowork/apps/<name>/ with a cowork.json, so it can be pushed like any other:

{ "name": "whatsapp", "version": "1.0.0", "title": "WhatsApp", "icon": "icon.png",
"web": { "url": "https://web.whatsapp.com/", "scope": "https://web.whatsapp.com/" } }
  • web.url is an https page (http only on localhost) with no credentials in it. scope is an https prefix on the same origin; it defaults to url up to its last /. A manifest has web.index (a web app) or web.url (a site app), not both, and fileRouting cannot point at a site app.
  • Install from the in-IDE browser: the address bar shows an install icon when the page has a Web App Manifest, and the menu offers "Install <name>…" (or "Install page as app…" for any https page). Or run cowork app install <https url>: the name, icon and color come from the site's own web app manifest (the page's title and icon when it has none). cowork app remove <name> deletes the files the install wrote; if cowork.json no longer points at the installed url, or a file changed, it keeps it and says so. Open one with cowork app open .cowork/apps/<name>/cowork.json.
  • In the right side bar: "display": "right" puts the site in a tab of its own in the right side bar, beside Chat and Claude Code — ChatGPT, Claude, Gemini next to your files. "display": "sidebar" puts it in a pane of the left side bar, opened from its activity-bar icon; with neither it opens in an editor tab. cowork app install https://chatgpt.com/ --title ChatGPT --display right installs it there (chatgpt.com and claude.ai turn scripts away, so the CLI installs them from the address with no icon — --title names them); for an installed one, set the key in its cowork.json. The page keeps running while its pane is hidden (another tab of the side bar, the side bar closed), so a long reply keeps streaming. In the right side bar any title works (ChatGPT, whose name starts with Chat, gets no rail icon as a tab app). A JSX app can be "display": "right" too: its sidebar UI then lives in the right side bar (open is a sidebar app's only — it runs from a rail click).
{ "name": "chatgpt", "version": "1.0.0", "title": "ChatGPT", "display": "right",
"web": { "url": "https://chatgpt.com/" } }
  • Notifications the site raises reach Cowork like an app's: a banner (muted per app), the bell, routines started with cowork deploy add <id> --notifications <app>, and cowork.onNotification. The site's unread count (navigator.setAppBadge) shows on the dock or taskbar icon. An OS banner click goes to the chat or page; the bell's Open shows the app but does not click for you.
  • Agents can look at and use a site app's tab (apps list|screenshot|click|type|press) with the person's consent, which names the site. The WhatsApp template tells agents never to read or send chats.

Limits

  • Desktop app only — sites refuse to be shown inside another page. In Cowork in Chrome, its installed web app or on a phone, opening a site app shows "<title> is part of the desktop app" (Get the Desktop App / Not Now) and the tab closes; a site app in a side bar says so in its pane.
  • In the right side bar the app's tab shows its title, not its icon or color. Dragging the tab to another side bar is remembered for you and wins over display.
  • A site notifies only while its tab is open: closing the tab stops it (a window hidden to the tray keeps it running), and there is no Web Push. Only the active workspace's site apps run; switching workspaces closes the others. At most 6 banners per app per minute.
  • A notification a site's own background worker raises is shown by the system without the app's name or mute. The page's own notifications are the ones Cowork attributes.
  • The unread count is on the dock or taskbar, not on the app's rail icon, and a site that does not set a badge gets the (N) from its page title. The rail icon itself arrives with the next engine release; until then open a site app right after installing it, from Manage Workspace, with cowork app open or from its notifications.
  • No address bar on a page outside scope. Switching accounts or a crash reload loses the page's state.
  • Ignored from the site's manifest: shortcuts, file and protocol handlers, share target, window-controls overlay. A site that checks the browser's user agent may need a fix from Cowork's side.
  • Install refuses localhost and private-network addresses when the start page is public.

A mail client — sidebar list + a routine-materialized editor view + a routed file type:

{
"name": "gmail",
"version": "1.0.0",
"title": "Gmail",
"description": "Read and triage your inbox without leaving Cowork.",
"icon": "mail",
"color": "#EA4335",
"entry": "gmail-sidemenu.jsx",
"display": "sidebar",
"open": { "routine": "gmail-open-inbox", "args": { "folder": "INBOX" } },
"routines": {
"gmail-open-inbox": "open-inbox.ts",
"gmail-fetch": "fetch.ts",
"gmail-send": "send.ts"
},
"fileRouting": { "**/*.maillist.json": "mail-list.jsx" },
"tags": ["email", "google"]
}

A dashboard in a tab:

{ "name": "sales-dashboard", "version": "0.1.0", "title": "Sales", "icon": "graph", "color": "#0F9D58",
"entry": "dashboard.jsx", "display": "tab", "routines": { "sales-load": "load.ts" } }

A quick-capture dialog:

{ "name": "quick-note", "version": "0.1.0", "icon": "note", "entry": "quick-note-modal.jsx", "display": "modal" }

A capability — a key to connect, and routines marked as tools for agents:

{ "name": "invoices", "version": "1.0.0", "title": "Invoices", "kind": "capability",
"credential": { "key": { "fields": ["token"], "url": "https://billing.example.com/settings/api", "hint": "Settings › API › New token" } },
"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", "description": "The invoice id" } }, "required": ["id"] } }
} }

A headless app (routines + a viewer for a file type, no icon):

{ "name": "csv-tools", "version": "1.2.0", "routines": { "csv-summarize": "summarize.ts" },
"fileRouting": { "**/*.csv": "csv-viewer.jsx" } }

statusBar puts one item in the footer, left, after Workspace: <name>. Its routine returns what to show — or null to hide it:

export default async (args, ctx) => args.action === "disconnect"
? (await disconnect(), null) // a click's work; the item refreshes after
: (await isConnected())
? { text: "jotas@34.135.78.28", icon: "remote", tooltip: "Connected over SSH",
click: { args: { action: "disconnect" }, confirm: "Disconnect from the VM?" },
data: { connected: true } }
: { text: "Connect to VM", icon: "plug", click: { args: { action: "connect" } },
data: { connected: false } };
  • text (required), icon (a codicon; sync~spin spins), tooltip, tone: "warning" | "error" (colors the item).
  • click: { args, confirm? } — clicking runs the SAME routine with args (after a modal when confirm is set) while the item spins; a thrown error shows as a notification. menu: [{ label, args, confirm? }] instead lets the user pick one action. Either way the item refreshes after.
  • data — free-form (≤ 16 KB) for the app's widgets: cowork.statusBar.get("<name>").data.
  • It refreshes when it appears, every interval seconds while the window has focus, when focus returns, after a click, and on cowork.statusBar.refresh(name). A failing refresh shows the app's title as a warning (the error in its tooltip; a click retries).
  • Each refresh is a routine run (audited, tagged status-bar): keep the routine cheap — a local check, not a network round trip — and the interval as long as you can live with.
.cowork/apps/gmail/
cowork.json
gmail-sidemenu.jsx ← entry (naming: *-sidemenu.jsx / *-modal.jsx is convention only)
mail-list.jsx ← fileRouting target
open-inbox.ts fetch.ts send.ts ← routines
README.md ← optional; travels with the app
SKILL.md ← optional; how an agent uses the app (linked as .claude/skills/<name>)

SKILL.md tells an agent how to USE the app:

  • its files and data;
  • setup and environment (test vs production);
  • the routines to run;
  • the rules that bite.

Its front matter is name (the app's name) and a one-line description ending in "Use when …". Cowork links every enabled app that has one as the project skill .claude/skills/<name>, pointing at the app folder, so Claude Code knows the app from the start of a session. Keep it short: the README stays the full reference.

Everything in the folder travels when the app is pushed/installed. Keep generated data (caches, exports, .db files that hold user data) OUT of the app folder — write them elsewhere in the workspace (ctx.data writes to .cowork/data/<app>/; cowork table create <name> makes .cowork/data/<name>.db), so an update never collides with user data and an edited app stays "clean".

  • Routine ids are ONE namespace per workspace: first app folder wins. A fileRouting glob two apps claim (Docs and WordCraft both **/*.docx) asks the user once which app opens it and remembers the answer in .cowork/workspace.json openWith; until then the first switched-on app is the route. Prefix routine ids with the app name (gmail-fetch, not fetch).

.cowork/workspace.json — workspace STATE, not registrations

Section titled “.cowork/workspace.json — workspace STATE, not registrations”

The file holds state only (it never travels with an app or a published workspace); the keys:

{
"publishTo": "<team-server slug>",
"startup": { "home": "gmail" },
"disabled": { "apps": [".cowork/apps/gmail-v1-2-3"], "routines": ["gmail-send"] },
"rail": { "folders": [{ "id": "google", "title": "Google", "color": "#0090ff",
"members": ["workbench.view.cowork.app.gmail", "workbench.view.cowork.app.calendar"] }] },
"deployments": { "gmail-sync": { "target": "cloudflare-worker", "mode": "local", "schedule": "0 * * * *" } }
}
  • disabled.apps takes an app FOLDER (switches off its icon, routes and routines — including files a later update adds) or a single file; disabled.routines takes routine ids (listed but refuse to run). Off-only switches; safe to commit.
  • rail.folders groups activity-bar icons into folders (members are view-container ids; an app's is workbench.view.cowork.app.<name>). Users make them by dragging one icon onto another. A folder holds icons of one rail group — app icons and the system icons never share one.
  • publishTo is the default team server for cowork push — set it with cowork push --to <ts> --remember (or "Use as this workspace's default" when publishing from Manage Workspace).
  • startup.home names the app the window opens on (default: the app named home); startup.left / right (booleans) open or close the side bars at start.
  • Nothing else registers anything: containers, file routes, routines and open modes come from the app's cowork.json, so the app is self-contained and updates re-register it automatically.