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 This writes the canonical shape:An extension is not an npm package, so there is no
src/extensions discovery seam. Pass the
extension name and the target module it attaches to. The directory name
becomes the extension’s composition key.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
The surface-level actor guard for
/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./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 The build discovers
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.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
Two rules apply, the same ones a custom module’s schema
follows:
booking_*_details table. Re-run the generator with --with-schema, or write
schema.ts by hand:- 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 Skip this step if the extension contributes routes or behavior only and owns
no table.
migrations/,
then apply the whole chain.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:
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.