[ RUNTIME ] agents + event-driven systems

Agents and workflows
that survive.

A durable runtime for AI agents and event-driven systems. Survive crashes, wait for input, derive state from events. One binary.

Emit React Derive Rewind
local

One Go binary. NATS, SQLite, and the dashboard embedded, with no Docker required.

production

Postgres + external NATS + S3. Multi-node when you need it.

sdks

Node, Go, and Python. Plus a browser client and a LangGraph saver.

license

Closed source. Ironflow is pre-1.0. Ironflow License (FSL v1.1 with an Apache-2.0 future grant). APIs can change between releases.

[ 01 ] contents

What we cover, in order.

First the shape of the runtime, then what you build on it.

[ 02 ] the problem

You assemble three products, then own the seams.

An orchestrator for retries. A broker for fan-out. An event store for history. Then you own the seams for as long as the system lives.

  • Delivery semantics disagree. One memoizes, one redelivers.
  • Ordering has two owners. Nothing arbitrates between them.
  • Local dev needs all three before a line of your logic runs.
Orchestrator retries · waits durable steps Broker pub/sub · fan-out consumer groups Event store append-only log projections THE SEAMS YOU OWN at-least-once vs memoized · ordering arbitration · replay across two stores · idempotency keys PLUS three sets of upgrades, three failure modes, three things to run on a laptop
Three products, and the seams between them are where the incidents live.

[ 03 ] positioning

One binary carrying durable execution, pub/sub, and event sourcing together.

Orchestrators give you durable execution, but no event sourcing and no pub/sub, so a broker and a store stay in the stack. This is one dependency instead of three.

Your reasoning layer LangGraph · provider SDK · your own loop prompts · model access · planning IRONFLOW RUNTIME Events pub/sub Durable steps waits · retries Projections derived views Replay rebuild entity streams · KV · config · secrets · outbox · policies · optional recording SQLite or Postgres + NATS JetStream embedded locally, external at scale
Ironflow sits under the reasoning layer. That layer stays whatever you chose.

What that buys you

  • One process locally. ironflow serve, nothing else to install.
  • One history. Append-only, inspectable, replayable.
  • One upgrade path. Same engine everywhere it ships.

Continuous History: events plus recorded execution steps, in one append-only log.

[ 04 ] architecture

The same binary runs your laptop and your cluster.

The components do not change between modes. What changes is whether NATS and the database live inside the process or beside it.

SINGLE NODE · EVERYTHING EMBEDDED ironflow serve · one OS process HTTP + Connect :9123 Worker API HTTP long-poll Dashboard React, embedded Engine executor · scheduler · dispatch · projections · outbox · secrets NATS JetStream embedded, bound to loopback SQLite one file on disk Your functions reach the engine on :9123, never NATS CEILING single-node crash-resume only MULTI-NODE · INFRASTRUCTURE EXTERNALIZED the same binary, three times, with no embedded infrastructure ironflow --node-id node-1 API · engine · dashboard ironflow --node-id node-2 API · engine · dashboard ironflow --node-id node-3 API · engine · dashboard External NATS cluster seeds, TLS, creds file PostgreSQL shared run and step state RULE external NATS needs PostgreSQL and s3:// blobs. One choice. ACROSS NODES cluster claim dedup · heartbeat sweep · stale-claim reclaim · run resumes on a surviving node
Same components either way. Multi-node pulls NATS, the database, and blobs out of the process.
what moves out

NATS, the database, and blob storage. Everything else is the same code.

what that unlocks

A node can die mid-run and another picks the run up.

the constraint to remember

SQLite is single-node. Clustering needs Postgres and S3-compatible blobs.

[ 05 ] vocabulary

Four verbs cover the whole runtime.

The working vocabulary. Everything that follows sits under one of these.

01 · emit

Events in

An event enters from a client, webhook, cron, or another function.

  • Typed event emission
  • Webhook and cron triggers
  • Wildcard pub/sub subjects
02 · react

Functions run

A function subscribes and executes as durable steps that survive a crash.

  • Durable steps, memoized by ID
  • Sleep and wait-for-event
  • Push (HTTP) or pull (HTTP long-poll)
03 · derive

Views build

Projections fold history into read models, instead of state you mutate.

  • Managed pure reducers
  • External side-effect projections
  • Rebuild from history
04 · rewind

History replays

History is append-only, so you can go back: resume, replay, rebuild.

  • Resume from the last good step
  • Patch a failed step and continue
  • Replay a stream, upcast old events

[ 06 ] proof

A crash is an interruption, not a restart.

Five-step agent run. The worker takes a kill -9 mid-flight. A new worker resumes the same run and skips the four completed steps. Resume waits for the dead worker's lease to expire, so expect 90 seconds to 3 minutes.

Durability trace for an agent that resumes after a worker crash The worker process line breaks at kill -9. Four completed step results remain in the durable step store, cross the failure boundary, restore into a new worker, and let the same run continue with post-comment. DURABILITY TRACE RUN / rn_8f3c • ATTEMPT / 02 RESUMED same run id · state intact WORKER A / PID 4812 completed work WORKER B / PID 4927 continues the same run 01 / TOOL fetch-diff done · stored 02 / MODEL llm.review done · stored 03 / STEP classify done · stored 04 / WAIT approve done · event KILL -9 PROCESS LOST RESTORE 4 results from step store NO REPEATED WORK 05 / TOOL post-comment RUNNING DURABLE STEP STORE DURABLE RESULTS step/01 output stored step/02 output stored step/03 output stored step/04 event stored COMPLETED OUTPUTS OUTLIVE THE WORKER PROCESS STATE SURVIVES PROCESS LOST DURABLE STATE INTACT 0 COMPLETED CALLS REPEATED rn_8f3c / LIVE
✓ survives

Completed step results, memoized by step ID.

↻ repeats

The step in flight. Write steps so a re-run is safe.

the precise claim

Exactly-once applies to memoized step results. Not a general delivery guarantee.

[ 00 ] react · durable execution

Steps are the unit of survival.

A function is ordinary code. Each step's result is stored under its ID, so a run that restarts replays the finished steps from the store and runs only what is left.

import { ironflow } from "@ironflow/node";

export const fulfilOrder = ironflow.createFunction(
  { id: "fulfil-order", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    // The engine stores the result under this step id. A restarted
    // run skips the call and reuses the result.
    const reserved = await step.run("reserve-stock", () =>
      reserveStock(event.data)
    );

    // Durable sleep: the run parks and the engine wakes it after 1h.
    await step.sleep("hold-window", "1h");

    // Park until payment.confirmed arrives with the same data.orderId
    // as the event that started the run. With no match in 24h, the
    // run fails.
    const payment = await step.waitForEvent("await-payment", {
      event: "payment.confirmed",
      match: "data.orderId",
      timeout: "24h",
    });

    return { reserved, paid: payment.data };
  }
);

Same three steps in each SDK: a memoized step, a durable sleep, and a wait for a matching event.

Step primitives

step.runRun code once. The result is memoized by step ID.
step.sleep / sleepUntilWait for a duration or until a wall-clock time. No worker is held.
step.waitForEventPark until a matching event arrives, or time out.
step.parallel / mapFan out branches or a collection, then join.
step.invokeCall another function as a child run and wait for its result.
step.invokeAsyncStart a child run and get its run ID back at once.
step.publishPublish to a pub/sub topic from inside a step.
step.compensateRegister an undo for a finished step. See Sagas.

When things go wrong

  • Resume from the last good step
  • Pause a live run
  • Inject a corrected step output
  • Retries with backoff
  • Non-retryable errors fail fast
  • Sagas unwind partial work
  • Circuit breakers at dispatch

Exactly-once applies to memoized step results. The step in flight when a worker dies runs again, so write steps that are safe to repeat.

