Download

01

The package

Manifest

An extension is an npm-shaped package. package.json names it and holds the manifest under napdock; other keys are ignored. Commands, slots, themes and keys aren't declared here: the ui part adds them at run time.

package.json
{  "name": "@acme/tasks",  "version": "1.2.0",  "napdock": {    "schema": 1,    "id": "tasks",    "title": "Tasks",    "description": "What's left on this branch.",    "icon": "list-check",    "platforms": ["macos-arm64"],    "ui": { "entry": "dist/ui.js", "scopes": { "default": "session", "allowed": ["session", "workspace"] } },    "main": { "entry": "dist/main.js", "permissions": { "network": ["api.linear.app"], "workspace": "read" } },    "connections": [{ "kind": "github", "reason": "Lets Tasks read the pull request's checks" }]  }}
package.json
KeyTypeLimits
name string, required 1–214 characters
version string, required Semantic version such as 1.2.0 or 1.2.0-beta.1, 64 characters
napdock manifest, required Below
napdock
KeyTypeLimits and meaning
schema 1, required The manifest's version
id string, required ^[a-z][a-z0-9-]*$, 64 characters; unique among installed extensions
title string, required 1–80 characters, shown in the rail and Settings
description string 280 characters
icon icon name One of the app's 47 icons (see Tokens)
platforms array 1–4 of macos-arm64, macos-x64, linux-x64, linux-arm64; absent means every platform
ui object entry, scopes, and panel: false for a part with no rail entry
main object entry and permissions (below)
helpers array Up to 8 native programs, names distinct
agent object Tools, hooks and skills for agents
connections array Up to 4 accounts it asks the user for
Paths
RuleValue
Entries and helper paths Relative, /-separated, no ., .. or empty segment, 512 characters
Part and helper names ^[a-z][a-z0-9-]*$, 32 characters; main is reserved

Scopes

You declare which scopes an extension allows; the user picks one, and each scope gets its own copy of the extension. Scope decides which selection reaches a copy and how long its state lives, never what it may do.

ScopeOne copy perNotes
session Session The narrowest; state goes when the session does
workspace Workspace Shared by the sessions of one project
device Device Shared by every session running on one Mac or cloud machine
user Everything Shown as “Everything”; follows every selection

Connections

An extension never holds a token. It names the account it needs and why; on first use the app asks the user, showing the reason, and makes the calls itself.

KeyValue
kind github or mcp, required
reason 1–200 characters, completing “Lets …”
scope user (default) or workspace
required true asks at start instead of on first use
server { name, url } for an MCP server: name 1–120, an https:// URL up to 2048
Rules One github at most, user-scoped, no server; a workspace MCP connection names its server; each server once, by origin

Install and limits

Add an extension from a folder in Settings › Extensions, or install a folder, tarball or npm package. Install scripts never run. Every file is hashed at install and checked before it runs.

LimitValue
One bundled entry 4 MiB
Whole package 64 MiB and 4,096 files
package.json 1 MiB
Folder depth 16
File names ASCII letters, digits and ._@+-; no symbolic links
Skill 64 files, 1 MiB
Installed extensions 128
Imports a ui entry may make effect, @napdock/extensions, /kit, /ipc; no Node or Bun built-ins
Imports a main entry may make effect, @napdock/extensions/main, /ipc
Imports an agent entry may make effect, @napdock/extensions/agent
Updates Keep their grants from the same origin, unless the new version asks for more

02

The ui part

defineUi and ctx

defineUi(setup) returns the part. setup runs once per copy and again on a remount; the panel's render runs after every batch of events. Its id, title and scopes come from the manifest.

