> ## 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.

# Extend an existing module

> Add a route, a behavior, or a detail table to a module you do not own, via a deployment-local extension in src/extensions: scaffold it, mount it, optionally give it its own table, migrate it, and verify, all upgrade-safe.

Reach for an [extension](/docs/platform/fundamentals/extensions) 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](/docs/platform/extending/apps), not an extension. If you only want to swap one
implementation behind a contract, that is a [provider](/docs/guides/swap-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`.

<Note>
  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](#ship-it-as-a-package) at the end).
</Note>

## Extend a module

<Steps>
  <Step title="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.

    ```bash theme={null}
    voyant generate extension booking-notes --module bookings
    ```

    This writes the canonical shape:

    ```
    src/extensions/booking-notes/
      index.ts       # default-exports the extension (mounted automatically)
      routes.ts      # the Hono routes it contributes
      validation.ts  # Zod request schemas
    ```

    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.
  </Step>

  <Step title="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](/docs/guides/consume-the-api) for the conventions.

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

    export const bookingNotesRoutes = new Hono()
      // GET /v1/admin/bookings/:bookingId/notes
      .get("/:bookingId/notes", (c) => {
        requireUserId(c)
        return c.json({ data: [] })
      })
      // POST /v1/admin/bookings/:bookingId/notes
      .post("/:bookingId/notes", async (c) => {
        requireUserId(c)
        const body = await parseJsonBody(c, createBookingNotesSchema)
        return c.json({ data: body }, 201)
      })
    ```

    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.
  </Step>

  <Step title="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.

    ```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 are also supported
    })
    ```

    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:

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

  <Step title="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:

    ```ts theme={null}
    // src/extensions/booking-notes/schema.ts
    import { typeId } from "@voyant-travel/db/lib/typeid-column"
    import { pgTable, text, timestamp } from "drizzle-orm/pg-core"

    export const acmeBookingNotes = pgTable("acme_booking_notes", {
      id: typeId("bookingNotes"),
      // Plain text reference to the core booking, no cross-module FK.
      bookingId: text("booking_id").notNull(),
      body: text("body").notNull(),
      createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
      updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
    })
    ```

    Two rules apply, the same ones a [custom module's schema](/docs/platform/extending/apps)
    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](/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. See
      [data models](/docs/concepts/how-it-is-built) for TypeID prefixes and
      keys.
  </Step>

  <Step title="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.

    ```bash theme={null}
    pnpm db:generate:deployment   # drizzle-kit generate for the deployment config
    pnpm db:migrate               # collector applies bundle, then deployment sources
    ```

    Skip this step if the extension contributes routes or behavior only and owns
    no table.
  </Step>

  <Step title="Verify">
    Run the preflight, which asserts the extension is composed and, if it owns a
    table, migrated and in sync. Then exercise the route.

    ```bash theme={null}
    voyant doctor
    curl http://localhost:3300/v1/admin/bookings/<bookingId>/notes
    ```
  </Step>
</Steps>

## 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:

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

## 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:

```ts theme={null}
// packages/quotes/src/booking-extension.ts (shape)
import type { Extension } from "@voyant-travel/core"
import type { HonoExtension } from "@voyant-travel/hono/module"

const quotesBookingExtensionDef: Extension = {
  name: "quotes-booking",
  module: "bookings",
}

export const quotesBookingExtension: HonoExtension = {
  extension: quotesBookingExtensionDef,
  routes: bookingQuoteExtensionRoutes,
}
```

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

<CardGroup cols={2}>
  <Card title="Extensions" icon="puzzle-piece" href="/docs/platform/fundamentals/extensions">
    The concept behind the seam: what an extension owns, the HonoExtension shape,
    and how the platform ships its own bookings extensions.
  </Card>

  <Card title="Build an app" icon="cube" href="/docs/platform/extending/apps">
    Build a whole new bounded capability when an extension is not enough.
  </Card>

  <Card title="Link entities" icon="link" href="/docs/concepts/how-it-is-built">
    Relate your extension table to framework records without a hard foreign key.
  </Card>

  <Card title="Data models" icon="table" href="/docs/concepts/how-it-is-built">
    TypeID prefixes, money, soft deletes, indexes, and migrations.
  </Card>
</CardGroup>
