Skip to content

Widgets (.jsx / .tsx) and the window.cowork SDK

Types for everything below are scaffolded into .cowork/local/cowork.d.ts in a Cowork workspace (ambient — nothing to import). Read that file when you need an exact signature.

  • One file = one module. export default a React component (function). Transpiled by the daemon (Bun), evaluated in a webview with a require shim.
  • No relative imports. import x from './helpers' fails ("not in the cowork webapp allowlist"). Every widget is standalone: duplicate tiny helpers, and put shared or privileged logic in a ROUTINE (cowork.routines.run).
  • Only these imports resolve (anything else throws at load):
    • react, react/jsx-runtime, react-dom, react-dom/client
    • @/components/ui/<name> — the stock shadcn/ui set: accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, button, calendar, card, carousel, chart, checkbox, collapsible, command, context-menu, dialog, drawer, dropdown-menu, hover-card, input, input-group, input-otp, label, menubar, native-select, navigation-menu, pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner, switch, table, tabs, textarea, toggle, toggle-group, tooltip
    • @/lib/utils (cn), clsx, tailwind-merge, class-variance-authority
    • lucide-react (icons), recharts (charts), @xyflow/react (node/edge canvases, CSS included), @xterm/xterm + @xterm/addon-fit + @xterm/addon-web-links (terminals, CSS included)
    • radix-ui, react-day-picker, sonner, vaul, cmdk, react-resizable-panels, embla-carousel-react, next-themes, input-otp
  • No npm installs, no CDN scripts, no fetch to third-party APIs with credentials — the webview has no secrets and no filesystem. Network + secrets + files → a routine.
  • Tailwind classes work (compiled at runtime). Copy-pasted shadcn block JSX works unchanged.
  • Use the shadcn semantic tokens — bg-background, text-foreground, bg-card, text-muted-foreground, border, bg-primary text-primary-foreground, bg-accent … They follow the workbench theme (light/dark) live with zero code.
  • dark: variants follow the WORKBENCH theme (not the OS).
  • Don't hardcode colors (#fff, bg-white, text-black). A hardcoded-light UI gets an automatic dark filter on dark themes, which looks worse than tokens.
  • Fill the pane: the root element is sized by the host; use h-full / flex layouts and let inner regions scroll.
