> ## Documentation Index
> Fetch the complete documentation index at: https://voyant.travel/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Extensions

> Add or change behavior around an existing module without forking it. The HonoExtension shape, deployment-local extensions, extension tables, transactions, and how the platform ships its own bookings extensions.

A **module** is a new bounded capability. An **extension** is the seam for everything that hangs *off* an existing one: a route that the owning module does not ship, a vertical detail table attached to a core record, behavior that fires around a module's lifecycle. The defining test is that an extension adds to a module without introducing a new canonical capability of its own. If the thing you are building owns canonical state and behavior, it is a [module](/docs/concepts/how-it-is-built); if it decorates a module that already exists, it is an extension.

Bookings is the clearest example. The core `@voyant-travel/bookings` module owns the booking record and its lifecycle, but the routes mounted under `/v1/admin/bookings` come from several packages: quotes, inventory, finance, distribution, and supplier handling each contribute their own slice. None of them forks bookings. Each ships a `HonoExtension` that names `bookings` as its target, and the deployment composes them onto the same surface.

## What an extension owns

An extension can contribute three kinds of thing to its target module:

* **Routes.** Admin or public Hono routes mounted under the target module's prefix, so a booking-tax route from finance lands under `/v1/admin/bookings` without finance hard-coding that path.
* **Its own tables.** A 1:1 detail table that hangs off a core record (for example `booking_quote_details` keyed by `booking_id`), keeping vertical columns out of the slim core table.
* **Lifecycle behavior.** A `bootstrap` run once per isolate, and `hooks` the platform can dispatch around module operations.

What an extension does **not** own is the core record itself. The dependency direction is one-way: the extension package depends on the module it attaches to (for the foreign key and the contract), never the reverse. That is what lets a deployment drop the extension without the module knowing it existed.

## The HonoExtension shape

An extension is a small descriptor plus the routes it contributes. The `extension` field carries the metadata; the route fields carry the surface.

```ts theme={null}
interface HonoExtension {
  extension: Extension        // { name, module, requiresTransactionalDb?, hooks?, bootstrap? }
  adminRoutes?: Hono          // mounted at /v1/admin/{extension.module}
  publicRoutes?: Hono         // mounted at /v1/public/{extension.module}
  lazyAdminRoutes?: () => Promise<Hono>   // lazy variant, imported on first hit
  lazyPublicRoutes?: () => Promise<Hono>
  publicPath?: string         // override the public mount segment (defaults to extension.module)
}
```

The `extension` descriptor itself is the core `Extension` type:

```ts theme={null}
interface Extension {
  name: string                // unique extension identifier, e.g. "booking-notes"
  module: string              // the module it attaches to, e.g. "bookings"
  requiresTransactionalDb?: boolean
  hooks?: Record<string, (...args: unknown[]) => Promise<void> | void>
  bootstrap?: (...) => Promise<void> | void
}
```

The single most important field is `extension.module`. It names the module being extended, and the platform derives the mount prefix from it: an extension with `module: "bookings"` and `adminRoutes` is mounted at `/v1/admin/bookings`, the same prefix the bookings module uses. Because many extensions can target one module, give your extension routes their own sub-paths so they sit alongside the module's own routes rather than colliding with them.

