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

## The module rules

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

## Theming

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

## `window.cowork` (also the global `cowork`)

| 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](/docs/guides/routines.md)). |
| `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](#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. |

## Dialogs

`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 `value`s.
- **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.

```js
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 })) { /* … */ }
```

## A complete widget

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:

```jsx
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>
  )
}
```

## Not `.html`

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