Skip to content

@ironflow/browser

Browser client for Ironflow. Provides real-time subscriptions, workflow triggers, and event emission for web applications.

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.

Terminal window
npm install @ironflow/browser
import { ironflow } from '@ironflow/browser';
// Configure once at app startup
ironflow.configure({
serverUrl: 'http://localhost:9123',
});
// Subscribe to events
const sub = await ironflow.subscribe('events:order.*', {
onEvent: (event) => console.log('Order:', event),
});
// Invoke a function by ID and wait for its result
const run = await ironflow.invoke('process-order', {
data: { orderId: '123' },
});
console.log(run.output);
// Emit events
await ironflow.emit('order.approved', { orderId: '123' }, { version: 1 });
// Cleanup
sub.unsubscribe();

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',
});

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 sent
app.client.subscribe(...); // everything else lives here

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

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(); // IronflowConfig
if (ironflow.isConfigured) { /* ... */ }

Replace the credential without re-running configure() — for rotating a short-lived session token.

ironflow.setAuth({ token: session.accessToken });
ironflow.setAuth(undefined); // clear

Manually connect to the server.

await ironflow.connect();

Disconnect from the server.

ironflow.disconnect();

Listen for connection state changes.

const unsubscribe = ironflow.onConnectionChange((state) => {
// state: 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
console.log('Connection state:', state);
});
// Stop listening
unsubscribe();

Emit an event.

// Basic emit
await ironflow.emit('order.approved', {
orderId: '123',
approvedBy: 'user@example.com',
});
// With options
await ironflow.emit('order.approved', { orderId: '123' }, {
version: 2, // Event schema version (default: 1)
idempotencyKey: 'unique-key', // Deduplication key
});

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.

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 key
const withOptions = await ironflow.emitSync('order.placed', { orderId: '123' }, {
timeout: 60000,
idempotencyKey: 'order-123-placed',
});

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' },
]);

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);

Fetch one stored event by id.

const event = await ironflow.getEvent('evt_abc123');

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 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 run
console.log(result.functionId); // 'process-order'
console.log(result.status); // 'completed'
console.log(result.output); // Run output
console.log(result.durationMs); // Wall-clock duration
// Type-safe invoke (TInput — the input payload), with options
interface 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.

Get run status.

const run = await ironflow.getRun('run_abc123');
console.log(run.status); // Run status
console.log(run.attempt); // Current attempt number
console.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)

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 runs
console.log(runs.nextCursor); // Next page cursor

Cancel a running workflow.

await ironflow.cancelRun('run_abc123');

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);
}

List the entity streams a run touched.

const { entityIds } = await ironflow.getRunStreams('run_abc123');

Pause running workflows, inspect step outputs, inject modifications, and resume:

// Pause at next step boundary
await ironflow.pauseRun("run_abc123");
// Get paused state with completed steps
const state = await ironflow.getPausedState("run_abc123");
for (const step of state.steps) {
console.log(step.name, step.output, step.injected);
}
// Inject modified output
const result = await ironflow.injectStepOutput(
"run_abc123",
"step_xyz",
{ corrected: true },
"Fix calculation"
);
// Resume with injected data
await 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");

Inspect historical run state at any timestamp. Requires recording: true on the function.

// Get run state at a specific point in time
const 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 run
const 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 time
const stepOutput = await ironflow.getStepOutputAt(
"run_abc123", "step_xyz", new Date("2024-01-15T10:30:00Z")
);
console.log(stepOutput.output, stepOutput.patched, stepOutput.injected);

Get the current state of a projection.

