Appearance
Migrating Core Features to Extensions
This guide is the recipe for converting an existing core feature into a builtin.* extension. It is the companion to Authoring Extensions: that doc teaches you how to build an extension from scratch (the manifest schema, the register() signature, the contribution-point catalogue); this doc assumes you have read it and focuses on the delta — how to lift a feature that already lives in core, move it behind the extension contract without changing its behaviour, and avoid the traps that a migration (as opposed to a greenfield extension) creates.
v0.1 is pre-stable
The extension API is at PLUGIN_API_VERSION = "0.1.0" — explicitly pre-stable. Host interfaces gain contribution points additively and migration mechanics (notably the runtime toggle and cross-node federation) will evolve toward 1.0.0. Patterns here reflect the migrations that have landed (builtin.notifications-push, builtin.scheduler, builtin.heartbeat, …). This is a living document — when a new migration surfaces a novel pattern or pitfall, add it.
Overview
Hapimatic uses build-time composition (see The composition model): an extension is a directory in the repo that a codegen step wires into the build. Migrating a core feature therefore is not a runtime plugin operation — it is a refactor that relocates code into an extension directory, declares a manifest, wires the feature into the host through contribution points instead of inline construction, and registers the directory in the root extensions.config.ts.
A migration is done correctly when:
- Every observable behaviour of the feature is identical before and after — a migration is a refactor, not a feature change.
- The feature's code lives under
<pkg>/src/extensions/<name>/and is reachable only through the loader. - Disabling the extension (removing it from
extensions.config.ts, or anextension_prefstoggle) cleanly removes the feature's surface, unless the extension isrequiredAtBuild: true. git log --followstill traces the moved files' history (you usedgit mv).
Before you migrate anything, decide whether it should be an extension at all.
Should this become an extension?
The core/extension boundary comes from epic #1264. The one-line test:
The disable test
Ask: "Would another Hapimatic user reasonably want to disable this feature?" — Yes → extension. No → core. "Maybe" → leave it in core for v0.1 and revisit when a concrete reason to disable it appears.
Stays in core
Foundation and cross-cutting infrastructure is not feature-toggleable. It stays in core because multiple extensions (and core itself) depend on it.
| Feature class | Examples | Reason |
|---|---|---|
| Foundation infrastructure | Configuration loader, SQLite Store, SyncEngine, Hono server, Socket.IO + SSE | The substrate every extension builds on |
| Auth | JWT, CLI token, Telegram Mini App init validation | Cross-cutting security boundary; not toggleable |
| Federation core | NodeRegistry, RemoteTerminalProxy | Mesh integrity invariant; extensions opt into federation, never replace it |
| CLI session lifecycle | claude launcher, daemon, tmux, recovery | Core process lifecycle; extensions hook in via host.lifecycle.* |
| Web app shell | main.tsx, App.tsx, router root, AppContext, useAuth | Top-level container; extensions add routes to it, not replace it |
| Message normalization | server-side message ingest pipeline | Multiple consumers (chat, notifications, federation) |
| Shared services with cross-cutting consumers | PushService | Used by 5 unrelated core systems — see below |
The canonical shared-service case is PushService. During the notifications-push migration (#1271), only the notification channel (PushNotificationChannel) moved into the extension. PushService itself stayed at server/src/push/pushService.ts because five unrelated core systems call it directly — errorLogger, scheduler, taskExecutor, stallWatchdog, and heartbeat. When a feature is a thin wrapper around a service that other systems depend on, move the wrapper, keep the service in core, and reach it from the extension through a late-bound host accessor (host.getPushService()).
Becomes an extension
| Feature class | Examples | Reason |
|---|---|---|
| Notification channels | builtin.notifications-push, builtin.notifications-telegram | Bounded scope; a user may want only one channel |
| Agent backends | builtin.agent-codex, builtin.agent-gemini | Each has its own auth/setup; users may use only one |
| Background services | builtin.scheduler, builtin.heartbeat, builtin.watchdog | Independent lifecycles; many installs disable all three |
| Visualisation surfaces | builtin.tool-views, builtin.error-log-viewer, builtin.analytics | Pure presentation; disabling has no system impact |
| External integrations | builtin.git-rpc, builtin.github-issues, builtin.telegram-miniapp | Optional connectors; opt-in by definition |
Gray area (deferred for v0.1)
Some surfaces are plausible future contribution points but stay in core for now: the session emoji picker, session header chrome, the MCP-toggle UI, file upload, the permissions UI, and the search/filter bar. Leave these in core until a concrete "user wants to disable this" case emerges — over-extracting is churn.
Before you start
Confirm all of the following before running the recipe:
- [ ] The feature is classified as "extension" per the decision criteria.
- [ ] You have read Authoring Extensions — it is the reference for the manifest schema,
register()signatures, and the contribution-point catalogue this recipe assumes. - [ ] The substrate is in place: the codegen + per-package loaders (#1270) and the pilot (#1271) are merged. (They are, as of this writing.)
- [ ] You are working in a worktree on a feature branch for the sub-issue's PR.
- [ ] You have a researcher directive that enumerates every file to move, create, and modify (
files_to_modify/files_to_create). - [ ] You have identified the feature's cross-cutting consumers — the core systems that import from it. These decide what can move versus what must stay in core (the
PushServicerule above).
The migration recipe
Each step below is a copy-pasteable atom. Replace <name> with your extension's name (e.g. scheduler) and <feature> with the core directory it currently lives in (e.g. push).
Identify the migration scope
List every file that touches the feature, then sort each into one of three buckets:
bash
# Consumers to update (imports OF the feature):
grep -rn "from .*<feature>" server/src cli/src web/src shared/src \
--include='*.ts' --include='*.tsx'
# Tests that exercise the feature:
find . -name '*.test.ts' -path '*<feature>*'- MOVE — files owned entirely by the feature → become
<pkg>/src/extensions/<name>/<file>. - STAYS IN CORE — files used by multiple unrelated systems (the
PushServiceprecedent). The extension reaches these through a late-bound host accessor. - MODIFY — core files that construct or reference the feature inline → get rewritten to consume the extension through the loader.
Record this split in your directive's files_to_modify / files_to_create metadata.
Create the extension directory
bash
mkdir -p server/src/extensions/<name> # server surface
mkdir -p web/src/extensions/<name> # only if the extension has a web surface
mkdir -p cli/src/extensions/<name> # only if the extension has a cli surfaceA multi-runtime extension ships as two directories that share one id — one per runtime, each declaring only its own entry point. builtin.scheduler, builtin.heartbeat, and builtin.git-rpc all use this shape. Put the metadata that must stay in sync across both halves in shared/src/extensions/<name>/manifestShared.ts and spread it into each manifest.ts (see the multi-runtime example).
Move the feature files with git mv
Always git mv — it preserves history so git log --follow and git blame keep working across the move:
bash
git mv server/src/<feature>/foo.ts server/src/extensions/<name>/foo.tsThen fix the relative imports in each moved file. The adjustment is mechanical: a file moving from server/src/<feature>/ to server/src/extensions/<name>/ gains one directory level, so ../bar becomes ../../bar:
bash
grep -rn "from '\.\./" server/src/extensions/<name>/Shared extension types (e.g. NotificationChannel) now import from the shared protocol package rather than the old internal path:
ts
// Before:
import type { NotificationChannel } from "../notifications/notificationTypes";
// After:
import type { NotificationChannel } from "@hapi/protocol/ext";Author the manifest
Create <pkg>/src/extensions/<name>/manifest.ts exporting a HapiExtensionManifest. See The manifest for the full field reference. The migration-relevant fields:
ts
import type { HapiExtensionManifest } from "@hapi/protocol/ext";
import { PLUGIN_API_VERSION } from "@hapi/protocol/ext";
export const manifest: HapiExtensionManifest = {
id: "builtin.<name>",
version: "0.1.0",
apiVersion: PLUGIN_API_VERSION,
displayName: "<Human-readable name>",
description: "<One-sentence summary>",
defaultEnabled: true, // false to preserve an opt-in feature's default
requiredAtBuild: false, // true only if core calls into the feature directly
entryPoints: {
server: async () => import("./server"),
// add web / cli only for the surfaces this extension actually has
},
};Set defaultEnabled to the feature's pre-migration default so behaviour is unchanged: notifications-push is defaultEnabled: true + requiredAtBuild: true (it is the notification floor); notifications-telegram is defaultEnabled: false (it only ran when a bot token was configured).
Author register()
Create <pkg>/src/extensions/<name>/server.ts (and web.tsx / cli.ts for other surfaces). register takes one argument — the host — and returns void or a Promise. Core services are reached through the host's late-bound accessors, not a second parameter:
ts
import type { ServerRegister } from "@hapi/protocol/ext";
export const register: ServerRegister = async host => {
const pushService = host.getPushService() as PushService | null;
if (!pushService) throw new Error("[builtin.<name>] pushService unavailable");
host.notifications.addChannel(new MyChannel(pushService));
};register() runs before core services exist
On the server, register() runs early — after Store but before SyncEngine, NotificationHub, and PushService are fully wired. The late-bound accessors (host.getSyncEngine(), host.getNotificationHub(), …) may return null during register(). Register contributions synchronously, but construct and start any long-running work (a poller, a bot, a scheduler loop) inside host.lifecycle.onStartup(), which the loader drains after every core service exists. builtin.heartbeat and builtin.scheduler both construct their service in onStartup for exactly this reason.
Note the config distinction: host.config.get() reads the extension's ownextension_prefs row. To read a core setting (miniAppUrl, telegramBotToken, …) use host.getCoreConfig().
Remove the inline construction from core
Delete the feature's imports and inline new <Class>(...) from server/src/index.ts (and any other core file that built it). The loader now aggregates the feature from the extension's register() call. Preserve every boot-ordering invariant: move the loader call to the position the inline construction occupied — do not reorder unrelated boot steps.
diff
- import { PushNotificationChannel } from "./push/pushNotificationChannel";
- const channels: NotificationChannel[] = [
- new PushNotificationChannel(pushService, miniAppUrl),
- ];
- notificationHub = new NotificationHub(syncEngine, channels, { errorLogger });
+ // The loader collects channels from each enabled extension's register()
+ // call; the hub is fed the aggregated list. Its options shape and its
+ // before-HeartbeatService / before-SchedulerService ordering are preserved.
+ notificationHub = new NotificationHub(syncEngine, loaderChannels, { errorLogger });Register the directory in extensions.config.ts
Append your directory path to the root extensions.config.ts array. It holds directory path strings, not manifest imports — the codegen resolves each directory's manifest.ts:
ts
export const enabledExtensions: readonly string[] = [
// … existing entries …
"server/src/extensions/<name>",
];Append-only; rebase before editing
extensions.config.ts is a high-contention hotspot — every migration appends one line. Never reorder or rewrite existing entries, and rebase before editing so you do not clobber a parallel migration's append. A multi-runtime extension appends one line per runtime directory (both resolve to the same extension_prefs row, so they enable/disable together).
Regenerate the committed registry
bash
bun run gen:extensionsThis emits <pkg>/src/extensions.generated.ts — static manifest imports for the enabled extensions, filtered per runtime by manifest.entryPoints[runtime]. The generated files are committed (CI runs typecheck/tests on a fresh checkout with no codegen step) and deterministic (regeneration leaves git status clean). Confirm your extension appears:
bash
grep -c "extensions/<name>/manifest" server/src/extensions.generated.tsUpdate the moved tests' import paths
bash
grep -rln "from .*<feature>" --include='*.test.ts' --include='*.test.tsx'Apply the same one-extra-../ adjustment as the source files, or switch to the @/extensions/<name>/ alias. Never skip, comment out, or weaken a test to make CI pass — if a test breaks only because the feature moved, fix its import, not its assertion.
Typecheck, lint, test, build
bash
bun run typecheck
bun run lint
bun run test
bun run build:single-exeAll four must pass with no errors and no new warnings. build:single-exe runs gen:extensions and verifies the codegen output integrates with the single binary.
Verify feature parity on the worktree instance
Deploy to the worktree and reproduce the feature's pre-migration happy path end-to-end, then confirm the disable path — see Testing checklist for the full procedure.
Version bump and PR
bash
bun run version:testOpen a PR to the default branch, titled feat(extensions): migrate builtin.<name> (#<sub-issue>). The PR body should reference epic #1264, note any in-path bug fixes, and include feature-parity evidence. Do not put Closes #<N> / Fixes #<N> / Resolves #<N> in any commit message or the PR body — the issue-closer owns the close.
Worked example: migrating notifications-push
The pilot migration (#1271, merge commit 412aacc7) moved the push and Telegram notification channels into extensions. It is the reference every later migration follows. Below, each recipe step maps to what the pilot actually did for notifications-push.
Merged code is authoritative
This example reflects the code as of commit 412aacc7. If the recipe and the merged code ever disagree, the merged code wins — please file an issue to update this page.
| Recipe step | What the pilot did |
|---|---|
| Identify scope | MOVE server/src/push/pushNotificationChannel.ts; STAYS IN CORE server/src/push/pushService.ts (5 core consumers); MODIFY server/src/index.ts |
| Create directory | server/src/extensions/notifications-push/ — server-only, no web surface |
git mv + imports | git mv server/src/push/pushNotificationChannel.ts server/src/extensions/notifications-push/pushNotificationChannel.ts (Git recorded a 93%-similarity rename); NotificationChannel now imports from @hapi/protocol/ext |
| Manifest | id: "builtin.notifications-push", defaultEnabled: true, requiredAtBuild: true (push is the notification floor — several core services call PushService directly) |
register() | Reads host.getPushService() + host.getCoreConfig() (a new accessor added by the pilot), constructs new PushNotificationChannel(pushService, miniAppUrl), calls host.notifications.addChannel(...) |
| Remove core inline | server/src/index.ts no longer constructs any channel inline — the NotificationHub is fed entirely by loader-aggregated channels, with its options shape and its before-HeartbeatService / before-SchedulerService ordering preserved |
extensions.config.ts | Appended "server/src/extensions/notifications-push" |
| Codegen | bun run gen:extensions emitted the static import into server/src/extensions.generated.ts |
| Tests | notificationHub tests are structural (fake-channel) and needed no change |
| CI gates | typecheck / lint / test / build:single-exe all green |
| Feature parity | Push cascade verified on the worktree — permission requests still trigger push, ready notifications still arrive |
| Version + PR | version:test, PR opened and merged after approval |
The pilot migrated a sibling in the same PR — notifications-telegram — which illustrates the opt-in default: its manifest is defaultEnabled: false (matching the pre-migration behaviour where Telegram only ran with a bot token configured), and its bot start() / stop() moved onto host.lifecycle.onStartup / onShutdown. Feature parity for token-configured installs is preserved by the extension_prefs seed, which inserts an enabled = 1 row when a token is present without ever overwriting an explicit enabled = 0.
Common pitfalls
Federation interactions
The federation mesh treats each node as independent. Extensions are per-node by default — they run on the local node and do not auto-replicate state to peers. This is correct for almost every extension: notifications fire locally, the scheduler runs locally, heartbeat tracks the local session. An extension that genuinely needs cross-node coordination must opt in explicitly (in v0.1, plan for per-node behaviour only; the cross-node opt-in mechanism is a v0.2 design). Epic #1264 states the default: "extensions are per-node, do not auto-replicate; each extension may opt into federation behaviour."
The practical trap: a migrated feature that felt global in core (because core happened to run on one node) is now explicitly per-node. Push subscriptions, for instance, are per-instance — notifications fan out only to the originating node. Verify the feature's cross-node expectations during migration; do not assume core gave it mesh-wide reach.
Persistence layer
Extensions own their tables through host.store.addTable(...) / host.store.addMigration(...) (see Plugin-owned tables). The host auto-prefixes a table name to ext_<sanitizedId>_<tableName>, where <sanitizedId> is the id with [^a-zA-Z0-9] replaced by _ and lowercased — so builtin.scheduler owns ext_builtin_scheduler_*. Extension migrations run after all core migrations. An extension may foreign-key into core tables (e.g. sessions); cross-extension foreign keys are not allowed (extensions are independently toggleable).
Migrating a feature that already has core-owned tables needs a one-time rename migration to relocate the legacy tables under the ext_<id>_* prefix. builtin.scheduler (#1274) is the reference; its server/src/extensions/scheduler/migration.ts is worth reading in full. The load-bearing points:
Target names come from the same constants the query layer imports (
./tableNames). Declaring a second set of names in the migration is how you get a scheduler that migrates cleanly and then throwsno such tableon its first query.Idempotency matrix — the migration must handle every boot state:
Legacy table New table Behaviour absent absent fresh install → CREATEwith the full schemapresent absent ALTER TABLE … RENAME TO ext_…(data preserved)absent present already migrated → no-op present present zombie legacy table: drop it iff empty, else leave it and report (a human must resolve a genuine split-brain) One transaction wraps the whole migration, so a partial failure cannot leave one table renamed and its foreign-key partner not.
Spell out the full schema. Core's
scheduled_tasksshape was the union of aCREATE TABLEblock plus ~20migrateAddColumncalls; copying only theCREATEblock would ship short tables to fresh installs while the query layer reads the full column set. Reconcile against a livePRAGMA table_infobefore trusting either source.Add a regression test that asserts the migration is idempotent and preserves data.
Lifecycle ordering
Core boot has implicit ordering invariants. From the pilot: the NotificationHub must exist before HeartbeatService and SchedulerService are constructed because both depend on it. When you remove inline construction (recipe step 6), preserve the boot position — move the loader call to where the inline construction was.
The subtler trap is extension load order. Because extensions.config.ts is append-only, you cannot rely on one extension loading before another. The fix is to keep any resource two extensions share in core, exposed via a host accessor. builtin.heartbeat drains a SystemEventQueue that the scheduler also uses; that queue is core-owned (host.getSystemEventQueue()), injected into HeartbeatService at onStartup, so it resolves live regardless of whether heartbeat or scheduler loads first. Had the queue been owned by one extension, the append-only ordering would let the other snapshot null.
Wire shutdown symmetrically: release long-lived work (timers, pollers, bot connections) in host.lifecycle.onShutdown().
Cross-extension coupling
An extension must never create a runtime dependency on another extension's code — a direct runtime import becomes a hidden hard dependency that breaks when the imported extension is disabled. Resolve cross-extension handles through the host's service registry instead:
ts
// Provider extension, in onStartup (after it has constructed its service):
host.services.register("scheduler", schedulerService);
// Consumer extension:
const scheduler = host.services.get<SchedulerService>("scheduler"); // null if disabledbuiltin.heartbeat reads the scheduler this way and treats a null result as "no scheduler present". For fire-and-forget signalling rather than handle sharing, use the event bus (host.events.emit(...) / host.events.subscribe(...)).
import type is fine; a runtime import is not
A type-only import type { SchedulerService } from "../scheduler/…" is erased at compile time and is acceptable for typing the host.services.get<T>() result. What you must avoid is a value import that pulls the other extension's code into your module graph. A dependsOn: ["builtin.other"] entry in the manifest declares intent (a future resolver will enforce it; it is not yet enforced in v0.1).
Build-time tree-shaking
A disabled extension's code must not appear in the reachable bundle.
- Web — entry points are dynamic
import(), so Vite code-splits every extension into its own chunk; a disabled extension's chunk is never fetched. - Server — the codegen emits static imports only for extensions listed in
extensions.config.ts. A directory that exists in the source tree but is not referenced byextensions.generated.tsis dropped by Bun's tree-shaker.
requiredAtBuild: true opts an extension out of this — it always loads, regardless of prefs. Use it only for surfaces core depends on (the always-present notifications-push floor, the always-mounted /error-logs route).
In-path bug fixes
A migration PR is well positioned to fix bugs in the code being moved: the reviewer is already reading that code and the diff is already large. This follows the project-wide In-Path Bug Fixing rule — fix bugs in your path, flag unrelated ones.
The scheduler migration (#1274) is the precedent; it fixed three bugs discovered while relocating the code:
- A
GET /schedules/:id/historyroute that did not filter by namespace — a cross-namespace data leak. - Internal mutation helpers that took no namespace parameter, allowing multi-tenant corruption under concurrent writes.
- A
start()timer + subscription that leaked when the service stopped before the daemon connected — stored as class-level references and released instop().
Guidance:
- In-path bug in the moved code → fix it in the same PR; record it as a separate requirement in your directive and note it in the PR body.
- Unrelated bug (a file outside the migration scope) → do not scope-creep; flag it to the user or file a follow-up issue.
- Genuinely multi-day fix → defer to its own issue and reference it from the PR as a follow-up.
What to look for while relocating a service:
- Namespace filtering on multi-tenant routes (
c.get("namespace")extraction and propagation through every service call). - Resource leaks — timers, subscriptions, listeners that do not release on
stop(). console.log/warn/errorin lifecycle or tick paths that should beerrorLogger?.info/warn(msg, { category: "<name>" }).- Route catch blocks that should use the shared
logAndJsonhelper.
Convention checklist
A final pass over the migration diff before opening the PR:
- [ ] All moved files used
git mv;git log --follow <file>shows continuity. - [ ] Relative imports rewritten with the extra
../level. - [ ] Shared types import from
@hapi/protocol/ext, not the old internal paths. - [ ]
console.*in lifecycle/tick paths replaced witherrorLogger?.info/warn(msg, { category: "<name>" }). - [ ] Pure, side-effect-free modules keep their
console.*— do not injecterrorLoggerinto a pure module. - [ ] Route catch blocks use the shared
logAndJsonhelper. - [ ]
extensions.config.tsedited append-only, on a rebased branch. - [ ]
extensions.generated.tsregenerated and committed (git statusclean afterbun run gen:extensions). - [ ] No
Closes #<N>/Fixes #<N>/Resolves #<N>in commits or the PR body. - [ ] Version bumped via
bun run version:test.
Testing checklist
Feature-parity verification
The migration is a refactor: every observable behaviour must hold before and after. Enumerate the feature's behaviours and a verification for each:
| Pre-migration behaviour | Post-migration verification |
|---|---|
| e.g. permission request triggers a push notification | reproduce end-to-end on the worktree instance |
e.g. GET /api/<feature> returns the expected shape | drive the UI, then curl -s GET to cross-check |
For UI-touching migrations, verify through the actual UI with Playwright — never inject state via write API calls (that bypasses the real code path). A read-only curl -s GET is fine after a UI action, as a cross-check.
Toggle behaviour
Confirm the feature responds to being disabled:
- Confirm the feature works enabled (
extension_prefs.<id>.enabled = true, or absent whendefaultEnabled: true). - Disable it — set
extension_prefs.<id>.enabled = false(in v0.1, before the runtime toggle lands, the equivalent is removing the directory fromextensions.config.tsand rebuilding). - Restart the server (v0.1 has no hot toggle).
- Confirm the feature is gone — routes 404, UI elements absent, background tasks stopped.
- Re-enable, restart, confirm it resumes.
A requiredAtBuild: true extension is non-disableable by design — the toggle test does not apply; document the exception instead.
Static analysis gates
| Command | Catches |
|---|---|
bun run typecheck | import-path drift, register signature mismatch, missing manifest fields |
bun run lint | leftover unused imports from the move, missing return types, dead code |
bun run test | behavioural regressions and import-path breakage |
bun run build:single-exe | codegen integration failures; missing static imports in extensions.generated.ts |
bun run format:check | Prettier drift on new/moved files |
All are CI gates; every one must pass before the PR is ready for review.
Reference
- Where extension code lives —
<pkg>/src/extensions/<name>/(server,web,cli), with shared metadata inshared/src/extensions/<name>/manifestShared.ts. - The registry — root
extensions.config.ts(append-only directory paths);bun run gen:extensionsemits the committed<pkg>/src/extensions.generated.ts. - Which sub-issue a feature belongs to — the epic #1264 progress section lists the migration roster and dependency chain.
- How core services reach an extension — read the loader (
<pkg>/src/ext/loader.ts) and the late-bound core accessors onServerExtensionHost.
See also
- Authoring Extensions — the manifest schema,
register()signatures, the full contribution-point catalogue, and configuration validation. - Epic #1264 — the extension-system architecture and full migration roster.
- The pilot migration #1271 (
notifications-push+notifications-telegram) — the canonical worked example. builtin.scheduler#1274 — the persistence-rename and in-path-bug-fix reference.builtin.heartbeat#1275 — the cross-extensionhost.servicesand lifecycle-ordering reference.