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

# Realtime

> Provider-agnostic realtime channels, the invalidation-hint bridge that fans domain events out to live UIs, and the operator subscription pattern.

Most admin and customer screens can stay current without polling. Voyant ships a provider-agnostic realtime layer that pushes live updates to channels, and a React layer that turns those pushes into query invalidations. The platform never couples to a single transport vendor, and the whole feature is optional: a deployment with no provider configured stays inert.

The backend lives in `@voyant-travel/realtime`. The React hooks live in `@voyant-travel/realtime-react`. This page covers both, plus how they relate to the managed [Voyant realtime service](/docs/services/realtime).

## The core idea: hints, not records

The realtime layer does not stream changed records over channels. It streams **invalidation hints**, small `{ event, entity, id }` envelopes that say "this thing changed, refetch it." The React layer reacts by invalidating matching React Query keys, which then refetch over the existing authenticated HTTP path.

This design has three consequences worth understanding:

* HTTP stays the source of truth. A channel never carries entity data, so it never leaks state through channel capabilities.
* At-most-once delivery is acceptable. A missed hint self-heals on the next refetch or `staleTime` tick.
* The transaction that emitted the event is never blocked by a publish.

<Note>
  If a channel ever needs at-least-once delivery, that is a durable channel-push pattern (write an intent row, drain it from a workflow), not this module. The realtime module ships the at-most-once tier by design.
</Note>

## The provider interface

The transport is injected through `RealtimeProvider`. Voyant is one implementation; any pub/sub backend (a hosted vendor, a self-hosted WebSocket or SSE service) can satisfy the same interface.

```ts theme={null}
import type { RealtimeProvider } from "@voyant-travel/realtime/types"

interface RealtimeProvider {
  readonly name: string
  publish(channel: string, message: { event: string; data: unknown }): Promise<void>
  mintClientToken(input: {
    clientId: string
    capabilities: Record<string, ReadonlyArray<"subscribe" | "publish" | "presence">>
    ttlSeconds?: number
  }): Promise<{ token: string; expiresAt: string }>
}
```

`mintClientToken` is the abstraction line. Every vendor mints tokens differently, but the token-mint route never knows the vendor. The package ships two built-in providers:

| Factory                                         | Subpath                                          | Backend                      |
| ----------------------------------------------- | ------------------------------------------------ | ---------------------------- |
| `createLocalRealtimeProvider()`                 | `@voyant-travel/realtime/providers/local`        | in-memory, for dev and tests |
| `createVoyantCloudRealtimeProvider({ client })` | `@voyant-travel/realtime/providers/voyant-cloud` | Voyant transport             |

To bring your own backend, implement `RealtimeProvider` and pass it through. No framework change is required.

## Wiring the module

`@voyant-travel/realtime` is part of the standard product graph. It owns no schema, is stateless, and contributes its runtime ports through the `runtime` entry in its `voyant.package.v1` manifest, so the token-mint route and the deferred event bridge are resolved into the application rather than assembled by hand.

What you do configure is the **provider** and the **bridge routes** that fan domain events out to channels:

```ts theme={null}
import { createVoyantCloudRealtimeProvider } from "@voyant-travel/realtime/providers/voyant-cloud"

const realtime = {
  // Resolve the provider from runtime bindings.
  resolveProviders: (bindings) => [
    createVoyantCloudRealtimeProvider({ client: getCloudClient(bindings) }),
  ],

  // Fan domain events out to channels as invalidation hints.
  bridgeRoutes: {
    "booking.confirmed": (e) => ["admin", `booking:${e.bookingId}`],
    "booking.fully-paid": (e) => ({
      channels: ["admin", `booking:${e.bookingId}`],
      hint: { entity: "booking", id: e.bookingId },
    }),
    "availability.slot.changed": (e) => ({
      channels: ["admin", `product:${e.productId}`],
      hint: { entity: "availability", id: e.productId },
    }),
  },

  // Let portal customers subscribe to the bookings they own.
  resolvePortalScope: async (c) => {
    const personId = await lookupPersonId(c.get("db"), c.get("userId"))
    if (!personId) return null
    return { personId, bookingIds: await ownedBookingIds(c.get("db"), personId) }
  },
}
```

The token route is served on both surfaces, at `POST /v1/admin/realtime/token` and `POST /v1/public/realtime/token`. With no provider configured, the token route returns `503` and no bridge subscribers register.

## The event bridge