src/ui.ts
import { defineUi, space } from "@napdock/extensions";export default defineUi((ctx) => {  const done = ctx.signal(0);  ctx.panel({    title: "Tasks",    icon: "list-check",    badge: () => (done() > 0 ? { text: String(done()), tone: "success" } : null),    render: (ui, context) => {      ui.column({ gap: space["3"], padding: space["4"] }, () => {        ui.heading(context.workspace?.name ?? "No workspace");        ui.button("Done", { key: "done", onClick: () => done.update((n) => n + 1) });      });    },  });});
ctx
MemberWhat it does
ctx.signal(initial) State: call it to read, .set(value) or .update(fn) to write. An equal write (Object.is) does nothing; a write outside a frame wakes the app
ctx.panel(item) The rail panel: { title, icon?, render(ui, context), badge?(context) }
ctx.add(point, item) A contribution (below). A function item is re-evaluated every frame; undefined withdraws it. Returns a remover
ctx.on(kind, listener) A session or lifecycle event (see Events)
ctx.task(effect) Runs an Effect with the host's services; task() is running, succeeded, failed or cancelled; restart(), cancel()
ctx.debounce(ms, run) Runs once calls pause, on one host timer, at least 250 ms; .now(value) runs at once
ctx.host Commands to the app (see Host)
ctx.main(contract) A typed client for the extension's own main part
ctx.provide(contract, handlers) Serves another extension's calls while this copy lives
ctx.use(contract) A client for a contract another extension or the app provides
ctx.contribute(registry, item) Adds an item to a registry
ctx.list(registry) Reads every item in a registry it owns, or an app registry or feed
ctx.i18n(catalog) t(key, params?), by the user's locale; en is required
context, passed to render, badge and listeners
FieldValue
scope The scope the user chose
selection { device, workspace, session }, each nullable; the scope's own level never changes for a copy
workspace { id, name, repository, defaultBranch } or null
session The live session (below) or null
connections Up to 128 { id, name, scope, kind, enabled, authenticated }
mentions Up to 16 connection ids the user picked for this copy
viewport { width, height }
mode light or dark
locale A BCP 47 tag such as pt-BR
visible Whether it's on screen
presentation, expanded docked, filling or window; expanded when not docked
now() Wall-clock milliseconds, for live durations
context.session
FieldValue
id, title, project Who it is
status unknown, starting, running, waiting, idle, stopped, interrupted, failed
goal { objective, status, tokensUsed, timeUsedSeconds } or null
approvals Up to 32 waiting: { id, title, description }
turns Up to 512: { id, index, prompt, reply, startedAt, endedAt, outcome }
tools Up to 2,000 calls: name, category (command, read, edit, search, web, mcp, agent, plan, other), phase, times, exitCode, edits
files Up to 300 changed: { path, additions, deletions, changes, patch }
now The snapshot's time
Frozen Read it, copy what you sort: the next update is applied to it

Events

ctx.on(kind, listener) returns an unsubscribe. Each listener gets the event and the context.

KindCarries
mount Nothing: the copy started or restarted
visibility visible
theme mode
layout presentation
sessionAttach, sessionDetach sessionId
statusChange status
turnStart, turnEnd turnId, at; turnEnd adds outcome
userMessage turnId, at
messageDelta turnId, characters
toolStart, toolEnd toolId, turnId, name, at; toolEnd adds phase
approvalRequest, approvalResolved requestId; the request adds title
goalChange Nothing
filesChange Up to 64 paths
gap missed: events were dropped; rebuild from the session
helperEvent helper, name, dataJson
helperExit helper, reason; the next call starts it again
surfaceState helper, stream, state (connecting, live, ended), size, reason

Contribution points

Add with ctx.add(Point, item). Ids match ^[a-z][a-z0-9._-]*$ (64 characters) and are unique across points; dialog. and registry. are reserved. An item that throws or doesn't fit is left out and reported once; the rest still go.

