Skip to main content
Reach for an extension when you want to add to a module that already exists: a route the owning module does not ship, a detail table that hangs off a core record, or behavior around a module’s lifecycle. An extension attaches to a module instead of replacing it. If the thing you are building owns canonical state and behavior of its own, it is a module, not an extension. If you only want to swap one implementation behind a contract, that is a provider. The defining test for an extension is that it decorates a module without introducing a new canonical capability. This guide adds a booking-notes extension to the standard bookings module, directly inside a deployment (the operator application). It is auto-discovered at build time, so nothing in the platform is edited and the extension survives voyant upgrade.
A deployment-local extension lives in src/extensions/<name>/ and is the common path for behavior specific to one app. If the extension is reusable across deployments, it ships from a package instead (see Ship it as a package at the end).

Extend a module

1

Scaffold the folder

Generate the extension into the src/extensions discovery seam. Pass the extension name and the target module it attaches to. The directory name becomes the extension’s composition key.
This writes the canonical shape:
An extension is not an npm package, so there is no package.json or tsconfig.json. Pass --public to mount on the public surface instead of admin, and --with-schema to also emit a schema.ts detail table.
2

Author the routes

The contributed routes are an ordinary Hono router. They mount under the target module’s prefix, so the paths below are relative to /v1/admin/bookings. Give them their own sub-path so they sit alongside the bookings module’s own routes instead of colliding with them. Validate input through parseJsonBody and assert the caller with requireUserId. See API routes for the conventions.
The surface-level actor guard for /v1/admin/* already ran in the createApp middleware chain before these handlers, so they only need finer checks. Drop requireUserId(c) for routes that are intentionally anonymous.
3

Mount it from index.ts

Default-export the extension with defineDeploymentExtension, imported from @voyant-travel/framework. The extension.module field names the target module, and the platform derives the mount prefix from it. The extension.name field is this extension’s own identifier.
The build discovers src/extensions/*/index.ts through extensionsFromGlob over an import.meta.glob (compiled to static imports, so it works on static imports) and the router is mounted onto the bookings surface. There is no manifest to edit and no registry to wire.If the extension needs an injected provider or resolver, take the factory form instead, the same way modules do:
4

Add a detail table (optional)

When the extension needs to persist data, it adds its own 1:1 detail table keyed by the core record’s id, rather than widening the slim core table. This is exactly how the platform keeps the bookings table lean while quotes, products, and finance each attach their columns through a dedicated booking_*_details table. Re-run the generator with --with-schema, or write schema.ts by hand:
Two rules apply, the same ones a custom module’s schema follows:
  • Do not hard-FK across module boundaries. Reference the core booking with a plain text("booking_id") column, not a cross-package .references(). Pair the association with a link when you need it resolved in the query graph.
  • Prefix deployment-owned tables (for example acme_booking_notes) so they stay clear of the platform’s global table namespace. See data models for TypeID prefixes and keys.
5

Generate and apply the migration

A deployment-owned extension table is a deployment migration source, applied after the platform bundle. Generate it into the deployment’s migrations/, then apply the whole chain.
Skip this step if the extension contributes routes or behavior only and owns no table.
6

Verify

Run the preflight, which asserts the extension is composed and, if it owns a table, migrated and in sync. Then exercise the route.

Run an interactive transaction

If an extension route runs an interactive transaction (db.transaction(...)), set requiresTransactionalDb on the descriptor. Extensions mount under their target module’s prefix, so this flag forces the transaction-capable db client onto that surface even when the module itself does not declare it:

Ship it as a package

A deployment-local extension is the right tool for behavior that only your app needs. When an extension is reusable across deployments, it ships from a package and the platform references it by registry key instead. The standard bookings surface is assembled from exactly these package-shipped extensions, including @voyant-travel/quotes/booking-extension and @voyant-travel/finance/booking-tax. Each package exposes its extension on a dedicated sub-path so a deployment can import just the extension without pulling in the package’s full surface. A package extension exports a HonoExtension directly, for example:
Whether an extension ships as a package or lives in src/extensions, its runtime semantics are identical: it names a target module, and the platform mounts it onto that module’s surface. Packaging is a distribution decision, not a different mechanism.

Next steps

Extensions

The concept behind the seam: what an extension owns, the HonoExtension shape, and how the platform ships its own bookings extensions.

Build an app

Build a whole new bounded capability when an extension is not enough.

Link entities

Relate your extension table to framework records without a hard foreign key.

Data models

TypeID prefixes, money, soft deletes, indexes, and migrations.