const result = await ironflow.getProjection<OrderStats>('order-stats');
console.log(result.state); // Typed projection state
console.log(result.name); // Projection name
console.log(result.partition); // Partition key (or '__global__')
console.log(result.version); // Projection version
console.log(result.lastEventSeq); // Sequence number of the last processed event
console.log(result.lastEventId); // ID of the last processed event
console.log(result.lastEventTime); // Timestamp of the last processed event (Date)
console.log(result.mode); // 'managed' | 'external'
// With partition
const resultWithPartition = await ironflow.getProjection('order-stats', {
partition: 'customer-123',
});

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);
}

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 number
console.log(status.lag); // Number of unprocessed events
console.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.

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 replay
const subWithPartition = await ironflow.subscribeToProjection('order-stats', {
onUpdate: (state) => console.log(state),
}, {
partition: 'customer-123',
replay: 1, // Replay the latest update on connect
});
// Cleanup
sub.unsubscribe();

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 names
console.log(result.rows); // Array of string[] rows
console.log(result.typedRows); // Same rows with SQL types preserved
console.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 allowed
predicate := column ('=' | '!=' | '<>' | '<' | '<=' | '>' | '>=') value
| column ['NOT'] LIKE value
| column IS ['NOT'] NULL
| column ['NOT'] IN (value, ...)
| column '@@' tsquery -- full-text match
tsquery := ('plainto_tsquery' | 'websearch_to_tsquery') '(' 'string' ')'
value := 'string' | number | TRUE | FALSE

Anything 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 index
const similar = await ironflow.querySQLProjection('chunks', {
where: 'index_version = 7',
orderBy: `embedding <=> '[${queryEmbedding.join(',')}]'`,
limit: 10,
});
// Ranked full-text search — reaches a GIN index
const 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.

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 */ }

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 });

Subscribe to events matching a pattern.

// Basic subscription
const sub = await ironflow.subscribe('events:order.*', {
onEvent: (event) => console.log(event),
onError: (error) => console.error(error),
onStateChange: (state) => console.log(state),
});
// Type-safe subscription
interface 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 options
const 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);
// Unsubscribe
sub.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.

PatternDescription
events:order.*All order events
events:order.createdSpecific event
events:*All events
system.run.*All run updates
system.run.{runId}.*Specific run updates
system.run.{runId}.step.{stepId}Specific step updates
const sub = await ironflow.subscribe(
['system.run.*', 'events:order.*', 'events:payment.*'],
{
onEvent: (event) => console.log(event),
}
);
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;

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 once
group.unsubscribeAll();

Number of subscriptions currently attached on this client. Useful in tests and teardown assertions.

console.log(ironflow.getActiveSubscriptionCount());

Get a KV client for bucket management and key operations:

const kv = ironflow.kv();
const info = await kv.createBucket({
name: "sessions",
description: "User session data",
ttlSeconds: 3600,
maxValueSize: 65536,
history: 3,
});
await kv.deleteBucket("sessions");
const buckets = await kv.listBuckets();
for (const b of buckets) {
console.log(`${b.name}: ${b.values} keys, ${b.bytes} bytes`);
}
const info = await kv.getBucketInfo("sessions");
// info.name, info.values, info.bytes, info.history, info.created_at

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

Unconditional write. Returns the new revision:

const { revision } = await bucket.put("user:123", { name: "Alice" });

Write only if the key does not exist. Throws on conflict (HTTP 412):

const { revision } = await bucket.create("user:123", { name: "Alice" });

Write only if the revision matches. Throws on mismatch (HTTP 412):

const { revision } = await bucket.update("user:123", newValue, entry.revision);

Soft-delete (tombstone):

await bucket.delete("user:123");

Permanently remove key and all history:

await bucket.purge("user:123");

List keys with optional wildcard filter:

const keys = await bucket.listKeys("user.*");
// Pass no argument for all keys

Watch 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 watching
watcher.stop();

See the KV Store Guide for detailed usage patterns.


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.updatedAt
const 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.


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.

Reads events from an entity stream.

const { events, totalCount } = await ironflow.streams.read("order-123", {
fromVersion: 1,
limit: 50,
direction: "forward",
});

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>

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 identifier

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 identifier
console.log(snapshot.entityId); // Entity ID
console.log(snapshot.entityType); // Entity type
console.log(snapshot.entityVersion); // Stream version this snapshot represents
console.log(snapshot.state); // The materialized state object
console.log(snapshot.createdAt); // ISO 8601 creation timestamp

List the entity streams visible in the current environment.

const streams = await ironflow.streams.listStreams();

Read the unified event history for an entity.