PointItemLimit
Panel { title, icon?, render, badge? } 1
Command { id, title, keybinding?, slash?, enabled?, run(argument) }; argument is the text after a slash command, else null 64
Keybinding { command, key }, replacing a command's default key 64
SettingsPage { id, title, keywords?, render }, a full tree 640 wide 4
Slot { id, place, title, render }: titlebar, statusbar, composer.pill, session.header, sidebar.section, message.actions; 48 nodes 8
MenuItem { id, menu, title, icon?, run(target) }: menus session.row, project.row, file, message, tool 32
ToolRenderer { id, tools, accepts?, render(ui, call) }; tools are names or globs like mcp__linear__* 8
MarkdownRenderer { id, languages, accepts?, render(ui, fence) } for fenced blocks 8
FileViewer { id, title, extensions?, mimeTypes?, accepts?, render(ui, file) }, shown as Open with… 8
LinkHandler { id, title, patterns, priority?, open(url) }; * stays in a segment, ** crosses them 8
Theme { id, title, mode, tokens }; tokens are tones as #rrggbb 4
Keys and slash commands
RuleValue
Keybinding GPUI syntax such as cmd-shift-k; needs cmd, ctrl or alt unless it's f1–f20
Slash ^[a-z][a-z0-9-]*$, 32 characters; a duplicate is dropped from the later command
Renders Cached by renderer, key and revision; 16 answered per frame, 64 queued; null declines

03

Views

The view builder

render(ui) describes the view; every call adds a node. Previews below run the real SDK: choose a kind to see it drawn beside its code.

Layout
MethodDraws
ui.box(options, children) Any box: direction, gap, padding, align, justify, wrap, background, border, radius, shadow, opacity, scroll
ui.row · ui.column A box in one direction
ui.card A column with a surface, a border and rounded corners
ui.section(title, options, children) A titled group, with trailing controls
ui.memo({ key, deps }, children) Reuses what it drew while every dep is Object.is the same
ui.spacer(size?) · ui.divider(axis?) Fills space, or a line
ui.hovercard(options, shown, card) A card while the pointer is over the first node
ui.disclosure(text, open, options, children) A header that shows or hides what's inside
Text and media
MethodDraws
ui.text(value, options) Text up to 16,384 characters: variant, color, weight, align, truncate, italic, selectable, shimmer (short plain text only, 16 a view)
ui.title · heading · label · caption Text in that variant
ui.rich(spans) Up to 256 spans, each with color, weight, code, italic, underline, strike
ui.code(text, { language, wrap }) Code up to 65,536 characters, highlighted off the main thread; a language that isn't a code fence's draws plain
ui.markdown(text) Markdown in the app's type, 65,536 characters
ui.kbd(keys) A key cap
ui.icon(name, { color, size }) One of the app's icons, 8–96 points
ui.image(asset, { fit, radius }) · ui.asset(...) An image or SVG sent once with the frame
ui.canvas({ height, label }, draw) Drawing commands, up to 12,000
Status
MethodDraws
ui.badge(text, { tone, icon }) A pill in a badge tone
ui.progress(value, { tone, indeterminate }) A bar from 0 to 1, or moving
ui.spinner() · ui.skeleton() Loading, animated by the app
ui.stat(label, value, { hint }) A label over a metric
ui.empty(icon, title, body, actions?) A centered placeholder
Controls
MethodHandler
ui.button(text, { variant, size, icon, selected, disabled }) onClick; variants primary, secondary, ghost, danger
ui.iconButton(icon, label) onClick; a small ghost button, label as tooltip
ui.toggle(text, checked) · ui.checkbox onToggle(checked)
ui.segmented(choices, value, { label }) onSelect(value); 16 choices
ui.tabs(choices, value, { label }) onSelect(value); 16 tabs
ui.select(choices, value, { label, placeholder }) onSelect(value); 256 choices
ui.combobox(choices, value, { label }) onSelect(value); filters as you type
ui.radio(choices, value, { label }) onSelect(value); 32 choices
ui.menu(text, items) onSelect(value); 64 items, with separators
ui.input(value, { key, label, placeholder, rows }) onInput(value), onEnter(value)
ui.slider(value, { label, min, max, step }) onSlide(value)
ui.number(value, { label, min, max, step }) onNumber(value)
ui.date(value, { label, min, max }) · ui.time(value, { label, seconds }) onDate(day), onTime(time)
Collections and rich nodes
MethodDraws
ui.list({ label, count, rowHeight }, row) Up to 100,000 rows, only the visible ones drawn; follow keeps the end in view
ui.table({ columns, sort, count, rowHeight }, row) Up to 32 columns, rows drawn as a list
ui.tree(items, { selected }) An outline, up to 2,000 visible rows, 32 deep
ui.chart(series, { variant, labels }) · ui.sparkline(values) Line, area or bar: 8 series of 500 values
ui.diff(path, patch, { layout }) A file's diff, unified or split
ui.review({ files, threads, selection }) A whole change set, virtualized, with threads; onReview
ui.surface(helper, stream, { fit, input }) Live video from a helper, with input forwarded
ui.use(component, props) A component or a kit piece (below)

