How an extension works, shown by one running.
Every panel on this page is a real extension: the same SDK, run by the same worker code the app runs, answering the same requests. Press things. Each chapter slows one idea down until you can see it.
Ten chapters, about fifteen minutes. The reference lists every API, field and limit.
01 Describe, don’t draw
A panel is a function of its state.
You never touch pixels. Your render function says what the panel holds
right now (a heading, some text, two buttons) and the app draws it with its own
renderer, at the window’s frame rate, in its own theme.
State lives in signals. Write one and the whole render runs
again. That sounds wasteful; it isn’t, because what it returns is a list of small
objects, and only the ones that differ ever leave the worker.
import { defineUi, space } from "@napdock/extensions";
export default defineUi((ctx) => {
const count = ctx.signal(0);
ctx.panel({
title: "Counter",
render: (ui) => {
ui.column({ gap: space["3"], padding: space["4"] }, () => {
ui.heading("A counter");
ui.text("Each click is one event in and one frame out.", { color: "muted" });
ui.row({ gap: space["2"], align: "center" }, () => {
ui.button(`Clicked ${String(count())}`, {
key: "add",
variant: "primary",
onClick: () => { count.update((n) => n + 1); },
});
ui.button("Reset", { key: "reset", onClick: () => { count.set(0); } });
});
});
},
});
});
The tree it sent
Click the button. The handler lights up, then all of render, then the
one node that changed glows in the tree. That glow is everything the app received.
02 One click, round trip
The app asks; the worker answers.
Your extension runs in its own sandboxed worker, apart from the app. They speak in turns: the app sends a request (what happened, what’s selected, which tree it’s showing) and the worker answers with a frame. One line of JSON each way.
The worker never pushes. When something changes on its own, a timer or a reply, it
sends a one-line wake and the app asks for a frame a moment later.
The app · main thread
The worker · sandboxed Bun
- handler
- render
- compare
Follow one click.
Press the button and watch it go round, slowed down so you can.
The JSON is real: it’s what crossed between this page and the worker for your click.
03 Keys and patches
Only what changed travels.
After each render the worker compares the new nodes with the ones it sent. Nothing
different, and the frame just says unchanged. A few different, and it sends
a patch with only those. When the patch would weigh more than half the
tree, or the app holds no tree, the whole tree goes instead.
It compares by id, and you choose ids with key. Without one, a node is
named by its position, so inserting at the top renames everything below it. Try the
same edit on both lists.
- No frames yet. Do something to the list.
- No frames yet. Do something to the list.
Each bar is one frame: how much of the whole tree it carried. Keyed rows move without being resent; unkeyed rows are all new to the app.
04 Writes batch
A thousand writes, one frame.
Signals don’t redraw when you write them. The worker runs every handler for a request, then renders once and answers once. Write a signal a thousand times in a loop and the app still gets a single frame, with the last value.
Time works the same way. ctx.host.after asks the app to keep the timer, so
the worker sleeps until it fires, then does one round.
import { defineUi, space } from "@napdock/extensions";
export default defineUi((ctx) => {
const count = ctx.signal(0);
const bump = (times: number) => {
for (let i = 0; i < times; i++) {
count.update((n) => n + 1);
}
};
ctx.panel({
title: "Batching",
render: (ui) => {
ui.column({ gap: space["3"], padding: space["4"] }, () => {
ui.text(count().toLocaleString("en"), { key: "count", variant: "metric" });
ui.text("writes so far", { color: "muted" });
ui.row({ gap: space["2"], wrap: true }, () => {
ui.button("Write once", { key: "one", onClick: () => { bump(1); } });
ui.button("Write 1,000 times", {
key: "many",
variant: "primary",
onClick: () => { bump(1000); },
});
ui.button("Write every second", {
key: "tick",
onClick: () => {
// A host timer: the worker sleeps until it fires, then one frame follows.
const tick = (left: number) => {
bump(1);
if (left > 1) {
ctx.host.after(1000, () => { tick(left - 1); });
}
};
ctx.host.after(1000, () => { tick(5); });
},
});
});
});
},
});
});
- Writes
- 0
- Frames
- 0
- From a timer
- 0
05 Choices don’t wait
The switch moves under your finger.
Your extension owns every value it shows, so strictly a switch should only flip when the frame saying so arrives. On a busy worker that’s a beat too late, and it feels broken.
So tabs, toggles, disclosures, segmented controls, selects and radio groups show your choice the moment you make it, before your handler has run, and the next frame settles it. Drag the delay up and compare.
The switch sits still until the worker answers.
It moves under your finger; the frame confirms it.
06 Motion is the app’s
You name the motion; the app plays it.
An extension can’t animate by sending frames sixty times a second, and it doesn’t
need to. Spinners, skeletons, a shimmering “running” line and the progress bar all
move in the app. Give a node enter: "rise" or "fade" and the
app plays it the first time that id appears, and never again on a patch.
With Reduce Motion on, the app plays none of it. Nothing to handle in your code.
- Frames since your last click
- 0
The spinner, the shimmer and each note’s entrance run in the app. The worker sent its frame and went back to sleep.
07 The budget
A slow extension slows itself, not the app.
The app times every answer twice: how long the worker took, and how long the main thread spent applying the frame. Over the last eight answers, the first should average under 100 ms and the second under 8 ms, a frame at 120 Hz. The first answer after a start, which includes loading the extension, doesn’t count.
Go over either and the app asks for the frames that can wait (the session feed, wakes) less and less often: 200 ms after the first slow answer, doubling up to every two seconds, until that cost drops to three quarters of its budget. Each cost recovers on its own. Settings says which extension is slow and why.
60 session updates in six seconds
Within budget: it follows the session at the feed’s pace.
Clicks and typing still reach it the moment it’s free; only the feed waits.
This is the app’s own rule, run in the page: each bar is one update of a streaming session.
08 Where it draws
A panel is one place of many.
The same ui builder draws everywhere. You add a contribution to a
point (a slot in the title bar, a renderer for a tool call, a command) and the app
asks for its view along with the panel’s, in the same frame.
Panel · Panel
ctx.panel({ title: "Tasks", icon: "list-check", render, badge })Beside the session, filling the window, or in its own.
And off the page
09 Four parts
Each part gets its own sandbox.
Everything so far happened in the ui part, which can’t reach the network or
your files. When an extension needs to do more, it adds a part that may, and says
exactly what in its manifest. You allow each one once; you can take it back in
Settings.
-
uiA Bun worker per extension
- May
- Describe views, keep state, ask the app for things
- May not
- Reach the network, start programs, read your files
defineUi((ctx) => { … }) -
mainBun under a Seatbelt profile built from its manifest
- May
- Only the programs, hosts, workspace access and variables it declares
- May not
- Anything it didn’t declare; nothing at all until you allow it
defineMain({ ipc: [implement(Repos, { top })] }) -
helpersNative programs, checked at every start by hash and the Apple team they name
- May
- Stream video into a surface, take input, answer calls
- May not
- Start before you allow them, or run if they changed after install
Helper.call("sim", "boot", params, Result) -
agentAn MCP server for Claude Code, Codex and Pi
- May
- Give agents tools, hooks and skills
- May not
- Reach a runtime you haven’t granted it
defineAgent({ tools: [defineTool({ … })] })
The parts are listed in package.json, under napdock. The
user picks the scope from the ones you allow: one copy of your extension per
session, per workspace, per device, or one for everything. Scope decides what a copy
sees and how long its state lives, never what it may do.
{ "name": "tasks", "version": "0.1.0", "napdock": { "schema": 1, "id": "tasks", "title": "Tasks", "icon": "list-check", "ui": { "entry": "dist/ui.js", "scopes": { "default": "session", "allowed": ["session", "workspace"] } } }}
10 Keep going
That’s the whole model.
Describe the view from state. Key what moves. Let writes batch and timers sleep. Leave
motion to the app, and keep render cheap so you stay inside the budget.
Everything else is vocabulary.