member what
routines.run(id, args?, { timeoutMs? }) Run a routine; resolves its return value, rejects with its error — e.name is the kind the routine threw (ctx.error("NotFound", …), NotConnected from ctx.credential), e.runId the run (cowork routine runs show <id>). Cowork's own NotFound (no such routine, or its capability is not installed) comes before any run and has no e.runId; a NotFound the routine threw always has one. Identical calls in flight share one run; a busy daemon (TooManyRoutines) is waited out. Default timeout 30 s, max 300 s.
routines.subscribe(id, args, { interval?, timeoutMs?, onData?, onError? }) → { refresh(): Promise, close() } Keep a routine's answer fresh — use this instead of setInterval. Runs now, then every interval seconds (min 5, default 60) while a pane of this app is on screen. Every pane of the app asking the same routine with the same args shares ONE loop (the sidebar and its tab poll once); identical answers are not re-sent; a pane opening later gets the last answer at once. NotConnected pauses the loop until refresh() (call it after the user connects) or (inside the IDE) until a pane of the app is shown again; other errors back off. refresh() always answers — from a run that starts after the call, so it sees a change you just made — even when nothing changed, and resolves once the answer reached onData/onError — await it for a spinner. Subscribe at mount and keep it open; close() on unmount.
app.post(msg) / app.onMessage(cb) This app's own panes (sidebar, tabs, dialog) talking: post reaches every OTHER open pane of the same app in this workspace; onMessage returns the unsubscribe. E.g. a page posts { type: "changed", id } after a save and the sidebar calls its subscription's refresh().
credentials.get(name) / credentials.connect(name, fields?) A connection of the capabilities this app uses — declared in their cowork.json credential (an app declares none); a sign-in is named after its service (google, shared by every Google capability), a key after its capability. get → { name, kind: "oauth" | "key" | "both", state: "connected" | "needs-scopes" | "not-connected", account?: { email }, missingScopes, usedBy, fields?, url?, hint? } — ready when state === "connected"; never a token or a key. connect signs in (opens the service's consent for what every capability using it needs; the sign-in finishes in the browser — then refresh()) or, with fields ({ token }), saves a key; one that takes either signs in unless fields are given. A sign-in rejects NotHere in a machine's (remote) window.
routines.runs(id?, {app?}) Bounded run history (metadata only); app narrows to one package's runs. Each run names its package (app, kind) and, when nested, chain — the routines that called it.
state.get() / state.set(v) Per-view persisted state (survives hide/show + reload). Use it as a stale-while-revalidate cache: paint the cached value, refresh underneath. Keep it BOUNDED.
openFile(path) Open a workspace file (routes through fileRouting).
openExternal(url) Programmatic external open (trust prompt). Plain <a href="https://…"> is already routed — no need to call it for links.
browser.open(url) / browser.status() The desktop app's in-IDE browser: open shows an http(s) page beside the IDE, in a browser tab of the desktop app's browser group (a tab already on that site is reused, else a new tab) → { embedded: true }; without it (web, code-server) it falls back to openExternal → { embedded: false }. status() → { open, tabs: [{ url, title, active }] } or null — poll it (only while visible) to follow the user, e.g. pick an id out of the URL they reached. For setup guides: put the guide in a TAB (a routed file) — it sits in the files group beside the browser group where its pages open.
notify({ title, body?, tag?, urgent? }) An OS notification from this app — a banner from the desktop app (even with its window closed), or from a browser tab on this workspace (best effort). Only the active workspace's notifications show. Rejects in a machine's (remote) window. title is required and shows exactly as sent — put the app's name in it if you want it there (≤ 100 characters; body ≤ 300, tag ≤ 64 — longer is cut). A tag replaces this app's not-yet-shown notification with the same tag; urgent: true keeps the banner up and asks for attention (dock bounce, flashing taskbar). A click opens the app. Muted by the user (Manage Workspace › the app's Notifications switch) or past 6 a minute → dropped, and it still resolves; rejects BadRequest without a title. To notify while no pane is open (new mail), call ctx.notify from the routine that polls — a routine outside a .cowork/apps/<name>/ package gets BadRequest, and a capability's notifications go to the one app that uses it (the click opens that app; its switch or the capability's mutes them). Every notification also appears in the workbench's Notifications center (the bell) as <App>: <title> — <body>; the banner shows only while the window is not focused.
onNotification(cb) Subscribe to THIS app's notifications — its own cowork.notify and its routines' / capabilities' ctx.notify, as they are raised: cb({ app, appTitle, title, body?, tag?, urgent?, at }); returns an unsubscribe function. Live in every pane of the app; a muted app delivers none. An event, not a poll. n.app says which app it was posted as, not who posted it. Another app's notifications are for a routine (notifications trigger).
capture.record({ video?, audio?, live?, file? }) Records the screen (video: "screen", the default, or "window"; false = sound only) with the sound the computer plays (audio.system) and/or the microphone (audio.mic). Declare what you use in cowork.json "capture". Every recording first asks the person in Cowork's dialog, which names your app, what it asks for and what it gets; nothing starts without that Allow, and a recording shows a pill with Stop (the desktop app's own window, or a bar in a browser tab). Resolves once recording with a session { id, file, onAudio(cb), onStop(cb), stop() }; the file lands at file (default Recordings/<date-time>-<app>.webm, inside the workspace) when it stops — Stop, stop(), the system ending the share, or the window closing (what was recorded is kept). live: true also streams the mix to onAudio as 16 kHz mono Int16Array frames of 20 ms — the app hears what the person hears; say so in your UI. One capture per workspace. Rejects by name: Denied (the person said no, or the OS refused), NotDeclared, Busy, DiskFull (under 2 GB free), Unsupported (a page that cannot capture — cowork app serve, plain http on another machine). Panes only: a routine cannot capture.
capture.shot({ source?, file?, image? }) One picture of a screen or window, after the same dialog. image: true hands the PNG back as image (a Blob); otherwise (or also, with file) it is saved and path says where (default Recordings/<date-time>-<app>.png). Needs "capture": ["screenshot"].
capture.current() This app's recording in progress, or null — re-attach onAudio / onStop after the pane remounts.
badge(count) This app's count on the OS badge, summed over the apps: on the desktop app's dock / taskbar icon for the active workspace only; in a browser tab, a (N) prefix on its title. Another workspace's count waits until that workspace is active. State, not an event: the latest call wins, 0 clears, nothing expires — report it on every poll (a restart clears it). A muted app's count leaves the sum. From a routine: ctx.badge(n).
openedFile Set when this widget was opened for a routed file or folder: {path, workspaceRelativePath, basename, ext, matchedGlob} (a folder: matchedGlob ends in / — tell it by that; its ext is ""). The widget gets the PATH, not the content — read/parse (or list) it in a routine.
workspaceRoot Absolute workspace path.
log(...parts) A line in the "Cowork" Output panel.
visible / onVisibility(cb) Whether the pane/tab is on screen (the Page Visibility API lies inside webviews). routines.subscribe already gates on it; use it for anything else you do while visible.
tables.schema/query/exec({ db, … }) SQLite .db files (workspace-relative); query is read-only, ≤ 500 rows per page.
queue.send(q, { body, subject?, to?, as?, review?, ref?, meta?, delayS?, files? }) / receive(q, { as?, mine?, leaseS? }) / ack(q, id, receipt, { result?, files?, review?, to? }) / nack(q, id, receipt, { delayS?, error? }) / approve(q, id) / reject(q, id, comment) / reply(q, id, body, { to?, as?, files? }) / retry(q, id) / tasks({ queue?, view?, agent?, q? }) / items(q, { view?, agent? }) / peek / list() / summary() / receivers(q?) / remove / purge The workspace's task queues (.cowork/local/queues.db, the Tasks view) — work for agents and scripts (cowork queue receive --as <name> in a /loop). A task: subject, status (todo · in_progress · needs_review · done · failed), from, to (an agent, owner or anyone), review, the request (body, files) and entries (its thread). Body ≤ 1 MiB; files as { name, text | base64 }; receive → the oldest task for you + its receipt, or null.
journal.skills() / entries(skill, { status?, limit? }) / add({ skill, kind?, text, source? }) / resolve(skill, ids?) The skill journal: learnings / errors per skill, open until an agent folds them into the SKILL.md and resolves them. entries lists oldest first, the open ones unless status says otherwise, up to limit (1–200, default 200).
agent.setContext(v) / agent.select(text) / clearContext() / clearSelection() / setAutoSelection(bool) Feed the IDE's AI agent. Text the user selects in the app already reaches it automatically.
ui.dialog({ message, detail?, type?, fields?, buttons?, alert?, scope? }) Cowork's dialog → { label, values } — see Dialogs. (alert/confirm/prompt don't work in webviews.)
ui.confirm({ title?, body?, confirmLabel?, cancelLabel?, destructive? }) ui.dialog's yes/no shorthand → true on confirm, false on Cancel or Esc. destructive: true for a delete.
chat.ask(text) Open the workbench chat and submit text.
consent.request({ value, title, body, detail }) Ask the user to approve a command → {granted, ticket?}.
stream.open({ argv, consent, onData, onEnd }) One long-lived process with raw stdio (terminals, tail -f). Needs a consent grant/ticket; max 2 open per view.
identity.currentUser() The signed-in user {uid, email} (or null).
viewSource() Open this app's source in the text editor.
setTitleActions([...]) / onTitleAction(cb) Title-bar buttons on an app's pane in the standalone runner (cowork app serve) only; the IDE ignores them, so put an app's buttons in its UI.
closeTab() Close this app's editor tab.
statusBar.get(app) / statusBar.onChange(cb) An app's footer item (statusBar in its manifest) as last drawn, with its data — or null. onChange((app, item) => …) is called with each app's current item right away, then on every change — subscribe instead of reading get at mount.
statusBar.refresh(app?) / statusBar.click(app) Re-run an app's footer routine now (after your widget changed what it reports) / act as if the user clicked it — a widget's "Connect" button can simply be the footer item's click.

cowork.ui.dialog(opts) resolves { label, values }: the button's label and the fields' values (by field id) plus the button's own values. Cancel or Esc → label: null, with values as the fields were left — test label, never values.

  • Fields: text · textarea · number · checkbox · select · color · section (read-only: label + placeholder text, monospace). values[id] is a string (a number: a number, "" while empty; a checkbox: a boolean). select and color take options: [{ label, value }] — a swatch's value is a hex color (#rgb … #rrggbbaa) or "" for no color.
  • Buttons: { label, role: "submit" | "cancel", primary?, destructive?, card?, sublabel?, values? }; a card is an option row in the body. With no buttons, one OK.
  • To refuse an answer, ask again with alert: { variant: "destructive", text } and the typed values.
  • Scope: leave the default window (dims the whole IDE) for anything that creates, changes or deletes data; scope: "pane" (dims only this pane) is ONLY for a choice that changes how things look, like a theme.
const { label, values } = await cowork.ui.dialog({
type: "none", message: "Rename the report?",
fields: [{ id: "name", type: "text", label: "Name", value: report.name }],
buttons: [{ label: "Cancel", role: "cancel" }, { label: "Rename", primary: true, role: "submit" }],
});
if (label === "Rename") await cowork.routines.run("rename-report", { name: values.name });
if (await cowork.ui.confirm({ title: "Delete the report?", confirmLabel: "Delete", destructive: true })) { /* … */ }

The CRUD template (cowork template install cowork/app-sidebar-workbench --name <n>) is the full reference; this is the same pattern for a connected app's sidebar:

import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
export default function Inbox() {
const cowork = window.cowork
const [data, setData] = useState(() => cowork.state.get() ?? null) // instant paint
const [error, setError] = useState(null)
const [connecting, setConnecting] = useState(false)
const inbox = useRef(null)
useEffect(() => {
// One loop for the app, while a pane is on screen — never setInterval.
inbox.current = cowork.routines.subscribe('mail-inbox', { limit: 25 }, {
interval: 60,
onData: (result) => { setData(result); setError(null); cowork.state.set(result) },
onError: setError, // NotConnected pauses the loop
})
return () => inbox.current.close()
}, [])
async function connect() {
await cowork.credentials.connect('google') // the sign-in of the capability this app "uses"
setConnecting(true) // the sign-in finishes in the browser
}
// NotConnected paused the loop: it resumes on refresh() — or, in the IDE, by itself when a pane
// of the app is shown again (the user coming back from the browser).
if (error?.name === 'NotConnected') {
return (
<div className="flex flex-col gap-2 p-4">
<Button onClick={connect}>Connect Google</Button>
{connecting && <Button variant="outline" onClick={() => inbox.current.refresh()}>I've signed in</Button>}
</div>
)
}
return (
<div className="flex h-full flex-col gap-2 bg-background p-3 text-foreground">
{error && <p className="text-sm text-destructive">{error.message} <span className="font-mono text-xs opacity-70">{error.runId?.slice(0, 8)}</span></p>}
{!data && !error && [0, 1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
{(data?.messages ?? []).map((m) => (
<button key={m.id} type="button" className="rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => cowork.agent.select(m.subject)}>
{m.subject}
</button>
))}
</div>
)
}

entry must be .jsx / .tsx. A .html file renders in a sandboxed iframe that gets only cowork.agent — no routines, no theme tokens — so build app UIs as .jsx.