Node options

Every builder takes these, besides its own.

OptionValue
key A stable id: give one to anything that moves, comes or goes
enter fade or rise, played the first time the id is drawn
tooltip 512 characters
a11y role, label, description, selected, expanded, level, position, setSize, value, shortcut
focusable, cursor Keyboard focus and the pointer's shape
width, height, min…, max… Points
grow, shrink, fill Share of free space (0–100), whether it shrinks, stretch across
Handlers onClick, onDoubleClick, onContextMenu, onHover, onPointerDown, onPointerMove, onPointerUp, onWheel, onKeyDown, onFocus, onBlur, onChange, onSubmit, onCancel, onResize
Event fields x, y, button, clickCount, key, repeat, shift, alt, control, command, inside, value, checked, deltaX, deltaY, width, height

Tokens

Name the theme's values and the view follows the user's theme, light or dark. Raw colors work too, with rgb, hex, fade and paints like gradient, stripes and checker.

Space: space["4"] is 16 points
TokenPoints
0 · px · 0.5 · 1 · 1.5 · 2 0 · 1 · 2 · 4 · 6 · 8
2.5 · 3 · 3.5 · 4 · 5 10 · 12 · 14 · 16 · 20
6 · 8 · 10 · 12 24 · 32 · 40 · 48
Text, radius and tone
KindValues
Text variants title, heading, body, label, caption, metric, code, prose
Weights normal, medium, semibold, bold
Radii none, small, medium, large, full
Shadows none, small, medium, large
Badge tones neutral, accent, success, warning, danger, info

Components and the kit

component(name, setup) makes a reusable piece with its own Local.state. The kit's pieces are built only on public nodes, so you can replace any of them.

src/ui.ts
import { List } from "@napdock/extensions/kit";ui.use(List, {  label: "Files",  count: files.length,  rowHeight: 24,  height: 320,  row: (ui, index) => ui.text(files[index] ?? ""),});
Kit pieceFor
List Thousands of rows, drawn as they scroll in
Table Columns, sorting and rows
Tree An outline the user expands
Tabs Tabs and their content
Split Two panes with a divider
Toolbar A row of actions
Field, Form Labeled inputs and their layout
Search, EmptyState A search box, and what shows with nothing found
LogView Streaming log lines that follow the end

04

Asking the app

Host commands

ctx.host commands go out with the next frame and return at once. At most 32 requests leave per frame; the rest follow.

CommandDoes
compose(text) · setComposerText(text) Inserts at the cursor, or replaces, for the user to send
attach(attachment) A workspace file, an image (base64 PNG, JPEG, GIF, WebP) or a note
open(target) The panel, a file at a line, or a session
expand(on) · focus(node) Fills the window or docks; moves keyboard focus
run(command) An app command: show-agent, show-files, focus-composer, toggle-sidebar, open-palette, open-settings, go-back and others
notify(text, tone) · toast(text, tone) A notification or a toast, 256 characters
copy(text) · openUrl(url) Clipboard write; an https link in the browser
after(ms, callback) A timer the app keeps: 250 ms to an hour, 16 at once; returns a cancel
storage.set(key, value, store?) Writes a value; reads are Effects
manageConnections() Opens connection management

Effect modules

Anything that answers is an Effect, run with ctx.task. Failures are CapabilityError with a code and a safe message.

