| title | Plugin SDK |
|---|---|
| description | Every call, hook, component and type in @berth/plugin and @berth/plugin/ui. |
@berth/plugin is in
packages/plugin-sdk.
It is types only: at runtime the app provides one shared copy of
@berth/plugin, @berth/plugin/ui and React to every plugin. A guide is in
writing a plugin.
type Activate = (berth: BerthPluginContext) => void | Dispose | Promise<void | Dispose>;
type Dispose = () => void;A plugin's default export. definePlugin(activate) is an identity function
that gives activate its types.
| Member | What it is |
|---|---|
id |
The plugin's id (its folder's name) |
api |
BerthApi: a typed client for the laptop agent and every box |
orchestrate |
BerthOrchestrate: agents driving agents |
prompts |
BerthPrompts: the saved prompt library |
linear |
BerthLinear: the person's Linear issues, once they added a key |
helper |
BerthHelper: the plugin's own program on a box, for a plugin whose manifest carries one |
storage |
get<T>(key, fallback), set(key, value): small JSON values kept on this computer, apart from other plugins' |
Each returns a Dispose. Everything is also removed when the plugin unloads.
| Call | What it does |
|---|---|
addScreen({ id, title, description?, layout?, Component }) |
A screen under the app's header strip. layout: "fill" hands it the whole area. Component gets { berth }. |
addSidebarItem({ id, title, icon?, screen }) |
A place that opens one of its screens, under the sidebar's More menu until the person pins it. icon is a lucide name. |
addWorktreePanel({ id, title, icon?, Component }) |
A view about one worktree, opened as a tab or split. Component gets { berth, box, location, worktree, path, main? }. |
addWorktreeSection({ id, title?, Component }) |
A section under the composer where work starts in a worktree. Component gets { berth, box, location, worktree, path, main? }. |
addHomeWidget({ id, title, icon?, sizes, Component, … }) |
A widget for Home's grid, in its Add widget picker under your plugin's name. Component gets { berth, size, visible, preview? }. |
addCommand({ id, title, group?, shortcut?, run }) |
An entry in the command palette (⌘K) |
addStatusBarItem({ id, Component, align? }) |
Something in the status bar, left or right |
addTheme(theme) |
A Theme |
on(type, handler) |
Calls handler(event) for events of a type (agent.waiting), a prefix (worktree.*), or every event (*) |
| Call | What it does |
|---|---|
notify(title, body?) |
A toast in the app, and a system notification when it is not focused (by default: Settings → Notifications decides, under messages from plugins) |
openScreen(id) |
One of the plugin's screens |
openSettings(section?) |
The app's Settings, on a section when given one ("integrations", "plugins") |
openTerminal(box, session) |
A session's terminal |
openUrl(url) |
A URL in the system browser |
openWorktree({ box, location, worktree, path, main? }) |
Brings a worktree's workspace to the front |
openBrowser(url, { split?: "row" | "col" }) |
A page in a browser tab of the current worktree, or beside the focused pane |
openPanel(panel, { split? }) |
One of the plugin's worktree panels in the current worktree |
Where work starts in a worktree (its launcher, when no tabs are open, and a new agent's "What should Claude Code do?" screen) the app lists what runs there under the composer: the dev server's private URL, other web tools such as Prisma Studio or Storybook, and data stores, each with Open in tab and copy. Helpers that only listen, such as an agent's headless Chrome, fold away under Other ports.
addWorktreeSection puts a section of your own below that, for the worktree in front. The app gives it a small heading (title) and the composer's width; keep it to a few rows.
import { definePlugin, type WorktreeSectionProps } from "@berth/plugin";
import { Button } from "@berth/plugin/ui";
function Deploys({ berth, worktree }: WorktreeSectionProps) {
const url = `https://${worktree}-shop.vercel.app`;
return (
<div className="flex items-center gap-2 px-2 text-sm">
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{url}</span>
<Button size="xs" variant="outline" onClick={() => berth.openBrowser(url)}>
Open in tab
</Button>
</div>
);
}
export default definePlugin((berth) => {
berth.addWorktreeSection({ id: "deploys", title: "Preview deploys", Component: Deploys });
});The hello-ports example plugin adds one too: the ports open in the worktree, as buttons.
Home is a grid of widgets under the composer, which people arrange themselves. addHomeWidget adds one to its Add widget picker, listed under your plugin's name; once someone adds it, it sits on Home with the app's own.
| Field | What it is |
|---|---|
id |
Unique within your plugin. Home keeps it as <plugin>/<id>, so ids never clash. |
title, description? |
Its heading, and one line in the picker |
icon? |
A lucide name for its heading |
category? |
"Agents", "Work", "Code", "Fleet" or "You": where the picker files it. An issue tracker is "Work". |
sizes |
The sizes it takes, the first being the one it's added at: s 1×1, m 2×1, t 1×2, l 2×2, w 4×1, on a four-column grid of 184px rows |
source? |
One line for the picker: where its data comes from and how often it's read |
useCount? |
A hook for the number in its heading: a number, or { n, urgent } (urgent is amber, for something waiting on the person) |
Component |
Its body. Gets { berth, size, visible, preview? }. |
The app draws the card around it: the heading (icon, title, count, your plugin's name), the menu (size, Refresh now, remove), and an error boundary, so a widget that throws shows a note in its own card. Your component draws only the rows.
Some rules keep Home quick and steady:
- Fetch only while
visible. It is false when the widget is off screen, Home is behind a worktree, or the window is hidden.useWidgetDatadoes this for you, keeps the last read in your storage so it shows at once next time, and reads again on Refresh now. - Draw for the room you get. A narrower window has two columns, or one, and a widget is never wider than the grid. Cards are container queries (
@max-[320px]:hidden), so hide secondary bits by width. - Hold your size while loading. Show
WidgetSkeletonrows, never nothing, so Home doesn't move when data arrives. - Say when there's nothing.
WidgetEmptytakes a scene, a title, a hint and one action. - In the picker's preview,
previewis true and nothing can be clicked: don't take focus or start anything on mount.
import { definePlugin, useWidgetData, type HomeWidgetProps } from "@berth/plugin";
import { WidgetEmpty, WidgetRow, WidgetSkeleton } from "@berth/plugin/ui";
interface Issue { id: string; title: string; url: string }
function Assigned({ berth }: HomeWidgetProps) {
const { data, error } = useWidgetData<Issue[]>("assigned", () => fetchAssigned(berth.storage.get("key", "")), { every: 2 * 60_000 });
if (!data) return error ? <WidgetEmpty scene="storm" title="Couldn't reach Linear" hint={error} /> : <WidgetSkeleton rows={3} />;
if (!data.length) return <WidgetEmpty scene="calm" title="Nothing assigned to you" />;
return (
<div>
{data.slice(0, 6).map((i) => (
<WidgetRow key={i.id} onClick={() => berth.openUrl(i.url)}>
<span className="w-16 shrink-0 font-mono text-[11px] text-muted-foreground">{i.id}</span>
<span className="min-w-0 flex-1 truncate">{i.title}</span>
</WidgetRow>
))}
</div>
);
}
export default definePlugin((berth) => {
berth.addHomeWidget({
id: "assigned",
title: "Linear",
description: "Issues assigned to you, not done.",
icon: "ListTodo",
category: "Work",
sizes: ["l", "m", "t", "w"],
source: "Linear's API with your key, every 2 minutes while on screen",
Component: Assigned,
});
});The built-in Linear widget (plugins/issues/src/home.tsx) is this, reading through berth.linear so it never holds a key. The other built-in widgets from plugins are worth reading: Pull requests and CI failures in plugins/pull-request/src/home.tsx, Agent usage in plugins/usage/src/home.tsx, Notes in plugins/notes/src/home.tsx, and the smallest, Hello ports, in plugins/hello-ports.
A built-in plugin that is off until turned on can list its widgets' titles in berth-plugin.json ("homeWidgets": ["Agent usage"]), so the picker offers to turn it on.
| Call | Returns |
|---|---|
status() |
Status: boxes, forwards, routes, the proxy |
boxes() |
BoxStatus[]: paired boxes and whether they are online |
locations(box) |
Location[], each with its worktrees |
sessions(box) |
Session[] |
stats(box) |
Stats: memory, swap, disks, load, agents |
services(box) |
Service[]: listening ports by worktree |
info(box) |
BoxInfo: os, arch, build, tools, agent presets |
request<T>(box, method, path, body?) |
Any box API call: request("devl", "GET", "ports") |
createTask(box, task) |
TaskResult: a worktree with an agent in it (title? names the work) |
renameSession(box, session, title) |
Session: names a session's work, as the app shows it; "" clears it |
serviceUrl(box, port) |
The private URL of a port on a box |
The same as berth session send|wait, berth exec and berth loop
(orchestration). Sessions are named by box and
session name; locations as the box API names them, "shop" or
"shop/checkout".
| Call | What it does |
|---|---|
send(box, session, text, opts?) |
Types text as one paste, then Enter (opts.enter: false only types it; a boolean is enter). Resolves with a SendResult: { sent, queued?, duplicate?, turn?, seq?, at }, the turn it started and the box's time. opts.when: "idle" holds it on the box until the agent is idle; it is refused while the agent waits for someone unless opts.force, which is for a person answering it; opts.idemKey makes a retry return the turn it already made. |
waitTurn(box, turn, { until?, timeout?, signal? }) |
Resolves with { turn, state, timed_out } once the turn ends (finished, exited, lost) or, with until: "waiting", also when it waits for someone; or after timeout seconds. Needs a box whose info().capabilities has "turns". |
wait(box, session, states, { after?, timeout?, signal? }) |
Resolves once the agent reports one of states after after, its program exits ("exited"), or timeout seconds pass (timed_out). For a box without turns, pass the at from send as after. |
exec(box, location, command, timeout?) |
Runs a command to completion: { exit_code, output, truncated? } |
handoff({ box, from, agent, prompt, worktree? }) |
Starts another agent on a session's work: in a new worktree when given one (a handoff run, with a handoff packet, on a box with runs), otherwise beside it. Resolves with the new session. |
review({ box, from, agent, prompt? }) |
A second agent in the same worktree that reviews the first one's changes (a review run on a box with runs) |
loop({ box, session, prompt, check, max?, signal?, onProgress? }) |
Prompt, wait, check, and send failures back until it passes. Returns { id, done }; done settles with a LoopResult whose outcome is passed, failed, needs-you, exited, timed-out, cancelled or error. It shows in the app's loops panel. On a box with runs it is a loop run there, which keeps going if the app quits; aborting signal cancels it. |
runs.start(box, req, { idemKey? }) |
Starts a run: { template, params }, { flow: steps } or { flow_id, scope, data }. Resolves with its RunSummary. |
runs.get(box, id), runs.list(box, { status?, template?, limit? }) |
A Run with its steps, gate and candidates; summaries, newest first |
runs.decide(box, id, { approve, note?, pick?, step? }), runs.cancel(box, id) |
Decide the gate it waits at (pick: the attempt, from 0); stop it |
runs.done(box, id, { signal?, onUpdate? }) |
Resolves with the run once it ends; aborting only stops watching |
The library the app's prompt picker and broadcast use, one document on this
laptop (/v1/app/prompts). Bodies take {{variables}}: the built-ins fill
in from the session a prompt goes to ({{worktree.name}},
{{worktree.path}}, {{branch}}, {{base}}, {{project}},
{{location}}, {{box}}, {{agent}}); the rest people fill in when
sending.
| Member | What it is |
|---|---|
list() |
The library now (until the laptop holds one, the starter prompts) |
load(), save(prompts) |
Read it again; replace it |
subscribe(listener) |
Called whenever it changes; returns how to stop |
starters, builtins |
The starter prompts; the built-in variables |
variables(prompt) |
The variables people fill in for a prompt |
fill(body, values, target?), segments(body, values, target?) |
A body with its variables filled, as text or as pieces |
newId() |
An id for a new prompt |
openPicker({ box?, session?, promptId? }) |
The app's picker: choose a prompt, fill it in, send it |
openBroadcast({ promptId?, text?, targets? }) |
The app's broadcast: one prompt to several agents |
Linear through the laptop agent, which holds the person's
API key and makes the calls. A plugin never sees the key, and can't reach
api.linear.app itself: the app's window only talks to the agent.
| Call | Returns |
|---|---|
status() |
LinearStatus: { connected, viewer?, workspace? }. Not connected is an answer, not an error |
teams() |
LinearTeam[]: { id, key, name } |
issues({ team?, mine? }) |
LinearIssue[]: up to 100 that are not done or cancelled, the most recently updated first; a team's (by its key), the person's own, or both |
issue(id) |
LinearIssueDetail by id or identifier ("WEB-12"): the issue with description, comments and links |
comment(id, body) |
Comments as the person. id is the issue's id, not its identifier |
Without a key the calls other than status() reject with code: "linear_off"; with a key Linear refuses, "linear_auth". Send the person
to the key with berth.openSettings("integrations").
A plugin can carry a program of its own that runs on a box: a helper.
The plugin's berth-plugin.json names it under box.helper, with a build
for each platform and each build's checksum; the laptop agent downloads the
one for a box, checks it, and hands it to the box, which keeps it running,
gives it a folder for its data and passes requests through to its HTTP API.
Leadline is built this way.
berth.helper is always the calling plugin's own helper: there is no
plugin id to pass, and no way to reach another plugin's.
| Call | What it does |
|---|---|
status(box) |
HelperStatus: { plugin, box, state, platform, version?, latest?, update?, error? }. state is off (not on this box: never installed, or turned off; a box doesn't say which), installing, starting, running, failed (with error), unsupported (the box's berthd is from before helpers, or there is no build for its platform), or none: the plugin as installed names no helper. update is true when the box runs another helper than the plugin carries (latest is its version). A box that is offline rejects |
install(box) |
Installs the helper on the box, or updates it, and turns it on. Resolves with the HelperStatus it reached, which can still be installing or starting: read status until it settles |
remove(box, { purge? }) |
Stops the helper and removes it. What it recorded stays in its folder for the next install, unless purge. Resolves with the HelperStatus it left; its attached lists the running services ({ location, worktree, service }) that were started while the helper was on and still point at it. Nothing restarts them: tell the person which to restart |
request(box, method, path, body?) |
The helper's own API through the box. path is written from the helper's /api/: request("devl", "GET", "queries?limit=50") asks the helper for /api/queries?limit=50 (the box path helpers/<id>/api/api/queries?limit=50). Rejects with code: "helper_off" (status 409) when the helper isn't running there, and with the helper's own code and message when it refuses |
attachEnv(box, { location, worktree, env? }) |
Has the box look in a worktree's own .env files for the variables your manifest's attach_env names, and tell your helper what it found: the same call to its attach path as before a service starts, with .env as command. For a project whose dev servers Shipyard does not start. worktree is its name; "" is the location's main checkout. Resolves with a HelperEnvLook: { files, skipped, variables, answer, … }, where files are the dotenv files that were read (of .env, .env.development, .env.local, .env.development.local, a later one winning), skipped the ones that were there and were not read ({ file, reason }), variables the matching ones as { name, file, sent, reason? }, and answer your helper's own answer without its env. Never a value. Nothing found is an answer, not an error, and looking again replaces what the last look found. Rejects with code: "box_outdated" (a 404) on a box whose berthd is from before this, helper_off (409), helper_no_attach (409, the manifest names no attach), helper_unreachable (502) and refused (403, a hook on the box said no). With env ({ DATABASE_URL: "…" }), no file is read: your helper is told those variables, which the person gave, with .manual as command, in place of what it was told that way before; env: {} takes it back. Each name must be one attach_env names (400 bad_request). What is given is a secret: send it, and keep and show none of it. An older Shipyard has no attachEnv: check typeof berth.helper.attachEnv === "function". See a look in the worktree's .env |
Installing puts a program on someone's machine, so ask first, saying which program and which box: Shipyard doesn't ask for you. The consent dialog and the plugin's row in Settings already say the plugin can do this, by the program's name, and because the manifest is part of what is allowed, a plugin can't gain a helper or change it without being asked about again.
{
"box": {
"helper": {
"name": "berth-leadline",
"version": "0.1.0",
"args": ["serve", "--dir", "{dir}", "--addr", "127.0.0.1:0"],
"ready": "helper.json",
"assets": {
"linux-amd64": { "url": "https://…", "sha256": "…", "size": 123 }
}
}
}
}{dir} is the folder the box gives the helper, and ready a file the
helper writes there once it is listening. A request goes through the box
as JSON, one answer for one request, so a helper's event stream can't be
followed this way: read again on a timer while your view is on screen.
From @berth/plugin, backed by the app's live state:
| Hook | What it gives |
|---|---|
useBerth() |
The plugin's context |
useBoxes() |
BoxStatus[] |
useLocations(box), useSessions(box), useStats(box) |
Live, or undefined while loading |
useProjects() |
Every project across every box: { id, name, slug?, remote?, defaultBox, members } |
useCurrentWorktree() |
The worktree the component is for: inside a pane, that pane's (a tab can hold panes of two worktrees side by side); elsewhere, the focused pane's. Undefined with no worktree open |
useEvent(type, handler) |
on for a component's lifetime |
useStorage(key, initial) |
Like useState, kept in the plugin's storage |
useWidgetData(key, load, { every }) |
For a Home widget: { data?, error?, loading, updated?, refresh() }. The last read shows at once (kept in the plugin's storage under key); load() runs again once it is older than every ms, only while the widget is visible, and at once on Refresh now. Put what load depends on in key. |
And two helpers:
worktreeLocation({ location, worktree, main }): how the box API names a worktree,"shop"for the main checkout,"shop/checkout"otherwise.sessionName(session, { sessions?, locations?, place?, agent? }): what the app calls a session: its title ("Fix checkout webhook"), "Fix checkout webhook · Claude Code" withagent, or without a title the agent's name ("Claude Code 2");placeputs where it runs first ("shop / checkout-fix · Codex").
From @berth/plugin/ui, the app's own kit (coss ui, built on Base UI), with
the same props as in the app:
| Kind | Components |
|---|---|
| Layout | ViewHeader, PluginPage, Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel, FrameFooter, Card, CardHeader, CardTitle, CardDescription, CardPanel, CardFooter, Separator, ScrollArea |
| Controls | Button, Input, Textarea, Switch, Checkbox, ToggleGroup, ToggleGroupItem, PickOne, BoxFilter, FilterChip, AgentPicker, Tabs, TabsList, TabsTab, TabsPanel, Menu, MenuTrigger, MenuPopup, MenuItem, MenuGroup, MenuGroupLabel, MenuSeparator |
| Display | Badge, Kbd, Spinner, Skeleton, Icon (any lucide icon by name), AgentIcon, Alert, AlertTitle, AlertDescription, Meter, MeterLabel, MeterTrack, MeterIndicator, MeterValue, Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Empty, EmptyHeader, EmptyTitle, EmptyDescription |
| Overlays | Tip, Tooltip, TooltipTrigger, TooltipPopup, AlertDialog and its parts, Sheet, SheetPopup, SheetHeader, SheetTitle, SheetDescription, SheetPanel, SheetFooter, SheetClose |
| Home widgets | WidgetRow (one 36px line, a button with onClick), WidgetEmpty (scene, title, hint, action, onAction), WidgetSkeleton (rows) |
| Charts | Chart: bars, lines or areas drawn by the app's own chart components, in the theme's colours. <Chart spec={{ type: "area", x: "x", y: [{ key: "calls", label: "Calls", color: "1" }], data: rows }} />, where spec is a chart artifact's type, data, x, y, units and stacked. It fills its box, 16rem high unless className says otherwise. Give a time series' x as short labels ("14:05") |
| SQL | SqlEditor: the File tab's editor for a statement or a few, with SQL highlighting, completion and a key to run, loaded on first use. <SqlEditor value={sql} onChange={setSql} onRun={(selection) => run(selection)} schema={{ orders: ["id", "status"] }} dialect="postgresql" />. onRun is ⌘↵ and gets the selection as offsets into value (from === to for a cursor); onSelect reports it as it moves, for a Run button of your own; schema is each table (or schema.table) with its columns and defaultSchema the one whose tables complete bare; error={{ from, to? }} underlines where a statement failed until the text changes. It fills its box, 10rem high unless className says otherwise |
| Helpers | For a plugin that carries a program for boxes. HelperBox: one box's state (Off, Installing…, Running, Failed, Can't run it, Offline) with Turn on… behind a confirm that names the program, Update, Turn off and Remove data…. <HelperBox berth={berth} box={name} online={online} copy={copy} /> is a row (an <li>) of your screen's list of boxes, with children shown under a running helper; variant="panel" is a worktree panel's body on a box where the helper isn't running, and draws nothing once it is. copy (a HelperCopy) is what only your plugin can say: its name, its program, what the program does, and what removing its data deletes. useHelperState(berth, box, active) is the same state for your own code: { status?, fault?, old?, refresh() }, read again while active |
| Utility | cn(...classes) |
| Diffs | loadDiffs(): the app's diff renderer (@pierre/diffs, highlighted in workers, themed like the app), loaded on first call and shared with the app. parse(patch) reads a git patch; <default> draws its files as a scrolling list, split or unified. The built-in Diff panel uses it. |
packages/plugin-sdk/src/ui.ts lists every export with a note on each.
The JSON the laptop agent and boxes speak, with field names exactly as the
Go structs have them, in packages/plugin-sdk/src/types.ts:
| Type | Fields |
|---|---|
BoxStatus |
name, address, network?, fingerprint, state (connecting, online, offline, untrusted), error?, latency_ms?, since, link? (slow?, reason?, max_ms?, jitter_ms?, path?) |
Location |
name, path, repo, worktrees?, scripts, agents?, remote?, slug?, default_branch?, repo_trust? (none, trusted, untrusted or changed) |
Worktree |
name, path, branch?, head?, main?, setting_up?, port?, title?, parent? (the path of the worktree whose agent handed this one off) |
Session |
name, location?, dir, command?, created, attached, exited, agent?, agent_state?, state_since?, preset?, turn?, state_seq?, fidelity?, queued?, ask?, title? (what the work is called: the first line of its first prompt, or a rename), driver? (terminal) |
Turn |
id ("<session>#<n>"), session, agent?, n, origin?, sent_seq?, end_seq?, state (queued, pending, running, waiting, finished, exited, lost), queued?, started?, ended?, waits? (each { start, end?, reason?, ask? }), fidelity?, idem_key?, status? |
Ask |
tool?, input? (the command or file path, at most 300 characters), why?, message? |
QueuedPrompt |
turn, preview (the first 280 characters), length, origin?, at |
SendResult, TurnWait |
as send and waitTurn above |
AgentState |
idle, running, waiting, finished |
AgentPreset |
id, name, command, prompt_flag? |
Service |
location, worktree, path, port, process?, main? |
WorktreeService |
name, run, autostart?, state, unit, port? |
Stats |
hostname, uptime_s?, cpus, load?, memory, swap, disks, agents, hooks |
TaskRequest |
location, name, branch?, base?, agent?, command?, prompt?, from_session?, pr?, ref?, open? |
TaskResult |
worktree, session |
BerthEvent |
seq?, type, time, box?, origin?, error?, data? |
Theme |
id, name, appearance (dark, light), colors (17 UI colours, and optional info, state text colours, link, selection), terminal (20 terminal colours, and optional selectionForeground), optional syntax (a Shiki theme for code) |
Hook |
on, run, tool?, timeout?, source? |
TaskTemplate |
as in task templates |
To put these together, start from writing a plugin.