@nominalso/vibe-bridge
v0.10.1
Published
Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking). Safe to import from SSR, React Server Components, and edge/Worker runt
Readme
@nominalso/vibe-bridge
Iframe-side SDK for building Nominal Vibe Apps — standalone web apps (typically built with Lovable) embedded in the Nominal platform via a cross-origin <iframe>. The bridge connects your app to its Nominal host over a typed postMessage protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync.
For AI agents / Lovable: the wiring is always the same —
new VibeAppBridge()(no config — it auto-detects the Nominal host),await bridge.connect()once, then call data methods. Copy the quickstart below verbatim; it is the complete happy path. The bridge runs in the browser, but the package is safe to import from anywhere (SSR / React Server Components / edge) — see below.
Install
npm install @nominalso/vibe-bridgeShips ESM + CJS with self-contained TypeScript types. posthog-js is an optional dependency (installed automatically, but the SDK degrades gracefully if it is absent) used only when the host enables in-iframe session recording — see Session recording & custom events.
Safe to import anywhere (SSR, RSC, edge)
import { VibeAppBridge, BridgeError, HttpBridgeError, type ContextPayload } from '@nominalso/vibe-bridge' is safe from any file — an SSR entry, a React Server Component, a Cloudflare Worker (workerd + nodejs_compat), a shared util, or a client component. The package draws a clean server/client boundary:
- Importing has zero side effects and
new VibeAppBridge()is pure — the constructor stores options and touches nowindow/document/history/crypto/posthog-js. Nothing runs until you call a method. - The browser is only needed when a method actually runs. Calling one where there is no
windowthrows aBridgeErrorwith code'SERVER_ENVIRONMENT'(a clear, coded error — neverwindow is not defined). - RSC / edge / Worker runtimes resolve — via the
react-server,worker,workerd,edge-light, anddenoexport conditions — to a server-safe stub with the identical API, so a Server Component can reference the bridge without executing anything. The client build carries a'use client'directive so RSC bundlers place it on the client side of the boundary. - Plain Node (and jsdom/happy-dom test runners like Vitest and Jest) gets the real, self-guarding build — so component tests that import the package by name work against a real
windowand can call bridge methods normally; on a real Node server (nowindow) a call throwsSERVER_ENVIRONMENTat runtime. posthog-jsis optional and loaded lazily only when recording is enabled, so it never lands in a server bundle.
You therefore do not need createClientOnlyFn, dynamic import() wrappers, or typeof window guards around construction. Construct at the top of a client component and connect() in an effect:
// A client component in any SSR framework (Next.js App Router, TanStack Start,
// Remix, SvelteKit). Top-level import is fine; no client-only wrapper needed.
'use client' // Next.js App Router: mark your own component; the SDK is already marked.
import { useEffect, useState } from 'react'
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
export function useVibeBridge() {
const [ctx, setCtx] = useState<ContextPayload | null>(null)
useEffect(() => {
const bridge = new VibeAppBridge() // pure — safe even if this line runs during SSR
bridge.connect().then(setCtx).catch(console.error) // only runs in the browser
return () => bridge.destroy() // idempotent + no-op on the server
}, [])
return ctx
}Migrating from an earlier version? If you wrapped construction in
createClientOnlyFn/ a dynamicawait import('@nominalso/vibe-bridge'), guarded it withtypeof window !== 'undefined', or reimplementedinstanceofchecks by duck-typingerr.code/err.statusto avoid importing the error classes — you can delete all of it. ImportVibeAppBridge,BridgeError, andHttpBridgeErrordirectly, and useBridgeError.isBridgeError(err)/HttpBridgeError.isHttpBridgeError(err)(below).
Quickstart
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
// 1. Construct. No config needed — the bridge auto-detects the Nominal app
// that embeds it. (Pin the host origin explicitly only if you want to;
// see "Pinning the host origin" below.)
const bridge = new VibeAppBridge()
// 2. Connect ONCE on init and await it before any other call.
// Resolves with the tenant/user context (or rejects after 10s).
const ctx: ContextPayload = await bridge.connect()
// ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
// 3. Read Nominal data (any of the ~46 named operations).
const accounts = await bridge.getChartOfAccounts({
path: { subsidiary_id: ctx.subsidiaryId },
})
// 4. Upload a file through the host.
const uploaded = await bridge.upload(file, {
entityType: 'JOURNAL_ENTRY',
entityId: '123',
onProgress: (p) => console.log(`${p.progress}%`),
})
// 5. Submit a Close-Management task output — the ONLY write path into Nominal.
await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })
// 6. Tear down on unmount.
bridge.destroy()Pinning the host origin (optional)
By default you pass nothing — the bridge talks only to the Nominal page that
actually embeds it. That's safe because Nominal serves Vibe Apps behind a
frame-ancestors CSP, so only nom-ui can frame your app. Most apps need nothing
more.
Pass parentOrigin to pin explicitly — defense in depth, or when running
outside Nominal's edge. Drive it from an env var, or use a list / glob
patterns to accept dynamic preview deployments:
new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })# .env.local
VITE_PARENT_ORIGIN=http://localhost:3000A pattern's * matches exactly one DNS label or port (anchored, scheme
literal): https://*.vercel.app matches https://pr-7.vercel.app but not
https://a.b.vercel.app or https://pr-7.vercel.app.evil.com. Patterns are
honoured only when this app's own page is served from a recognised preview host
(*.vercel.app, *.lovable.app, localhost, …) — on a production custom
domain pattern entries are ignored and only exact origins match. Always include
at least one exact origin alongside any patterns.
API
new VibeAppBridge(options)
| Option | Type | Default | Description |
| ---------------- | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| parentOrigin | string \| string[] | auto | Optional. Omit to auto-detect the embedding Nominal host. Pass to pin: exact origins always match; glob patterns (https://*.vercel.app) match only when this app runs on a preview host. See above. |
| requestTimeout | number | per-op | Global timeout (ms) before a call rejects with BridgeError code 'TIMEOUT'. Omit to use per-operation defaults: API 30000, UPLOAD_FILE 120000, INVALIDATE_CACHE 10000, GET_CONTEXT 5000. |
connect(): Promise<ContextPayload>
Call once on init and await it before anything else. Polls the host every 500 ms until context arrives; rejects with Bridge connect timed out after 10 s (usually a parentOrigin mismatch or the host hasn't mounted). If no concrete parent origin can be resolved (e.g. a pattern-only parentOrigin on a non-preview host), it rejects immediately with BridgeError code PARENT_ORIGIN_UNRESOLVED. Concurrent calls return the same promise.
Data operations
Every operation has a typed named method (e.g. bridge.getChartOfAccounts(payload)); payloads and return types come straight from the Nominal API. For any operation, you can also call the generic escape hatch:
// `type` autocompletes to every operation name and narrows payload + return.
const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })See the operation catalog for the full list.
upload(file, options): Promise<UploadResponse>
Uploads a File through the host (converts to ArrayBuffer first — sandboxed iframes cannot pass File handles across windows). Resolves with { attachmentId, name } once Nominal has stored it.
| Option | Type | Description |
| ------------ | ----------------------------- | ----------------------------------------------------------- |
| entityType | string | Domain entity the file attaches to, e.g. 'JOURNAL_ENTRY'. |
| entityId | string \| number (optional) | Id of the entity, when applicable. |
| onProgress | (p: UploadProgress) => void | Called with incremental progress (0–100). |
Subroute deep-linking
reportSubroute(subroute, { replace? })— manually report the current subroute. Usually unnecessary — standard SPA navigation (history.pushState/replaceState) is auto-detected. Use it for hash-based or non-standard routers.onSubrouteRequest(cb): () => void— register a callback for host-initiated navigation (browser back/forward). Returns an unsubscribe function. If you don't register one, the SDK falls back tohistory.pushState+ apopstateevent, which works for most SPA routers.
destroy()
Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount. Idempotent and server-safe — calling it when connect()/attach() never ran (or where there is no window) is a harmless no-op, so it is safe as a React effect cleanup.
attach() (advanced, optional)
Installs the browser wiring (message listener + origin resolution) without starting the handshake. You almost never need this — connect() calls it for you, and any transport method lazily attaches on first use. Use it only if you want the bridge receiving host pushes before you call connect(). Throws BridgeError code 'SERVER_ENVIRONMENT' if there is no window.
Session recording & custom events
The cross-origin iframe is invisible to the host's own session replay (same-origin policy), so the bridge drives its own posthog-js — but only when the host opts in. posthog-js is an optional dependency, loaded lazily (await import('posthog-js')) the first time the host enables recording, so it is never bundled into a server build and adds nothing to apps that don't record. If it isn't installed, recording and track() degrade to a no-op (the bridge warns once, never throws). When the host pushes a posthog block with enabled: true, the bridge:
- inits
posthog-jswithrecordCrossOriginIframes: true(so your replay is stitched into the host's recording) and masking that mirrors nom-ui —maskAllInputs: falsewithmaskInputOptions: { password: true }(inputs unmasked for richer insight, password fields kept masked); - calls
identify()with the host-resolveddistinctId, so the recording and your events attribute to the same PostHog person as nom-ui; - if the host also passes its
sessionId(itsposthog.get_session_id()), seeds it viabootstrap.sessionIDsotrack()events share the host's$session_idand line up with the stitched replay.
The host is the single source of truth. If it sends no posthog block (older host, or recording disabled for this env/app), the bridge initializes nothing — enabled is the kill-switch even though posthog-js is bundled. You configure nothing in the app.
track(event, properties?)
Capture a custom product event. No-op until connect() has resolved with PostHog enabled by the host — so it's always safe to call.
bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })Namespace your events (e.g. vibe:<slug>:<action>) so they stay filterable from the host's own events. Events are sent directly from the iframe's PostHog instance and tie to the same person via identify(). By default they carry the iframe instance's $session_id; when the host supplies its sessionId in the posthog context, the bridge seeds it so events share the host's $session_id and correlate with the stitched replay (best-effort — a one-time seed at connect, so a later host-session rotation can diverge).
App-side CSP. Whoever serves your app must allow, on the app's own origin,
connect-src https://*.i.posthog.com https://us-assets.i.posthog.comandworker-src blob:(recording runs in a web worker). Noscript-srcentry is needed —posthog-jsis bundled into your app via the bridge, not loaded from a CDN.
Exports
VibeAppBridge, BridgeError, HttpBridgeError, BRIDGE_VERSION, and the types VibeAppBridgeOptions, UploadOptions, ContextPayload, AuthPayload, BridgeSubsidiary, PostHogContext, UploadResponse, UploadProgress, RequestRegistry, NavigateAction, SetSideNavPayload, InvalidateCachePayload, InvalidateResult. The same public surface (types identical) is available at the @nominalso/vibe-bridge/server subpath, which always resolves to the server-safe stub.
Common recipes
React — connect on mount, tear down on unmount:
import { useEffect, useState } from 'react'
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
function useVibeBridge() {
const [ctx, setCtx] = useState<ContextPayload | null>(null)
useEffect(() => {
const bridge = new VibeAppBridge()
bridge.connect().then(setCtx).catch(console.error)
return () => bridge.destroy()
}, [])
return ctx
}Upload with a progress bar:
await bridge.upload(file, {
entityType: 'JOURNAL_ENTRY',
onProgress: (p) => (progressBar.style.width = `${p.progress}%`),
})An operation without a named method — use the typed request():
const events = await bridge.request('GET_AUDIT_EVENTS', {})Handle a failure by its code:
A rejected operation throws a BridgeError carrying the host's code ('RATE_LIMITED', 'FILE_TOO_LARGE', 'TIMEOUT', …); branch on it instead of string-matching the message. A failed Nominal API call throws the HttpBridgeError subtype (code: 'REQUEST_FAILED'), which adds the HTTP status — branch on err.status (e.g. 404) — and the API's parsed error response as err.body.
import { BridgeError, HttpBridgeError } from '@nominalso/vibe-bridge'
try {
await bridge.getAccounts({})
} catch (err) {
if (HttpBridgeError.isHttpBridgeError(err) && err.status === 404) {
// handle not-found
}
if (BridgeError.isBridgeError(err) && err.code === 'RATE_LIMITED') {
// back off and retry
}
throw err
}Show a rejected write on the right field (err.body):
err.body is the API's own error response, forwarded for every failed status so your app has the whole failure to act on. It is typed unknown because the shape belongs to the API rather than the protocol, and it is undefined against a host that predates the field — so narrow and guard before reading. Nominal sends { status, message, details }, where details maps each rejected field to its message:
try {
await bridge.postTaskOutput({ body: draft })
} catch (err) {
if (HttpBridgeError.isHttpBridgeError(err) && err.status === 422) {
const { details } = (err.body ?? {}) as { details?: Record<string, unknown> }
setFieldErrors(details ?? {}) // → { account_id: 'Field required' }
return
}
throw err
}Don't read err.message for this — it is a generic API request failed with status 422. A 5xx body can carry server-internal text, so log it rather than rendering it verbatim to a user.
instanceof BridgeError also works within a single bundle. Prefer the static BridgeError.isBridgeError(err) / HttpBridgeError.isHttpBridgeError(err) guards when an error might cross independently-built bundles (e.g. a server bundle catching an error thrown by the client bundle) — they match a process-global brand rather than class identity, so they survive bundler realm boundaries.
Common mistakes
// ❌ WRONG — calling a data method before connect() resolves.
const bridge = new VibeAppBridge({ parentOrigin })
const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: 1 } })
// ✅ CORRECT — await connect() first; it establishes the session context.
const bridge = new VibeAppBridge({ parentOrigin })
const ctx = await bridge.connect()
const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.subsidiaryId } })// ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
// ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
// ✅ CORRECT — scheme + host + port only, matching the embedding app.
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
// ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })// ❌ WRONG — upload takes a File object, not a path or FormData.
await bridge.upload('/tmp/report.pdf', { entityType: 'JOURNAL_ENTRY' })
// ✅ CORRECT — pass the File (e.g. from an <input type="file">).
await bridge.upload(fileInput.files![0], { entityType: 'JOURNAL_ENTRY' })Operation catalog
All operations are reachable as bridge.<method>(payload) or bridge.request('<OPERATION>', payload).
Accounting — chart of accounts
| Method | Operation |
| ---------------------------------- | ------------------------------------- |
| getChartOfAccounts | GET_CHART_OF_ACCOUNTS |
| getCoaTree | GET_COA_TREE |
| getCoaFlatSimple | GET_COA_FLAT_SIMPLE |
| getCoaGrouped | GET_COA_GROUPED |
| getCoaAccount | GET_COA_ACCOUNT |
| getSubsidiaryAvailableCurrencies | GET_SUBSIDIARY_AVAILABLE_CURRENCIES |
| getAccounts | GET_ACCOUNTS |
| getAccount | GET_ACCOUNT |
Accounting — exchange rates
| Method | Operation |
| -------------------------- | ----------------------------- |
| getConversionRates | GET_CONVERSION_RATES |
| getEffectiveExchangeRate | GET_EFFECTIVE_EXCHANGE_RATE |
| getExchangeRateByDate | GET_EXCHANGE_RATE_BY_DATE |
Accounting — dimensions
| Method | Operation |
| -------------------------------- | ----------------------------------- |
| getDimensions | GET_DIMENSIONS |
| getDimensionValues | GET_DIMENSION_VALUES |
| getDimensionValuesHierarchical | GET_DIMENSION_VALUES_HIERARCHICAL |
| getDimensionAccountAssignments | GET_DIMENSION_ACCOUNT_ASSIGNMENTS |
Accounting — journal entries
| Method | Operation |
| ------------------- | --------------------- |
| getJournalEntries | GET_JOURNAL_ENTRIES |
| getJournalLines | GET_JOURNAL_LINES |
| getJournalEntry | GET_JOURNAL_ENTRY |
Activity — period instances
| Method | Operation |
| ---------------------------- | ------------------------------- |
| getPeriods | GET_PERIODS |
| getPeriodInstance | GET_PERIOD_INSTANCE |
| getPeriodInstanceBySlug | GET_PERIOD_INSTANCE_BY_SLUG |
| getPeriodProgressBreakdown | GET_PERIOD_PROGRESS_BREAKDOWN |
Activity — activity definitions & instances
| Method | Operation |
| ----------------------------- | --------------------------------- |
| getActivityDefinitions | GET_ACTIVITY_DEFINITIONS |
| getActivityDefinition | GET_ACTIVITY_DEFINITION |
| getActivityInstances | GET_ACTIVITY_INSTANCES |
| getActivityInstance | GET_ACTIVITY_INSTANCE |
| getActivityInstanceByPeriod | GET_ACTIVITY_INSTANCE_BY_PERIOD |
| getActivityInstanceTasks | GET_ACTIVITY_INSTANCE_TASKS |
| getActivityPeriodTasks | GET_ACTIVITY_PERIOD_TASKS |
Activity — task definitions & instances
| Method | Operation |
| ---------------------------- | -------------------------------- |
| getTaskDefinitions | GET_TASK_DEFINITIONS |
| getTaskDefinition | GET_TASK_DEFINITION |
| createTaskDefinition | CREATE_TASK_DEFINITION |
| updateTaskDefinition | UPDATE_TASK_DEFINITION |
| getTaskDefinitionsByFilter | GET_TASK_DEFINITIONS_BY_FILTER |
| getTaskInstances | GET_TASK_INSTANCES |
| getTaskInstance | GET_TASK_INSTANCE |
| getTaskInstancesByFilter | GET_TASK_INSTANCES_BY_FILTER |
| postTaskOutput | POST_TASK_OUTPUT |
Accounting — account reconciliation
| Method | Operation |
| -------------------------------- | ----------------------------------- |
| uploadReconciliationSource | UPLOAD_RECONCILIATION_SOURCE |
| previewReconciliationSource | PREVIEW_RECONCILIATION_SOURCE |
| refreshReconciliationSource | REFRESH_RECONCILIATION_SOURCE |
| getLatestReconciliationCapture | GET_LATEST_RECONCILIATION_CAPTURE |
| listReconciliationSources | LIST_RECONCILIATION_SOURCES |
| getReconciliationTasks | GET_RECONCILIATION_TASKS |
Audit trail
| Method | Operation |
| ---------------------- | ------------------------- |
| getAuditEvents | GET_AUDIT_EVENTS |
| getEntityAuditEvents | GET_ENTITY_AUDIT_EVENTS |
Period manager — fiscal calendars
| Method | Operation |
| -------------------- | ---------------------- |
| getFiscalCalendars | GET_FISCAL_CALENDARS |
| getFiscalCalendar | GET_FISCAL_CALENDAR |
Tenancy
| Method | Operation |
| ------------------------------- | ---------------------------------- |
| getSubsidiaries | GET_SUBSIDIARIES |
| getSubsidiary | GET_SUBSIDIARY |
| getSubsidiaryParentCurrencies | GET_SUBSIDIARY_PARENT_CURRENCIES |
| getTenantUsers | GET_TENANT_USERS |
File helpers
uploadReconciliationSourceFile/previewReconciliationSourceFilewrap the upload/preview ops for browserFileinputs.connect()(context) andupload()(generic attachments) have dedicated methods and are not in this table.
How it fits together
The host side is @nominalso/vibe-host, used by the Nominal app (nom-ui). See the repository for the full protocol, architecture, and connect() semantics. Bundled agent docs ship in this package under docs/ and AGENTS.md.
License
UNLICENSED — proprietary. © Nominal. All rights reserved.
