- SDKs & APIs
- @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.
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, // Accepted but not enforced — reconnects are unbounded 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,
// Retry policy for file uploads (optional). Same fields as the Node client: // maxAttempts (3), initialDelayMs (100), maxDelayMs (10000), backoffMultiplier (2), // connectionRetryDelayMs (2000), onRetry. retry: { maxAttempts: 3 },
// Target environment ID (optional). Unset sends no header: the server uses the API key's environment. environment: 'env_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();
// Read the current state synchronouslyconst state = ironflow.connectionState;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);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 TriggerBatchResult per
event, in order. A failed item carries error (code and message, for
example invalid_argument for a reserved name) and no event ID. It does not
fail the call, and the other items still emit.
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: previousPage.nextCursor, // for the next page});
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 controller = new AbortController();
const typed = await ironflow.invoke<Input>('process-order', { data: { orderId: '123' }, timeout: 60000, idempotencyKey: 'order-123', signal: controller.signal, // optional AbortSignal — aborting cancels the run server-side});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);}steps[].createdAt, updatedAt, startedAt, endedAt, sleepUntil, waitTimeout and patchedAt are Date values. An unreadable value throws SchemaValidationError.
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 recordingProfile: "all" (or the legacy 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('Updated partition:', event.partition, event.lastEventSeq); console.log('Last processed event:', event.id); }, 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();Subscribing does not send a current snapshot. Updates can repeat even when the
reducer returns unchanged state; treat them as state-save notifications.
event.partition identifies the partition, event.id is the last processed event
ID, and event.lastEventSeq is its cursor. event.name is empty with current
servers because update frames do not include a last event name.
replay: 1 requests the latest retained matching update, not a snapshot. With an
all-partitions pattern it is one update in total, not one per partition. There
may be no retained update, and a projection whose last rebuild failed replays
none until a rebuild succeeds. getProjectionStatus shows the failure. The subscribe promise confirms that the transport has started the
subscription; it does not prove the server has attached its live consumer. Replay
callbacks can arrive afterward. Keep the subscription open to receive them.
For an initial view of one partition, start the subscription before reading its state. Serialize refreshes and remember notifications received during a read. A periodic refresh also recovers from updates missed while the server attaches or reconnects, even if no later notification arrives:
const partition = 'customer-123';let dirty = false;let refreshing = false;let disposed = false;
async function refresh() { if (disposed) return; dirty = true; if (refreshing) return; refreshing = true; try { while (dirty && !disposed) { dirty = false; const snapshot = await ironflow.getProjection<OrderStats>('order-stats', { partition }); if (!disposed) render(snapshot.state); } } finally { refreshing = false; }}
const reportError = (error: unknown) => console.error(error);const sub = await ironflow.subscribeToProjection<OrderStats>('order-stats', { onUpdate: () => { void refresh().catch(reportError); }, onError: reportError,}, { partition });const timer = setInterval(() => { void refresh().catch(reportError); }, 5000);void refresh().catch(reportError);
// Call when the view is unmounted.function cleanup() { disposed = true; clearInterval(timer); sub.unsubscribe();}Replace OrderStats and render with your view’s type and renderer. This recipe
refreshes from the server instead of comparing the snapshot’s registry-wide
lastEventSeq with a partition update cursor. It serializes reads and retries every five seconds while mounted; it is not an
atomic snapshot-and-stream API. A successful periodic read recovers an update
missed during attachment or reconnect within the polling interval plus read
latency. Failed reads are reported and retried on a later tick. For a whole board,
refresh each partition you display (or load a complete view), rather than treating
one replayed update as the state of every partition.
Before the worker’s first save, getProjection returns state: {}. The server
cannot run the worker’s initialState() function. An empty object alone does not
distinguish an unsaved partition from a reducer that saved an empty object; use
your view’s empty/loading state until application data is available.
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.
executeSQL
Section titled “executeSQL”Run a read-only SQL query against the projection tables. A projection read such as
getProjection or querySQLProjection is the preferred read model. Use executeSQL
for ad-hoc queries that those methods cannot express. It needs the runs:read permission.
const result = await ironflow.executeSQL('SELECT id, total FROM order_totals WHERE total > 100', { timeoutMs: 5000, // optional; default 5000, maximum 30000 maxRows: 100, // optional; default 1000, maximum 10000});
console.log(result.columns); // ['id', 'total']console.log(result.rows[0]); // { id: 'order-1', total: '150' }console.log(result.totalRows); // 1Every cell is a string, because the server stringifies every column type. A SQL NULL
is the empty string. Each row is an object keyed by column name.
listProjectionHistory
Section titled “listProjectionHistory”List the definitions that were archived before a projection was replaced or deleted. The list is newest first. The current definition is not in it.
let page = await ironflow.listProjectionHistory('order-totals', { limit: 20 });while (page.hasMore) { page = await ironflow.listProjectionHistory('order-totals', { limit: 20, beforeRevision: page.nextBeforeRevision, // exclusive cursor });}limit defaults to 50, and the maximum is 200. beforeRevision is an exclusive cursor.
Pass the nextBeforeRevision of the previous page. A value of 0 means no older revision
exists. Each entry has revision, previousDefinition, actorId, changeReason,
changeType and recordedAt, a Date.
getProjectionRevision
Section titled “getProjectionRevision”Read one archived definition by its revision number.
const entry = await ironflow.getProjectionRevision('order-totals', 3);console.log(entry.previousDefinition.sqlDefinition);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.
// emit() returns no sequence — resolve the event id to one with waitForEvent first.const { eventId } = await ironflow.emit('order.placed', { orderId: '1' });const wait = await ironflow.waitForEvent(eventId, 'order-stats', { timeoutMs: 5000 });const result = await ironflow.waitForProjectionCatchup('order-stats', { minSeq: wait.targetSeq, 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 });waitForProjectionCatchupStream
Section titled “waitForProjectionCatchupStream”The same wait as waitForProjectionCatchup, as an AsyncIterable<WaitProgress>. It
yields a frame each time the projection cursor advances, then one terminal frame
(terminal: true) on catch-up, timeout or a server-reported error. Transport and
cancellation errors reject iteration. currentSeq, targetSeq and
behindByEvents are bigint. The server caps the wait at 300 s regardless of
timeoutMs. Pass signal to cancel early; leaving the for await loop also ends the
stream.
for await (const p of ironflow.waitForProjectionCatchupStream('order-stats', { minSeq: 42, // e.g. targetSeq from waitForEvent partition: 'customer-123', // optional timeoutMs: 5000, // optional signal: abortController.signal, // optional})) { if (p.terminal) { if (p.caughtUp) break; throw new Error(`wait failed: timedOut=${p.timedOut} error=${p.error}`); } console.log(`behind by ${p.behindByEvents}`);}Subscriptions
Section titled “Subscriptions”subscribe
Section titled “subscribe”Every subscribe() call opens an independent server subscription, including calls
with the same pattern. Each handle receives its own events with its own options,
including replay, cursor, filter and acknowledgment mode. handle.unsubscribe()
closes only that handle, including after reconnect. Each array entry and each
subscribeToProjection() call has the same independent ownership.
Duplicate calls consume additional server subscription slots and may create additional broker consumers. Share a handle and fan out locally when the page needs one live subscription. Consumer groups still distribute work across members; duplicate broadcast handles each receive their own events.
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”Manual ack needs a MANUAL consumer group and the WebSocket transport. A plain subscription always acknowledges automatically. See Manual Acknowledgment.
import type { AckableSubscription } from '@ironflow/browser';
ironflow.configure({ serverUrl: 'http://localhost:9123', transport: 'websocket' });
let sub: AckableSubscription;sub = await ironflow.joinConsumerGroup('order-workers', '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 } },});Run frames and createRunVersionFilter
Section titled “Run frames and createRunVersionFilter”A subscription to system.run.> delivers frames for run transitions; one write can publish two (.failed and .updated). The payload of a system.run.{runId}.{event} frame is a RunEvent. Step frames (system.run.{runId}.step.{stepId}.{event}) have a different shape.
interface RunEvent { id: string; functionId: string; eventId: string; executionMode: string; status: string; attempt: number; maxAttempts: number; version?: number; // Rises with every write to the run; absent on `deleted` frames, when the committed version is unknown, and on older servers createdAt: string; updatedAt: string; startedAt?: string; completedAt?: string; output?: unknown; error?: unknown; timestamp: string;}
type RunVersionFilter = (event: SubscriptionEvent) => boolean;
function createRunVersionFilter(options?: { maxRuns?: number }): RunVersionFilter;Frames for one run can arrive out of order, so a completed frame can land before the running one. createRunVersionFilter() returns a filter that drops older run and step frames and late frames for a deleted run. It passes frames outside system.run., frames with no usable version, and frames whose version ties the highest seen. It remembers up to maxRuns run and step identities (default 10000), then forgets the least recent. Use one filter per subscription.
import { ironflow, createRunVersionFilter, type RunEvent } from '@ironflow/browser';
const keep = createRunVersionFilter();await ironflow.subscribe('system.run.>', { onEvent: (event) => { if (keep(event)) render(event.data as RunEvent); },});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_atcreated_at is a Date. An unreadable value throws SchemaValidationError.
Bucket Handle
Section titled “Bucket Handle”Get a bucket handle for key operations:
const bucket = kv.bucket("sessions");Returns an entry whose value contains the stored bytes encoded as base64.
const entry = await bucket.get("user.123");// entry.key, entry.value, entry.revision, entry.created_at, entry.operationcreated_at is a Date, the time this revision was written. A deleted or purged key returns 404, so an entry always has one. Compare-and-swap uses revision, not this time.
getJSON
Section titled “getJSON”getJSON(key: string): Promise<KVEntry> decodes the stored bytes as UTF-8 JSON and preserves the entry metadata.
const entry = await bucket.getJSON("user.123");console.log(entry.value); // The stored JSON value, typed as unknownValidate the application-specific shape before using value. Invalid base64, UTF-8, or JSON throws IronflowError with code INVALID_KV_JSON. HTTP errors retain their existing codes. Plain string writes are raw text and are not automatically JSON-quoted.
Unconditional 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 (also available in @ironflow/node):
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.
File Storage
Section titled “File Storage”ironflow.files()
Section titled “ironflow.files()”Get a files client. It has the same methods as the Node files() client, with browser
bodies (Blob, File, ArrayBuffer, Uint8Array, string). A browser body is always
replayable, so contentLength is not needed. See the
Files guide.
const files = ironflow.files();const inbox = files.bucket("inbox");
await inbox.put("avatars/me.png", fileFromInput, { contentType: fileFromInput.type });const file = await inbox.get("avatars/me.png");const blob = new Blob([await file.arrayBuffer()], { type: file.contentType });The browser client retries an upload after a retryable failure (a network error, 408, 429
or 5xx other than 501). Every browser body can be sent again, so every upload is eligible. Set retry in the
client config to change the policy. The two sign calls are retried too: they only create a
URL. No other files call is retried. An upload has no fixed
timeout, because a timer would cancel a large upload.
uploadToSignedUrl / downloadFromSignedUrl
Section titled “uploadToSignedUrl / downloadFromSignedUrl”An end user has no Ironflow credentials. Your backend calls signUpload() or
signDownload() and sends the URL to the browser. These two helpers use a plain fetch and
send no credentials. They do not need configure().
import { uploadToSignedUrl, downloadFromSignedUrl } from "@ironflow/browser";
const info = await uploadToSignedUrl(url, file); // (url, body, contentType?)const res = await downloadFromSignedUrl(downloadUrl); // a fetch Responseinfo is a FileInfo. FileInfo and FileBucketInfo createdAt and updatedAt, and SignedUrl.expiresAt from the sign calls, are Date values. An unreadable value throws SchemaValidationError.
uploadToSignedUrl takes the content type from a Blob or File. For an ArrayBuffer or
Uint8Array, pass contentType as the third argument. If the content type does not match the
URL, the server returns 415.
Errors: PreconditionFailedError (412), PayloadTooLargeError (413) and
UnsupportedMediaTypeError (415) come from @ironflow/browser. ConflictError (409) and
UnauthorizedError are not re-exported by this package. It bundles its own copy of
@ironflow/core, so test error.name instead of instanceof. A bad or expired token throws
UnauthorizedError with the server message and no API-key advice. A bucket with signed URLs off throws an IronflowError with code
SIGNED_URLS_DISABLED.
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.updatedAtupdatedAt is a Date, to the second on get and list. An unreadable value throws SchemaValidationError. Compare-and-swap uses revision, not this time.
const configs = await config.list();for (const entry of configs) { console.log(`${entry.name}: rev ${entry.revision}`);}Watch for real-time config changes. The browser SDK subscribes to the
system.config.{name}.updated and system.config.{name}.deleted topics over the
shared /ws connection. It sees each set, patch and delete; a delete has data: null.
It does not use the REST watch route, so it does not get the current document on
connect. Call get() for that.
const watcher = await config.watch("feature-flags", { onUpdate: (event) => { // event: ConfigWatchEvent ({ type: "config_update", name, data, revision, updatedAt }) // updatedAt is a Date. A frame with an unreadable value goes to onError. 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.
getEvent() and streams.read() expose emittedBy when the engine recorded an
authenticated emitter (for example apikey:<id> or user:<id>). It is
undefined when absent; payload fields do not establish emitter identity.
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 full event history for an entity, oldest first. The SDK follows the server’s page cursor, so the result is complete for any number of events. Optional fromVersion, from and to (both times inclusive) limit the result; to alone gives the events that make up the entity’s state at that time. A string bound must be RFC 3339 and is sent unchanged, so it keeps nanosecond precision; a Date has milliseconds only. limit caps the number of entries returned.
const history = await ironflow.streams.getEntityHistory('order-123');const asOfTuesday = await ironflow.streams.getEntityHistory('order-123', { to: '2026-10-06T23:59:59.999999999Z', // end of Tuesday, UTC});timestamp is a Date. Pass timestampExact, not timestamp, as a from or to bound: stored timestamps are finer than a Date can hold, so a bound taken from timestamp can drop the entry it came from.
streams.subscribe
Section titled “streams.subscribe”Subscribe to the live event feed of one entity stream.
const sub = await ironflow.streams.subscribe('order-123', { entityType: 'order', // required — builds the `entity:{entityType}.{entityId}.>` pattern onEvent: (event) => console.log(event.name, event.data),});
sub.unsubscribe();Consumer Groups
Section titled “Consumer Groups”Join a consumer group for load-balanced event processing. joinConsumerGroup always uses manual acknowledgment, which needs the WebSocket transport (transport: 'websocket' in configure()); the default ConnectRPC transport rejects it.
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);}Each entry’s created_at, updated_at and next_run are Date values. next_run is absent unless the function has a schedule.
listWorkers
Section titled “listWorkers”List the HTTP pull workers registered in the environment. In a cluster the list covers every node.
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);createdAt and updatedAt are Date values. An unreadable value throws SchemaValidationError.
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);}recordedAt on each entry is a Date.
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 filtered = 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—Datethe event was recorded
createdAt is a Date. An unreadable value throws SchemaValidationError. fromTimestamp and toTimestamp take a Date or a string. Page with cursor, not with a returned createdAt. Rows hold finer than milliseconds, so toTimestamp: event.createdAt excludes that row.
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'], },});created_at on a schema from schemas.get, getVersion or list is a Date. It is absent on the schemas.register result, because the server answers that call with a status only.
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.event_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”Resolve the schema version chain from one version to another without persisting anything. The server returns the data unchanged: upcasters run in SDK workers, not on the server.
const result = await ironflow.schemas.testUpcast({ eventName: 'order.placed', fromVersion: 1, toVersion: 2, data: { orderId: '123', total: 99.99 },});console.log(result.stepsApplied); // [{ fromVersion: 1, toVersion: 2, description: "upcast to v2" }]console.log(result.data); // the input, unchangedschemas.listHistory
Section titled “schemas.listHistory”List the schema definitions that were archived before a replacement or deletion. History
is per event name and version, newest first. The paging rules are the same as for
listProjectionHistory.
const page = await ironflow.schemas.listHistory('order.placed', 2, { limit: 20 });for (const e of page.entries) { console.log(e.revision, e.changeType, e.previousDefinition.schemaJson);}schemas.getRevision
Section titled “schemas.getRevision”Read one archived schema definition by its revision number.
const entry = await ironflow.schemas.getRevision('order.placed', 2, 1);Webhooks
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 ends (Date)createdAt, updatedAt and verifySecretPrevExpiresAt are Date values. Pass updatedAtExact, not updatedAt, as expectedUpdatedAt: the server compares for equality at finer than a Date can hold.
A 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.updatedAtExact, // 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.
created_at is a Date. expires_at and last_used_at are optional Date values, absent on a key with no expiry and on a key not used yet. apiKeys.create and apiKeys.rotate also return key, the one-time secret, as a string.
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. created_at and updated_at are Date values.
orgs.create
Section titled “orgs.create”Needs a platform credential. It calls POST /api/v1/platform/orgs.
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”created_at on each role is a Date.
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');Admin: Policy set
Section titled “Admin: Policy set”updated_at is an optional Date, absent at version 0, and created_at on each versions() entry is a Date. The write guard is ifVersion, so no timestamp goes back to the server.
Manage the Rego policy set of the organization. Requires admin permissions (orgs:manage for writes). The set is authorization Layer 2 and can only deny. See Authorization policy sets.
policySet.get
Section titled “policySet.get”The active set. version is 0 when the organization has no set. enforcement_active is false when the server stores the set but does not enforce it.
const current = await ironflow.policySet.get();policySet.put
Section titled “policySet.put”Replace the set if its current version is ifVersion (0 for a new set). A new set starts in monitor mode. Leave out mode to keep the current mode.
const saved = await ironflow.policySet.put( { modules: { 'authz.rego': 'package ironflow.authz\n\ndeny contains "no production" if input.request.environment == "env_prod"\n', }, mode: 'monitor', }, current.version,);A 412 throws PreconditionFailedError: another admin wrote first, so read the set again. A 503 with the code policy_set_not_propagated is not retryable. The write committed as the version in the message. To finish propagation, call put again with the same body and ifVersion set to that saved version (a no-op). Never resend with the old version.
policySet.validate
Section titled “policySet.validate”Compile the set without storing it. It rejects with the per-file errors when the set is invalid.
await ironflow.policySet.validate({ modules: { 'authz.rego': source } });policySet.dryRun
Section titled “policySet.dryRun”Evaluate one input against a candidate set. Leave out set to use the active set.
const result = await ironflow.policySet.dryRun({ set: { modules: { 'authz.rego': source } }, input: { request: { action: 'runs:delete' }, subject: { roles: ['member'] } }, explain: true,});// result.decision is 'allow' or 'deny', whatever the mode of the setpolicySet.versions
Section titled “policySet.versions”const versions = await ironflow.policySet.versions(); // newest 100, without filespolicySet.version
Section titled “policySet.version”const v3 = await ironflow.policySet.version(3); // one version, with its filespolicySet.rollback
Section titled “policySet.rollback”Store the files and decision rule of an earlier version as a new version. The active mode stays.
const restored = await ironflow.policySet.rollback(3, saved.version);If the server answers 503 policy_set_not_propagated, call rollback again with ifVersion set to the saved version.
Error codes
Section titled “Error codes”IronflowError.code carries one of the values of POLICY_SET_ERROR_CODES:
import { POLICY_SET_ERROR_CODES } from '@ironflow/browser';
try { await ironflow.policySet.put(input, current.version);} catch (err) { if (err instanceof IronflowError && err.code === POLICY_SET_ERROR_CODES.selfLockout) { // The set would lock admins out; the message names the blocking subject }}The keys are compileError, v0Syntax, reservedNamespace, builtinForbidden, schemaError, entrypointType, badFileName, tooLarge, compileTimeout, badMode, badDecision, sprintfFormat, badRequest, forbidden, notFound, versionConflict, preconditionRequired, selfLockout, rateLimited, busy and notPropagated. See Policy set errors.
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. createdAt is a Date.
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();health() is liveness. ready() reads GET /ready, which checks the database, NATS and the engine streams. A server that is not ready makes it throw an IronflowError with code NOT_READY, status 503 and the failing dependencies in details.issues, such as stream:PUBSUB. It makes one attempt and does not retry.
const { status, warnings } = await ironflow.ready(); // status is "ready"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.
subscribe(pattern, options, lookupKey) receives a local per-call identity.
Custom transports must echo a supplied lookupKey as the fourth argument of
onSubscribed and onSubscribeFailed. The identity stays local and must not be
replaced by a pattern lookup. Missing identities reject pending calls with a
transport-contract error. For reconnect, pass the previous subscription ID
as the third argument, or retain the active ID when restarting the same stream.
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);Options: timeoutMs, idempotencyKey (server-side dedup) and signal (an AbortSignal; aborting makes the server cancel the run). Errors: ValidationError, AgentInvokeTimeoutError, RunFailedError, RunCancelledError and AbortError.
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), onCancelled: () => console.warn('cancelled'), onError: (err) => console.error('transport', err),}, { replay: 1000 }); // replay default: 1000
sub.unsubscribe();The promise resolves when the subscription attaches, before any replayed event arrives. The caller owns the subscription’s lifetime: do not unsubscribe as soon as the await returns, or the replay is lost. To stop after the run settles, call unsubscribe() from onComplete, onFailed or onCancelled. The SDK does not close the subscription on a terminal event, because ResumeRun can move a failed run back to running under the same runId.
agents.subscribe ranks run and step frames independently by version and preserves full dotted step IDs in onStep. Lower versions are dropped. A remembered deletion blocks later run and step callbacks, including unversioned frames, until its ID is evicted from the bounded cache. For your own subscribe call, use createRunVersionFilter() (also exported as a type-checked RunEvent) the same way as in the Node SDK.
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);Options: minSeq (read-your-writes; the call waits for the projection to reach that sequence), timeoutMs (catch-up wait, default 30000), partition and signal. Throws MemoryCatchupTimeoutError when the projection does not reach minSeq in time, and an IronflowError with code failed_precondition when the projection’s last rebuild failed.
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,// PreconditionFailedError, PreconditionRequiredError, PayloadTooLargeError,// UnsupportedMediaTypeError, plus isRetryable and isIronflowError.// MemoryCatchupTimeoutError (agents.readMemory), ConflictError, UnauthenticatedError and// UnauthorizedError are not re-exported. The package bundles its own copy of @ironflow/core,// so `instanceof` against a class imported from @ironflow/core does not match: test error.name.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.- The server’s
retryableflag wins. When a REST error body has a booleanretryable,error.retryablefollows it (a507or a501isfalse). Without the flag,5xxand429are retryable, and501is not. The offline queue followserror.retryable, so it dead-letters a501or a507and does not retry it. The browser client does not resend a write by itself. - 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/browser';
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;}Exported Types
Section titled “Exported Types”The package re-exports the following types from @ironflow/core for convenience:
Run types: Run, RunStatus, RunInfo, ListRunsOptions, ListRunsResult, RunStep, RunStepsResult, RunStreamsResult
Function types: FunctionStatus, RegisteredFunction, FunctionChangeType, FunctionHistoryEntry, ListFunctionHistoryOptions, ListFunctionHistoryResult
Event types: IronflowEvent, EmitOptions, EmitResult, TriggerBatchEvent, TriggerBatchResult, StoredEvent, ListEventsOptions, ListEventsResult, EventNameCount, ListEventNamesOptions, ListEventNamesResult
Pub/Sub types: PublishOptions, PublishResult
Invoke/Trigger types: InvokeResult, EmitSyncResult, InvokeSyncOptions, InvokeSyncResult
Run frame types: RunEvent, RunVersionFilter
Subscription types: SubscribeOptions, Subscription, AckableSubscription, SubscriptionEvent, SubscriptionErrorInfo, SubscriptionCallbacks, ConnectionState, AckHandle
Consumer group types: ConsumerGroup, ConsumerGroupConfig, ConsumerGroupStatus, AckMode, BackpressureMode, UpdateConsumerGroupInput
Entity stream types: AppendEventInput, AppendOptions, AppendResult, ReadStreamOptions, StreamEvent, StreamInfo, EntitySubscribeOptions, StreamListEntry, EntityHistoryEntry, EntityHistoryOptions
Projection types: ProjectionStatusInfo, ProjectionStateResult
Definition history and raw SQL types: DefinitionHistoryOptions, DefinitionHistoryEntry, DefinitionHistoryPage, ProjectionDefinition, ProjectionHistoryEntry, ProjectionHistoryPage, SchemaDefinition, SchemaHistoryEntry, SchemaHistoryPage, ExecuteSQLOptions, ExecuteSQLResult
Policy set types: PolicySet, PolicySetMode, PolicySetInput, PolicySetVersion, PolicySetDryRunInput, PolicySetDryRunResult
Audit types: AuditEvent, AuditTrailResult, GetAuditTrailOptions, ListAuditEventsOptions
Webhook management types: WebhookSource, CreateWebhookSourceInput, UpdateWebhookSourceInput, RotateWebhookSecretInput, DisableWebhookSignatureVerificationInput, WebhookDelivery, ListWebhookDeliveriesOptions
Tenant types: Tenant, ProvisionTenantInput, ProvisionTenantResult
KV types: KVBucketConfig, KVBucketInfo, KVEntry, KVPutResult, KVListKeysResult, KVListBucketsResult, KVWatchEvent, KVWatchCallbacks, KVWatchOptions, KVWatcher
Config types: ConfigResponse, ConfigEntry, ConfigWatchCallbacks, ConfigWatchEvent
File storage types: FileBucketConfig, FileBucketInfo, FileInfo, FileObject, ListFilesOptions, ListFilesResult, PutFileOptions, GetFileOptions, MoveFileOptions, CopyFileOptions, SignUploadOptions, SignedUrl, BrowserFileBody
Browser-specific types: IronflowClient (type only — construct through ironflow or createClient()), IronflowConfig, IronflowConfigOptions, ReconnectConfig, VisibilityConfig, AuthConfig, BrowserSubscribeOptions, SubscriptionGroup, Transport, TransportCallbacks, TransportFactory, TransportOptions
Agent types: AgentsNamespace, AgentInvokeOptions, AgentInvokeResult, AgentProgressEvent, AgentStepEvent, AgentSubscribeCallbacks, AgentSubscribeRuntimeOptions, AgentReadMemoryOptions, AgentMemoryResult
Offline queue types: CreateClientOptions, OfflineQueueConfig, QueueApi, QueuedWriteResult, QueuedWrite, QueuedWriteKind, DeadLetteredWrite, QueueState, QueueStats, WriteStatus, WriteLostReason, OfflineQueue, OfflineQueueOptions, SendOutcome. The last three are type-only: OfflineClient owns the queue, so you cannot construct one.
Logger: Logger
Errors and error utilities (see Error Handling): IronflowError, ConnectionError, SubscriptionError, TimeoutError, ValidationError, NotConfiguredError, RunWaitTimeoutError, RunFailedError, RunCancelledError, AgentInvokeTimeoutError, QueueFullError, PreconditionFailedError, PreconditionRequiredError, PayloadTooLargeError, UnsupportedMediaTypeError, POLICY_SET_ERROR_CODES, isRetryable, isIronflowError
Utilities: patterns, DEFAULT_SERVER_URL, DEFAULT_WS_URL, DEFAULT_TIMEOUTS, getServerUrl, getWebSocketUrl, DEFAULT_CONFIG, mergeConfig, createWebSocketTransport, createConnectRPCTransport, createRunVersionFilter, queueDbName
Classes: BrowserKVClient, BrowserKVBucketHandle, BrowserFilesClient, BrowserFileBucketHandle, BrowserConfigClient, SubscriptionManager, OfflineClient, createClient
Signed URL helpers: uploadToSignedUrl, downloadFromSignedUrl
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