Appearance
Authoring Extensions
Hapimatic ships most of its optional surface — notification channels, agent backends, the scheduler, tool-view renderers, diagnostic pages — as extensions: self-contained packages that plug into the core through a small, typed contract. This guide is the authoritative reference for writing one.
v0.1 is pre-stable
The extension API is at version 0.1.0 (PLUGIN_API_VERSION). Host interfaces may gain contribution points additively without an API-version bump, and a few slot surfaces are declared but not yet consumed (each is flagged below). Pin your manifest's apiVersion to PLUGIN_API_VERSION and expect additive growth, not breaking churn. See API Stability & Deprecation for the authoritative stable/experimental classification, versioning semantics, and deprecation policy.
The composition model
Hapimatic uses build-time composition (not runtime plugin loading). An extension is a directory in the repo that a codegen step wires into the build. Nothing is downloaded or dynamically discovered at runtime — the set of extensions is fixed when the binary is built.
extensions.config.ts root registry — the list of enabled extension dirs
│ bun run gen:extensions
▼
scripts/generate-extension-registry.ts
│ emits one file per package (manifests only)
▼
server/src/extensions.generated.ts
web/src/extensions.generated.ts ← *.generated.* — committed, prettier/eslint-ignored
cli/src/extensions.generated.ts
│ imported by
▼
<pkg>/src/ext/loader.ts per-runtime loader
│ for each ENABLED extension: await manifest.entryPoints.<runtime>()
▼
your extension's register(host) ← you write thisThe pieces:
extensions.config.ts(repo root) — an append-only array of directory paths, each pointing at an extension that exports amanifest.scripts/generate-extension-registry.ts(bun run gen:extensions) — reads that config, imports eachmanifest.ts, and emits one<pkg>/src/extensions.generated.tsper runtime carrying only the manifests that declare an entry point for that runtime. Output is deterministic and committed (CI runs on a fresh checkout with no codegen step). It is also wired intobuild:single-exe(beforebuild:web), so a normal build always regenerates it.- The per-package loader (
server/src/ext/loader.ts,web/src/ext/loader.ts,cli/src/ext/loader.ts) — iterates the generated registry, decides which extensions are enabled, resolves each one'sregisterfunction lazily, and calls it with the appropriate host object.
Enable model (differs per runtime)
Whether an extension actually loads depends on the runtime:
| Runtime | Enable source | Notes |
|---|---|---|
| Server | extension_prefs SQLite table (get(id, defaultEnabled)) | An explicit enabled = 0 row survives and is not masked by defaultEnabled. |
| Web | GET /api/extension-prefs → Map<id, boolean> | Absent id falls back to defaultEnabled; a failed fetch → empty map → every extension uses its default. Each web entry is a dynamic import(), so Vite code-splits every extension into its own chunk — a disabled extension's chunk is never fetched. |
| CLI | extension_prefs via direct SQLite (cli/src/ext/prefs.ts) — requiredAtBuild || get(id, defaultEnabled) | As of #1290 the CLI reads the SOLE extension_prefs store (fail-soft: a missing DB → defaultEnabled), so a hapi ext disabled extension's registration is withheld. Manage enablement with hapi ext enable/disable <id> (takes effect on restart). |
To CHANGE enablement (as opposed to reading it), two surfaces write the same extension_prefs table:
| Surface | How |
|---|---|
| Admin → Extensions (web, #2329) | /admin/extensions lists every extension in the build — read via GET /api/extension-registry, toggled via PUT /api/extension-prefs/:id. Both are authenticated; the pre-auth GET /api/extension-prefs above stays read-only. |
| CLI | hapi ext enable/disable <id> |
requiredAtBuild: true short-circuits every enable check — the extension always loads. Use it for surfaces core depends on (e.g. the always-present /error-logs route). Because the flag short-circuits, a persisted enabled: false row for such an extension is inert: PUT /api/extension-prefs/:id rejects that write with 409 instead of storing misleading state, and the Admin page renders these extensions as non-toggleable rather than as a control that silently no-ops.
Fail-soft loading
Every loader wraps each extension's register (and its dynamic import) in a try/catch. A register that throws is logged and skipped — its partial contributions are dropped and the remaining extensions still load. A broken extension never blocks boot. Design accordingly: contribute atomically, and do not rely on a partially-applied registration.
The manifest
Every extension exports a manifest — a HapiExtensionManifest — from a manifest.ts file. It is a pure type-level contract: static metadata plus the async factories that resolve each runtime's register function.
ts
// server/src/extensions/my-extension/manifest.ts
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { PLUGIN_API_VERSION } from "@hapi/protocol/ext";
export const manifest: HapiExtensionManifest = {
id: "acme.my-extension",
version: "0.1.0",
apiVersion: PLUGIN_API_VERSION,
displayName: "My Extension",
description: "A one-line summary shown in settings.",
defaultEnabled: true,
entryPoints: {
server: async () => import("./server"),
},
};Import specifier
The extension API is published from the shared package as @hapi/protocol/ext. Shared metadata constants (for multi-runtime extensions) live at @hapi/protocol/extensions/<name>/manifestShared. Everything you import as an extension author comes from @hapi/protocol/ext.
Manifest schema
Every field of HapiExtensionManifest:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | ✅ | — | Globally unique id. Built-ins use builtin.<name>; third-party extensions should namespace (e.g. acme.todo). Sanitized ([^a-zA-Z0-9] → _, lowercased) to derive table prefixes and log tags, so acme.todo → ext_acme_todo_… tables and [ext:acme.todo] log lines. |
version | string | ✅ | — | The extension's own semantic version, independent of apiVersion. |
apiVersion | string | ✅ | — | The plugin API version the extension targets. Set to the imported PLUGIN_API_VERSION constant. |
displayName | string | ✅ | — | Human-readable name for settings surfaces. |
description | string | ✅ | — | Short description of what the extension does. |
defaultEnabled | boolean | ✅ | — | Whether the extension is enabled when no extension_prefs row exists. |
dependsOn | readonly string[] | ⬜ | — | Ids this extension depends on. Declared but not yet enforced in v0.1 — reserved for a future resolver. |
conflictsWith | readonly string[] | ⬜ | — | Ids this extension conflicts with. Declared but not yet enforced in v0.1. |
requiredAtBuild | boolean | ⬜ | false | When true, bypasses the enable check on every runtime — the extension always loads. Use for surfaces core depends on. |
configSchema | z.ZodTypeAny | ⬜ | — | A Zod schema validating this extension's own config blob. When present, host.config.get() validates against it. See Configuration validation. |
entryPoints | { server?, web?, cli? } | ✅ | — | Async factories, one per runtime the extension targets. Each returns a module exporting register. See below. |
Entry points
entryPoints maps each runtime to an async factory that returns a module with a register export:
ts
entryPoints: {
server?: () => Promise<{ register: ServerRegister }>;
web?: () => Promise<{ register: WebRegister }>;
cli?: () => Promise<{ register: CliRegister }>;
}In practice each factory is a dynamic import:
ts
entryPoints: {
server: async () => import("./server"),
web: async () => import("./web"),
}Declaring a factory is what makes the extension appear in that runtime's generated registry — the codegen filters entries by manifest.entryPoints[runtime]. An extension may declare any subset of the three runtimes. Multi-runtime extensions typically use two directories that share one id — see the multi-runtime worked example.
The register() function
Each entry-point module exports a register function. It receives one argument — the host for that runtime — and returns void or a Promise. That is the entire signature; there is no second "dependencies" parameter (core services are reached through the host, see Dependency injection).
ts
import type {
ServerRegister,
WebRegister,
CliRegister,
} from "@hapi/protocol/ext";
// Each is (host) => void | Promise<void>:
type ServerRegister = (host: ServerExtensionHost) => Promise<void> | void;
type WebRegister = (host: WebExtensionHost) => Promise<void> | void;
type CliRegister = (host: CliExtensionHost) => Promise<void> | void;Server — contribute a notification channel, a route, tables, a service handle:
ts
// server/src/extensions/my-extension/server.ts
import type { ServerRegister } from "@hapi/protocol/ext";
export const register: ServerRegister = async host => {
host.routes.add(() => createMyRoutes(host.getErrorLogger()));
host.lifecycle.onStartup(async () => {
// Long-running work starts here, not in register() — see the note below.
});
};Web — contribute tool-view components:
tsx
// web/src/extensions/my-extension/web.tsx
import type { WebRegister } from "@hapi/protocol/ext";
import { MyResultView } from "./views/MyResultView";
export const register: WebRegister = async host => {
host.toolViews.addResult("MyTool", MyResultView);
};CLI — contribute a command:
ts
// cli/src/extensions/my-extension/cli.ts
import type { CliRegister } from "@hapi/protocol/ext";
import { myCommand } from "./myCommand";
export const register: CliRegister = host => {
host.commands.add(myCommand);
};register() runs early — defer long-running work
On the server, register() runs before core services (SyncEngine, NotificationHub, …) exist. The late-bound core accessors (host.getSyncEngine(), etc.) may return null during register(). Register your contributions synchronously, but start any long-running work (a poller, a bot, a socket) in host.lifecycle.onStartup(), which the loader drains after core services are constructed.
Contribution-point catalogue
The host object your register receives exposes the contribution points for its runtime. Below is every method on each host, its signature, and a usage example. Where a v0.1 surface is declared but not yet consumed, it is flagged explicitly.
Server host — ServerExtensionHost
notifications.addChannel(channel): void
Register a NotificationChannel (a { sendReady, sendPermissionRequest, sendQuestion?, sendError? } object). The channel is accumulated and merged into the NotificationHub by the loader.
ts
host.notifications.addChannel({
sendReady: async session => {
/* notify that `session` is ready */
},
sendPermissionRequest: async session => {
/* notify a permission prompt */
},
});agents.register(id, factory): void
Register an agent-backend factory under id. Throws if id is already registered.
ts
host.agents.register("my-agent", () => buildMyBackend());routes.add(factory): void
Register a Hono route factory. The loader collects every factory and mounts it under /api after the JWT auth middleware. Wrap the factory in an outer () => thunk so its dependencies are resolved lazily at mount time (post-boot), not at register() time:
ts
host.routes.add(() =>
createMyRoutes(() => host.getSyncEngine(), {
errorLogger: host.getErrorLogger(),
})
);capabilities.add(entry): void — server host (#3752)
Contribute a capability-catalog entry — the machine-readable "what this harness can do" record surfaced by GET /api/capabilities/catalog, the hapi capabilities tools, and the /settings/capabilities panel. Gated on the catalog.contribute privilege (default-deny). The facade validates the entry against the catalog schema (rejecting </>/markdown-link text, a bad id prefix, or a missing availability.scope), stamps owner = <your manifest id> (you cannot spoof another owner), and the loader stamps trusted from provenance (a runtime filesystem extension is forced trusted: false).
ts
host.capabilities.add({
id: "acme.export-report",
domain: "telemetry",
surface: "rest",
description: "Export the acme telemetry report",
whenToUse: "When an agent needs the acme telemetry export",
invocation: { kind: "rest", method: "GET", path: "/api/ext/acme/report" },
auth: "jwt",
riskTier: "readonly",
availability: { scope: "user" },
});This is the server host only — the CLI and web hosts are stateless/singleton with no live-catalog assembler to reach. A filesystem extension contributes the same entries declaratively via the manifest.json top-level catalog: [...] key (see Directory + serializable manifest.json), never through code.
store.addTable(spec): void
Queue a CREATE TABLE IF NOT EXISTS ext_<sanitizedId>_<tableName> (<schema>). spec.tableName must match /^[a-zA-Z][a-zA-Z0-9_]*$/ (no quotes, spaces, or leading digit) or the call throws. The table is created when the loader runs extension migrations (after core migrations). See Plugin-owned tables.
ts
host.store.addTable({
tableName: "notes",
schema: "id TEXT PRIMARY KEY, body TEXT NOT NULL",
});
// → CREATE TABLE IF NOT EXISTS ext_acme_todo_notes (...)store.addMigration(migration): void
Register a versioned ExtensionMigration ({ version, up }). The host runs each migration's up(db) in registration order when the loader calls Store.runExtensionMigrations().
ts
host.store.addMigration({
version: 1,
up: db => {
/* db is a bun:sqlite Database (typed `unknown`; narrow it) */
},
});events.subscribe(listener) => () => void and events.emit(event): void
A server-internal event bus, typed on ExtensionEvent ({ type, payload? }). This is deliberately distinct from sync events — extension events never reach the SSE wire protocol. subscribe returns an unsubscribe function. Each listener runs in its own try/catch, so one bad subscriber cannot abort an emit.
ts
const unsubscribe = host.events.subscribe(event => {
if (event.type === "acme.something") {
/* … */
}
});
host.events.emit({ type: "acme.something", payload: { count: 1 } });config.get<T>(): T
Read this extension's own config from its extension_prefs row. When the manifest declares a configSchema, the returned value is validated and typed; an invalid row throws. See Configuration validation.
ts
const cfg = host.config.get<InferExtensionConfig<typeof manifest>>();Extension config vs. core config
host.config.get() reads the extension's own prefs row — not core settings. To read core configuration (e.g. telegramBotToken), use the host.getCoreConfig() accessor below.
lifecycle.onStartup(cb): void and lifecycle.onShutdown(cb): void
Register callbacks the loader drains once. onStartup runs after all extensions' register() calls complete and core services are constructed; onShutdown runs during graceful shutdown. Neither returns an unsubscribe handle (they are drained once). Start long-running work here.
ts
host.lifecycle.onStartup(async () => {
await myPoller.start();
});
host.lifecycle.onShutdown(async () => {
await myPoller.stop();
});services.register(id, handle): void and services.get<T>(id): T | null
A cross-extension handle registry. Publish a service (typically from inside onStartup) so another extension — or core — can resolve it by id. Resolution is lazy: get searches every successfully-registered host at call time, so a handle published in onStartup is resolvable even though the loader has already returned. register throws on a duplicate id; get returns null for an unknown id.
ts
host.lifecycle.onStartup(() => {
host.services.register("acme.scheduler", schedulerHandle);
});
// elsewhere:
const scheduler = host.services.get<SchedulerHandle>("acme.scheduler");log: ExtensionLogger
A structured logger scoped to your extension id (log, error, warn, info, debug, each (message, context?) => void). Resolved lazily as a child of the core ErrorLogger.
ts
host.log.info("started", { channels: 2 });
host.log.error("failed to reach upstream", { url });Late-bound core accessors
Escape hatches to core services for extensions that need them. Every accessor is resolved at call time and may return null before boot completes — read them lazily (inside a route factory or onStartup), never captured once at register() time. They return unknown (or a narrower type where noted); narrow at the call site.
| Accessor | Returns | Purpose |
|---|---|---|
getSyncEngine() | unknown | The core SyncEngine. |
getPushService() | unknown | Web-push service. |
getNotificationHub() | unknown | The NotificationHub. |
getSystemEventQueue() | unknown | The system event queue. |
getRpcRegistry() | unknown | Federation RPC registry. |
getNodeRegistry() | unknown | Federation node registry. |
getFederationApiToken() | string | null | Federation API token. |
getJobRegistry() | unknown | Process-wide JobRegistry (register a SupervisedLoop to appear in the System Jobs portal). |
getJobNodeId() | string | Federation nodeId for grouping supervised-loop rows ("standalone" when unset). |
getRawDb() | unknown | Raw bun:sqlite Database for extension store classes. |
getCoreConfig() | unknown | The core Configuration instance (for core settings like telegramBotToken). |
getStore() | unknown | The core Store. |
getErrorLogger() | unknown | The core ErrorLogger. |
Web host — WebExtensionHost
The web host is a React-context facade. Its consumption maturity varies by slot in v0.1 — read the status flags carefully.
toolViews.addCompact(toolName, view), addFullInput(toolName, view), addResult(toolName, view) — ✅ consumed
Register the component that renders a given tool's compact (inline) view, full (dialog) input view, or result view in the chat thread. view is a React component receiving ToolViewProps ({ block, metadata }); pass a component reference or a React.lazy() result. These are read back by ToolCard, so they take effect immediately.
tsx
import type { WebRegister } from "@hapi/protocol/ext";
import { MyResultView } from "./views/MyResultView";
export const register: WebRegister = async host => {
host.toolViews.addResult("MyTool", MyResultView);
host.toolViews.addCompact("MyTool", MyCompactView);
};sessionDetail.addTab(spec), addInfoField(spec), addAction(spec) — ⚠️ partial
Contribute to the session-detail view: a tab ({ id, label, order?, render }), an info field (same shape), or an action button ({ id, label, order?, onClick }). Specs are rendered by the slot components in web/src/ext/slots.tsx (SessionDetailTabs, SessionDetailInfoFields, SessionDetailActions) in deterministic order (ascending order, ties by id). Tabs use minimal chrome and remount on switch (key={activeId}), so per-tab state does not bleed across tabs.
Which of the three are actually mounted (the "partial" in this heading):
| Slot | Mounted? | Consumer |
|---|---|---|
addTab | ✅ yes | SessionChat.tsx renders <SessionDetailTabs /> |
addInfoField | ✅ yes (since #1285) | SessionChat.tsx renders <SessionDetailInfoFields /> |
addAction | ⛔ no | SessionDetailActions exists but nothing mounts it yet |
Each mount is gated on its slot being non-empty, so a surface with zero contributions renders no DOM at all. addInfoField shipped unmounted in #1267 and stayed dead until two issues needed it in the same week (#1284, #1285) — which is the general lesson here: a renderer existing in slots.tsx does not imply a consumer renders it. Verify the mount before relying on a slot.
Worked examples for the two mounted slots:
builtin.token-usage(web/src/extensions/token-usage/) — the canonical info-field demo: a compact token indicator, web-only, reading core data.builtin.session-notes(web/src/extensions/session-notes/) — the full server+web pattern: a lazy-loaded tab plus a count badge in the info-field row, backed by the extension's own table and REST routes.
Zero-prop render contract.
slots.tsxnarrowsrenderto a zero-propComponentType— the host renders<Render />and passes nothing. A slot component that needs the current session must source it itself; the built-in example usesuseParams({ from: "/sessions/$sessionId" }), the same callSessionPagemakes. That couples the component to the router's route shape, which fails at typecheck (not silently at runtime) if the path ever changes.
tsx
host.sessionDetail.addTab({
id: "acme.notes",
label: "Notes",
order: 10,
render: MyNotesTab, // component or React.lazy(...)
});preferences.addSection(spec) — ⚠️ declared, renderer not yet mounted
Contribute a settings section ({ id, label, order?, render }). The PreferencesSections renderer exists but ships unmounted in v0.1; a future release wires it into the preferences drawer. Register the section now, but do not expect it to appear until that consumer lands.
navigation.addRoute(spec) — ⛔ declared but unconsumed in v0.1
Store a navigation-route spec ({ id, label, order?, path, render, validateSearch? }). In v0.1 nothing reads these: slots.tsx:NavigationRoutes() is a symbolic no-op and router.tsx builds its route tree statically at module-eval (TanStack Router infers valid paths from a static tree). Every built-in web extension that "owns" a route therefore keeps a static import in router.tsx and its register is a deliberate no-op — see web/src/extensions/error-log-viewer/web.tsx and web/src/extensions/git-rpc/web.tsx. Do not use navigation.addRoute to add a working route in v0.1; contribute the route the way the pilots do (a static route module imported by router.tsx) until a getNavigationRoutes() consumer is wired in.
CLI host — CliExtensionHost
commands.add(definition): void
Contribute a CLI command. definition is a CommandDefinition ({ name, requiresRuntimeAssets, run }). Throws on a duplicate name or the reserved implicit-default name "claude". After registration, hapi <name> dispatches to your command.
ts
host.commands.add({
name: "hello",
requiresRuntimeAssets: false,
run: async ({ commandArgs }) => {
console.log("Hello from my extension!", commandArgs);
},
});The run receives a CommandContext ({ args, subcommand?, commandArgs }). Set requiresRuntimeAssets: false for a command that does not need the compiled runtime (embedded web assets / session graph); true for commands that start a session or server.
agents.register(id, factory): void
Register an agent-backend factory (passthrough to the core AgentRegistry).
ts
host.agents.register("my-agent", buildMyBackendFactory());lifecycle.onSessionStart(cb) => () => void and onSessionEnd(cb) => () => void
Subscribe to CLI session lifecycle. The callback receives the Session record and returns void or a Promise; each returns an unsubscribe function.
ts
const off = host.lifecycle.onSessionStart(session => {
host /* … */;
});CLI lifecycle limitations (v0.1)
Lifecycle events fire only for sessions built on the Claude and Codex paths (AgentSessionBase + createRunnerLifecycle). The generic agent runner (used by Gemini / ACP / OpenCode) does not route through these seams and emits nothing. Handlers run in subscription order, async handlers are not awaited (own your async lifetime), and a handler that throws is contained and logged — it never aborts the session it reports on.
Worked example: Hello World
This walkthrough builds a minimal CLI extension — hapi hello — from scratch. It touches every step of the composition model, and you can run the result without consulting source code.
Create the extension directory and manifest
Extensions live under <pkg>/src/extensions/<name>/. A CLI extension goes in cli/src/extensions/. Create cli/src/extensions/hello/manifest.ts:
ts
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { PLUGIN_API_VERSION } from "@hapi/protocol/ext";
export const manifest: HapiExtensionManifest = {
id: "acme.hello",
version: "0.1.0",
apiVersion: PLUGIN_API_VERSION,
displayName: "Hello",
description: "Prints a friendly greeting from the CLI.",
defaultEnabled: true,
entryPoints: {
cli: async () => import("./cli"),
},
};Implement register
Create cli/src/extensions/hello/cli.ts. The module the entry-point factory resolves must export a register function:
ts
import type { CliRegister } from "@hapi/protocol/ext";
export const register: CliRegister = host => {
host.commands.add({
name: "hello",
requiresRuntimeAssets: false,
run: async ({ commandArgs }) => {
const who = commandArgs[0] ?? "world";
console.log(`Hello, ${who}!`);
},
});
};Register the extension in extensions.config.ts
Add your directory path to the root extensions.config.ts array. Append only — never reorder or rewrite existing entries (the file is a high-contention hotspot; rebase before editing):
ts
export const enabledExtensions: readonly string[] = [
// … existing entries …
"cli/src/extensions/hello",
];Regenerate the registry and build
Regenerate the committed registry, then build. build:single-exe also runs gen:extensions for you, so a full build is enough — but running the codegen alone is a fast way to confirm your manifest loads:
bash
bun run gen:extensions # emits <pkg>/src/extensions.generated.ts
bun run build:single-exe # full binary (runs gen:extensions + build:web + …)If your manifest is malformed (missing manifest export or a non-string id), gen:extensions fails loudly here.
Run it
Because the CLI enable model uses manifest defaults only and you set defaultEnabled: true, the command is live immediately:
bash
hapi hello # → Hello, world!
hapi hello Hapimatic # → Hello, Hapimatic!That is a complete, functioning extension: a manifest, a register that calls a contribution point, an extensions.config.ts entry, and a rebuild.
Worked example: a multi-runtime extension
An extension can contribute to more than one runtime. The convention — used by builtin.scheduler, builtin.heartbeat, and builtin.git-rpc — is two directories that share one id: one per runtime, each declaring only its own entry point. The codegen filters by manifest.entryPoints[runtime], so the server directory lands only in the server registry and the web directory only in the web registry — the web bundle never imports server code, and the shared id is not a duplicate registration.
Because both halves share one id, they also share a single extension_prefs row: enabling or disabling the extension toggles both halves together.
This example, acme.greeter, adds a server API route and a web tool-view.
1. Shared metadata. So the two manifests cannot drift on id / version / defaultEnabled, put the static fields in the shared package and spread them. Create shared/src/extensions/greeter/manifestShared.ts:
ts
export const GREETER_EXT_META = {
id: "acme.greeter",
version: "0.1.0",
apiVersion: "0.1.0",
displayName: "Greeter",
description: "A server route and a web tool-view, one extension.",
defaultEnabled: true,
} as const;Add an export entry for it in shared/package.json (mirroring the existing ./extensions/*/manifestShared entries):
json
"./extensions/greeter/manifestShared": "./src/extensions/greeter/manifestShared.ts"2. Server half. server/src/extensions/greeter/manifest.ts:
ts
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { GREETER_EXT_META } from "@hapi/protocol/extensions/greeter/manifestShared";
export const manifest: HapiExtensionManifest = {
...GREETER_EXT_META,
entryPoints: {
server: async () => import("./server"),
},
};server/src/extensions/greeter/server.ts:
ts
import type { ServerRegister } from "@hapi/protocol/ext";
import { createGreeterRoutes } from "./routes";
export const register: ServerRegister = async host => {
// Wrap in an outer thunk so core accessors resolve at mount time (post-boot).
host.routes.add(() => createGreeterRoutes(host.getErrorLogger()));
};3. Web half. web/src/extensions/greeter/manifest.ts:
ts
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { GREETER_EXT_META } from "@hapi/protocol/extensions/greeter/manifestShared";
export const manifest: HapiExtensionManifest = {
...GREETER_EXT_META,
entryPoints: {
web: async () => import("./web"),
},
};web/src/extensions/greeter/web.tsx:
tsx
import type { WebRegister } from "@hapi/protocol/ext";
import { GreeterResultView } from "./views/GreeterResultView";
export const register: WebRegister = async host => {
host.toolViews.addResult("Greet", GreeterResultView);
};4. Register both halves in extensions.config.ts (append-only):
ts
export const enabledExtensions: readonly string[] = [
// … existing entries …
"server/src/extensions/greeter",
"web/src/extensions/greeter",
];Regenerate and build (bun run gen:extensions && bun run build:single-exe). The server route mounts under /api after auth, and the web tool-view renders in the chat thread — from one logical extension that enables and disables as a unit.
Why not navigation.addRoute for the web half?
This example uses toolViews.addResult because it is a consumed web surface in v0.1. If your web contribution is a full page/route, follow the pilots (error-log-viewer, git-rpc): keep the route module under web/src/extensions/<name>/routes/… and static-import it from router.tsx, leaving the web register a no-op — navigation.addRoute is not wired into the router yet (see the catalogue).
Filesystem extensions (Pattern 2, opt-in — HAPI_FS_EXTENSIONS)
Everything above describes Pattern 1 (build-time composition): a TypeScript extension under <package>/src/extensions/<name>/, registered in extensions.config.ts and bundled into the binary by codegen. Pattern 2 (runtime loading, issue #1289) lets you drop an extension onto disk and load it without a rebuild. It ships gated OFF — set HAPI_FS_EXTENSIONS=1 (truthy true/1/yes) to enable it. It is server + CLI only (the browser has no filesystem; web runtime loading is #1294).
⚠️ A filesystem extension runs in-process at host privilege — there is no OS/VM isolation in v0.1 (see ADR-0003). Its host-API reach is bounded by the capability model below (default-deny), but a granted capability is real access — only enable
HAPI_FS_EXTENSIONSfor extensions whose declared capabilities you have reviewed.
Security model — capabilities (#1291, ADR-0003)
Every privileged host-facade call is gated against the capabilities the extension declares in its manifest. Build-composed builtin.* extensions (compiled into the binary) are trusted and bypass the gate; runtime filesystem extensions are default-deny gated — an undeclared privileged call throws CapabilityDeniedError, and the loader logs and skips that extension. Ambient surfaces (config.get on your own prefs, the extension event bus, log, the services registry, lifecycle hooks) are never gated. Declare what you need in the manifest's capabilities array:
json
{ "id": "acme.demo", "...": "...", "capabilities": ["store", "ui.toolViews"] }| Capability | Grants (host method) |
|---|---|
notifications | notifications.addChannel |
agents | agents.register (server + cli) |
http.routes | routes.add — mounts authenticated /api routes |
store | store.addTable / addMigration (auto ext_<id>_-prefixed) |
store.raw | getRawDb() — raw SQLite, bypasses the table prefix (high-risk) |
secrets.federationToken | getFederationApiToken() — a live secret (high-risk) |
core.config | getCoreConfig() — core settings incl. secrets (high-risk) |
core.internals | getSyncEngine / getStore / getRpcRegistry / … core services |
catalog.contribute | capabilities.add (server) — contribute a capability-catalog entry (#3752) |
cli.commands | commands.add |
harness | harness.register (cli) — contributes a catalog CLI harness (install recipe + capability manifest + driver binding, #3120) |
ui.toolViews · ui.sessionDetail · ui.preferences · ui.navigation | the matching web contribution points |
The canonical taxonomy + rationale is in shared/src/ext/capabilities.ts and ADR-0003.
Directory + serializable manifest.json
Place each extension in its own directory under ${HAPI_HOME}/extensions/:
- prod →
~/.hapimatic-private/extensions/<name>/ - dev →
~/.hapimatic-dev/extensions/<name>/ - worktree →
~/.hapimatic-wt-<port>/extensions/<name>/ - bare CLI default →
~/.hapi/extensions/<name>/
A build-time manifest.ts declares entryPoints.* as import() thunks — which cannot be serialized to JSON. So a filesystem extension ships a manifest.json whose entry points are relative path strings and which has NO configSchema:
json
{
"id": "publisher.my-extension",
"version": "1.0.0",
"apiVersion": "0.1.0",
"displayName": "My Extension",
"description": "Loaded from the filesystem at runtime",
"defaultEnabled": true,
"entryPoints": { "server": "./server.js", "cli": "./cli.js" }
}apiVersion must be compatible with the host's PLUGIN_API_VERSION (same major; minor <= the host's). Entry points may be .js or .ts (the Bun runtime transpiles .ts at import). Each referenced module exports a register(host) — the contract is IDENTICAL to a build-time extension; only the discovery mechanism differs. manifest.json intentionally has no per-extension config validation (a Zod schema is not JSON-serializable); per-extension config for filesystem extensions is a documented follow-up. A filesystem extension must be self-contained — a Bun compiled binary does not reliably resolve an extension's own node_modules (Bun #15374/#8967).
Two optional keys carry capability information (#1291, #3752):
capabilities— a string array of the privileges the extension requires (the capability model above; default-deny for filesystem extensions). e.g."capabilities": ["store", "catalog.contribute"].catalog— a distinct top-level array of capability-catalog ENTRIES the extension contributes declaratively (the JSON analog of the server host'scapabilities.add). Each entry is validated against the catalog schema at load — a</>/markdown-link in any text field, a bad id prefix, or a missingavailability.scopefails the WHOLE extension's load. Merged into the live catalog withownerset to the extension id andtrusted: false(runtime-loaded), and reported byhapi capabilities auditasruntime-only, never as drift. Keep catalog entries undercatalog, NEVER nested undercapabilities(that key is the privilege array, and an object there silently denies every privilege).
json
{
"id": "publisher.my-extension",
"capabilities": ["catalog.contribute"],
"catalog": [
{
"id": "telemetry.my-export",
"domain": "telemetry",
"surface": "rest",
"description": "Export my telemetry",
"whenToUse": "When an agent needs my export",
"invocation": { "kind": "rest", "method": "GET", "path": "/api/ext/mine" },
"auth": "jwt",
"riskTier": "readonly",
"availability": { "scope": "user" }
}
]
}Loading semantics
- Discovery: at host startup,
scanFilesystemExtensions()reads every<dir>/manifest.json, validates it againstFsExtensionManifestSchema, and builds animport()thunk from each entry-point path. - Order (R6): builtins first (in
extensions.config.tsorder), then filesystem extensions sorted by id. An id (or its sanitized form) that collides with a builtin's or an earlier filesystem extension's is skipped with a warning (builtin/first wins). - Enablement: the SERVER honors
extension_prefs+defaultEnabled(as for builtins); the CLI honorsdefaultEnabledonly (locked §5g — no CLI prefs source yet; #1290 is the seam that will add one). - Removal (R5): delete the directory and restart — the extension is simply absent from the next scan.
- Fail-soft (R2): a missing / malformed / schema-invalid / apiVersion-incompatible manifest, or an id collision, skips that ONE extension with a logged error — it never crashes the host or blocks others.
Common patterns
Lazy loading
Lazy loading is structural, not something you hand-roll. Each entryPoints.<runtime> is an async () => import(...) factory, so:
- On the web, Vite code-splits every extension into its own chunk. A disabled extension's chunk is never fetched.
- On the server/CLI, the loader
awaits the factory only for enabled extensions — a disabled extension's module graph is never imported.
Keep heavy dependencies out of the module's top level. On the CLI especially, the entry-point module is imported on every hapi invocation (the loader awaits each enabled extension's entryPoints.cli()), so a top-level import of the auth / server / session graph regresses startup for every command. Keep top-level imports light and defer heavy work with an await import(...) inside run:
ts
run: async ({ commandArgs }) => {
// Heavy modules load only when the command actually runs.
const { runAgentSession } = await import("@/agent/runners/runAgentSession");
await runAgentSession({ /* … */ });
};Dependency injection
Extensions never import core modules directly — they reach core through the host. On the server, core services are late-bound getters resolved at call time:
ts
export const register: ServerRegister = async host => {
// ❌ Do NOT capture at register() time — the service may be null pre-boot.
// const engine = host.getSyncEngine();
// ✅ Resolve lazily, inside a route factory or onStartup.
host.routes.add(() => createRoutes(() => host.getSyncEngine()));
host.lifecycle.onStartup(async () => {
const engine = host.getSyncEngine(); // now non-null
await start(engine);
});
};For cross-extension dependencies, publish a handle with host.services.register (from onStartup) and resolve it with host.services.get — resolution is lazy, so ordering between extensions does not matter.
Error handling
The loaders are fail-soft: a register that throws is logged and skipped, and its partial contributions are dropped. Two consequences:
- Contribute atomically. Do all your
host.*calls up front; ifregisterthrows partway through, whatever it already registered is discarded, so never rely on a partially-applied registration. - Guard late-bound accessors. They can be
nullbefore boot. Check, and fail loudly with a clear, id-prefixed message if a required service is genuinely missing:
ts
const engine = host.getSyncEngine() as SyncEngine | null;
if (!engine) {
throw new Error("[acme.greeter] syncEngine unavailable at register()");
}Inside handlers you own (event listeners, lifecycle callbacks), the host already contains throws for you — an event listener that throws is caught so it cannot abort the emit loop, and a CLI lifecycle handler that throws is contained and logged. Still, log context so failures are diagnosable via host.log.
Configuration validation with Zod
An extension declares its config shape as a Zod schema on the manifest's configSchema. When present, host.config.get() validates the extension's extension_prefs row against it and returns the parsed, typed value — an invalid row throws with a clear, id-prefixed message. Wiring it up is two steps.
1. Declare configSchema on the manifest:
ts
import { z } from "zod";
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { PLUGIN_API_VERSION } from "@hapi/protocol/ext";
const configSchema = z.object({
pollIntervalMs: z.number().int().positive().default(60_000),
channel: z.string().min(1),
});
export const manifest: HapiExtensionManifest = {
id: "acme.poller",
version: "0.1.0",
apiVersion: PLUGIN_API_VERSION,
displayName: "Poller",
description: "Polls an upstream on an interval.",
defaultEnabled: false,
configSchema,
entryPoints: {
server: async () => import("./server"),
},
};2. Read validated config in register (or later):
ts
import type { ServerRegister, InferExtensionConfig } from "@hapi/protocol/ext";
import { manifest } from "./manifest";
export const register: ServerRegister = async host => {
// Validated against configSchema and typed as { pollIntervalMs; channel }.
const cfg = host.config.get<InferExtensionConfig<typeof manifest>>();
host.lifecycle.onStartup(() => startPolling(cfg.pollIntervalMs, cfg.channel));
};InferExtensionConfig<TManifest> extracts the parsed config type from the manifest's configSchema (or never when no schema is declared), so you get compile-time types for free.
Validating other blobs. For config that does not come from extension_prefs (a request body, a file), the shared helpers validate a value against any schema directly:
ts
import {
validateExtensionConfig, // throws a ZodError on failure
safeValidateExtensionConfig, // returns { success, data } | { success, error }
} from "@hapi/protocol/ext";
const parsed = validateExtensionConfig(configSchema, someInput);
const result = safeValidateExtensionConfig(configSchema, someInput);
if (!result.success) host.log.warn("bad config", { error: result.error.message });Where config comes from
host.config.get() reads the extension's own extension_prefs row. It is distinct from core configuration — for core settings (e.g. telegramBotToken), use host.getCoreConfig(). No built-in pilot ships a configSchema yet; the mechanism is fully implemented and ready for third-party use.
Plugin-owned tables
An extension can own SQLite tables. Every extension table is auto-prefixed with ext_<sanitizedId>_, where sanitizedId is the manifest id with non-alphanumeric characters replaced by _ and lowercased. An extension can never bypass its prefix, so two extensions cannot collide on a table name.
For example, builtin.scheduler → builtin_scheduler → its tables are ext_builtin_scheduler_tasks and ext_builtin_scheduler_task_executions.
Creating a table
The simplest path is store.addTable — you supply the body-only SQL (the columns between the parens) and the host emits the prefixed CREATE TABLE IF NOT EXISTS:
ts
export const register: ServerRegister = async host => {
host.store.addTable({
tableName: "notes", // → ext_acme_todo_notes
schema: `
id TEXT PRIMARY KEY,
body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
`,
});
};The tableName must match /^[a-zA-Z][a-zA-Z0-9_]*$/ — no quotes, spaces, or leading digit — or the call throws.
Always address tables by their prefixed name
Your query layer must use the same prefixed names the migration created. The pattern builtin.scheduler uses is a single source-of-truth constants module (tableNames.ts) imported by both the migration and the queries:
ts
// tableNames.ts
export const NOTES_TABLE = "ext_acme_todo_notes";Declaring one name in the migration and a different one in the queries yields an extension that migrates cleanly and then throws no such table on its first query.
Migrations
For anything beyond a fresh CREATE — renames, backfills, multi-step schema evolution — register a versioned ExtensionMigration with store.addMigration. The host runs each migration's up(db) in registration order when the loader calls Store.runExtensionMigrations(), which happens after all core migrations, so every core table exists by the time your up runs.
ts
import type { Database } from "bun:sqlite";
import type { ExtensionMigration } from "@hapi/protocol/ext";
const NOTES_TABLE = "ext_acme_todo_notes";
export const notesMigration: ExtensionMigration = {
version: 1,
// `up` receives the raw db typed as `unknown` (the shared contract keeps
// `shared/` free of a bun:sqlite dependency) — narrow it at the boundary.
up: (db: unknown) => {
const database = db as Database;
database.run(
`CREATE TABLE IF NOT EXISTS ${NOTES_TABLE} (
id TEXT PRIMARY KEY,
body TEXT NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0
)`
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_${NOTES_TABLE}_pinned
ON ${NOTES_TABLE}(pinned) WHERE pinned = 1`
);
},
};
// in register():
host.store.addMigration(notesMigration);Write migrations to be idempotent (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, existence checks before ALTER), because runExtensionMigrations() may run them on every boot. version is extension-authored metadata for your own auditing and idempotency logic — the Store runs migrations in registration order, not by version number. For a production example of a non-trivial, fully-idempotent extension migration (a table rename that preserves a foreign key), read server/src/extensions/scheduler/migration.ts.
Reference
Built-in extensions
Real extensions to read as examples. Paths are relative to the repo root.
| Extension | Runtime(s) | Surfaces used | Path |
|---|---|---|---|
builtin.notifications-telegram | server | notifications.addChannel, lifecycle.on*, getCoreConfig | server/src/extensions/notifications-telegram/ |
builtin.notifications-push | server | notifications.addChannel | server/src/extensions/notifications-push/ |
builtin.scheduler | server + web | routes.add, store.addMigration, services.register, lifecycle.on* | server/src/extensions/scheduler/, web/src/extensions/scheduler/ |
builtin.watchdog | server | routes.add, lifecycle.on* | server/src/extensions/watchdog/ |
builtin.git-rpc | server + web | routes.add (web half is a static-route no-op) | server/src/extensions/git-rpc/, web/src/extensions/git-rpc/ |
builtin.heartbeat | server + web | routes.add, services.register | server/src/extensions/heartbeat/, web/src/extensions/heartbeat/ |
builtin.github-issues | server | routes.add | server/src/extensions/github-issues/ |
builtin.tool-views | web | toolViews.add* | web/src/extensions/tool-views/ |
builtin.error-log-viewer | web | requiredAtBuild (static-route no-op) | web/src/extensions/error-log-viewer/ |
builtin.analytics | web | requiredAtBuild (static-route no-op) | web/src/extensions/analytics/ |
builtin.agent-gemini | cli | commands.add, agents.register | cli/src/extensions/agent-gemini/ |
builtin.agent-codex | cli | commands.add | cli/src/extensions/agent-codex/ |
Key source files
| File | What it defines |
|---|---|
shared/src/ext/types.ts | HapiExtensionManifest, the *Register types, config-validation helpers |
shared/src/ext/hosts.ts | The three host interfaces |
shared/src/ext/contributions.ts | Contribution-point argument shapes |
shared/src/ext/version.ts | PLUGIN_API_VERSION |
extensions.config.ts | The enabled-extension registry (append-only) |
scripts/generate-extension-registry.ts | Codegen (bun run gen:extensions) |
server/src/ext/host.ts, web/src/ext/host.tsx, cli/src/ext/host.ts | Host implementations |
server/src/ext/loader.ts, web/src/ext/loader.ts, cli/src/ext/loader.ts | Per-runtime loaders |
Commands
bash
bun run gen:extensions # regenerate the committed registry from extensions.config.ts
bun run build:single-exe # full build (runs gen:extensions automatically)The extension system is Pattern 1 (build-time composition) of the Extension System Architecture epic. The manifest's entryPoints async-factory shape is forward-compatible with a future Pattern 2 dynamic-import loader without an API-version bump.
Runtime web extensions (Pattern 2, #1294)
Pattern 2 loads extensions from the filesystem at runtime instead of composing them into the binary at build time. The server/CLI half (#1289) scans ${HAPI_HOME}/extensions/<id>/manifest.json and dynamic-imports each server entry from disk. The web half (#1294) is the browser complement: the browser can't read the filesystem, so the server serves each extension's built bundle over HTTP and the browser dynamic-imports it from a runtime URL.
Gated behind HAPI_FS_EXTENSIONS (default OFF). With the flag off the whole runtime path is inert — the discovery endpoint returns [] and /ext-assets/* 404s — so behavior is byte-identical to a build-time-only deployment. Filesystem loading executes arbitrary host-privilege code, so it stays opt-in until the sandboxing decision (#1291) lands.
The filesystem manifest (manifest.json)
A filesystem extension ships a serializable manifest.json (validated by FsExtensionManifestSchema in shared/src/ext/fsManifest.ts). It mirrors the build-time HapiExtensionManifest but expresses entryPoints.<runtime> as relative path strings (not import() thunks) and adds an optional styles array:
json
{
"id": "acme.dashboard",
"version": "1.0.0",
"apiVersion": "0.1.0",
"displayName": "Acme Dashboard",
"description": "A runtime-loaded web extension",
"defaultEnabled": true,
"entryPoints": { "web": "./web.js" },
"styles": ["./style.css"]
}The referenced files live under the extension's dist/: ${HAPI_HOME}/extensions/acme.dashboard/dist/{web.js, style.css}.
How the web half loads
- Discovery —
GET /api/ext-web-manifests(pre-auth) returns each enabled filesystem extension declaring awebentry as{ id, webEntry, styles, enabled, capabilities }. Disabled extensions are omitted; enable state resolves viaextension_prefs(same store as build-time extensions).capabilitiescarries the extension's declared UI capabilities so the browser can default-deny gate it (#1291). - Serving —
GET /ext-assets/<id>/dist/<path>serves the bundle files with correct MIME types (.js→text/javascript,.css→text/css), a path-traversal guard, and an explicit404for missing files. It is mounted OUTSIDE/api, so it is public like the app's own/assets/*. - Loading —
web/src/ext/runtimeLoader.ts(run frommain.tsxafter the build-time loader, beforebootstrap()) injects eachstylesentry as a<link>, then dynamic-imports the web entry from its/ext-assets/...URL and calls the module'sregister(host)— the sameregister()contract and the samewebExtensionHostsingleton build-time extensions use.
The register() function and every contribution point above are identical between build-time and runtime extensions — only the discovery + delivery mechanism differs.
Security model (v0.1)
Runtime-loaded web JS executes in the main browser context with access to the app DOM, localStorage (including the JWT auth token), and the app APIs — there is no isolation boundary today (no iframe / Worker / Shadow DOM; that is the deferred H2 upgrade in ADR-0003). What is enforced now is the capability model (#1291): a runtime web extension's privileged UI contribution points (toolViews / sessionDetail / preferences / navigation) are default-deny gated against its declared capabilities by web/src/ext/runtimeLoader.ts. HAPI_FS_EXTENSIONS-off remains the master switch; capability gating bounds an enabled extension's host-API reach.