The extension contract
An extension is a plain object.
interface DevToolbarExtension {
id: string; // identity: dedupe, panel state, storage scope
label: string;
contractVersion?: number; // core warns once on a mismatch
align?: "start" | "end"; // default "start"
order?: number; // ascending, within the region
priority?: number; // lowest collapses into ⋮ first
hidden?: boolean; // you compute this — core has no ctx
keepMounted?: boolean; // panel state survives closing
compact?: (props: CompactSlotProps) => React.ReactNode;
panel?: (props: PanelSlotProps) => React.ReactNode;
overlay?: (props: OverlaySlotProps) => React.ReactNode; // modal; never collapsed
// Core aggregates; it renders no palette. A function is re-enumerated on
// every pass, so a command that only exists later is still reachable.
commands?: AnyToolbarCommand[] | (() => AnyToolbarCommand[]);
// Aggregated the same way as commands; /ext/diagnostics renders the roster.
diagnostics?: () => unknown;
start?(api: ExtensionRuntimeApi): void | (() => void);
}compact and panel are render functions, not components, so core can hand down state you cannot otherwise know:
interface CompactSlotProps {
isOverflowed: boolean; // rendered in the ⋮ menu rather than the bar
isPanelOpen: boolean;
density: "compact" | "comfortable";
openPanel(): void;
closePanel(): void;
togglePanel(): void; // the one every trigger actually wants
styleNonce?: string; // CSP nonce for a stylesheet this slot injects
}
interface PanelSlotProps {
isActive: boolean; // false only for keepMounted panels
density: "compact" | "comfortable";
height: number;
close(): void;
styleNonce?: string;
}
interface OverlaySlotProps {
density: "compact" | "comfortable";
position: "bottom" | "top";
styleNonce?: string;
}overlay is for a surface the bar cannot host — a dialog, a picker, a layer drawn over the page. It renders once, inside the toolbar root, for as long as you are present, not hidden and the bar is visible, and overflow never collapses it: a compact item that has collapsed into the ⋮ menu is not in the DOM at all, which would cost an extension its modal (and its key binding) exactly when the window got narrow. Most overlays render null most of the time. /ext/command-menu is the worked example for a modal; /ext/overlays for a persistent surface, where the trick worth stealing is z-index: -1: the toolbar root is a stacking context above your app, so a negative-z child of it paints over the page and under the bar, the panel and the palette.
Core keeps reporting visibility rather than acting on it, so start() keeps running while the bar is hidden even though your overlay is not rendered. If yours is modal, close it on api.subscribeVisibility(false) and gate any key binding on api.isVisible() — otherwise it reappears, unasked, when the bar comes back. If it observes the page, detach the observers there too: measuring for a surface that is not rendered is pure waste.
start(api) runs once per mount, for background work:
interface ExtensionRuntimeApi {
signal: AbortSignal; // aborts on teardown
isVisible(): boolean;
subscribeVisibility(cb: (visible: boolean) => void): () => void;
storage: ToolbarStorage; // scoped to this extension
getCommands(): readonly AnyToolbarCommand[]; // the live aggregation
runCommand(id: string, input?: unknown): Promise<boolean>; // false = nothing declares it
invokeCommand<Out>(id, input?): Promise<CommandInvocation<Out>>; // ... and what it returned
getDiagnostics(): readonly ExtensionDiagnostics[]; // one entry per present extension
}getCommands() / runCommand() / invokeCommand() / getDiagnostics() are how an extension reads the aggregation without importing a value from core — useToolbarCommands() and useDevToolbar().getCommands() are for the host application. All three re-enumerate on call, so they are never behind. runCommand() rejects with the command's own error if its run() throws or rejects, instead of swallowing it — catch it at the call site.
Core reports visibility and never pauses you on your own behalf — a cumulative counter that silently stops counting is worse than one that keeps going.
Both isVisible() and subscribeVisibility() report the effective visibility: the controlled visible prop where the host passes one, the persisted store value otherwise. The callback fires from a post-commit effect — after React has committed the change, not synchronously inside the update that caused it — and not on subscribe, so read isVisible() for the current value. Flips that cancel out inside one batch (a hide then a show) are coalesced and deliver nothing.
Two lifecycle rules the metrics extension paid for, so you do not have to:
hiddenis not "unpainted", it is "does not exist here". A hidden extension is neverstart()ed and is torn down if it becomes hidden; its panel is unmounted and closed; and it contributes no commands, sorunCommand()and the ⌘K palette cannot reach it either. Anything less and hiding a collector would leavefetchpatched, its request table on screen, and a copy-to-clipboard command one keystroke away for a user not allowed to see any of it. To merely collapse an item out of sight, usepriority.Build your extension once, at module scope. The object identity is the lifecycle:
start()belongs to the object core first saw, so rebuilding it inside render leaves the bar rendering a second object that owns nothing. Core warns once per id when it detects this —extension "<id>" was rebuilt after it started— and keeps the first object'sstart(). A hot-module reload of the module that builds your extensions does the same thing; reload the page.The corollary: a factory option cannot be changed at runtime. Handing
<DevToolbar>a freshly built object for a running id is that same warning, not a reconfiguration: the new closure is never started, so the bar renders a control wired to a runtime nobody is driving, and the symptom is a chip that has quietly stopped updating. To changepresentation,label,injectStylesor any other factory option while the app is running, remount the toolbar —<DevToolbar key={mode} …>— or reload the page.examples/playground/src/App.tsxis the worked example.The corollary every real app meets on day one: the object cannot read a hook, yet what it shows — the signed-in user, the workspace, the resolved flags — lives in React or in a store. Do not reach for a module-scope variable that an effect keeps current: reset in a cleanup, it reads as absent between StrictMode's double mount. Give the extension a
Readableinstead — acreateSource()written from the component throughuseSource(), a Zustand/Redux store passed as-is, orderive()over several — and subscribe to it instart(), publishing on notify. On teardown core abortssignaland then calls the cleanupstart()returned, so a subscription released from both must have an idempotent cleanup — the second call finds nothing left to release./ext/environmentand/ext/flagsaccept one directly;readInput()andisReadable()in the kit are the two lines your ownstart()needs to do the same, while still accepting a plain value or a polled getter.
Writing one that looks native
A minimal one:
const build: DevToolbarExtension = {
id: "build-info",
label: "Build",
align: "end",
priority: 90,
compact: ({ openPanel }) => (
<button type="button" data-dtb-part="trigger" onClick={openPanel}>
{import.meta.env.VITE_COMMIT?.slice(0, 7) ?? "dev"}
</button>
),
panel: () => <BuildDetails />,
};A dozen lines, and it imports nothing. That is the floor on purpose: an extension is a plain object, and none of what follows is required to render one. The same floor is how a third-party devtool gets onto the bar — { id, label, panel: () => <Whatever /> } and core supplies the trigger; embedding.md is that recipe, the bring-your-own-CSS rule that goes with it, and the optional embed() frame helper.
The next step, when you want it to look like the first-party panels rather than like a <button> on a page, is @nejcm/dev-toolbar/kit — the shared stylesheet and the thin controls over it. Nothing there is a framework; each control renders the DOM you would have written, plus a data-dtb-kind the shared sheet styles. A useful order to pick them up:
Chip first, because the compact slot is what everybody sees. A dot, a label and a value, laid out and coloured like every other chip in the bar:
import { Chip } from "@nejcm/dev-toolbar/kit";
compact: ({ isPanelOpen, togglePanel }) => (
<button type="button" data-dtb-part="trigger" aria-expanded={isPanelOpen} onClick={togglePanel}>
<Chip label="build" value={commit} severity="ok" data-dtb-part="build-chip" />
</button>
),severity colours the dot and the value from --dtb-ok / --dtb-warn / --dtb-danger, so your chip agrees with the rest of the bar for free.
If you want the consumer of your extension to be able to restyle that chip the way they can restyle the first-party ones — an icon of theirs, icon-only, icon plus value — the kit publishes the vocabulary for it: one presentation option holding a preset, a ReactNode icon, a render callback over your own view type and an accessible-name override, plus the four pure helpers that resolve it. The shape, the preset table, the two guarantees it enforces and a worked bar control are in kit.md. No icon set ships with this package; icons are the consumer's assets, passed in.
Note and Action next — the two things every panel has. Note is muted, margin-free secondary text; Action is a <button type="button"> carrying the shared reset, so your buttons match the ones next door and cannot accidentally submit a form.
<Note data-dtb-part="build-note">Read from `import.meta.env` at build time.</Note>
<Action data-dtb-part="build-action" onClick={reload}>Reload</Action>Rows then, once the panel has more than a sentence to say. It is the two-column key/value <dl> grid the first-party panels print their readouts into; Row is one <dt>/<dd> pair:
<Rows data-dtb-part="build-rows">
<Row label="Commit">{commit}</Row>
<Row label="Built">{builtAt}</Row>
</Rows>SearchField last, when the list is long enough that scrolling it stops working. It pairs with the kit's matchesQuery(haystack, query), and the state stays yours:
const [query, setQuery] = useState("");
const visible = rows.filter((row) => matchesQuery([row.label, row.value], query));
…
<SearchField label="Search build facts" value={query} onChange={setQuery} />Two more things belong in the same step. useExtensionSurface(store, injectStyles, ensureStyles, styleNonce) is how a slot stays cheap — the slot returns a component, the component subscribes to your store and ensures your stylesheet. And if you ship CSS, createStyleInjector plus ensureKitStyles is the pair that delivers the shared sheet alongside your own. The full reference, including what deliberately is not in the kit, is docs/kit.md; examples/playground/src/kitDemo.tsx is one extension built entirely from it, end to end.
For subtree-scoped tools, register from inside the tree instead:
const { register } = useDevToolbar();
useEffect(() => register(routeTool), [register]);There is no global registry — it would break SSR, break two toolbars on one page, and leak between tests.
A slot that throws degrades to an error chip. The bar and every other extension keep working. In the compact and panel slots the chip is itself a retry button, so a slot that threw on transient state can be brought back without reloading. The optional onExtensionError callback receives the normalized error and slot metadata while the local failure is still logged to the console. docs/architecture.md.
Contract v2 — commands with input and a result
CONTRACT_VERSION is 2. A command may now say what it takes and hand back what it produced:
interface ToolbarCommand<In = void, Out = void> {
id: string;
label: string;
description?: string; // prose for a reader deciding whether to call it
group?: string;
keywords?: string[];
shortcut?: string; // hint; bound only with bindCommandShortcuts
input?: CommandInputSchema; // absent = takes nothing
run(input: In): Out | Promise<Out>;
}If you wrote an extension against v1, you have nothing to do. In and Out both default to void, so a run(): void you already wrote still satisfies run(input: void): void, and a roster typed readonly ToolbarCommand[] still type-checks. Declaring contractVersion: 1 remains legal — core warns once in the console and changes nothing else, exactly as ADR-003 describes. Bump the number when you start using input, description or a return value; leave it alone otherwise, or drop the field.
One exception, and it is a compile error rather than a surprise at runtime.ExtensionRuntimeApi gained a required invokeCommand. Core is what provides that object, so writing an extension is unaffected — but if you construct one, which in practice means a hand-rolled fake api in your tests, it will not type-check until you add the method. It is required rather than optional on purpose: an optional method every caller has to guard is a weaker contract than one core guarantees. The one-line stub is:
invokeCommand: async () => ({ ok: false, reason: "unknown-command" }) as const,fakeExtensionApi() from /testing is the answer that survives the next widening too — reach for it rather than writing the object out by hand.
This is the only part of v2 that is not purely additive in source terms.
The one thing worth knowing: to give a command a typed In, declare the generic explicitly. Inside a ToolbarCommand[] literal, contextual typing infers In as void:
const setFlag: ToolbarCommand<{ key: string; value?: FlagValue }> = {
id: "flags.set",
label: "Set a feature flag override",
input: {
fields: {
key: { type: "string", required: true },
value: { type: ["boolean", "string", "number"] },
},
},
run: ({ key, value }) => runtime.applyOverride(key, value),
};CommandInputSchema is deliberately small
It is not JSON Schema and not Zod. Zero runtime dependencies is a rule here, and every shape a toolbar command has actually needed is a flat bag of boolean | string | number | enum:
interface CommandInputSchema {
fields: Readonly<Record<string, CommandInputField>>;
}
type CommandInputField =
| { type: CommandInputType | readonly CommandInputType[]; ...common } // boolean|string|number
| { type: "enum"; values: readonly CommandInputValue[]; ...common };
// ...common: description?, required?, default?No nesting, no arrays, no anyOf/$ref, no minimum/pattern, and no validator. The schema is a description for a reader — a palette deciding whether it can render a form, an agent deciding what to pass. run() is the only thing that knows what its own input means, so run() is what refuses bad input, by throwing a message that says why. /ext/flags' flags.set refuses a value of the wrong type rather than coercing it, which is the same rule its panel editor already followed.
The array form of type is there because polymorphic values are real: flags.set's value is whatever type the named flag has, and a schema that could not say so would be a lie the first time it was used.
Running one, and reading the result
run() may return a value. runCommand still resolves a boolean — it is published, and widening it would make every if (await runCommand(id)) pass silently — so the result comes back through invokeCommand:
const outcome = await api.invokeCommand("diagnostics.capture");
// { ok: true, result: DiagnosticSnapshot } — the snapshot it just capturedAvailable as useDevToolbar().invokeCommand(id, input?) in the host, api.invokeCommand in an extension, invokeCommand(id, { input, scope }) as a bare export, and toolbar.invokeCommand() in @nejcm/dev-toolbar/testing. All of them reject with the command's own error if run() throws — only /ext/agent turns that into a value, because a rejection crossing page.evaluate arrives as a bare string.
What a palette does with input
/ext/command-menu skips every command that declares input. It has no form to render one with, and a row that cannot be run — or one that runs with undefined and throws — would both be worse than not listing it. Those commands stay fully reachable through getCommands(), invokeCommand() and /ext/agent. A form in the palette is a later change, not a missing piece of this one.