- SDK Reference
- @ironflow/browser
@ironflow/browser
Browser client for Ironflow. Provides real-time subscriptions, workflow triggers, and event emission for web applications.
Installation
Section titled “Installation”The package is public on npm and installs without authentication. For building from source, see the Makefile and per-package README.md files in the repo.
npm install @ironflow/browserQuick Start
Section titled “Quick Start”import { ironflow } from '@ironflow/browser';
// Configure once at app startupironflow.configure({ serverUrl: 'http://localhost:9123',});
// Subscribe to eventsconst sub = await ironflow.subscribe('events:order.*', { onEvent: (event) => console.log('Order:', event),});
// Invoke a function by ID and wait for its resultconst run = await ironflow.invoke('process-order', { data: { orderId: '123' },});console.log(run.output);
// Emit eventsawait ironflow.emit('order.approved', { orderId: '123' }, { version: 1 });
// Cleanupsub.unsubscribe();Client
Section titled “Client”configure
Section titled “configure”Configure the singleton client. Call once at app startup.
ironflow.configure({ // Server URL (optional, default: http://localhost:9123) serverUrl: 'https://ironflow.example.com',
// Transport: 'connectrpc' (default) or 'websocket' transport: 'connectrpc',
// Authentication (optional for local development) // Prefer a short-lived session token issued by a trusted backend. auth: { token: session.accessToken, // apiKey: 'ifkey_...', // Development only; never ship in a browser bundle },
// Reconnection settings reconnect: { enabled: true, // Enable auto-reconnect (default: true) maxAttempts: 10, // Max reconnection attempts (default: 10) backoff: { initial: 1000, // Initial delay in ms (default: 1000) max: 30000, // Max delay in ms (default: 30000) multiplier: 2, // Backoff multiplier (default: 2) }, },
// Tab visibility handling visibility: { pauseOnHidden: true, // Pause subscriptions when tab hidden (default: true) reconnectOnVisible: true, // Reconnect when tab visible (default: true) },
// Custom logger (optional) logger: console, // or false to disable logging
// Request timeout in ms (optional) timeout: 30000,
// Target environment (optional, default: 'default') environment: 'default',});createClient
Section titled “createClient”Create a client whose writes are persisted to IndexedDB before they are sent, so they survive going offline, a reload, or a crash. Opt-in, and separate from the ironflow singleton — see Offline Write Queue.
import { createClient } from '@ironflow/browser';
const app = await createClient({ serverUrl: 'https://ironflow.example.com', auth: { token: session.accessToken },
// Everything above is the same shape configure() takes. offlineQueue: { identity: currentUser.id, // required — see Identity below },});
await app.emit('order.approved', { orderId: '123' }); // persisted, then sentapp.client.subscribe(...); // everything else lives hereAsync because the outbox has to be opened before the first write can be answered honestly. Returns an OfflineClient, not an IronflowClient — only emit and streams.append are wrapped; the full client is on .client.
getConfig / isConfigured
Section titled “getConfig / isConfigured”Read back the resolved configuration. getConfig() throws NotConfiguredError
if configure() has not run; isConfigured is a boolean getter that never
throws.
const config = ironflow.getConfig(); // IronflowConfigif (ironflow.isConfigured) { /* ... */ }setAuth
Section titled “setAuth”Replace the credential without re-running configure() — for rotating a
short-lived session token.
ironflow.setAuth({ token: session.accessToken });ironflow.setAuth(undefined); // clearConnection Management
Section titled “Connection Management”connect
Section titled “connect”Manually connect to the server.
await ironflow.connect();disconnect
Section titled “disconnect”Disconnect from the server.
ironflow.disconnect();onConnectionChange
Section titled “onConnectionChange”Listen for connection state changes.
const unsubscribe = ironflow.onConnectionChange((state) => { // state: 'connecting' | 'connected' | 'disconnected' | 'reconnecting' console.log('Connection state:', state);});
// Stop listeningunsubscribe();Event Emission
Section titled “Event Emission”Emit an event.
// Basic emitawait ironflow.emit('order.approved', { orderId: '123', approvedBy: 'user@example.com',});
// With optionsawait ironflow.emit('order.approved', { orderId: '123' }, { version: 2, // Event schema version (default: 1) idempotencyKey: 'unique-key', // Deduplication key});publish
Section titled “publish”Publish to a developer pub/sub topic without triggering workflow functions.
const result = await ironflow.publish( 'notifications', { userId: '123', message: 'Hello!' }, { idempotencyKey: 'notification-123' },);
console.log(result.eventId);console.log(result.sequence);On a client from createClient this method behaves differently: the event is written to the outbox and the promise resolves once it is stored, not once the server has it. The return type is QueuedWriteResult, not EmitResult.
emitSync
Section titled “emitSync”Emit an event and wait for every run it triggers. Calls TriggerSync, which
blocks until the runs finish or the timeout elapses.
Returns EmitSyncResult[] — one element per matched trigger, in the order the
server reports them. An event that matches nothing returns []; that is not an
error.
Run outcomes are never thrown: with N results there is no unambiguous choice
of which failure to raise, and an aggregate throw would hide the runs that
succeeded. Inspect status, error and waitTimedOut per element. Transport,
protocol and validation errors still throw. Use invoke when you
want one function and an exception on failure.
The timeout does not cancel the runs. The request transport uses the selected wait plus five seconds of response grace, even when the client’s ordinary request timeout is shorter.
const results = await ironflow.emitSync('order.placed', { orderId: '123' });
// With an event schema version (#1955). Omit for 1.const v2 = await ironflow.emitSync('order.placed', { orderId: '123' }, { version: 2,});
for (const result of results) { console.log(result.functionId); // Function that was triggered console.log(result.status); // 'completed' | 'failed' | 'cancelled' | ... console.log(result.output); // Run output console.log(result.error); // { message, code? } when the run failed console.log(result.durationMs); // Wall-clock duration console.log(result.waitTimedOut); // true — the run is still going server-side}
// With a custom timeout (default: 30 000 ms) and a deduplication keyconst withOptions = await ironflow.emitSync('order.placed', { orderId: '123' }, { timeout: 60000, idempotencyKey: 'order-123-placed',});triggerBatch
Section titled “triggerBatch”Emit several events in one round trip. Returns one EmitResult per event, in
order.
const results = await ironflow.triggerBatch([ { event: 'order.placed', data: { orderId: '1' } }, { event: 'order.placed', data: { orderId: '2' }, idempotencyKey: 'order-2' },]);listEvents
Section titled “listEvents”Page through stored events. All filters are optional.
const page = await ironflow.listEvents({ names: ['order.placed'], sources: ['api'], since: '2026-01-01T00:00:00Z', limit: 50, cursor: page?.nextCursor,});
console.log(page.events, page.hasNext, page.nextCursor);getEvent
Section titled “getEvent”Fetch one stored event by id.
const event = await ironflow.getEvent('evt_abc123');listEventNames
Section titled “listEventNames”List the distinct event names seen in the environment, with counts. The server
scans up to scanCap events and sets truncated when it stopped early.
const { names, scanned, truncated, scanCap } = await ironflow.listEventNames({ since: '2026-01-01T00:00:00Z',});Run Management
Section titled “Run Management”invoke
Section titled “invoke”Run one function by function ID and wait for its result. Calls
IronflowService/InvokeFunctionSync, which targets exactly that function — no
trigger matching, no fan-out — and returns a single InvokeSyncResult.
Because there is exactly one run, invoke throws on the run’s outcome:
RunFailedError if it fails, RunCancelledError if it is cancelled, and
non-retryable RunWaitTimeoutError if the wait budget expires while the run is
still going (the run survives; poll getRun(runId)). This is the deliberate
counterpart to emitSync, which never throws on an outcome. See
ADR 0067.
Aborting the request cancels the run server-side: InvokeFunctionSync ties the
run’s lifetime to the request context, so a dropped connection stops the work
rather than leaving it orphaned.
// 'process-order' here is a FUNCTION ID, not an event name.const result = await ironflow.invoke('process-order', { data: { orderId: '123' },});
console.log(result.runId); // ID of the runconsole.log(result.functionId); // 'process-order'console.log(result.status); // 'completed'console.log(result.output); // Run outputconsole.log(result.durationMs); // Wall-clock duration
// Type-safe invoke (TInput — the input payload), with optionsinterface Input { orderId: string }
const typed = await ironflow.invoke<Input>('process-order', { data: { orderId: '123' }, timeout: 60000, idempotencyKey: 'order-123',});To start a function by event and get its run IDs back without waiting, use
emit.
getRun
Section titled “getRun”Get run status.
const run = await ironflow.getRun('run_abc123');
console.log(run.status); // Run statusconsole.log(run.attempt); // Current attempt numberconsole.log(run.output); // Output (if completed)console.log(run.error); // Error (if failed)console.log(run.startedAt); // Start time (if started)console.log(run.endedAt); // End time (if finished)listRuns
Section titled “listRuns”List runs with filters.
const runs = await ironflow.listRuns({ functionId: 'process-order', // Filter by function status: 'running', // Filter by status limit: 50, // Max results cursor: 'abc123', // Pagination cursor});
console.log(runs.runs); // Array of runsconsole.log(runs.nextCursor); // Next page cursorcancelRun
Section titled “cancelRun”Cancel a running workflow.
await ironflow.cancelRun('run_abc123');getRunSteps
Section titled “getRunSteps”Get the recorded steps for a run.
const { steps, count } = await ironflow.getRunSteps('run_abc123');for (const step of steps) { console.log(step.stepId, step.status, step.output);}getRunStreams
Section titled “getRunStreams”List the entity streams a run touched.
const { entityIds } = await ironflow.getRunStreams('run_abc123');Scoped Injection
Section titled “Scoped Injection”Pause running workflows, inspect step outputs, inject modifications, and resume:
// Pause at next step boundaryawait ironflow.pauseRun("run_abc123");
// Get paused state with completed stepsconst state = await ironflow.getPausedState("run_abc123");for (const step of state.steps) { console.log(step.name, step.output, step.injected);}
// Inject modified outputconst result = await ironflow.injectStepOutput( "run_abc123", "step_xyz", { corrected: true }, "Fix calculation");
// Resume with injected dataawait ironflow.resumeRun("run_abc123");
// Patch a completed step's output in place (history editing). The pre-patch// value stays available as `originalOutput` on the step.await ironflow.patchStep("step_xyz", { corrected: true }, "Fix calculation");Time-Travel Debugging
Section titled “Time-Travel Debugging”Inspect historical run state at any timestamp. Requires recording: true on the function.
// Get run state at a specific point in timeconst snapshot = await ironflow.getRunStateAt("run_abc123", new Date("2024-01-15T10:30:00Z"));console.log("Status at that time:", snapshot.status);for (const step of snapshot.steps) { console.log(step.name, step.status, step.patched);}
// Get full timeline of audit events for a runconst events = await ironflow.getRunTimeline("run_abc123");for (const evt of events) { console.log(evt.timestamp, evt.eventType, evt.summary, evt.significant);}
// Get a specific step's output at a point in timeconst stepOutput = await ironflow.getStepOutputAt( "run_abc123", "step_xyz", new Date("2024-01-15T10:30:00Z"));console.log(stepOutput.output, stepOutput.patched, stepOutput.injected);Projections
Section titled “Projections”getProjection
Section titled “getProjection”Get the current state of a projection.
const result = await ironflow.getProjection<OrderStats>('order-stats');console.log(result.state); // Typed projection stateconsole.log(result.name); // Projection nameconsole.log(result.partition); // Partition key (or '__global__')console.log(result.version); // Projection versionconsole.log(result.lastEventSeq); // Sequence number of the last processed eventconsole.log(result.lastEventId); // ID of the last processed eventconsole.log(result.lastEventTime); // Timestamp of the last processed event (Date)console.log(result.mode); // 'managed' | 'external'
// With partitionconst resultWithPartition = await ironflow.getProjection('order-stats', { partition: 'customer-123',});listProjections
Section titled “listProjections”List all registered projections with their operational status.
const projections = await ironflow.listProjections();for (const p of projections) { console.log(p.name, p.status, p.mode, p.lag);}getProjectionStatus
Section titled “getProjectionStatus”Get the operational status of a single projection.
const status = await ironflow.getProjectionStatus('order-stats');console.log(status.status); // 'active' | 'rebuilding' | 'paused' | 'error'console.log(status.mode); // 'managed' | 'external'console.log(status.lastEventSeq); // Last processed event sequence numberconsole.log(status.lag); // Number of unprocessed eventsconsole.log(status.errorMessage); // Error message (if status === 'error')console.log(status.updatedAt); // Timestamp of last status update (Date)Projection lifecycle operations such as rebuild, pause, resume, and delete are operator concerns and are intentionally not exposed by the Browser SDK.
subscribeToProjection
Section titled “subscribeToProjection”Subscribe to real-time state updates for a projection.
const sub = await ironflow.subscribeToProjection<OrderStats>('order-stats', { onUpdate: (state, event) => { console.log('New state:', state); console.log('Triggered by event:', event.id, event.name); }, onError: (error) => console.error(error),});
// With partition and replayconst subWithPartition = await ironflow.subscribeToProjection('order-stats', { onUpdate: (state) => console.log(state),}, { partition: 'customer-123', replay: 1, // Replay the latest update on connect});
// Cleanupsub.unsubscribe();querySQLProjection
Section titled “querySQLProjection”Query a SQL-backed projection table with optional filtering, ordering, and pagination.
const result = await ironflow.querySQLProjection('board', { where: "status = 'OPEN'", orderBy: 'title ASC', limit: 50, offset: 0,});
console.log(result.columns); // Column namesconsole.log(result.rows); // Array of string[] rowsconsole.log(result.typedRows); // Same rows with SQL types preservedconsole.log(result.totalCount); // Total matching rows (before limit)rows is every value stringified. typedRows is the same data with its SQL
type intact: a numeric column arrives as a number, a boolean as a boolean, and
NULL as null. Exact-precision columns (numeric, decimal, money) arrive
as strings — JSON numbers are doubles, and rounding a money column silently is
worse than handing you the digits.
The where string is parsed into bound parameters rather than passed to the
database as SQL, so it accepts filters only — not arbitrary SQL:
where := predicate (('AND' | 'OR') predicate)* -- parentheses allowedpredicate := column ('=' | '!=' | '<>' | '<' | '<=' | '>' | '>=') value | column ['NOT'] LIKE value | column IS ['NOT'] NULL | column ['NOT'] IN (value, ...) | column '@@' tsquery -- full-text matchtsquery := ('plainto_tsquery' | 'websearch_to_tsquery') '(' 'string' ')'value := 'string' | number | TRUE | FALSEAnything outside that grammar — a subquery, a function call, a column-to-column
comparison, a schema-qualified name — is rejected with an error. Values are
always bound, so a literal containing SQL keywords (status = 'select') is
fine.
orderBy is parsed the same way, and reaches the two expressions an indexed
read model needs:
orderBy := item (',' item)*item := expr ['ASC' | 'DESC']expr := column | column ('<->' | '<=>' | '<#>') 'vector literal' -- pgvector distance | 'ts_rank' '(' column ',' tsquery ')' -- full-text rank// Nearest neighbours by cosine distance — reaches an HNSW indexconst similar = await ironflow.querySQLProjection('chunks', { where: 'index_version = 7', orderBy: `embedding <=> '[${queryEmbedding.join(',')}]'`, limit: 10,});
// Ranked full-text search — reaches a GIN indexconst hits = await ironflow.querySQLProjection('chunks', { where: "tsv @@ plainto_tsquery('quarterly revenue')", orderBy: "ts_rank(tsv, plainto_tsquery('quarterly revenue')) DESC", limit: 20,});The vector literal and the search string are bound as parameters, never concatenated. Both expression forms require the PostgreSQL backend; on SQLite they return an error saying so rather than a driver syntax error.
waitForProjectionCatchup
Section titled “waitForProjectionCatchup”Block until a projection has consumed up to minSeq (read-your-writes). Returns
a WaitResult; on success exactly one of caughtUp / timedOut is true.
const { eventId, sequence } = await ironflow.emit('order.placed', { orderId: '1' });const result = await ironflow.waitForProjectionCatchup('order-stats', { minSeq: sequence, timeoutMs: 5000,});if (result.caughtUp) { /* safe to read */ }waitForEvent
Section titled “waitForEvent”The same wait, keyed by an event id instead of a sequence — the client resolves the sequence for you.
await ironflow.waitForEvent(eventId, 'order-stats', { timeoutMs: 5000 });Subscriptions
Section titled “Subscriptions”subscribe
Section titled “subscribe”Subscribe to events matching a pattern.
// Basic subscriptionconst sub = await ironflow.subscribe('events:order.*', { onEvent: (event) => console.log(event), onError: (error) => console.error(error), onStateChange: (state) => console.log(state),});
// Type-safe subscriptioninterface OrderEvent { orderId: string; amount: number;}
const sub = await ironflow.subscribe<OrderEvent>('events:order.*', { onEvent: (event) => { // event.data is typed as OrderEvent console.log(event.data.orderId); },});
// With optionsconst sub = await ironflow.subscribe('events:*', { onEvent: (e) => console.log(e), replay: 10, // Replay last N events on connect trackState: true, // Enable .lastEvent access filter: 'event.data.amount > 100', // CEL filter expression backpressure: 'buffer', // 'buffer' (default) | 'drop' | 'block'});
// Access last event (when trackState is true)console.log(sub.lastEvent);
// Unsubscribesub.unsubscribe();replay applies only to the initial subscribe request. Reconnects preserve the
filter, consumer group, metadata, acknowledgment, backpressure, and namespace
options but omit the original replay count. A fan-out subscription without a
cursor reconnects at the current tail and can miss events published while it
was offline. Use startAfterSequence or a consumer group when that gap matters.
onEvent is not awaited. An async handler is fine — manual acknowledgment
needs one — but it runs unsupervised: the next event can be delivered before it
settles, so there is no ordering or backpressure guarantee, and a rejection
becomes an unhandled rejection rather than reaching onError. Catch inside the
handler, including around ack and nak, and serialize the work yourself if
you need ordering or a persisted cursor.
Pattern Syntax
Section titled “Pattern Syntax”| Pattern | Description |
|---|---|
events:order.* | All order events |
events:order.created | Specific event |
events:* | All events |
system.run.* | All run updates |
system.run.{runId}.* | Specific run updates |
system.run.{runId}.step.{stepId} | Specific step updates |
Multiple Patterns
Section titled “Multiple Patterns”const sub = await ironflow.subscribe( ['system.run.*', 'events:order.*', 'events:payment.*'], { onEvent: (event) => console.log(event), });Manual Acknowledgment
Section titled “Manual Acknowledgment”import type { AckableSubscription } from '@ironflow/core';
const sub = await ironflow.subscribe('events:*', { onEvent: async (event) => { if (!event.eventId) return;
try { await processEvent(event); await sub.ack(event.eventId); // Acknowledge } catch { await sub.nak(event.eventId); // Negative ack - redeliver } }, ackMode: 'manual',}) as AckableSubscription;Subscription Groups
Section titled “Subscription Groups”Manage multiple subscriptions together.
const group = ironflow.subscriptionGroup();
await group.add('system.run.abc123.*', { onEvent: (e) => console.log('Run:', e),});
await group.add('events:payment.*', { onEvent: (e) => console.log('Payment:', e),});
// Unsubscribe all at oncegroup.unsubscribeAll();getActiveSubscriptionCount
Section titled “getActiveSubscriptionCount”Number of subscriptions currently attached on this client. Useful in tests and teardown assertions.
console.log(ironflow.getActiveSubscriptionCount());KV Store
Section titled “KV Store”ironflow.kv()
Section titled “ironflow.kv()”Get a KV client for bucket management and key operations:
const kv = ironflow.kv();createBucket
Section titled “createBucket”const info = await kv.createBucket({ name: "sessions", description: "User session data", ttlSeconds: 3600, maxValueSize: 65536, history: 3,});deleteBucket
Section titled “deleteBucket”await kv.deleteBucket("sessions");listBuckets
Section titled “listBuckets”const buckets = await kv.listBuckets();for (const b of buckets) { console.log(`${b.name}: ${b.values} keys, ${b.bytes} bytes`);}getBucketInfo
Section titled “getBucketInfo”const info = await kv.getBucketInfo("sessions");// info.name, info.values, info.bytes, info.history, info.created_atBucket Handle
Section titled “Bucket Handle”Get a bucket handle for key operations:
const bucket = kv.bucket("sessions");const entry = await bucket.get("user:123");// entry.key, entry.value, entry.revision, entry.created_at, entry.operationUnconditional write. Returns the new revision:
const { revision } = await bucket.put("user:123", { name: "Alice" });create (If-Not-Exists)
Section titled “create (If-Not-Exists)”Write only if the key does not exist. Throws on conflict (HTTP 412):
const { revision } = await bucket.create("user:123", { name: "Alice" });update (Compare-and-Swap)
Section titled “update (Compare-and-Swap)”Write only if the revision matches. Throws on mismatch (HTTP 412):
const { revision } = await bucket.update("user:123", newValue, entry.revision);delete
Section titled “delete”Soft-delete (tombstone):
await bucket.delete("user:123");Permanently remove key and all history:
await bucket.purge("user:123");listKeys
Section titled “listKeys”List keys with optional wildcard filter:
const keys = await bucket.listKeys("user.*");// Pass no argument for all keysWatch for real-time key changes via WebSocket (browser SDK only):
const watcher = bucket.watch({ onUpdate: (event) => { console.log(`${event.key} ${event.operation}: rev ${event.revision}`); }, onError: (err) => console.error(err), onClose: () => console.log("Watch ended"),}, { key: "user.*" });
// Stop watchingwatcher.stop();See the KV Store Guide for detailed usage patterns.
Config Management
Section titled “Config Management”ironflow.configManager()
Section titled “ironflow.configManager()”Get a config client for environment-scoped configuration:
const config = ironflow.configManager();The Browser SDK deliberately exposes config reads and watches only. Create, update, and delete configs from a trusted server-side SDK, the CLI, or the dashboard. This keeps administrative credentials out of browser applications.
const entry = await config.get("app-settings");// entry.name, entry.data, entry.revision, entry.updatedAtconst configs = await config.list();for (const entry of configs) { console.log(`${entry.name}: rev ${entry.revision}`);}Watch for real-time config changes via WebSocket:
const watcher = await config.watch("feature-flags", { onUpdate: (event) => { // event: ConfigWatchEvent ({ type: "config_update", name, data, revision, updatedAt }) console.log("Config updated:", event.data); console.log("New revision:", event.revision); }, onError: (err) => console.error("Watch error:", err),});
// `watcher` is a Subscription — call unsubscribe() to stop watching.watcher.unsubscribe();See the Config Management explanation for detailed usage patterns.
Entity Streams
Section titled “Entity Streams”streams.append
Section titled “streams.append”Appends an event to an entity stream.
const result = await ironflow.streams.append("order-123", { name: "order.item_added", data: { itemId: "item-789" }, entityType: "order",}, { expectedVersion: 4 });
// result.entityVersion = 5// result.eventId = "evt-..."On a client from createClient this method is queued rather than sent, and returns QueuedWriteResult. expectedVersion is rejected at call time there unless it is -1 — a version read before the write was queued is stale by definition. See Offline Write Queue.
streams.read
Section titled “streams.read”Reads events from an entity stream.
const { events, totalCount } = await ironflow.streams.read("order-123", { fromVersion: 1, limit: 50, direction: "forward",});streams.getInfo
Section titled “streams.getInfo”Returns stream metadata, or null if no events have been written to this stream yet.
const info = await ironflow.streams.getInfo("order-123");// info.entityId, info.entityType, info.version, info.eventCount// info is null for streams with no events — safe to pass expectedVersion: 0 to append()Returns: Promise<StreamInfo | null>
streams.createSnapshot
Section titled “streams.createSnapshot”Save a materialized state snapshot at a specific stream version. Snapshots enable efficient rebuilds by avoiding replaying all events from the beginning.
const { snapshotId } = await ironflow.streams.createSnapshot("order-123", { entityType: "order", // Must match the stream's own type, or the call is rejected entityVersion: 42, // The stream version this snapshot represents state: { status: "OPEN", total: 99.99 }, // The materialized state});console.log(snapshotId); // Unique snapshot identifierstreams.getSnapshot
Section titled “streams.getSnapshot”Retrieve the latest snapshot at or before a given stream version.
const snapshot = await ironflow.streams.getSnapshot("order-123");// Optionally limit to snapshots before a specific version:const snapshotBeforeVersion = await ironflow.streams.getSnapshot("order-123", { beforeVersion: 50,});
console.log(snapshot.snapshotId); // Snapshot identifierconsole.log(snapshot.entityId); // Entity IDconsole.log(snapshot.entityType); // Entity typeconsole.log(snapshot.entityVersion); // Stream version this snapshot representsconsole.log(snapshot.state); // The materialized state objectconsole.log(snapshot.createdAt); // ISO 8601 creation timestampstreams.listStreams
Section titled “streams.listStreams”List the entity streams visible in the current environment.
const streams = await ironflow.streams.listStreams();streams.getEntityHistory
Section titled “streams.getEntityHistory”Read the unified event history for an entity.
const history = await ironflow.streams.getEntityHistory('order-123');streams.subscribe
Section titled “streams.subscribe”Subscribe to the live event feed of one entity stream.
const sub = await ironflow.streams.subscribe('order-123', { onEvent: (event) => console.log(event.eventName, event.data),});
sub.unsubscribe();Consumer Groups
Section titled “Consumer Groups”Join a consumer group for load-balanced event processing.
const sub = await ironflow.joinConsumerGroup( 'order-processors', // Group name 'events:order.*', // Pattern { onEvent: async (event) => { try { await processOrder(event); await sub.ack(event.eventId!); } catch { await sub.nak(event.eventId!, 5000); // redeliver after 5s } }, });Manage the durable group definition through consumerGroups. Listing follows
server cursors until every page has been returned, and an empty update is
rejected before it reaches the server.
const group = await ironflow.consumerGroups.create({ name: 'order-processors', pattern: 'events:order.*',});const groups = await ironflow.consumerGroups.list();const one = await ironflow.consumerGroups.get(group.name);await ironflow.consumerGroups.update(group.name, { maxInflight: 50 });await ironflow.consumerGroups.delete(group.name);Functions & Workers
Section titled “Functions & Workers”listFunctions
Section titled “listFunctions”List all registered functions in the current environment.
const functions = await ironflow.listFunctions();for (const fn of functions) { console.log(fn.id, fn.name);}listWorkers
Section titled “listWorkers”List connected pull-mode workers.
const workers = await ironflow.listWorkers();for (const worker of workers) { console.log(worker.id, worker.status);}getFunction
Section titled “getFunction”Get one function’s registered definition.
const fn = await ironflow.getFunction('process-order');console.log(fn.status, fn.version, fn.triggers);updateFunctionStatus
Section titled “updateFunctionStatus”Enable, pause or archive a function.
await ironflow.updateFunctionStatus('process-order', 'paused');// 'active' | 'paused' | 'archived'deleteFunction
Section titled “deleteFunction”await ironflow.deleteFunction('process-order');listFunctionHistory
Section titled “listFunctionHistory”Page through a function’s version history, newest first.
const { entries, hasMore } = await ironflow.listFunctionHistory('process-order', { limit: 20, fromVersion: 5,});for (const entry of entries) { console.log(entry.entityVersion, entry.changeType, entry.changeReason);}getFunctionAtVersion
Section titled “getFunctionAtVersion”const entry = await ironflow.getFunctionAtVersion('process-order', 3);console.log(entry.functionSnapshot);rollbackFunction
Section titled “rollbackFunction”Restore an earlier version. The rollback is itself a new version.
const fn = await ironflow.rollbackFunction('process-order', 3, 'bad deploy');Audit Trail
Section titled “Audit Trail”listAuditEvents
Section titled “listAuditEvents”Query the environment-wide audit stream, optionally filtering and paginating.
const page = await ironflow.listAuditEvents({ eventType: 'run.failed', limit: 50 });getAuditTrail
Section titled “getAuditTrail”Get the audit trail for a specific run, including all state transitions and step events.
const result = await ironflow.getAuditTrail('run-abc123');for (const event of result.events) { console.log(event.eventType, event.createdAt); console.log(event.stepId, event.payload);}console.log(result.totalCount);console.log(result.nextCursor); // Pagination cursor
// With filtersconst result = await ironflow.getAuditTrail('run-abc123', { eventType: 'step.completed', fromTimestamp: '2024-01-01T00:00:00Z', toTimestamp: '2024-02-01T00:00:00Z', limit: 100, cursor: result.nextCursor,});Each AuditEvent has:
id— Unique event IDrunId— Associated run IDfunctionId— Function that owns the runstepId— Step ID (if step-level event)eventType— Event type string (e.g.'step.completed','run.failed')payload— Event-specific datametadata— Optional string key-value metadatacreatedAt— ISO 8601 timestamp
Event Schema Registry
Section titled “Event Schema Registry”Manage event schemas and test upcast transformations. Requires appropriate API key permissions.
schemas.register
Section titled “schemas.register”Register a new event schema or a new version of an existing one.
const schema = await ironflow.schemas.register({ name: 'order.placed', version: 2, schema: { type: 'object', properties: { orderId: { type: 'string' }, totalCents: { type: 'integer' }, }, required: ['orderId', 'totalCents'], },});schemas.list
Section titled “schemas.list”List all registered event schemas.
const schemas = await ironflow.schemas.list();for (const s of schemas) { console.log(s.name, s.version);}schemas.get
Section titled “schemas.get”Get the latest version of an event schema by name.
const schema = await ironflow.schemas.get('order.placed');console.log(schema.version, schema.schema);schemas.getVersion
Section titled “schemas.getVersion”Get a specific version of an event schema.
const schema = await ironflow.schemas.getVersion('order.placed', 1);schemas.delete
Section titled “schemas.delete”Delete a specific version of an event schema.
await ironflow.schemas.delete('order.placed', 1);schemas.testUpcast
Section titled “schemas.testUpcast”Test an upcast transformation from one schema version to another without persisting anything.
const result = await ironflow.schemas.testUpcast({ eventName: 'order.placed', fromVersion: 1, toVersion: 2, data: { orderId: '123', total: 99.99 },});console.log(result.data); // Transformed event dataWebhooks
Section titled “Webhooks”Manage webhook sources and inspect delivery history. Requires appropriate API key permissions.
webhooks.create
Section titled “webhooks.create”Register a new webhook source. The ID is server-generated; name is the
required operator-facing label and is not unique.
const source = await ironflow.webhooks.create({ name: 'Stripe production', // Display label (required, not unique) eventPrefix: 'stripe', // Events will be emitted as 'stripe.*' verifyHeader: 'stripe-signature', // Header containing the signature verifyAlgorithm: 'hmac-sha256', // Signature algorithm verifySecret: 'whsec_...', // Webhook signing secret metadata: { env: 'production' }, // Optional metadata});
console.log(source.id); // 'wh_...' — server-generated// source.ingestToken is write-once (ADR 0048) — hand it to your server.// Do NOT log it: browser console output is captured by session-replay and// error-reporting SDKs and is readable by any installed extension.ingestToken is returned only here and by rotateIngestToken. The server
stores a hash, so a source whose token is dropped can never receive a delivery.
webhooks.getSource
Section titled “webhooks.getSource”Fetch a single source by ID.
const source = await ironflow.webhooks.getSource('wh_abc123');console.log(source.verifySecretSet); // Is a current secret configured?console.log(source.verifySecretPrevSet); // Is a previous secret still held?console.log(source.verifySecretPrevExpiresAt); // When the grace window endsA rotation grace window is active only when verifySecretPrevSet is true
and verifySecretPrevExpiresAt is in the future — the slot is not cleared
when it lapses.
webhooks.listSources
Section titled “webhooks.listSources”List all registered webhook sources.
const sources = await ironflow.webhooks.listSources();for (const s of sources) { console.log(s.id, s.name, s.eventPrefix, s.sourceType);}webhooks.updateSource
Section titled “webhooks.updateSource”Replace the editable fields on a source. name and metadata are
full-replace, so omitting metadata clears the column — fetch first and copy
across whatever you are not changing. The three verification fields are
preserve-on-omit (see below).
const current = await ironflow.webhooks.getSource('wh_abc123');await ironflow.webhooks.updateSource({ id: current.id, name: 'Stripe production (EU)', verifyHeader: current.verifyHeader, // preserve verifyAlgorithm: current.verifyAlgorithm, // preserve metadata: current.metadata, // preserve expectedUpdatedAt: current.updatedAt, // rejected with ABORTED if the row moved});verifyHeader, verifyAlgorithm and verifyConfig are all
preserve-on-omit: leaving one out keeps the stored value instead of
clearing it. Clearing them is how signature verification gets switched off by
accident — on a source with no descriptor there is nothing to rebuild the
legacy header/algorithm pair from, so a rename used to leave the server
accepting unsigned deliveries while still reporting verifySecretSet: true.
To stop verifying deliberately, call disableSignatureVerification, which
keeps the prior secret for the grace window and is auditable.
expectedUpdatedAt matters more here than on the sibling calls for the same
reason — without it an unguarded rename can revert a concurrent descriptor
change.
verifySecret is not editable here; use rotateSecret. eventPrefix and
sourceType are immutable after create.
webhooks.rotateSecret
Section titled “webhooks.rotateSecret”Rotate the verify secret (ADR 0024). The prior secret keeps verifying as prev
for the grace window, so provider retries signed with it still land.
// Server default grace (24 h, or whatever the cluster configures).await ironflow.webhooks.rotateSecret({ id: 'wh_abc123', verifySecret: 'whsec_new' });
// Explicit 1 h grace.await ironflow.webhooks.rotateSecret({ id: 'wh_abc123', verifySecret: 'whsec_new', graceSeconds: 3600,});
// Instant cutover.await ironflow.webhooks.rotateSecret({ id: 'wh_abc123', verifySecret: 'whsec_new', graceSeconds: 0,});graceSeconds is tri-state: omit it for the server default, 0 for an
instant cutover, or N seconds (capped at 604800 / 7 days — above that the
server returns InvalidArgument). Passing 86400 explicitly is not the same
as omitting it: it overrides whatever
IRONFLOW_WEBHOOK_SECRET_GRACE_HOURS_DEFAULT the cluster sets.
An empty verifySecret is rejected — use disableSignatureVerification.
webhooks.expireSecretPrev
Section titled “webhooks.expireSecretPrev”Force-expire the previous secret slot, ending a grace window early. Idempotent: a source with no previous secret comes back unchanged.
await ironflow.webhooks.expireSecretPrev('wh_abc123');webhooks.disableSignatureVerification
Section titled “webhooks.disableSignatureVerification”Stop verifying signatures. The prior secret is preserved as prev for the
grace window; after it lapses the source ingests unsigned.
await ironflow.webhooks.disableSignatureVerification({ id: 'wh_abc123' });await ironflow.webhooks.disableSignatureVerification({ id: 'wh_abc123', graceSeconds: 0 });Same tri-state graceSeconds as rotateSecret.
webhooks.rotateIngestToken
Section titled “webhooks.rotateIngestToken”Replace the per-source ingest token (ADR 0048).
const rotated = await ironflow.webhooks.rotateIngestToken('wh_abc123');// rotated.ingestToken is the only copy — send it straight to your server.// Never log it from a browser; see the note on webhooks.create above.There is no grace window: the previous token stops working the moment this returns, so update the provider’s URL immediately.
webhooks.deleteSource
Section titled “webhooks.deleteSource”Delete a webhook source by ID.
await ironflow.webhooks.deleteSource('wh_abc123');webhooks.listDeliveries
Section titled “webhooks.listDeliveries”List webhook delivery records with optional filtering.
const { deliveries, totalCount } = await ironflow.webhooks.listDeliveries({ sourceId: 'stripe', // Filter by source status: 'failed', // 'pending' | 'delivered' | 'failed' limit: 50, offset: 0,});
for (const d of deliveries) { console.log(d.id, d.sourceId, d.status, d.error);}Admin: API Keys
Section titled “Admin: API Keys”Manage API keys for the current environment. Requires admin permissions.
apiKeys.create
Section titled “apiKeys.create”const key = await ironflow.apiKeys.create({ name: 'ci-runner' });console.log(key.key); // The raw API key — shown only onceconsole.log(key.id); // Key ID for future operationsapiKeys.list
Section titled “apiKeys.list”const keys = await ironflow.apiKeys.list();for (const k of keys) { console.log(k.id, k.name, k.created_at);}apiKeys.get
Section titled “apiKeys.get”const key = await ironflow.apiKeys.get('ak_abc123');apiKeys.delete
Section titled “apiKeys.delete”await ironflow.apiKeys.delete('ak_abc123');apiKeys.rotate
Section titled “apiKeys.rotate”Rotate an existing key, revoking the old secret and issuing a new one.
const rotated = await ironflow.apiKeys.rotate('ak_abc123');console.log(rotated.key); // New raw secret — shown only onceAdmin: Organizations
Section titled “Admin: Organizations”Manage organizations. Requires admin permissions.
orgs.create
Section titled “orgs.create”const org = await ironflow.orgs.create({ name: 'Acme Corp' });orgs.list
Section titled “orgs.list”const orgs = await ironflow.orgs.list();orgs.get
Section titled “orgs.get”const org = await ironflow.orgs.get('org_abc123');orgs.update
Section titled “orgs.update”const org = await ironflow.orgs.update('org_abc123', { name: 'Acme Inc' });orgs.delete
Section titled “orgs.delete”await ironflow.orgs.delete('org_abc123');Admin: Roles
Section titled “Admin: Roles”Manage RBAC roles. Requires admin permissions.
roles.create
Section titled “roles.create”const role = await ironflow.roles.create({ name: 'editor', org_id: 'org_abc123',});roles.list
Section titled “roles.list”const roles = await ironflow.roles.list(); // All rolesconst orgRoles = await ironflow.roles.list('org_abc123'); // Scoped to orgroles.get
Section titled “roles.get”const role = await ironflow.roles.get('role_abc123');roles.update
Section titled “roles.update”const role = await ironflow.roles.update('role_abc123', { name: 'reviewer' });roles.delete
Section titled “roles.delete”await ironflow.roles.delete('role_abc123');roles.assignPolicy
Section titled “roles.assignPolicy”Attach a policy to a role.
await ironflow.roles.assignPolicy('role_abc123', 'policy_xyz');roles.removePolicy
Section titled “roles.removePolicy”Detach a policy from a role.
await ironflow.roles.removePolicy('role_abc123', 'policy_xyz');roles.listPolicies
Section titled “roles.listPolicies”const assignedPolicies = await ironflow.roles.listPolicies('role_abc123');Admin: Policies
Section titled “Admin: Policies”Manage authorization policies. Requires admin permissions.
policies.create
Section titled “policies.create”const policy = await ironflow.policies.create({ name: 'allow-read', effect: 'deny', actions: 'read', resources: '*', org_id: 'org_abc123',});policies.list
Section titled “policies.list”const policies = await ironflow.policies.list(); // All policiesconst orgPolicies = await ironflow.policies.list('org_abc123'); // Scoped to orgpolicies.get
Section titled “policies.get”const policy = await ironflow.policies.get('policy_abc123');policies.update
Section titled “policies.update”const policy = await ironflow.policies.update('policy_abc123', { actions: 'read,write',});policies.delete
Section titled “policies.delete”await ironflow.policies.delete('policy_abc123');Admin: Users
Section titled “Admin: Users”User administration, including password changes, is intentionally absent from the Browser SDK. Use the dashboard, CLI, generated Python client, or a trusted server-side SDK so credentials and administrative authority are not embedded in browser code.
Admin: Tenants
Section titled “Admin: Tenants”List tenants.
tenants.list
Section titled “tenants.list”const tenants = await ironflow.tenants.list();for (const t of tenants) { console.log(t.id, t.name);}tenants.provision
Section titled “tenants.provision”const provisioned = await ironflow.tenants.provision({ orgName: 'Acme', envName: 'production',});// provisioned.apiKey.key is shown only in this response.Server Introspection
Section titled “Server Introspection”detectTransport
Section titled “detectTransport”Auto-detect the best transport.
const transport = await ironflow.detectTransport();// Returns 'connectrpc' | 'websocket'health
Section titled “health”const { status, timestamp, version } = await ironflow.health();getCapabilities
Section titled “getCapabilities”const caps = await ironflow.getCapabilities();console.log(caps.transports, caps.features, caps.version);Custom Transports
Section titled “Custom Transports”For advanced use, the transport factories and their types are public exports.
import { createWebSocketTransport, createConnectRPCTransport, type Transport, type TransportOptions, type TransportCallbacks, type TransportFactory,} from '@ironflow/browser';
const transport = createConnectRPCTransport('http://localhost:9123', { autoReconnect: true, reconnectDelay: 1000, maxReconnectDelay: 30000, reconnectBackoff: 2,});WebSocket authentication uses credential-bearing subprotocol metadata. The
credential is not placed in the URL or reflected in the negotiated protocol.
Development builds emit a console warning when an ifkey_ environment key is
configured in a browser context.
Agents
Section titled “Agents”Browser helpers for agent() functions, exposed as ironflow.agents.*. See the @ironflow/browser README for the full surface.
agents.invoke
Section titled “agents.invoke”Trigger an agent and wait for its terminal run event.
const result = await ironflow.agents.invoke<{ category: string }>( 'doc-processor', { docId: 'doc-1' }, { timeoutMs: 60000 } // default: 30000);console.log(result.runId, result.output, result.durationMs);agents.subscribe
Section titled “agents.subscribe”Typed subscription to a running agent’s run and step events.
const sub = await ironflow.agents.subscribe(runId, { onProgress: (e) => console.log('progress', e.topic, e.status), onStep: (e) => console.log('step', e.stepId, e.type), onComplete: (r) => console.log('done', r.output), onFailed: (err) => console.warn('failed', err.message),}, { replay: 1000 }); // replay default: 1000
sub.unsubscribe();agents.readMemory
Section titled “agents.readMemory”Typed read of an agent memory projection.
const mem = await ironflow.agents.readMemory<DocMemory>('doc-processor-memory');console.log(mem.state, mem.version);Offline Write Queue
Section titled “Offline Write Queue”Opt-in durable outbox for writes, added by createClient. Writes go to IndexedDB first and are drained in strict FIFO by a background loop, so an app that is offline for minutes or hours does not lose them across a reload, a crash, or a discarded tab.
This is an outbox, not background sync. Nothing drains while the page is closed — the queue resumes when the app is next opened.
Queued: emit and streams.append. Everything else on .client is unchanged and still requires a live connection.
app.emit(...) the drain loop │ │ ▼ ▼ ┌───────────┐ FIFO, one at a time ┌──────────┐ │ IndexedDB │ ───────────────────────▶ │ server │ │ outbox │ └──────────┘ └───────────┘ │ permanent failure ▲ ▼ │ ┌────────────┐ still queued │ dead letter │ queue.deadLetter() / .retry() / .discard() on reload └────────────┘Configuration
Section titled “Configuration”const app = await createClient({ serverUrl: 'https://ironflow.example.com', offlineQueue: { identity: currentUser.id, // required
dbName: 'my-app-outbox', // default: derived from serverUrl + environment maxItems: 500, // writes before QueueFullError (default: 500) maxBytes: 5 * 1024 * 1024, // bytes before QueueFullError (default: 5 MB) maxRetentionMs: 7 * 86400000, // older writes are dead-lettered (default: 7 days) maxDeadLettered: 100, // dead-letter store cap (default: 100)
// Called once per write that will never be sent. onWriteLost: (write, reason, message) => { toast(`Could not send ${write.kind}: ${message}`); },
// Called when the server answers 401. The queue pauses, waits for fresh // credentials, applies them, and resumes. Without this a token expiring // mid-drain stalls the queue. // // Return `identity` too, not just the credential — a login prompt is // exactly where a DIFFERENT person can sign in. onAuthRequired: async () => { const session = await showLoginPrompt(); return { token: session.accessToken, identity: session.userId }; }, },});The default dbName scopes the outbox to the server and environment, so staging writes can never flush into production. queueDbName(serverUrl, environment) is exported if you need to compute that name yourself.
Identity
Section titled “Identity”identity is a stable id for the signed-in principal, supplied by your app. It is deliberately not derived from the credential: hashing an API key or token identifies the credential, so rotating it would make the same user look like a different one and quarantine their own queue.
Writes queued under a different identity are never sent. They are moved to the dead-letter store and reported through onWriteLost with reason "identity-mismatch", so they stay inspectable instead of vanishing. The same protection applies to serverUrl and environment ("destination-mismatch"), so a staging write cannot land in production because config changed while it was queued.
onAuthRequired is the one place this protection can be lost. It must return the identity of whoever signed in, not just the new credential — that return value is what rebinds the queue. Omit it and the queue keeps the previous identity, the pending writes still match, and they are transmitted under the new user’s credentials.
The callback is bounded at five minutes. That is generous, because the honest case is a person reading a login form — the bound exists so a promise that never settles (a dismissed modal, a hung auth server) cannot stall delivery for the life of the page. On timeout the queue backs off and asks again; nothing is dead-lettered. A return with no credential leaves the existing one in place rather than clearing it.
QueuedWriteResult
Section titled “QueuedWriteResult”What emit and streams.append return on an offline client.
interface QueuedWriteResult { queued: boolean; // false only when IndexedDB is unavailable and the write went direct localId: string; // client-side handle — pass to queue.watch() // when queued is false, this is the server's event id idempotencyKey: string; // dedup key the engine will see, fixed now so a retry reuses it pending: boolean; // delivery still outstanding}There is no runId. A run id cannot exist before the server creates the run, and the point of the queue is to answer before contacting the server. Subscribe for the outcome instead.
Queue controls, on app.queue. Stable across reads, so useEffect(..., [app.queue]) does not thrash.
// Snapshotconst { pending, inFlight, total, deadLettered, state } = app.queue.stats();// state: 'idle' | 'flushing' | 'paused' | 'blocked'
// Subscribe to queue-wide stats. Fires immediately with the current value.const unsubscribe = app.queue.subscribe((stats) => { setBadge(stats.pending);});
// Follow one write to its terminal state. Survives a reload — nothing attached// to the promise from emit() does.app.queue.watch(localId, (status) => { if (status.status === 'sent') markDelivered(status.eventId); if (status.status === 'lost') markFailed(status.reason, status.message);});
// Drain now, resetting the backoff — for a "Retry now" button.await app.queue.flush();
// False when the outbox could not be opened — no IndexedDB, or a database// written by a newer SDK. Writes then go direct.app.queue.enabled;total is the number of writes in the current drain session, so a UI can show “34 of 500”. A full 500-item queue drains in 500 sequential round trips — roughly 50 s at 100 ms RTT — which is the accepted cost of strict FIFO.
state is 'blocked' when the outbox holds records written by a newer SDK. Enqueue still works; delivery waits for a build that understands them. The state is not permanent — a cross-version window closes as soon as the newer tab drains its own records, and the next drain (including flush()) picks delivery back up. A newer database version is different — the outbox will not open at all, queue.enabled is false, and writes go direct.
Dead letter
Section titled “Dead letter”Writes that will never be sent are moved out of the queue rather than retried forever, so one bad write cannot block the FIFO behind it.
const lost = await app.queue.deadLetter(); // oldest firstfor (const entry of lost) { console.log(entry.write.localId, entry.reason, entry.message);}
await app.queue.retry(localId); // back on the queue, keeping its dedup keyawait app.queue.discard(localId); // forget itThe dead-letter store is capped at maxDeadLettered (default 100) and evicted oldest-first, so a write that sits there long enough is eventually dropped. queue.watch() cannot tell an evicted write from a delivered one and reports sent for both — see Limits.
One exception: a record written by a newer SDK is never evicted, because this build cannot read it and deleting it would destroy data a rollback would deliver fine. Those records still count against the cap, so the store shrinks to fit around them; if they alone exceed it, the store stays above the cap and the SDK warns on the console.
reason is one of:
| Reason | Meaning |
|---|---|
expired | Sat in the outbox past maxRetentionMs without being delivered |
rejected | A permanent failure — a 4xx other than 401 or 429, or a stored body or response the SDK could not parse |
identity-mismatch | Written under a different identity; quarantined rather than sent |
destination-mismatch | Enqueued against a different server or environment than the client now points at |
unknown-kind | Record names a write kind this SDK has no endpoint for |
Hitting maxItems / maxBytes is not in this list. emit and streams.append throw QueueFullError synchronously and store nothing, so there is no queued write to report lost.
Multiple tabs
Section titled “Multiple tabs”Tabs share one outbox and coordinate through the Web Locks API: exactly one tab drains at a time, and the others still enqueue and still see accurate counts. If the draining tab is closed mid-write, another picks up from the same record — which is why the idempotency key is fixed at enqueue.
Where navigator.locks is missing (Safari < 16) tabs may drain at the same time. Writes are still delivered and still deduped by idempotency key; only the strict global ordering is lost.
Shutdown
Section titled “Shutdown”await app.close(); // lets an in-flight write finish, then releases the database // and disconnects the underlying clientclose() also calls client.disconnect(), so every subscription on .client ends. If you only want to stop queueing, there is no separate call — this is a full shutdown. The in-flight write gets a bounded grace period, not an unbounded wait; past it the database closes and the drain loop’s next store call fails harmlessly.
Limits
Section titled “Limits”- Not background sync. Nothing is delivered while the page is closed.
- At-least-once, not exactly-once. Dedup relies on the engine’s idempotency key; see #1604.
- Not encrypted at rest. IndexedDB is readable by anything with access to the origin’s profile. Do not queue secrets.
- No
runIdsfrom a queuedemit, and noexpectedVersionon a queued append except-1(which means no concurrency check). - Callbacks attached to the promise from
emitdo not survive a reload.queue.watch()does. queue.watch()inferssentfrom absence. AlocalIdin neither the outbox nor the dead-letter store is reported as delivered. That is right for the normal case, but it also means an evicted dead letter, or anything watched after the browser clears the origin’s storage (Safari does this after roughly 7 days of no interaction), reportssentwhen it was never delivered. Treatsentfrom awatch()issued long after the write as weak evidence.
Error Handling
Section titled “Error Handling”// Every class below is re-exported by @ironflow/browser, so one import suffices:// IronflowError, ConnectionError, SubscriptionError, TimeoutError,// ValidationError, NotConfiguredError, RunWaitTimeoutError, RunFailedError,// RunCancelledError, AgentInvokeTimeoutError, QueueFullError,// plus isRetryable and isIronflowError.// MemoryCatchupTimeoutError (agents.readMemory) comes from @ironflow/core.import { IronflowError, ConnectionError, SubscriptionError, isRetryable} from '@ironflow/browser';
await ironflow.subscribe('events:*', { onEvent: (e) => console.log(e), onError: (error) => { if (error instanceof ConnectionError) { console.log('Connection lost, will auto-reconnect'); } else if (error instanceof SubscriptionError) { console.log('Subscription error:', error.message); }
if (isRetryable(error)) { // Error is transient, will be retried } },});
// Global error handlerironflow.onError((error) => { reportToSentry(error);});Browser-specific notes:
IronflowError.statuscarries the HTTP status when the failure came from a response.- HTTP 429 is retryable.
error.retryableandisRetryable(error)reporttruefor a rate-limited request. This changed in the release that added the offline queue; it was previously classified permanent. Code shaped likeif (!error.retryable) surfaceToUser()changes branch on a 429. RESPONSE_UNPARSEABLEis the code for a 2xx response whose body cannot be parsed. Non-retryable, and raised instead of a rawSyntaxError.QueueFullError(codeQUEUE_FULL, non-retryable) is thrown synchronously byemit/streams.appendon an offline client when the queue is atmaxItemsormaxBytes, or when the browser’s own storage quota is exhausted. Nothing is stored, so the write is the caller’s to handle.
import { QueueFullError } from '@ironflow/browser';
try { await app.emit('order.approved', { orderId: '123' });} catch (error) { if (error instanceof QueueFullError) { toast('Too many unsent changes — reconnect to continue.'); }}React Integration
Section titled “React Integration”Example custom hook for subscriptions:
import { useState, useEffect } from 'react';import { ironflow } from '@ironflow/browser';import type { SubscriptionEvent } from '@ironflow/core';
function useSubscription<T>(pattern: string) { const [events, setEvents] = useState<SubscriptionEvent<T>[]>([]);
useEffect(() => { const subPromise = ironflow.subscribe<T>(pattern, { onEvent: (event) => setEvents(prev => [...prev, event]), }); return () => { subPromise.then((sub) => sub.unsubscribe()); }; }, [pattern]);
return events;}
// Usagefunction OrderList() { const orders = useSubscription<OrderData>('events:order.*');
return ( <ul> {orders.map(order => ( <li key={order.eventId}>{order.data.orderId}</li> ))} </ul> );}Connection Status Hook
Section titled “Connection Status Hook”function useConnectionStatus() { const [status, setStatus] = useState<ConnectionState>('disconnected');
useEffect(() => { const unsubscribe = ironflow.onConnectionChange(setStatus); return unsubscribe; }, []);
return status;}Requirements
Section titled “Requirements”- Chrome 80+
- Firefox 75+
- Safari 13.1+
- Edge 80+
The offline write queue degrades rather than raising that baseline:
| Feature | Needs | Without it |
|---|---|---|
| Outbox persistence | IndexedDB (all of the above) | Queue disables itself, queue.enabled is false, writes go direct |
| Idempotency keys | crypto.randomUUID (Safari 15.4+) | Falls back to crypto.getRandomValues |
| One drainer across tabs | navigator.locks (Safari 16+) | Single-tab drain; tabs may interleave |
| Cross-tab pending counts | BroadcastChannel | Counters are tab-local |
createClient never throws for a missing capability — server-side rendering, a Node import, and a locked-down browser all keep working.
See Also
Section titled “See Also”- JavaScript SDK Overview
- Node Package - Server-side SDK
- Core Package - Shared types and utilities
- KV Store explanation - Key-value storage
- Config Management explanation - Environment-scoped configuration