@nominalso/vibe-bridge
v0.2.0
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).
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: this is a browser-only SDK. The wiring is always the same — construct
VibeAppBridgewith the hostparentOrigin,await bridge.connect()once, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
Install
npm install @nominalso/vibe-bridgeZero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
Quickstart
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
// 1. Construct with the origin of the Nominal app embedding this iframe.
// Must match exactly — drive it from an env var.
const bridge = new VibeAppBridge({
parentOrigin: import.meta.env.VITE_PARENT_ORIGIN,
})
// 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()parentOrigin via environment variable:
# .env (committed — standalone/dev default)
VITE_PARENT_ORIGIN=http://localhost:5173
# .env.local (git-ignored — override when testing inside nom-ui)
VITE_PARENT_ORIGIN=http://localhost:3000API
new VibeAppBridge(options)
| Option | Type | Default | Description |
| ---------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| parentOrigin | string | — | Origin of the Nominal app embedding this iframe. Must match the host origin exactly. |
| 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). 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.
Exports
VibeAppBridge, BridgeError, BRIDGE_VERSION, and the types VibeAppBridgeOptions, UploadOptions, ContextPayload, BridgeSubsidiary, UploadResponse, UploadProgress, RequestRegistry.
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({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
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).
import { BridgeError, HttpBridgeError } from '@nominalso/vibe-bridge'
try {
await bridge.getAccounts({})
} catch (err) {
if (err instanceof HttpBridgeError && err.status === 404) {
// handle not-found
}
if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
// back off and retry
}
throw err
}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 — parentOrigin must be an ORIGIN, not a URL with a path, and must match exactly.
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, exact match to the embedding app.
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })// ❌ 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 |
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 |
connect()(context) andupload()(file upload) 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.