src/ui.ts
import { Dialogs, Stores, Toasts } from "@napdock/extensions";import { Effect } from "effect";const clear = ctx.task(  Effect.gen(function* () {    if (!(yield* Dialogs.confirm({ title: "Clear the list?", message: "This can't be undone.", tone: "danger" }))) {      return;    }    yield* Stores.session.set("tasks", "[]");    yield* Toasts.show("Cleared", "success");  }),);
ModuleMembers
Dialogs confirm, prompt, select, custom (a view, as a dialog or a sheet); 4 at once
Toasts show(text, tone), ask({ text, actions }) with 1–3 actions
Composer insert, setText, attach
Layout openPanel, openFile(path, line?), openSession(id), fillWindow(on)
Commands run(command)
Stores instance and global (256 KiB each), session (64 KiB per session): get, set, read(key, schema), write(key, schema, value)
Selection session(), workspace(), device(), failing with ContextError when nothing is selected
Helper call(helper, method, params, resultSchema)
Mcp connectionMention, listTools, call, callJson through a connection the user picked
ExtensionHost Everything above, plus sleep(ms), sessions.list, sessions.start, and GitHub: request, pullRequests, pullRequest, submitReview
I18n locale, translator(catalog)
CapabilityError codes
CodeMeans
Denied · Authentication The user said no, or the account needs signing in
InvalidInput · InvalidResult What went out, or came back, didn't fit
Limit · Timeout · Cancelled Too many waiting, no answer in time, interrupted
MissingMention · AmbiguousMention No connection, or several, matched
Network · Protocol · Vault · Storage · Tool · Host The part that failed
Unavailable Nothing reachable provides it

Contracts between extensions

Extensions share typed interfaces. A contract is named owner.name@major; the owner is the extension that provides it, or app.

PieceValue
Contract.define(token, { methods }) 1–32 methods with Effect Schema input, output and error
Registry.define(token, schema) Items any extension can add, each under 2 KiB
App.browser, App.files Open a page or a workspace file; extensions may provide them too
App.devices, App.previewTargets, App.composerActions App registries: devices, pages to preview, composer pills
App.planUsage A feed only the app fills: plan usage windows
Limits 16 contracts provided and 16 used, 4 registries listed, 64 KiB per call, 32 waiting, 25 s deadline

05

Beyond the panel

The main part

main runs under a Seatbelt profile built from what it declares, and only once the user allows it. The panel reaches it through a contract defined once with Effect Schema.

src/main.ts
import { defineMain, implement } from "@napdock/extensions/main";import { Effect } from "effect";import { Repos } from "./contract.ts";export default defineMain({  ipc: [    implement(Repos, {      top: ({ owner }, ctx) =>        Effect.tryPromise(() => fetch(`https://api.github.com/users/${owner}/repos`).then((r) => r.json())),    }),  ],});
permissions
KeyGrants
exec Up to 16 absolute program paths it may run, each its own real path
network Up to 32 host names, or localhost; no IPs or wildcards. fetch and WebSocket are checked against them
workspace read or write under the open workspaces
env Up to 32 variable names passed through; NAPDOCK_*, DYLD_*, PATH and TMPDIR never are
Always Writes only to its data folder and temp; no reads under your home folder beyond those
MainContext and Ipc
MemberDoes
ctx.signal, ctx.dataDir, ctx.workspaces Shutdown, its private folder, the folders it may use
ctx.emit(contract, event, payload) Sends to every ui copy
ctx.setState(contract, state) Replaces state the panel reads with client.state()
ctx.provide(contract, handlers) Fills in a contract listed bare
Ipc.define({ name, methods, events, state }) 32 methods and 32 events; 64 KiB a message; 25 s a call, 32 at once
IpcError codes InvalidInput, InvalidOutput, Unavailable, Failed, Timeout, TooLarge, Busy

Helpers

Native programs for what TypeScript can't do, such as streaming a simulator. Each is hashed at install and checked at every start, and pinned to its Apple team when it names one.

PieceValue
Manifest { name, path, args?, teamId? }, 8 per package
Wire JSON-RPC 2.0 lines over stdio, 64 KiB each: initialize, stream.attach, stream.resize, stream.detach, shutdown
Input input.pointer, input.wheel, input.key, input.text, in stream coordinates
Video A Unix socket of BGRA or JPEG frames with a 32-byte header, or IOSurfaces on macOS; the newest frame wins
Limits 8,192 points a side, 10 s to start, 120 s a call