<Note>
  An older `routes` field mounts a single router at the legacy `/v1/{module}` surface. Prefer `adminRoutes` / `publicRoutes` so your contribution lands on the [correct surface](/docs/platform/fundamentals/api-routes#the-surface-split).
</Note>

## Extending a module in a deployment

A deployment extends a module by dropping a `HonoExtension` into its discovery seam. The CLI scaffolds the whole folder — pass the extension name and the module it attaches to:

```bash theme={null}
voyant generate extension booking-notes --module bookings
# --public for the public surface, --with-schema to add a detail table
```

That writes the shape below; the directory name becomes the extension's composition key. (You can also create it by hand.)

```text theme={null}
src/extensions/booking-notes/
  index.ts       # default-exports the extension → auto-mounted
  routes.ts      # the Hono routes it contributes
  schema.ts      # detail table (optional) → deployment migration source
  service.ts     # business logic (optional)
```

The `index.ts` uses `defineDeploymentExtension`, which accepts a ready `HonoExtension` **or** a factory that receives the deployment's injected capabilities:

```ts theme={null}
// src/extensions/booking-notes/index.ts
import { defineDeploymentExtension } from "@voyant-travel/framework"
import { bookingNotesRoutes } from "./routes.js"

export default defineDeploymentExtension({
  extension: { name: "booking-notes", module: "bookings" },
  adminRoutes: bookingNotesRoutes, // → /v1/admin/bookings/*
  // publicRoutes / lazyAdminRoutes / lazyPublicRoutes also supported
})
```

The routes are an ordinary Hono router. Give them a sub-path so they coexist with the bookings module's own routes on the shared surface:

```ts theme={null}
// src/extensions/booking-notes/routes.ts
import { Hono } from "hono"
import { parseJsonBody, requireUserId } from "@voyant-travel/hono"
import { createNoteSchema } from "./validation.js"

export const bookingNotesRoutes = new Hono()
  // GET /v1/admin/bookings/:bookingId/notes
  .get("/:bookingId/notes", async (c) => {
    requireUserId(c)
    const notes = await c.var.container.bookingNotesService.list(c.req.param("bookingId"))
    return c.json({ notes })
  })
  // POST /v1/admin/bookings/:bookingId/notes
  .post("/:bookingId/notes", async (c) => {
    requireUserId(c)
    const body = await parseJsonBody(c, createNoteSchema)
    const note = await c.var.container.bookingNotesService.create(c.req.param("bookingId"), body)
    return c.json({ note }, 201)
  })
```

That is the whole wiring. The deployment's composition picks the folder up at build time through a Vite glob — the same mechanism custom [modules](/docs/platform/fundamentals/modules#how-modules-compose) use — and `createApp` mounts it onto the bookings surface:

```ts theme={null}
// src/api/composition.ts (deployment-owned)
const discoveredExtensions = extensionsFromGlob<OperatorCapabilities>(
  import.meta.glob("../extensions/*/index.ts", { eager: true }),
)

export const deploymentLocalExtensions = { ...discoveredExtensions }
```

If the extension needs an injected provider or resolver, take the factory form instead, the same way modules do:

```ts theme={null}
export default defineDeploymentExtension((ctx) => ({
  extension: { name: "booking-notes", module: "bookings" },
  adminRoutes: createBookingNotesRoutes(ctx.capabilities),
}))
```

<Note>
  Everything under the deployment's `src/extensions` is yours, and the platform owns none of it. `voyant upgrade` bumps the platform packages and migration bundle and leaves your extensions and deployment migrations untouched. That is what makes extending a module upgrade-safe instead of a fork.
</Note>

## Extension tables

When an extension needs to persist data, it adds its **own** table rather than widening the core record. The convention is a 1:1 detail table keyed by the core record's id — this is exactly how the platform keeps the bookings table slim while quotes, products, and finance each attach their columns through a dedicated `booking_*_details` table.

A deployment-owned extension table is a deployment migration source, so it follows the same two rules a [custom module's schema](/docs/platform/fundamentals/modules#generating-a-module) does:

* **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](/docs/concepts/how-it-is-built) 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.

```bash theme={null}
pnpm db:generate:deployment   # emit the migration → deployment migration source
pnpm db:migrate               # collector applies framework bundle, then deployment
```

## Transactions

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:

```ts theme={null}
export default defineDeploymentExtension({
  extension: { name: "booking-notes", module: "bookings", requiresTransactionalDb: true },
  adminRoutes: bookingNotesRoutes,
})
```

## Lifecycle behavior

Beyond routes, the `Extension` descriptor can carry a `bootstrap` (run once per app isolate on the first request where bindings are available) and `hooks` the platform dispatches around module operations. For reacting to domain events more broadly — `booking.created`, `invoice.issued`, and the like — the [event bus](/docs/platform/fundamentals/events) and its subscribers are the documented path, and a packaged integration ships that wiring as part of an adapter or plugin bundle rather than a bare extension.

## Package-shipped extensions

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 instead and the platform references it by registry key. The bookings surface is assembled from exactly these:

```ts theme={null}
// packages/platform/src/composition.ts
extensions: {
  "@voyant-travel/bookings/booking-supplier-extension": () => bookingsSupplierExtension,
  "@voyant-travel/finance/bookings-create-extension": () => bookingsCreateExtension,
  "@voyant-travel/inventory/booking-extension": () => inventoryBookingExtension,
  "@voyant-travel/quotes/booking-extension": () => quotesBookingExtension,
  "@voyant-travel/distribution": () => distributionBookingExtension,
  // ...
}
```

Each entry is a package that exposes its extension on a dedicated sub-path (for example `@voyant-travel/quotes/booking-extension`) so a deployment can import just the extension without pulling in the package's full surface. 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 — see the [module/provider/extension/plugin taxonomy](/docs/platform/fundamentals/modules#modules-versus-providers-adapters-extensions-and-plugins).

<Warning>
  An extension that adds a route or a detail table to an existing module is an extension, not a plugin. Reserve plugin bundles for genuinely reusable cross-project packages. Starting at "plugin" inflates a one-route seam into a meta-framework.
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Modules" icon="cube" href="/docs/concepts/how-it-is-built">
    The capability an extension attaches to, and how to author a whole new one.
  </Card>

  <Card title="API routes" icon="route" href="/docs/guides/consume-the-api">
    The surface split, route authoring, and how an app composes module and extension routes.
  </Card>

  <Card title="Data models" icon="table" href="/docs/concepts/how-it-is-built">
    How an extension table authors its schema, keys, and migrations.
  </Card>

  <Card title="Events" icon="bolt" href="/docs/platform/fundamentals/events">
    The event bus for reacting to domain events across modules.
  </Card>
</CardGroup>