Child runs

A workflow can call another function as a step. invoke waits for the child's result and memoizes it like any other step. invokeAsync starts the child and moves on. The target function has no event triggers of its own.

import { ironflow } from "@ironflow/node";

export const shipOrder = ironflow.createFunction(
  { id: "ship-order", triggers: [{ event: "order.paid" }] },
  async ({ event, step }) => {
    // Waits for the child run. The target function has no event triggers.
    const label = await step.invoke<{ url: string }>(
      "create-label",
      event.data,
      { timeout: "2m" } // longest wait for the child; default 30s
    );

    // Fire and forget: returns the child run ID at once.
    // It is already durable, so do not wrap it in step.run.
    const { runId } = await step.invokeAsync("notify-customer", event.data);

    return { label: label.url, notifyRun: runId };
  }
);

invoke waits for the child result; invokeAsync returns the child run ID without waiting.

Wall-clock waits and durable publish

TypeScript

import { ironflow } from "@ironflow/node";

export const reminder = ironflow.createFunction(
  { id: "send-reminder", triggers: [{ event: "invoice.issued" }] },
  async ({ event, step }) => {
    // ISO 8601, set by the emitter
    const { dueAt } = event.data as { dueAt: string };

    // The run parks here until the due date.
    await step.sleepUntil("wait-for-due-date", dueAt);
    await step.publish("invoices.reminder-due", { invoiceId: "inv_42" });
  }
);

sleepUntil waits for a wall-clock time; publish is a durable pub/sub send that does not trigger functions.

In the dashboard

localhost:9123/runs/3d5811d9…
Run detail page with a five-step timeline, all steps completed
A five-step run. Each step shows its duration, and the time-travel rail replays the run's state at any point.

[ 00 ] react · function controls

Limits and timing live in the function config.

You declare concurrency, debounce, cancel-on-event, timeouts and retries on the function. The engine enforces them at dispatch, so there is no locking, queueing or timer code to write.

TypeScript

import { ironflow } from "@ironflow/node";

export const syncCustomer = ironflow.createFunction(
  {
    id: "sync-customer",
    triggers: [{ event: "customer.updated" }],

    // At most 5 runs at once for each customerId (a path in event.data).
    concurrency: { limit: 5, key: "customerId" },

    // Run once, 5s after the last event. maxWaitMs forces a run at least
    // every 60s, even when events keep coming.
    debounce: { periodMs: 5_000, key: "customerId", maxWaitMs: 60_000 },

    // Cancel a run when customer.deleted has the same customerId.
    cancelOn: [{ event: "customer.deleted", match: "customerId" }],

    // Limit for the whole run, in ms. Push mode only; pull mode has none.
    timeout: 600_000,
    // Default limit for each step.run.
    stepTimeout: "30s",

    // 5 attempts in total, the first included. The delay starts at 1s,
    // doubles each time, and stops growing at 60s.
    retry: {
      maxAttempts: 5,
      initialDelayMs: 1_000,
      backoffFactor: 2,
      maxDelayMs: 60_000,
    },
  },
  async ({ event, step }) => {
    // The step's own timeout replaces stepTimeout.
    await step.run("push-to-crm", () => pushToCrm(event.data), {
      timeout: "10s",
    });
  }
);

Concurrency, debounce, cancel-on-event, timeouts and retry live in one config object.

FieldWhat it does
concurrencyCaps running runs, in total or per key such as a customer ID. A run whose key does not resolve fails rather than slipping past the limit.
debounceCollapses a burst into one run after a quiet period. Keyed per value, with a maximum wait so a steady stream still runs.
cancelOnCancels a running workflow when a matching event arrives, such as customer.deleted.
timeout / stepTimeoutThe SDK enforces stepTimeout and a step's own timeout in both modes. The function timeout bounds the whole run in push mode only; pull mode does not apply it.
retryAttempts and exponential backoff. A non-retryable error skips them.

Units differ by SDK: milliseconds in TypeScript, time.Duration in Go, seconds or "30s" in Python. Keys and cancelOn.match are paths into data; a waitForEvent match is a path on the whole event.

Schedules

A cron trigger runs a function on a schedule. The trigger still names an event, which the engine emits on each tick. Schedules show in the dashboard. Pending debounce windows show in the CLI.

TypeScript

import { ironflow } from "@ironflow/node";

export const nightlyReport = ironflow.createFunction(
  {
    id: "nightly-report",
    // Every day at 02:00 UTC.
    triggers: [{ event: "report.tick", cron: "0 2 * * *" }],
  },
  async ({ step }) => {
    await step.run("build-report", () => buildReport());
  }
);

A cron trigger still names an event; the engine fires it on the schedule.

Operate a live run

Pause a run at the next step boundary, read its state, replace one finished step's output, and resume. The CLI has all of these. The dashboard edits a step's output from the run page. The MCP server, with --allow-writes, can resume or cancel a run.

CLI

# find a run
$ ironflow run list --function fulfil-order --status running --limit 5

# pause at the next step boundary, then read the completed steps
$ ironflow run pause run-abc123
$ ironflow run paused-state run-abc123

# patch one step's output; paused-state shows the step id
$ ironflow run inject run-abc123 run-abc123:reserve-stock:0 \
    --output '{"sku":"sku-2"}' --reason "wrong warehouse"

# continue the run
$ ironflow run resume run-abc123

# pending debounce windows
$ ironflow debounce list

# push-endpoint circuit breakers: inspect, then close one again
$ ironflow circuit-breaker list
$ ironflow circuit-breaker reset fulfil-order

You can pause a live run, fix one step's output, and resume it from the CLI.

ironflow run list

ID                                    FUNCTION           STATUS     STARTED
5f84f161-204e-46c9-9a50-b5271777e3f9  inventory-sync     completed  2026-10-24 01:30:22
24bd9a04-d0de-4c75-908f-1d59db13dd6d  process-order      completed  2026-10-24 01:28:50
5daa8411-17dd-4178-ba14-838c2136fce9  sync-profile       completed  2026-10-24 01:28:04
1f476017-7629-47b0-b300-ef850da71ccc  refund-approval    waiting    2026-10-24 01:27:58
12f230b3-4d1e-4da0-93c9-2435b9e14f64  fulfil-order-saga  failed     2026-10-24 01:27:58
b7ff73fd-ccd7-4335-b346-af0402bd0f89  process-order      completed  2026-10-24 01:27:58
96a0395a-c582-475e-9680-b6c480deaf91  process-order      completed  2026-10-24 01:27:57
189d8163-1d3e-4c6f-89d3-2f2c0f91f370  process-order      completed  2026-10-24 01:27:56
e3494de8-f2e5-4cfd-96d6-09e71a135ad0  process-order      completed  2026-10-24 01:27:56
3096f60f-3637-477b-b27d-1789983df5fc  process-order      completed  2026-10-24 01:27:55
3d5811d9-372a-4e4c-a38d-bc6baf0c36fb  process-order      completed  2026-10-24 01:27:54

Versions and lifecycle

Registering a changed function creates a new version. List a function's history and roll back to an earlier version. A function can be paused or archived without deleting it. Executable deployments give each build of your code an immutable identity, so you can see which build serves each function.

[ 08 ] react · sagas

Every workflow is already a saga.

No separate primitive. A step registers its undo with step.compensate(). If a later step fails terminally, the undos run in reverse.