`bridgeRoutes` is a declarative table from event name to channels and an optional hint. At bootstrap, the module registers one deferred [event](/docs/platform/fundamentals/events) subscriber per route. Because the subscribers are deferred (`inline: false`), they run after the HTTP response via the runtime scheduler and never block the emitting transaction. Publish failures are swallowed and routed to `onPublishError`, because a dropped hint is self-healing.

The module also exports the lower-level pieces: `createRealtimeService`, `resolveRealtimeCapabilities`, and the route helpers (`createRealtimeRoutes`, `buildRealtimeRouteRuntime`) for that path.

## Channel conventions

Channels and the capabilities that grant them follow a stable convention.

| Channel                       | Audience                          | Granted by             |
| ----------------------------- | --------------------------------- | ---------------------- |
| `admin`                       | all admin users of the deployment | admin session          |
| `booking:{bookingId}`         | admins and the booking's customer | session plus ownership |
| `portal:customer:{personId}`  | that customer in the portal       | portal session         |
| `notifications:user:{userId}` | a specific staff user             | admin session          |

## The operator pattern on the client

`@voyant-travel/realtime-react` makes existing screens live without rewriting their data layer. The transport is injected as a `RealtimeConnector`, so the React layer stays vendor-agnostic too.

Wrap the app once. `createRealtimeChannelConnector` adapts the Voyant `RealtimeChannel` into a connector, but any vendor works by implementing `RealtimeConnector` directly.

```tsx theme={null}
import { RealtimeChannel } from "@voyant-travel/cloud-sdk"
import {
  createRealtimeChannelConnector,
  RealtimeReactProvider,
} from "@voyant-travel/realtime-react"

const connector = createRealtimeChannelConnector(RealtimeChannel, { baseUrl: "/api" })

<QueryClientProvider client={queryClient}>
  <RealtimeReactProvider connector={connector} tokenEndpoint="/v1/admin/realtime/token">
    <App />
  </RealtimeReactProvider>
</QueryClientProvider>
```

Then make a screen live with `useLiveQueries`. It subscribes to channels and translates each invalidation hint into `queryClient.invalidateQueries` calls. The operator pattern is exactly this: subscribe to the `admin` channel and map each hint's `entity` to a query-key root.

```tsx theme={null}
import { useLiveQueries } from "@voyant-travel/realtime-react"
import { dashboardQueryKeys } from "@voyant-travel/admin/dashboard/query-options"

function DashboardLive() {
  useLiveQueries(["admin"], (hint) => {
    switch (hint.entity) {
      case "booking":
        return [dashboardQueryKeys.bookingsAggregates()]
      case "invoice":
        return [dashboardQueryKeys.financeAggregates()]
      default:
        return []
    }
  })
  return null
}
```

Keep `staleTime: 60_000` as a floor. A missed hint self-heals on the next stale tick, so a slow polling fallback remains a safe net under the hint-driven path.

For finer control, the package also exports `useChannel(channel, options)` to subscribe to a single channel with auto token-mint, reconnect, and `sinceId` resume, and `usePresence(channel, profile)` for member lists ("Ana is viewing this booking"). Custom hint-to-key mapping is available through `resolveInvalidationKeys` and the `HintToQueryKeys` type from `@voyant-travel/realtime-react/query-keys`.

## Framework realtime versus Cloud realtime

These are two layers, and they cooperate.

* **Framework realtime** (`@voyant-travel/realtime` and `@voyant-travel/realtime-react`) is the deployment-side contract: the provider interface, the event bridge, the token-mint route, and the React hooks. It is vendor-agnostic.
* **[Cloud realtime](/docs/services/realtime)** is the managed pub/sub transport: channels, presence, history, and `RealtimeChannel`. It is one implementation behind the provider interface, wired in through `createVoyantCloudRealtimeProvider`.

If you self-host a different backend, you keep the platform layer and swap the provider. If you use Voyant, the two snap together with no glue beyond the provider factory.

## Next steps

<CardGroup cols={2}>
  <Card title="Events" icon="bolt" href="/docs/platform/fundamentals/events">
    The fire-and-forget event bus the realtime bridge subscribes to.
  </Card>

  <Card title="Cloud realtime" icon="tower-broadcast" href="/docs/services/realtime">
    The managed pub/sub transport behind the Voyant provider.
  </Card>

  <Card title="Caching" icon="bolt-lightning" href="/docs/platform/fundamentals/caching">
    The HTTP path that hints invalidate, and its staleness model.
  </Card>

  <Card title="API routes" icon="globe" href="/docs/guides/consume-the-api">
    Where the token-mint route mounts and how surfaces are split.
  </Card>
</CardGroup>