const history = await ironflow.streams.getEntityHistory('order-123');

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();

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);

List all registered functions in the current environment.

const functions = await ironflow.listFunctions();
for (const fn of functions) {
console.log(fn.id, fn.name);
}

List connected pull-mode workers.

const workers = await ironflow.listWorkers();
for (const worker of workers) {
console.log(worker.id, worker.status);
}

Get one function’s registered definition.

const fn = await ironflow.getFunction('process-order');
console.log(fn.status, fn.version, fn.triggers);

Enable, pause or archive a function.

await ironflow.updateFunctionStatus('process-order', 'paused');
// 'active' | 'paused' | 'archived'
await ironflow.deleteFunction('process-order');

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);
}
const entry = await ironflow.getFunctionAtVersion('process-order', 3);
console.log(entry.functionSnapshot);

Restore an earlier version. The rollback is itself a new version.

const fn = await ironflow.rollbackFunction('process-order', 3, 'bad deploy');

Query the environment-wide audit stream, optionally filtering and paginating.

const page = await ironflow.listAuditEvents({ eventType: 'run.failed', limit: 50 });

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 filters
const 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 ID
  • runId — Associated run ID
  • functionId — Function that owns the run
  • stepId — Step ID (if step-level event)
  • eventType — Event type string (e.g. 'step.completed', 'run.failed')
  • payload — Event-specific data
  • metadata — Optional string key-value metadata
  • createdAt — ISO 8601 timestamp

Manage event schemas and test upcast transformations. Requires appropriate API key permissions.

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'],
},
});

List all registered event schemas.

const schemas = await ironflow.schemas.list();
for (const s of schemas) {
console.log(s.name, s.version);
}

Get the latest version of an event schema by name.

const schema = await ironflow.schemas.get('order.placed');
console.log(schema.version, schema.schema);

Get a specific version of an event schema.

const schema = await ironflow.schemas.getVersion('order.placed', 1);

Delete a specific version of an event schema.

await ironflow.schemas.delete('order.placed', 1);

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 data

Manage webhook sources and inspect delivery history. Requires appropriate API key permissions.

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.

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

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.

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);
}

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.

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.

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');

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.

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.

Delete a webhook source by ID.

await ironflow.webhooks.deleteSource('wh_abc123');

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);
}

Manage API keys for the current environment. Requires admin permissions.

const key = await ironflow.apiKeys.create({ name: 'ci-runner' });
console.log(key.key); // The raw API key — shown only once
console.log(key.id); // Key ID for future operations
const keys = await ironflow.apiKeys.list();
for (const k of keys) {
console.log(k.id, k.name, k.created_at);
}
const key = await ironflow.apiKeys.get('ak_abc123');
await ironflow.apiKeys.delete('ak_abc123');

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 once

Manage organizations. Requires admin permissions.

const org = await ironflow.orgs.create({ name: 'Acme Corp' });
const orgs = await ironflow.orgs.list();
const org = await ironflow.orgs.get('org_abc123');
const org = await ironflow.orgs.update('org_abc123', { name: 'Acme Inc' });
await ironflow.orgs.delete('org_abc123');

Manage RBAC roles. Requires admin permissions.

const role = await ironflow.roles.create({
name: 'editor',
org_id: 'org_abc123',
});
const roles = await ironflow.roles.list(); // All roles
const orgRoles = await ironflow.roles.list('org_abc123'); // Scoped to org
const role = await ironflow.roles.get('role_abc123');
const role = await ironflow.roles.update('role_abc123', { name: 'reviewer' });
await ironflow.roles.delete('role_abc123');

Attach a policy to a role.

await ironflow.roles.assignPolicy('role_abc123', 'policy_xyz');

Detach a policy from a role.

await ironflow.roles.removePolicy('role_abc123', 'policy_xyz');
const assignedPolicies = await ironflow.roles.listPolicies('role_abc123');

Manage authorization policies. Requires admin permissions.