AS EACH STEP SUCCEEDS, ITS UNDO IS PUSHED ONTO A STACK charge-payment ✓ registers refund-payment reserve-inventory ✓ registers release-inventory ship-order ✗ non-retryable failure THE STACK UNWINDS · LAST IN, FIRST OUT 1 release-inventory undoes step 2 2 refund-payment undoes step 1 each compensation is itself a durable step, memoized like any other WHY THAT LAST LINE MATTERS Crash mid-unwind and the resumed run skips the compensations that already completed. You do not refund the same card twice. WHEN COMPENSATION DOES NOT FIRE Transient errors that retry. Cancelling a run mid-saga. And push mode, where the closures live in a process the engine cannot re-enter.
not a separate thing

A job, a recorded execution, and a saga all run on one engine, differing only in the undos they register.

observable

The undo shows up in run history beside the forward path.

the honest limit

Cancelling a run does not run compensations. The proposal to add it (#571) was closed as not planned.

In code

Register the undo right after the step it undoes. Throw a non-retryable error when retrying cannot help, and the registered compensations run in reverse order.

TypeScript

import { ironflow, NonRetryableError } from "@ironflow/node";

export const checkout = ironflow.createFunction(
  { id: "checkout", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    const charge = await step.run("charge-card", () =>
      chargeCard(event.data)
    );

    // Undo for charge-card. It runs if the run later fails for good.
    step.compensate("charge-card", () => refundCard(charge.id));

    await step.run("create-shipment", async () => {
      const res = await createShipment(event.data);
      // Retrying cannot fix a rejected address. Fail now; the refund runs.
      if (!res.ok)
        throw new NonRetryableError("carrier rejected the address");
      return res;
    });
  }
);

If a later step fails, registered compensations run in reverse order; NonRetryableError skips the retries.

In the dashboard

localhost:9123/runs/12f230b3…
Run detail page for a failed saga run
A failed run. The completed steps keep their results, and the failing step shows its error.

[ 09 ] react · execution modes

Two ways to run the same function.

A deployment choice, not a code rewrite. Same function, different transport. See execution modes.

PUSH MODE · serve() Ironflow dispatcher HTTP POST Your endpoint serverless / container Short tasks. Deploys anywhere that accepts an HTTP request. Bounded by the platform timeout. PULL MODE · createWorker() Ironflow dispatcher HTTP long-poll Your worker long-lived process Long-running work. The worker polls for jobs, with an opt-in ConnectRPC stream. No push timeout to design around.
Push suits short serverless tasks. Pull suits long work.
heartbeats

Workers heartbeat every 30s. A sweep reclaims a dead worker's runs.

capacity

Workers declare concurrency, and are never handed more than they claimed.

mix freely

Both coexist in one deployment. Pick per function, not per system.

Pull: a worker

A worker long-polls the engine over HTTP, runs jobs up to its declared concurrency, and heartbeats while it works.

import { createWorker } from "@ironflow/node/worker";
import { fulfilOrder } from "./functions.js";

const worker = createWorker({
  serverUrl: "http://localhost:9123",
  functions: [fulfilOrder],
  // Most jobs this worker runs at once. The default is 10.
  maxConcurrentJobs: 4,
});

await worker.start(); // blocks until the worker stops

Each worker polls the server over HTTP for jobs and runs your functions.

import { createStreamingWorker } from "@ironflow/node/worker-streaming";
import { fulfilOrder } from "./functions.js";

// Opt-in ConnectRPC streaming worker: same config, same start().
const worker = createStreamingWorker({ functions: [fulfilOrder] });
await worker.start();

The streaming worker takes the same config and swaps the HTTP poll for a ConnectRPC stream.

Push: an HTTP handler

Mount a handler in the web framework you already run. The engine signs each request, and the handler checks the signature. A push function is registered with its endpoint URL.

import { serve } from "@ironflow/node/serve";
import { fulfilOrder } from "./functions.js";

// Next.js App Router route handler.
// Express: app.post("/api/ironflow", handler).
export const POST = serve({
  functions: [fulfilOrder],
  // Your code reads the key. With no key, no request is verified.
  signingKey: process.env.IRONFLOW_SIGNING_KEY,
});

In push mode the engine POSTs each run to your HTTP endpoint, and the signing key verifies the request.

Push mode from any language

The push protocol is a documented HTTP contract. ironflow conformance push sends the engine's own requests to your endpoint, so a handler in a language without an SDK can prove it speaks the protocol.

CLI

# level 3 = transport, steps and sleep yield; the key adds signature checks
ironflow conformance push --url http://localhost:8080/ironflow \
  --signing-key $IRONFLOW_SIGNING_KEY --level 3 --json

It sends the engine's own requests to your endpoint and needs no Ironflow server; exit code 1 means a check failed.

[ 10 ] emit · events and pub/sub

The broker is already inside.

  • SDK emit. Server, worker, or browser.
  • Webhooks. Provider callbacks, signature verified.
  • Cron triggers. A function on a schedule.
  • CLI. ironflow emit order.placed
SUBJECT ironflow.{project}.{env}.events.order.placed Function durable run starts Projection read model updates Browser live UI over WebSocket WILDCARD PATTERNS order.* one segment order.> everything below the engine scopes subjects to project and environment for you
One event, many consumers, one stream.

Emit once, even when the caller retries

An idempotency key makes a repeated emit return the original event. Keys are environment-wide. Two variants: triggerBatch sends many events in one call and reports each failed item, and TriggerSync starts the matching functions and waits for their results.

import { createClient } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// A repeat emit with the same key returns the original event instead of
// emitting a second one.
const { eventId, runIds } = await ironflow.emit(
  "order.placed",
  { orderId: "ord_123", total: 99.5 },
  { idempotencyKey: "order.placed:ord_123" },
);

// emit returns once the event is stored. It does not wait for the runs.
console.log(eventId, runIds);

A repeat emit with the same key returns the original event instead of emitting a second one; keys are environment-wide.

Event schemas

Register a JSON Schema per event name and version. Enforcement is off by default; set it to warn to log violations or reject to refuse them at ingest.

import { createClient } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// Registering a schema does not enforce it. The server must turn on
// IRONFLOW_EVENT_SCHEMA_ENFORCEMENT.
await ironflow.schemas.register({
  name: "order.placed",
  version: 1, // an emit with no version uses 1
  schema: {
    type: "object",
    required: ["orderId", "total"],
    properties: { orderId: { type: "string" }, total: { type: "number" } },
  },
});

In reject mode a payload that fails its registered schema gets a 400 and nothing is written; enforcement is an environment variable only, with no YAML field.

Topics and filtered subscriptions

emit starts functions. publish sends to a topic and starts nothing, for service-to-service messages and live UIs. A subscriber can pass a CEL filter that runs on the server, so events that do not match never cross the wire.

// server
import { createClient } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// publish() never triggers functions; emit() does.
await ironflow.publish(
  "orders.eu.shipped",
  { orderId: "ord_123", total: 250 },
  // same key while the server still holds it: no second message
  { idempotencyKey: "shipped:ord_123" },
);

publish() never triggers functions; * matches one segment and the filter option is a server-side CEL expression over data and topic.

Webhook sources

A webhook source turns provider callbacks into events after it checks the signature. Rotating the secret keeps both secrets valid for a grace window, so no delivery is dropped. Every delivery is logged, and ironflow webhook deliveries lists them.

TypeScript

import { createClient } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// Ironflow checks the HMAC of each delivery against the header
// before it accepts the event
const source = await ironflow.webhooks.create({
  name: "GitHub production",
  eventPrefix: "github",
  verifyHeader: "X-Hub-Signature-256",
  verifyAlgorithm: "hmac-sha256",
  verifySecret: "whsec_old",
});
console.log(source.ingestToken); // shown only once; store it now

// Both secrets verify for 1 h, so the provider can switch over
// without dropped deliveries.
await ironflow.webhooks.rotateSecret({
  id: source.id,
  verifySecret: "whsec_new",
  graceSeconds: 3600,
});
// optional: end the overlap early
await ironflow.webhooks.expireSecretPrev(source.id);

During the grace window the old and new secrets both verify; the CLI can list sources but cannot create or rotate them.

In the dashboard

localhost:9123/events
Events page listing recent events
The event log with names, sources and the runs each event started.

[ 11 ] derive · event sourcing

One append. Two destinations.

A projection does not read one entity's stream. Every write lands in two places, and projections read the second.

streams.append one transaction 1 · ENTITY STREAM · rehydrate one thing Order-123 v1 OrderPlaced v2 ItemAdded v3 OrderShipped Order-124 v1 OrderPlaced v2 OrderShipped vertical slice per entity · snapshot + replay tail 2 · GLOBAL EVENT LOG · ordered by nats_seq 1 Order-123 OrderPlaced 2 User-42 UserSignedUp 4 Order-124 OrderPlaced 5 Order-123 OrderShipped horizontal timeline across every entity delivery: append writes an outbox row in the same transaction, the outbox worker drains to NATS JetStream, and projections consume from there with automatic checkpoints filter by event name PROJECTIONS · each one its own store and checkpoint OrderDetailView one row per order · managed reducer CustomerOrdersList partitioned by customer · folds across many streams Search index external mode · your side effects, your store VIEWS ARE CHEAP Add a view, replay the log, and it exists. Get the shape wrong and you rebuild it, because the log is the truth and the view never was.
A view is never the source of truth, which is why throwing one away costs nothing.
write side

expectedVersion is the concurrency control, so a concurrent writer gets a conflict rather than a lost write.

schema drift

Upcasters transform old events on read. History never needs a migration.

rebuild

One call replays the log from the start.

The write side in code

expectedVersion rejects a stale writer and the idempotency key makes a retry safe. Deleting a stream writes a $stream.deleted tombstone. purge also removes the history behind it.

import { createClient } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// expectedVersion: 0 = create only. A later writer passes the
// version it read; a stale one gets 409.
const { entityVersion, eventId } = await ironflow.streams.append(
  "ord_123",
  { name: "order.placed", entityType: "order", data: { total: 99.5 } },
  { expectedVersion: 0, idempotencyKey: "place:ord_123" },
);

// Second write builds on the version the first one returned
await ironflow.streams.append(
  "ord_123",
  { name: "order.paid", entityType: "order", data: { method: "card" } },
  { expectedVersion: entityVersion, idempotencyKey: "pay:ord_123" },
);

// Tombstones the stream; purge also deletes every earlier event.
// Irreversible.
await ironflow.streams.delete("ord_123", { purge: true });
console.log(eventId);

expectedVersion rejects a stale writer, the idempotency key makes retries safe, and purge removes the history behind the tombstone.

Upcasters

TypeScript

import { createEventDefinitionRegistry, defineEvent } from "@ironflow/core";
import { createFunction, serve } from "@ironflow/node";

type OrderPlacedV1 = { orderId: string; total: number };
type OrderPlacedV2 = OrderPlacedV1 & { currency: string };

// Two versions of one event name
const orderPlacedV1 = defineEvent({ name: "order.placed", version: 1 });
const orderPlacedV2 = defineEvent({
  name: "order.placed",
  version: 2,
  // Rewrites stored v1 events on read; v2 added `currency`.
  upcast: (data) => ({ ...(data as OrderPlacedV1), currency: "USD" }),
});

// serve() gets this registry and upcasts event data with it
const eventDefinitions = createEventDefinitionRegistry();
eventDefinitions.register(orderPlacedV1);
eventDefinitions.register(orderPlacedV2);

const fulfil = createFunction(
  { id: "fulfil-order", triggers: [{ event: "order.placed" }] },
  // always the v2 shape
  async ({ event }) => (event.data as OrderPlacedV2).currency,
);

export default serve({ functions: [fulfil], eventDefinitions });

The v2 definition carries the v1-to-v2 step and handlers always receive the latest shape; producers should emit new events with { version: 2 } so only old ones pass through the upcaster.

In the dashboard

localhost:9123/streams/order-1042
Entity stream detail page for order-1042
One entity stream: each event with its version.

[ 12 ] derive · cqrs

The log is the only thing the two sides share.

Event sourcing is how you store. CQRS is how you split. Neither needs the other.

WRITE SIDE · intent, validated PlaceOrder command handler a function you write read stream · fold · validate no Aggregate class exists it is a pattern, not a primitive streams.append expectedVersion THE EVENT LOG The single integration point. The write side appends and the read side projects, with no call between them. READ SIDE each view shaped for the question being asked OrderDetailView the order page CustomerOrdersList the account dashboard the next one you need add it, replay, done queries hit read models never the event log EVENTUAL CONSISTENCY IS THE TRADE The read side lags the write side. Ironflow gives you read-your-writes: waitForEvent(eventId), waitForCatchup({ minSeq }), or a live projection subscription.
Commands change state, queries read views. The log is all they share.
what Ironflow removes

No aggregate class, no event-store table, no outbox, and no checkpoint table to own.

what stays yours

Command validation, the fold, and your causation IDs.

worth saying

CQRS without event sourcing, or event sourcing without CQRS. Either works.

Three kinds of projection

managed

Pure reducer

A function of state and event. The engine stores the state and the checkpoint. Rebuild replays the log.

external

Your side effects

The handler writes to your own store, such as a search index. The engine tracks the checkpoint.

sql

A real table

Declare the table and one statement per event. The engine runs it. No worker.

// worker process
import {
  createProjection,
  createWorker,
  ironflow,
  type IronflowProjection,
} from "@ironflow/node";

type OrderStats = { orders: number; revenue: number };

// initialState makes this a managed projection: Ironflow keeps the state
const orderStats = createProjection<OrderStats>({
  name: "order-stats",
  events: ["order.placed"],
  initialState: () => ({ orders: 0, revenue: 0 }),
  // Pure reducer: return a new state; no clocks, no I/O.
  handler: (s: OrderStats, e: { data: unknown }) => ({
    orders: s.orders + 1,
    revenue: s.revenue + (e.data as { total: number }).total,
  }),
});

// A Node worker needs at least one function to register.
const placeOrder = ironflow.createFunction(
  { id: "place-order", triggers: [{ event: "order.requested" }] },
  async ({ event, step }) => {
    // Command handler: validate, then append order.placed.
  },
);

// Register the projection with a worker
await createWorker({
  functions: [placeOrder],
  projections: [orderStats] as IronflowProjection[],
}).start();

The worker runs the reducer, waitForEvent blocks until a write is visible, and the browser receives each new state; waitForCatchup needs a sequence number that stream appends do not return.

A projection can be paused, resumed and rebuilt, and its definition history is kept. When several workers register the same projection, one holds an exclusive lease and runs it. The others wait as standbys.

Commands that run once

The write side has its own retry problem: a client resends a command. The command dedup helper lets the first caller claim the command ID, and every later caller gets the stored result.

TypeScript

import { createClient } from "@ironflow/node";

type Entry =
  { status: "claimed" }
  | { status: "done"; result: { orderId: string } };

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
// create once, reuse
const dedup = ironflow.commandDedup<Entry>("order-commands");

export async function placeOrder(
  commandId: string,
  runHandler: () => Promise<{ orderId: string }>,
) {
  // Claim first. null means this caller won and must do the work.
  const prior = await dedup.tryClaim(commandId, { status: "claimed" });
  if (prior !== null) {
    // Someone else claimed it: replay a finished result
    if (prior.status === "done") return prior.result;
    // Still pending: the winner has not finished, or crashed
    throw new Error(
      "Command is pending; reconcile its effects before retrying",
    );
  }

  // On failure, release the claim so a retry can run
  let result: { orderId: string };
  try {
    result = await runHandler(); // effects must be idempotent
  } catch (err) {
    // only the claim winner reaches here
    await dedup.release(commandId).catch(() => {});
    throw err;
  }

  // Store the result; later callers with this id get it back
  await dedup.finalize(commandId, { status: "done", result });
  return result;
}

The first caller claims the command ID and runs the handler; later callers get the stored result, or a pending error if the winner has not finished.

Ask the data a question

ironflow sql runs read-only SELECT statements over events, runs, steps, functions and projection state, scoped to the current environment.

CLI

ironflow sql "SELECT name, COUNT(*) AS total FROM events GROUP BY name"
ironflow sql "SELECT id, status FROM runs WHERE status = 'failed' LIMIT 10" \
  --format json

ironflow sql runs read-only SELECT statements over events, runs, steps, functions, environments and the projection registry and state tables, scoped to the current environment.

Output

function_id        status     runs
process-order      completed  7
fulfil-order-saga  failed     1
inventory-sync     completed  1
refund-approval    waiting    1
sync-profile       completed  1

In the dashboard

localhost:9123/projections/order-stats
Projection detail page showing state
A managed projection with its current state and checkpoint.

[ 13 ] platform services

The parts you would otherwise bolt on.

Same binary, same hierarchy. Click a tile for detail.

Recording execution history is a setting you turn on, not a default. Ironflow does not claim an audit trail by default, and does not claim to satisfy any regulation.

[ 00 ] storage · data services

State that does not deserve a table.

Each environment has its own KV buckets, config documents, secrets and file buckets, in the same binary and under the same keys and roles. None of them needs another service.

KV, config and secrets

kv

Buckets with TTL and watch

TTL is set per bucket. Watch pushes changes over WebSocket, with key patterns.

config

Set, patch, watch

Environment-scoped JSON documents. Running workers see changes without a redeploy.

secrets

Declared per function

A function sees only the secrets it declares. Values are never returned to a client.

import { createClient, createFunction } from "@ironflow/node";

const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// KV: TTL is a bucket setting; every key in "sessions" expires
// after an hour.
const kv = ironflow.kv();
await kv.createBucket({ name: "sessions", ttlSeconds: 3600 });
await kv.bucket("sessions").put("user.42", { cart: ["sku_1"] });

// Watch keys that match "user.*"
kv.bucket("sessions").watch(
  { onUpdate: (e) => console.log(e.key, e.operation) },
  { key: "user.*" },
);

// Config: set replaces, patch merges (and creates the document if
// missing).
const config = ironflow.config();
await config.set("app-settings", { theme: "dark", retries: 3 });
await config.patch("app-settings", { retries: 5 });
config.watch("app-settings", { onUpdate: (e) => console.log(e.data) });

// Secrets: a function sees only the secrets it declares.
export const payout = createFunction(
  {
    id: "payout",
    triggers: [{ event: "payout.requested" }],
    secrets: ["STRIPE_KEY"],
  },
  async ({ secrets }) => secrets.get("STRIPE_KEY") !== undefined,
);

KV expiry is a bucket setting rather than a per-key one, config patch merges, and a function receives only the secrets it declares.

Files

File buckets with short-lived signed URLs. The browser uploads straight to storage with no Ironflow credentials, and a bucket can emit ironflow.file.* events that start a function.

import {
  createClient,
  createFunction,
} from "@ironflow/node";

const files = createClient({
  apiKey: process.env.IRONFLOW_API_KEY,
}).files();

// emitEvents: the bucket emits ironflow.file.* events.
// allowSignedUrls: the bucket accepts credential-free URLs.
await files.createBucket("inbox", {
  emitEvents: true,
  allowSignedUrls: true,
});

// Upload URL valid for 10 min; the upload is capped at 5 MB.
// Send `url` to the browser.
const { url } = await files.bucket("inbox").signUpload(
  "scans/ord_123.pdf",
  {
    ttlSeconds: 600,
    maxBytes: 5 * 1024 * 1024,
    contentType: "application/pdf",
  },
);

// Runs when a file lands in "inbox"
export const onScan = createFunction(
  {
    id: "process-scan",
    triggers: [{
      event: "ironflow.file.created",
      expression: "data.bucket == 'inbox'",
    }],
  },
  async ({ event }) => {
    const { path, etag } = event.data as {
      path: string;
      etag: string
    };
    // ifMatch pins the version that caused the event; delivery is
    // at-least-once, so key work on etag.
    return (
      await files.bucket("inbox").get(path, { ifMatch: etag })
    ).size;
  },
);

The browser uploads straight to storage with a signed URL, and the platform event triggers the function; delivery is at-least-once, so key work on the etag.

Blob overflow

Large step outputs, run outputs and snapshots can move out of the database to S3-compatible storage. The engine restores them on read, so a function sees the full value. Overflow is off until you set a threshold. A cluster always needs an s3:// blob URL.

apiVersion: ironflow/v1
kind: Server
spec:
  blobs:
    # blob backend; any S3-compatible store works
    url: "s3://ironflow-artifacts?endpoint=https://seaweedfs.internal:8333"
    # run and step outputs above this size go to the blob backend
    artifactThreshold: "1MB"

Outputs larger than the threshold move to the blob backend and are restored on read; credentials stay in the environment, never in the URL.

In the dashboard

localhost:9123/kv/sessions
KV bucket detail page listing keys
Keys in a KV bucket.

[ 00 ] governance · security

Who can do what, and who did it.

Every request carries a credential, resolves to one environment, and passes roles first and the organization's policy set second. Every event records who emitted it.

hierarchy

Org, project, environment

Every resource has a 7-segment IRN: irn:ironflow:org:project:type:env:id. A request names its environment by ID and resolves to exactly one.

credentials

Keys and sessions

ifkey_ keys for code, ifplatform_ keys for platform operators, and JWT sessions for the dashboard.

clean teardown

Deleting an environment

Deletes its secrets, config and KV buckets, and purges its retained pub/sub messages.

For multi-tenant installs, platform operators provision tenant organizations (ironflow tenant provision) and can act inside a tenant through impersonation. Each impersonated request is recorded in the audit log.

Keys and roles

A key belongs to one environment, carries one role, can expire, and can be limited to the agent tools it may call. Built-in roles are admin, developer and viewer. An organization can add custom roles.

CLI

# list roles to find the role ID for --role
ironflow role list

# expires after 720 h (30 days); --tool-scope grants an agent tool scope
ironflow apikey create refund-bot \
  --env env_default \
  --role role_developer \
  --expires 720h \
  --tool-scope billing-read

The key value prints once; --tool-scope limits which registered agent tools the key may call.

Policy sets in Rego

Roles answer "may this role do this". A policy set answers the questions roles cannot, such as "only finance may emit refunds" or "no production secrets during a change freeze". A policy set can only deny what roles allowed. It never grants. Enforcement is off by default, and a new set starts in monitor mode, which records what it would have denied and blocks nothing.

package ironflow.authz

# Policies only deny. RBAC grants; a policy cannot add permission.
# Only holders of the finance-admin role may emit payment.refund.* events.
deny contains "payment.refund.* needs the finance-admin role" if {
	# the request is an emit, and its resource holds a refund event name
	input.request.action == "events:emit"
	contains(input.request.resource, ":payment.refund.")
	# the caller lacks the role
	not "finance-admin" in input.subject.roles
}

A policy set can only deny on top of RBAC; roll it out in monitor mode, then switch to enforce.

Emit authorization and provenance

A policy can deny an emit by event name, so a key that may emit order.* cannot emit payment.refund.issued. The engine stamps every event with emitted_by from the caller's credential. The request body cannot set it, so an approval step can check who approved.

# Read who emitted an event. The engine sets it from the caller's
# credential; the event body cannot change it.
curl -s -H "Authorization: Bearer $IRONFLOW_API_KEY" \
  http://localhost:9123/api/v1/events/$EVENT_ID | jq -r .emitted_by
# Values: apikey:<id> | user:<id> | platform_user:<id>
#   | webhook:<source-id> | nats | platform

The engine stamps every event with its authenticated emitter, so an approval check can trust it.

Audit, redaction and retention

Admin and authorization actions write audit rows. Set the retention window, query the auth trail, and export a filtered window as JSONL with a record count and a SHA-256 footer.

Redaction replaces stored payloads with a placeholder and keeps the rows, IDs and ordering: ironflow run redact for a run and its steps, ironflow event redact for one event. Nightly retention for runs, events and deleted streams is opt-in.

CLI and HTTP

# Keep audit rows for a year (default 90; 0 disables pruning; minimum 7).
IRONFLOW_AUDIT_RETENTION_DAYS=365 ironflow serve

# Filtered auth-decision trail from the CLI.
ironflow audit auth-trail --org org_default --action events:emit \
  --from 2026-10-01T00:00:00Z --json

# Export a filtered window as JSONL. The header picks the environment.
curl --fail-with-body \
  -H "Authorization: Bearer $IRONFLOW_API_KEY" \
  -H 'X-Ironflow-Environment-ID: env_default' \
  'http://localhost:9123/api/v1/audit/export?category=security&from=2026-10-01&to=2026-10-24' \
  -o audit-export.jsonl

The export ends with a footer that carries a record count and a SHA-256 of the body.

Recording execution history is a setting you turn on. Ironflow does not claim an audit trail by default, and does not claim to satisfy any regulation.

Secrets and reserved names

Shell

# Keep a copy of the key. If you rotate it, old secrets become unreadable.
export IRONFLOW_MASTER_KEY=$(openssl rand -hex 32)

# With -f, the file reads the key as masterKey: ${IRONFLOW_MASTER_KEY}
ironflow serve -f ironflow.yaml

# Store a secret in one environment.
ironflow secret set STRIPE_KEY sk_test_123 \
  --description "Payments key" --env env_default

With a master key set, secrets are AES-256-GCM encrypted; without one they are stored in plaintext in dev mode.

Names that belong to the platform are reserved: event names under ironflow., the pub/sub prefixes events:, system., entity:, public. and topic:, and projections named ironflow_*. A write that uses one fails with a clear error.

An unreadable secret fails the run. A function never runs without the secrets it declared.

In the dashboard

localhost:9123/policies
Policies page
The organization's policy set, its mode and versions.

[ 14 ] agents

Durability wraps the reasoning layer you already chose.

Your framework owns prompts, model access, and planning. Ironflow wraps durable steps, waits, and events around the parts that must survive.

Agent primitives

agent()Define a durable agent run.
tool()A tool call recorded as a memoized step.
llm()A model call inside the durable boundary.
approve()Park for a human decision, for as long as it takes.
spawn()Start a child agent run.

Memory writes go through an entity stream, so agent history is replayable.

✓ shipped
  • Native agent APIs for Node, Go, and Python, with memory in an entity stream
  • Browser agent client
  • LangGraph checkpoint saver (IronflowSaver)
  • MCP server, to drive Ironflow from your IDE
  • SDK-defined tools, exposed to MCP clients with scopes
  • A2A server: ironflow a2a
  • Ironflow skills for Claude Code, Codex and Gemini CLI
  • @ironflow/agent, a coding agent for Zed and JetBrains. Bring your own model key.

The agent API in code

Tools, model calls and approvals are durable steps. A worker that dies mid-turn resumes without calling the model or the tool a second time.

TypeScript

import { agent, defineTool } from "@ironflow/node/agent";
import { z } from "zod";
// Your provider call. It returns { content, ... }.
import { callModel } from "./model.js";

// The zod schema checks the tool input before the handler runs.
const lookupOrder = defineTool({
  name: "lookup-order",
  input: z.object({ orderId: z.string() }),
  handler: async ({ orderId }) => ({ orderId, total: 120 }),
});

export const refundAgent = agent(
  {
    id: "refund-agent",
    triggers: [{ event: "refund.requested" }],
    tools: [lookupOrder]
  },
  async ({ event, tool, llm, approve }) => {
    const { orderId } = event.data as { orderId: string };

    // A tool call times out after 60s unless the tool sets its own limit.
    const order = await tool(lookupOrder, { orderId });

    // call() reaches your provider once. The step memoizes the reply.
    // Each complete() is one turn. The default limit is 20 (maxTurns).
    const draft = await llm.complete({
      messages: [
        {
          role: "user",
          content: `Draft a refund for ${JSON.stringify(order)}`
        }
      ],
      call: () => callModel(order),
    });

    // Wait for refund.approved with this run's id in data.runId. payload
    // shows beside the waiting step. If ttl expires, the run fails.
    const decision = await approve("refund.approved", {
      ttl: "24h",
      payload: draft.content
    });

    // emittedBy is the authenticated emitter that the engine recorded.
    return { approved: decision.approved, approvedBy: decision.emittedBy };
  },
);

Each tool, model call and approval is a durable step, so a crashed worker resumes instead of repeating them.

Let other agents in

Two doors, both governed by the same keys and scopes. Register SDK tools so MCP clients can call them, or put an Ironflow agent behind the A2A protocol so a remote agent can hand it a task.

import { exposeMcp } from "@ironflow/node/agent";
import { z } from "zod";

// Register the tools with the Ironflow server.
const handle = await exposeMcp({
  name: "billing",
  version: "1.0.0",
  // Your serve() mount. Ironflow POSTs signed calls here.
  callbackUrl: "https://tools.example.com",
  tools: [
    {
      name: "get-invoice",
      description: "Fetch an invoice by ID",
      // The zod schema becomes the tool's JSON Schema.
      input: z.object({ id: z.string() }),
      // The calling key needs this in its --tool-scope list.
      scopes: ["billing-read"],
      handler: async ({ id }) => ({ id, status: "paid" }),
    },
  ],
});
console.log(handle.toolNames); // ["billing.get-invoice"]

Tool calls are HMAC-signed, and the calling key needs each tool's scope. One A2A task is one Ironflow run.

LangGraph checkpoints

TypeScript

import { IronflowClient } from "@ironflow/node";
import { IronflowSaver } from "@ironflow/langgraph";
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";

// The saver stores LangGraph checkpoints in Ironflow entity streams.
const client = new IronflowClient({
  serverUrl: process.env.IRONFLOW_SERVER_URL!
});
const saver = new IronflowSaver({ client });

// The reducer keeps the newest value written to counter.
const State = Annotation.Root({
  counter: Annotation<number>({ reducer: (_a, b) => b, default: () => 0 }),
});

// The node loops until counter reaches 5. compile() wires in the saver.
const graph = new StateGraph(State)
  .addNode("step", (s) => ({ counter: s.counter + 1 }))
  .addEdge(START, "step")
  .addConditionalEdges("step", (s) => (s.counter >= 5 ? END : "step"))
  .compile({ checkpointer: saver });

// thread_id picks the entity stream that holds the checkpoints.
await graph.invoke(
  { counter: 0 },
  { configurable: { thread_id: "thread-1" } }
);

Restart with the same thread_id and the graph resumes from the last checkpoint, stored as an Ironflow entity stream.

Coding agents that know Ironflow

ironflow mcp gives a coding agent 25 read-only tools over runs, events and docs, and 41 with --allow-writes. The binary also ships the Ironflow skills, and @ironflow/agent is a coding agent for Zed and JetBrains that has them built in. For a hosted agent with no local binary, the engine can serve MCP itself over HTTP with the agent's own key. That remote endpoint is off by default.

{
  "mcpServers": {
    "ironflow": {
      "command": "ironflow",
      "args": ["mcp", "--allow-writes"],
      "env": { "IRONFLOW_API_KEY": "${IRONFLOW_API_KEY}" }
    }
  }
}

@ironflow/agent is bring-your-own-key: you supply an Anthropic, OpenAI, Gemini or OpenRouter key, and prompts go to that provider.

[ 15 ] sdks and apis

Node, Go, and Python, all first-class.

PackageUse caseRuns in
@ironflow/nodeWorkers, serve handlers, steps, projectionsNode.js
@ironflow/browserLive subscriptions, triggers, entity streamsBrowser
@ironflow/coreShared types, schemas, event versioningBoth
@ironflow/langgraphLangGraph checkpoint saverNode.js
sahina/ironflow-go/ironflowFull Go SDK: functions, steps, client, test clientGo 1.26+
ironflow-pyPython SDK: worker, serve (ASGI), agent, client, test clientPython 3.10+

A coverage manifest records which server routes each SDK calls, so gaps are visible.

  • ConnectRPC + protobuf. Go, TS, and Python clients from one definition.
  • REST where it suits.
  • HTTP long-poll for pull-mode workers, with an opt-in ConnectRPC stream.
  • WebSocket for live subscriptions.

Test without a server

The test client runs the real handler in memory. Mock steps, queue the events a run waits for, and assert on step outputs.

import { test, expect } from "vitest";
import { createTestClient } from "@ironflow/node/test";
import { fulfilOrder } from "./functions.js";

test("fulfils an order once payment is confirmed", async () => {
  const t = createTestClient({ functions: [fulfilOrder] });

  // The handler gets this value in place of the real step body.
  t.mockStep("reserve-stock", () => "sku-1");
  // Queue the event that waitForEvent receives. Send it before emit().
  t.sendEvent("payment.confirmed", { orderId: "ord_1" });

  // Run the function that order.placed triggers, in memory.
  const run = await t.emit("order.placed", { orderId: "ord_1" });

  expect(run.status).toBe("completed");
  expect(run.stepOutput("reserve-stock")).toBe("sku-1");
});

Test clients run the real handler in memory with mocked steps and queued events, with no server.

Contracts you can check

  • OpenAPI 3.1. The REST spec is embedded in the binary and served by it, so it matches the running server. Function registration and streams are ConnectRPC.
  • Version skew. The CLI and the SDKs read the server's version on every response and warn once when they differ.
  • Local discovery. A running engine writes a discovery file, so the CLI and SDKs find it with no URL to configure.
  • Push conformance. ironflow conformance push checks any HTTP handler against the push contract.

Shell

curl -s -H "Authorization: Bearer $IRONFLOW_API_KEY" \
  http://localhost:9123/api/v1/openapi.json \
  -o openapi.json

The spec is embedded in the binary, so it always matches the running server.

Browser writes that survive going offline

With an offline queue, the browser client writes emits and stream appends to IndexedDB first, then sends them in order when the connection returns, with the same idempotency key on every retry.

TypeScript

import { createClient } from "@ironflow/browser";

// Stand-in for your app's login refresh.
declare function refreshSession():
  Promise<{ token: string; userId: string }>;

const ironflow = await createClient({
  serverUrl: "https://ironflow.example.com",
  auth: { token: "<user-jwt>" },
  offlineQueue: {
    // Stable per signed-in user, not derived from the token.
    identity: "user_42",
    // A write past 200 queued writes throws QueueFullError.
    maxItems: 200,
    // After a 401, the queue pauses, calls this once, then resumes.
    onAuthRequired: async () => {
      const s = await refreshSession();
      return { token: s.token, identity: s.userId };
    },
    // Called once for each write that will never be sent.
    onWriteLost: (write, reason) =>
      console.warn("dropped", write.localId, reason),
  },
});

// Saves to IndexedDB first, then sends when online, in order.
// Every retry uses the same idempotency key.
const { localId } = await ironflow.emit("order.placed", {
  orderId: "ord_123"
});
// Follow the write by its local id: pending, then sent or lost.
ironflow.queue.watch(localId, (s) => console.log(s.status));

createClient returns a client whose emit and streams.append persist to IndexedDB first, then send in order with a fixed idempotency key.

[ 16 ] operate

The moments that matter are the ones under stress.

Inspect a failed run. Patch a step. Replay a stream. Rebuild a projection. Each has a surface.

embedded

Dashboard

Inside the binary at localhost:9123. More than twenty pages, shown below.

terminal

CLI

serve, emit, secret, outbox dlq, and more. The whole platform is scriptable.

terminal

Run inspector

ironflow inspect <run_id> opens a TUI debugger that time-travels a run.

ide

MCP server

ironflow mcp exposes runs and events to an AI assistant. Read-only by default.

desktop

Ironflow Desktop

Bundles the engine, so it runs without Docker, Node, or a CLI, and hosts the same dashboard. Each workspace gets its own engine. An Activity feed lists failed runs, dead letters, open breakers and failed deliveries, with requeue and discard for dead letters.

✓ macOS Apple Silicon ⚠ Windows / Linux experimental
telemetry

Observability

Optional OpenTelemetry and Prometheus, off unless enabled. /health and /ready.

Desktop's built-in agent is bring-your-own-key and stays off until you connect a provider account. Intel Macs are not supported and no Intel build is published.

The dashboard

Every page below is in the binary. Runs and their steps, the flow map, events, functions and schedules, entity streams and projections, KV, files, config and secrets, webhooks and their deliveries, topics, a request inspector, analytics, the audit log, and users, keys, roles and policies.

localhost:9123/runs
Runs list page
Runs across functions, filterable by status.

The CLI

Everything the dashboard does is scriptable. Most commands take --json.

ironflow --help (excerpt)

Usage:
  ironflow [command]

Available Commands:
  apikey          Manage API keys
  emit            Emit an event to the server
  event           Manage events and event schemas
  function        Manage functions
  init            Create a new Ironflow project
  inspect         Inspect a workflow run with the TUI debugger
  invoke          Invoke a function directly
  mcp             Start the MCP server for AI assistant integration
  policy          Manage the organization's Rego policy set
  projection      Manage projections
  run             Manage runs
  secret          Manage secrets
  serve           Start Ironflow server
  sql             Execute a SQL query
  stream          Manage entity streams
  subscribe       Subscribe to real-time events
  topic           Manage developer pub/sub topics
  webhook         Manage webhook sources and deliveries
  ... (and more; run `ironflow --help` for the full list)

Debug a run after the fact

ironflow inspect replays a run's recorded history in a terminal debugger. With --dap it serves the Debug Adapter Protocol, so VS Code can step through the same history.

CLI

# TUI debugger
ironflow inspect run_abc123

# Debug Adapter Protocol server for VS Code. Port 4711 by default;
# --dap-port changes it.
ironflow inspect run_abc123 --dap

# Step through events frame by frame
ironflow inspect run_abc123 --replay

Inspect is read-only: it replays recorded history and never re-executes the workflow.

Observability

built in

Analytics page

Run throughput, error rate, p50 and p95 duration, event ingest, and the functions that fail or run slow. No setup.

opt-in

Prometheus and OpenTelemetry

Metrics and traces, off until enabled. The Docker Compose monitoring profiles ship Grafana dashboards.

probes

Health and readiness

/health checks the database. /ready checks the database and NATS.

[ 17 ] deployment

How a deployment grows, in the order you meet it.

01 · local

One binary

For laptops, CI, demos, and small single-node setups.

  • brew, Scoop, Docker, or a signed binary
  • Nothing else to configure or run
  • Resumes after a worker crash; server-crash durability needs PostgreSQL
02 · self-hosted production

Postgres + external NATS

For more than one node, or a database that outlives the process.

  • The engine container with Docker Compose, in Starter, Cluster, and HA sizes
  • Crash-resume across nodes
  • Binaries signed with Sigstore cosign
03 · managed

Ironflow Cloud

⚠ planned · waitlist

Dedicated clusters, managed. Next slide covers what is not true of it yet.

Configuration as a file

config init writes a starting file, validate checks it, and serve -f runs from it. Templates exist for production, cluster and platform setups. serve --headless serves no dashboard, for an engine embedded in another app.

ironflow config init --prod > ironflow.yaml
# Check the file. It also lists env vars that -f makes serve ignore.
ironflow validate -f ironflow.yaml
ironflow serve -f ironflow.yaml

With -f, the file is the only source of truth; environment variables are read only where the file references them as ${VAR}.

Ways to deploy

one server

Single node

One engine container with embedded NATS. SQLite, or PostgreSQL through a Compose profile.

compose

Starter, Cluster, HA

One node; three nodes behind nginx; three nodes with PostgreSQL failover.

next to your app

Alongside your app

The engine as a companion container in your own Compose project, reached over HTTP.

# Get the Compose files
git clone --depth 1 https://github.com/sahina/ironflow-releases.git
cd ironflow-releases/deploy/docker-compose

# One engine container with embedded NATS and SQLite. API on :9123.
docker compose -f docker-compose.single-node.yml up -d

Every size runs on one machine. Starter, Cluster and HA run cluster mode with PostgreSQL, NATS and an S3 store.

The Docker Compose guide covers upgrades, backups and restores, and master key rotation.

[ 18 ] scenarios

What you can build on it.

One engine, several workload shapes. Three get a walkthrough next.

Ten more, built around one online store: What you can build with Ironflow.

walkthrough follows

Durable AI agents

Long tool-using loops with approval gates. Survive a redeploy mid-run.

walkthrough follows

Order fulfillment

Commerce flows with compensating actions and an event-sourced order.

walkthrough follows

Human-in-the-loop

Park for days waiting on a person, without holding a worker.

shape

Webhook fan-out

Verify a provider webhook, fan out with retries and a DLQ.

shape

ETL and batch

Pull-mode pipelines with no platform timeout. Fan out with step.map.

shape

Media processing

Transcoding and transcription that run for hours on an open stream.

shape

Real-time UIs

The browser SDK subscribes to wildcards. UIs update as runs move.

shape

CQRS services

Commands append, projections build the query side, rebuilds are routine.

shape

Scheduled operations

Cron health probes and remediation sagas that page a human on failure.

[ 19 ] scenario a

A research agent that survives a redeploy.

Three tools, a human approval, then results. Twenty minutes of work. You ship a deploy at minute twelve.

DURABLE AGENT RUN llm() plan ✓ memoized tool() search ✓ memoized tool() fetch ✓ memoized tool() summarize in flight at deploy approve() parks for a human memory entity stream append-only, replayable deploy at minute 12 AFTER THE NEW WORKER STARTS The first three steps replay from the store in milliseconds, so only summarize re-executes. The approval gate is still waiting, and no worker was held while it waited.
Twelve minutes of work survive. The re-run costs one step.
what it needs

Pull mode, so no platform timeout applies to a twenty-minute run.

what it costs you

Writing tool calls so a re-run is safe, which is the one discipline required.

what you see

The run, every step, and the pending approval, live in the dashboard.

[ 20 ] scenario b

Order fulfillment, where step four fails.

Reserve stock, charge the card, book the courier. The courier API is down. Compensations unwind, and the order's history records all of it.

FORWARD PATH reserve-stock ✓ done charge-card ✓ done book-courier ✗ upstream 503, retries exhausted print-label · never runs COMPENSATION UNWINDS (see Sagas) release-stock, then refund-card. What matters here is what the order remembers afterward. WHAT THE ORDER REMEMBERS · entity stream order-1042 OrderPlaced StockReserved PaymentCaptured FulfillmentFailed PaymentRefunded Five facts, none overwritten. The failure and the refund are as permanent as the sale.
Support asks what happened. Finance asks what was charged. Both answer from this one row of history.
the primitive

Sagas plus a circuit breaker, so a dead API stops being called.

the read side

A projection folds the stream into the summary the UI queries.

the guarantee

The outbox publishes atomically with the state change.

[ 21 ] scenario c

A workflow that waits three days for a person.

An expense needs a manager's sign-off. The manager is on leave. The workflow parks, escalates on timeout, and resumes when someone clicks approve.

expense.submitted event triggers the run step.run policy check ✓ over threshold step.waitForEvent expense.approved · timeout 72h no worker held · no connection open reimburse resumes on the event timeout branch escalate to the skip-level manager MEANWHILE The process can restart. The node can be replaced. You can deploy twice. The wait is state in the store, not a thread.
The wait is a row in a store, not a process held open. It costs no compute.
why this is hard elsewhere

Serverless cannot wait three days. A held thread is an outage waiting.

the UI side

The approver's page updates the moment the run moves.

the same shape

KYC, contract sign-off, and agent approval gates all use the same primitive.

In the dashboard

localhost:9123/runs/1f476017…
Run detail page for a run in the waiting state
A run parked on waitForEvent. No worker is held while it waits.

[ 22 ] managed offering

Ironflow Cloud.

⚠ planned, after 1.0 waitlist access pricing is planned pricing

Dedicated clusters, managed. Same engine, same SDKs, same dashboard, without you running Postgres and NATS.

What exists today

  • A static portal shell at app.ironflow.cloud. It has no reachable backend, so nobody can sign in.
  • A waitlist at ironflow.run/cloud.
  • Pricing pages describing planned tiers.
what is not true yet
  • Cloud is not generally available.
  • It is not self-serve. Checkout is gated off, so nothing is purchasable today.
  • Published pricing is planned pricing, not a live price list.
  • Hosted approval and collaboration UI, hosted run comparison, and additional managed regions are planned, not shipped.

Self-hosting is the path that works right now, and it is the same engine.

[ 23 ] licensing

Closed source, on an honor system.

The Ironflow License: the Functional Source License v1.1 with an Apache-2.0 future grant. Two years after each release, you may also use that release under Apache-2.0. We do not publish the engine's source code.

No license keys. No telemetry. No phone-home. Nothing between the download and production.

Closed source, not open source. The distinction matters and we hold to it. The LICENSE file and the licensing explainer are the only sources of legal wording.

free
  • Internal use
  • Personal projects
  • Evaluation, up to 90 days
  • Non-commercial education
  • Non-commercial research
  • Professional services
needs a license

A Competing Use: a commercial product or service that substitutes for Ironflow, or offers the same or similar functionality.

  • Hosted Ironflow resold as a service
  • A white-label workflow service

Contact details are in the LICENSE file.

[ 24 ] next

Five minutes to a running system.

# 1. start the server (--dev turns authentication off)
brew install sahina/tap/ironflow
ironflow serve --dev              # dashboard at :9123

# 2. scaffold a project (TypeScript worker; installs dependencies)
ironflow init my-app && cd my-app

# 3. run the worker (reloads on change), then send it something
pnpm dev
ironflow emit order.placed --data '{"orderId":"o-1","total":99.99}'

Runnable examples

Each example is a complete app in the release repository.

  • agents code review, document processing, reconciliation
  • travel-booking saga with compensation
  • fraud-detection events and scoring
  • cqrs-order and ddd-order-management
  • financial-rag and rag-core
  • file-upload signed URLs and file events
  • deletion-redaction
  • go-quickstart and quickstart
  • flask-single-container Python push mode
  • todo-web browser client
  • yaml-config
  • reference-app
Emit React Derive Rewind