The agent part

Tools, hooks and skills for Claude Code, Codex and Pi, served through the app's plugin gateway to the runtimes the user grants.

src/agent.ts
import { defineAgent, defineTool, Hook } from "@napdock/extensions/agent";import { Effect, Schema } from "effect";export default defineAgent({  tools: [    defineTool({      name: "open_tasks",      description: "Lists the tasks left on this branch.",      input: Schema.Struct({ limit: Schema.Number }),      run: ({ limit }) => Effect.succeed(`${String(limit)} tasks`),    }),  ],  hooks: { beforeTool: (call) => Effect.succeed(Hook.continue) },});
PieceValue
Tools 128 per agent; names ^[a-z][a-z0-9_]*$; agents see <extension>__<tool>; results of text, images and JSON, 8 MiB
beforeTool continue, modify(input), deny(reason), answer(result); 5 s
afterTool continue, replace(result); 5 s
prompt Context to add, 4,096 characters; 5 s
permission allow, deny(reason), ask; 10 s
Skills skills/<name>/SKILL.md with name and description, 16 per package
Grants The user allows agent tools, then each runtime; taking either back removes them mid-run

Testing

mount from @napdock/extensions/testing runs an extension against a fake app and checks every frame as the app would. Time moves only when you advance it.

src/ui.test.ts
import { mount } from "@napdock/extensions/testing";import { expect, test } from "bun:test";import ui from "./ui.ts";test("counts clicks", async () => {  const app = await mount(ui);  await app.click("Click me");  expect(app.find("Clicked 1")).toBeDefined();  await app.advance(1000);  app.dispose();});
MemberDoes
find, findAll, within, toText Query by text, label or role
click, type, submit, choose, scroll, fire Act like a user
command, menu, link, render, answer Drive contributions and dialogs
advance(ms), select, send, emit Time, selection, raw events, helper events
host.respond, host.helper, host.contract Fake answers for GitHub, MCP, helpers and contracts
Options session, scope, selection, viewport, mode, locale, main

06

Under the hood

Wire protocol 5

One JSON line each way over stdin and stdout of a sandboxed Bun worker: no network, no fork, no reads of your home folder. The app asks; the worker answers. The SDK writes all of this for you.

Request
FieldValue
version, revision, extension 5, a counter, the id
scope, selection, connections, mentions What this copy sees
viewport, mode, locale, presentation Where it's drawn
tree, contributions The revisions the app holds, or null
session { kind: "full" } or { kind: "diff", base, changes }; absent when unchanged
events Up to 1,024
Frame
FieldValue
view unchanged, tree { nodes, assets }, or patch { nodes, order?, assets? }
contributions unchanged or a list
badge, renders, requests, composition, blobs The rest of the answer; 32 requests a frame
sessionMissing The worker couldn't apply a session diff and wants it whole
Wake {"kind":"wake"} alone on a line: the app asks for a frame 8 ms later
Limits 256 MiB a line; 4 s an answer, then the worker is restarted

Performance

The framework draws once and patches, batches writes per frame, sends the session as diffs, draws only visible rows, plays motion in the app and highlights code off the main thread. What's left is yours: keep render and handlers cheap.

BudgetValue
Answer 100 ms, the worker's average over its last 8 answers; the first answer after a start isn't counted
Apply 8 ms of the main thread per frame, a frame at 120 Hz
Over Frames that can wait come 200 ms apart, doubling per answer over, up to every 2 s; each cost is back to normal at ¾ of its budget
Shown Settings › Extensions: “Answers take 240 ms, so it updates less often” or “Applying its updates takes 18 ms each, so it updates less often”
Rules of thumb
DoWhy
key anything that moves Unkeyed nodes are named by position
ui.memo costly parts Deps compare with Object.is: replace lists, don't mutate them
List for long lists Only visible rows are built
ctx.debounce for typing One host timer however fast the keys come
ExtensionHost.sleep, not Effect.sleep The worker sleeps; the app wakes it
Compute when data arrives Store results in signals, not in render
Skip work when not visible And keep slots small