const policy = await ironflow.policies.create({
name: 'allow-read',
effect: 'deny',
actions: 'read',
resources: '*',
org_id: 'org_abc123',
});
const policies = await ironflow.policies.list(); // All policies
const orgPolicies = await ironflow.policies.list('org_abc123'); // Scoped to org
const policy = await ironflow.policies.get('policy_abc123');
const policy = await ironflow.policies.update('policy_abc123', {
actions: 'read,write',
});
await ironflow.policies.delete('policy_abc123');

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.


List tenants.

const tenants = await ironflow.tenants.list();
for (const t of tenants) {
console.log(t.id, t.name);
}
const provisioned = await ironflow.tenants.provision({
orgName: 'Acme',
envName: 'production',
});
// provisioned.apiKey.key is shown only in this response.

Auto-detect the best transport.

const transport = await ironflow.detectTransport();
// Returns 'connectrpc' | 'websocket'
const { status, timestamp, version } = await ironflow.health();
const caps = await ironflow.getCapabilities();
console.log(caps.transports, caps.features, caps.version);

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.


Browser helpers for agent() functions, exposed as ironflow.agents.*. See the @ironflow/browser README for the full surface.

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);

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();

Typed read of an agent memory projection.

const mem = await ironflow.agents.readMemory<DocMemory>('doc-processor-memory');
console.log(mem.state, mem.version);

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 └────────────┘
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 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.

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.

// Snapshot
const { 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.

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 first
for (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 key
await app.queue.discard(localId); // forget it

The 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:

ReasonMeaning
expiredSat in the outbox past maxRetentionMs without being delivered
rejectedA permanent failure — a 4xx other than 401 or 429, or a stored body or response the SDK could not parse
identity-mismatchWritten under a different identity; quarantined rather than sent
destination-mismatchEnqueued against a different server or environment than the client now points at
unknown-kindRecord 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.

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.

await app.close(); // lets an in-flight write finish, then releases the database
// and disconnects the underlying client

close() 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.

  • 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 runIds from a queued emit, and no expectedVersion on a queued append except -1 (which means no concurrency check).
  • Callbacks attached to the promise from emit do not survive a reload. queue.watch() does.
  • queue.watch() infers sent from absence. A localId in 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), reports sent when it was never delivered. Treat sent from a watch() issued long after the write as weak evidence.

// 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 handler
ironflow.onError((error) => {
reportToSentry(error);
});

Browser-specific notes:

  • IronflowError.status carries the HTTP status when the failure came from a response.
  • HTTP 429 is retryable. error.retryable and isRetryable(error) report true for a rate-limited request. This changed in the release that added the offline queue; it was previously classified permanent. Code shaped like if (!error.retryable) surfaceToUser() changes branch on a 429.
  • RESPONSE_UNPARSEABLE is the code for a 2xx response whose body cannot be parsed. Non-retryable, and raised instead of a raw SyntaxError.
  • QueueFullError (code QUEUE_FULL, non-retryable) is thrown synchronously by emit / streams.append on an offline client when the queue is at maxItems or maxBytes, 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.');
}
}

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;
}
// Usage
function OrderList() {
const orders = useSubscription<OrderData>('events:order.*');
return (
<ul>
{orders.map(order => (
<li key={order.eventId}>{order.data.orderId}</li>
))}
</ul>
);
}
function useConnectionStatus() {
const [status, setStatus] = useState<ConnectionState>('disconnected');
useEffect(() => {
const unsubscribe = ironflow.onConnectionChange(setStatus);
return unsubscribe;
}, []);
return status;
}

  • Chrome 80+
  • Firefox 75+
  • Safari 13.1+
  • Edge 80+

The offline write queue degrades rather than raising that baseline:

FeatureNeedsWithout it
Outbox persistenceIndexedDB (all of the above)Queue disables itself, queue.enabled is false, writes go direct
Idempotency keyscrypto.randomUUID (Safari 15.4+)Falls back to crypto.getRandomValues
One drainer across tabsnavigator.locks (Safari 16+)Single-tab drain; tabs may interleave
Cross-tab pending countsBroadcastChannelCounters are tab-local

createClient never throws for a missing capability — server-side rendering, a Node import, and a locked-down browser all keep working.