[ 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.
One Go binary. NATS, SQLite, and the dashboard embedded, with no Docker required.
Postgres + external NATS + S3. Multi-node when you need it.
Node, Go, and Python. Plus a browser client and a LangGraph saver.
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.
[ 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.
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.
NATS, the database, and blob storage. Everything else is the same code.
A node can die mid-run and another picks the run up.
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.
Events in
An event enters from a client, webhook, cron, or another function.
- Typed event emission
- Webhook and cron triggers
- Wildcard pub/sub subjects
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)
Views build
Projections fold history into read models, instead of state you mutate.
- Managed pure reducers
- External side-effect projections
- Rebuild from history
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.
Completed step results, memoized by step ID.
The step in flight. Write steps so a re-run is safe.
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 }; } );
import "time" import "github.com/sahina/ironflow-go/ironflow" var FulfilOrder = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "fulfil-order", Triggers: []ironflow.Trigger{{Event: "order.placed"}}, }, func(ctx ironflow.Context) (any, error) { // The engine stores the result under this step id. A restarted // run skips the call and reuses the result. reserved, err := ironflow.Run(ctx, "reserve-stock", func() (string, error) { return reserveStock(ctx.Event) }) if err != nil { return nil, err } // Durable sleep: the run parks and the engine wakes it after 1h. if err := ironflow.Sleep(ctx, "hold-window", time.Hour); err != nil { return nil, err } // 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. paid, err := ironflow.WaitForEvent[any]( ctx, "await-payment", ironflow.EventFilter{ Event: "payment.confirmed", Match: "data.orderId", Timeout: 24 * time.Hour, }) if err != nil { return nil, err } return map[string]any{ "reserved": reserved, "paid": paid.RawData, }, nil })
from ironflow.worker import function @function(id="fulfil-order", triggers=[{"event": "order.placed"}]) async def fulfil_order(ctx): # The engine stores the result under this step id. A restarted # run skips the call and reuses the result. reserved = await ctx.step.run( "reserve-stock", lambda: reserve_stock(ctx.event.data) ) # Durable sleep: the run parks and the engine wakes it after 1h. await ctx.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. payment = await ctx.step.wait_for_event( "await-payment", event="payment.confirmed", match="data.orderId", timeout="24h", ) return {"reserved": 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.run | Run code once. The result is memoized by step ID. |
step.sleep / sleepUntil | Wait for a duration or until a wall-clock time. No worker is held. |
step.waitForEvent | Park until a matching event arrives, or time out. |
step.parallel / map | Fan out branches or a collection, then join. |
step.invoke | Call another function as a child run and wait for its result. |
step.invokeAsync | Start a child run and get its run ID back at once. |
step.publish | Publish to a pub/sub topic from inside a step. |
step.compensate | Register 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 }; } );
import "github.com/sahina/ironflow-go/ironflow" var ShipOrder = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "ship-order", Triggers: []ironflow.Trigger{{Event: "order.paid"}}, }, func(ctx ironflow.Context) (any, error) { // Wait for the child run; the target function has no event triggers. label, err := ironflow.Invoke[map[string]any]( ctx, "create-label", map[string]any{"orderId": "ord_1"}, ) if err != nil { return nil, err } // Fire and forget: returns the child run ID at once. child, err := ironflow.InvokeAsync( ctx, "notify-customer", map[string]any{"orderId": "ord_1"}, ) if err != nil { return nil, err } return map[string]any{"label": label, "notifyRun": child.RunID}, nil })
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
[ 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.
| Field | What it does |
|---|---|
concurrency | Caps 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. |
debounce | Collapses a burst into one run after a quiet period. Keyed per value, with a maximum wait so a steady stream still runs. |
cancelOn | Cancels a running workflow when a matching event arrives,
such as customer.deleted. |
timeout / stepTimeout | The 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. |
retry | Attempts 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.
A job, a recorded execution, and a saga all run on one engine, differing only in the undos they register.
The undo shows up in run history beside the forward path.
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
[ 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.
Workers heartbeat every 30s. A sweep reclaims a dead worker's runs.
Workers declare concurrency, and are never handed more than they claimed.
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
import ( "context" "os/signal" "syscall" ) import "github.com/sahina/ironflow-go/ironflow" func runWorker() error { // Ctrl-C or SIGTERM cancels ctx. ctx, stop := signal.NotifyContext( context.Background(), syscall.SIGINT, syscall.SIGTERM, ) defer stop() worker := ironflow.NewWorker(ironflow.WorkerConfig{ ServerURL: "http://localhost:9123", Functions: []ironflow.Function{FulfilOrder}, // Most jobs this worker runs at once. The default is 10. MaxConcurrentJobs: 4, }) return worker.Run(ctx) // blocks; cancelling ctx drains active jobs }
from functions import fulfil_order from ironflow.worker import Worker worker = Worker( functions=[fulfil_order], server_url="http://localhost:9123", # Most jobs this worker runs at once. The default is 10. max_concurrent_jobs=4, ) worker.run() # blocks; SIGINT or SIGTERM drains, a second signal 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();
import "github.com/sahina/ironflow-go/ironflow" // Opt-in ConnectRPC streaming worker: same WorkerConfig, same Run(ctx). func streaming(cfg ironflow.WorkerConfig) *ironflow.StreamingWorker { return ironflow.NewStreamingWorker(cfg) }
from functions import fulfil_order from ironflow.worker import StreamingWorker # opt-in ConnectRPC stream StreamingWorker(functions=[fulfil_order]).run() # same arguments as Worker
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, });
# app.py - run with: uvicorn app:app from functions import fulfil_order from ironflow.serve import serve app = serve([fulfil_order]) # signing key comes from IRONFLOW_SIGNING_KEY
import ( "net/http" "os" ) import "github.com/sahina/ironflow-go/ironflow" func main() { // The SDK does not read the env var, so pass the key here. With an // empty key, the handler accepts unsigned requests. handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{FulfilOrder}, SigningKey: os.Getenv("IRONFLOW_SIGNING_KEY"), }) // Mount the handler at the path of the endpoint URL. http.Handle("/api/ironflow", handler) http.ListenAndServe(":3000", nil) }
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
- Wildcard subscriptions over WebSocket, server or browser.
- Consumer groups. Competing consumers, load balanced.
- Subscription groups. Several patterns managed as one.
- Replay on subscribe. Catch up on history, then go live.
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);
ironflow emit order.placed \ --data '{"orderId":"ord_123","total":99.5}' \ --idempotency-key order.placed:ord_123
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" } }, }, });
# register the schema; this alone does not enforce it ironflow event schema register order.placed --version 1 \ --schema '{"type":"object","required":["orderId","total"]}' # off (default) | warn | reject IRONFLOW_EVENT_SCHEMA_ENFORCEMENT=reject ironflow serve # check that enforcement really validates events ironflow event schema check order.placed
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" }, );
// browser import { ironflow } from "@ironflow/browser"; ironflow.configure({ serverUrl: "https://ironflow.example.com", auth: { token: "<user-jwt>" }, }); // "*" matches one segment. The CEL filter runs on the server, // so non-matching events never leave it. const sub = await ironflow.subscribe<{ orderId: string; total: number }>( "topic:orders.*.shipped", { filter: "data.total > 100", onEvent: (e) => console.log("big shipment", e.data.orderId), }, ); // later: sub.unsubscribe()
import { createClient } from "@ironflow/node"; import { ironflow } from "@ironflow/browser"; // Create the group once (server side). // ackMode "manual" redelivers anything not acked. const admin = createClient({ apiKey: process.env.IRONFLOW_API_KEY }); await admin.consumerGroups.create({ name: "order-workers", pattern: "events:order.>", ackMode: "manual", }); // Each instance joins; every event reaches exactly one member. const sub = await ironflow.joinConsumerGroup( "order-workers", "events:order.>", { onEvent: async (e) => { console.log("handling", e.data); // ack after the work is done, not before if (e.eventId) await sub.ack(e.eventId); }, }, );
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
[ 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.
expectedVersion is the concurrency control, so a concurrent writer gets a conflict rather than a lost write.
Upcasters transform old events on read. History never needs a migration.
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);
# Append with a version check and an idempotency key ironflow stream append ord_123 --type order --event order.placed \ --data '{"total":99.5}' --expected-version 0 \ --idempotency-key place:ord_123 # Delete the stream and its history; --yes skips the prompt ironflow stream delete ord_123 --purge --yes
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
[ 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.
No aggregate class, no event-store table, no outbox, and no checkpoint table to own.
Command validation, the fold, and your causation IDs.
CQRS without event sourcing, or event sourcing without CQRS. Either works.
Three kinds of projection
Pure reducer
A function of state and event. The engine stores the state and the checkpoint. Rebuild replays the log.
Your side effects
The handler writes to your own store, such as a search index. The engine tracks the checkpoint.
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();
// API process import { createClient } from "@ironflow/node"; const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY }); // Write side: append returns the id of the new event const { eventId } = await ironflow.streams.append( "ord_123", { name: "order.placed", entityType: "order", data: { total: 99.5 } }, ); // Read-your-writes: wait until order-stats has processed that event. // On timeout the result has timedOut: true; the call does not throw. await ironflow.projections.waitForEvent( eventId, "order-stats", { timeoutMs: 5000 }, );
// browser import { ironflow } from "@ironflow/browser"; ironflow.configure({ serverUrl: "https://ironflow.example.com", auth: { token: "<user-jwt>" }, }); // onUpdate gets the new projection state after each change await ironflow.subscribeToProjection<{ orders: number }>( "order-stats", { onUpdate: (s) => console.log(s.orders) }, );
import { createClient } from "@ironflow/node"; const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY }); // The table plus one SQL handler per event; the server runs the handlers. // :entity_id and :data.* are bound from each event. await ironflow.sqlProjections.create({ name: "active_orders", tableSql: ` CREATE TABLE proj_active_orders ( order_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, total NUMERIC(12,2) )`, events: ["order.placed", "order.cancelled"], eventHandlers: { "order.placed": "INSERT INTO proj_active_orders (order_id, customer_id, total) VALUES (:entity_id, :data.customerId, :data.total)", "order.cancelled": "DELETE FROM proj_active_orders WHERE order_id = :entity_id", }, }); // Read side: filter the table with a SQL where clause const { rows } = await ironflow.sqlProjections.query( "active_orders", { where: "customer_id = 'acme'", limit: 50 }, ); console.log(rows);
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
[ 13 ] platform services
The parts you would otherwise bolt on.
Same binary, same hierarchy. Click a tile for detail.
KV store
Buckets, TTL, atomic ops, watch.
For state that does not deserve a table. Watch pushes changes to subscribers in real time.
Config management
Env-scoped JSON. Set, patch, watch.
Changes reach running workers without a redeploy.
Secrets
AES-256-GCM with a master key. Runtime-only reads.
Bound to functions declaratively. Never returned to a client.
API keys and roles
Prefixed keys plus JWT sessions.
Roles and permissions across the whole resource hierarchy.
Transactional outbox
Atomic publish. DLQ for the rest.
Events publish atomically with the state change that produced them.
Circuit breakers
DB-backed. Checked at dispatch.
Breaker state lives in the database, so it holds across every node.
Rego policy sets
Deny rules on top of roles.
A policy set can deny what RBAC allowed, never allow what it denied. Enforcement is opt-in, with a monitor mode first. It can deny an emit by event name.
Audit logging
Admin actions recorded.
Separate from execution recording, which is opt-in.
Files
Buckets, signed URLs, file events.
The browser uploads straight to storage. A file event can start a function.
Blob overflow
Large outputs move to S3.
Restored on read, so a function sees the full value. Off until you set a threshold.
Event schemas
Validate payloads at ingest.
JSON Schema per name and version. Off, warn or reject.
Webhook sources
Signed callbacks become events.
Rotate a secret with an overlap window. Every delivery is logged.
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
Buckets with TTL and watch
TTL is set per bucket. Watch pushes changes over WebSocket, with key patterns.
Set, patch, watch
Environment-scoped JSON documents. Running workers see changes without a redeploy.
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, );
ironflow secret set STRIPE_KEY sk_test_example \ --description "Stripe test key"
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; }, );
// browser import { uploadToSignedUrl } from "@ironflow/browser"; // url comes from signUpload() on the backend declare const url: string, file: File; // No Ironflow credentials: the token in the URL is the credential await uploadToSignedUrl(url, file);
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"
# or, as environment variables (bash/zsh; in fish use `set -x NAME value`) export IRONFLOW_BLOB_URL="s3://ironflow-artifacts?endpoint=https://seaweedfs.internal:8333" export IRONFLOW_ARTIFACT_THRESHOLD=1MB # credentials for the S3 backend export AWS_ACCESS_KEY_ID=<access-key> export AWS_SECRET_ACCESS_KEY=<secret-key>
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
[ 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.
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.
Keys and sessions
ifkey_ keys for code, ifplatform_ keys for platform operators,
and JWT sessions for the dashboard.
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 }
# creates the dir and .ironflow-policy.json ironflow policy pull ./policies # evaluate the set against one request, before it goes live ironflow policy dry-run ./policies --input request.json --explain # a new set starts in monitor mode ironflow policy push ./policies ironflow policy push ./policies --mode enforce # rollback saves an earlier version as a new version; the mode stays ironflow policy versions ironflow policy rollback 1 --dir ./policies
# ironflow.yaml spec: auth: # off by default; while off, no request meets a policy set policyEnforcement: true
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
import { createFunction } from "@ironflow/node"; export const release = createFunction( { id: "release", triggers: [{ event: "deploy.requested" }] }, async ({ event, step }) => { // Park the run until an approval with the same data.deployId arrives. // If the 24h timeout expires first, the run fails. const approval = await step.waitForEvent("wait-approval", { event: "deploy.approved", match: "data.deployId", timeout: "24h", }); // Set by the engine from the caller's credential; the event body // cannot forge it. if (approval.emittedBy !== "apikey:ak_release_bot") { throw new Error( `approval from ${approval.emittedBy ?? "unknown"} is not accepted` ); } return { deployId: event.data, approvedBy: approval.emittedBy }; }, );
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
[ 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.
- 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.
- Claude SDK adapter
- CrewAI adapter
Other frameworks can call Ironflow primitives today. They are not automatically hosted or instrumented, and we do not say they are.
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"]
# Remote callers send this token to the adapter. export IRONFLOW_A2A_BEARER_TOKEN=$(openssl rand -hex 24) # The adapter's own key for the server. It needs the developer role. export IRONFLOW_API_KEY=ifkey_... # --caller-input is <function-id>/<event-name>. A wait that is not # listed stays hidden from callers. Use the same name in approve(). ironflow a2a \ --agent code-review \ --caller-input code-review/code-review.post-review.approved \ --public-url https://agents.example.com # Agent Card, served at: # https://agents.example.com/agents/code-review/.well-known/agent-card.json
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": {
"command": "ironflow",
"args": ["mcp", "--allow-writes", "--server-url", "http://localhost:9123"]
}
}# Write the bundled skills to ./.agents/skills (matches the binary version). ironflow skills sync # Detect Claude Code, Codex and Gemini. Print the wiring commands. # It changes nothing. ironflow skills doctor # Or write to $HOME/.agents/skills for every project. ironflow skills sync --global
{
"agent_servers": {
"Ironflow": {
"type": "custom",
"command": "npx",
"args": ["-y", "@ironflow/agent"],
"env": { "IRONFLOW_SERVER_URL": "http://localhost:9123" }
}
}
}@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.
| Package | Use case | Runs in |
|---|---|---|
@ironflow/node | Workers, serve handlers, steps, projections | Node.js |
@ironflow/browser | Live subscriptions, triggers, entity streams | Browser |
@ironflow/core | Shared types, schemas, event versioning | Both |
@ironflow/langgraph | LangGraph checkpoint saver | Node.js |
sahina/ironflow-go/ironflow | Full Go SDK: functions, steps, client, test client | Go 1.26+ |
ironflow-py | Python SDK: worker, serve (ASGI), agent, client, test client | Python 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.
- Node, Go, and Python each ship a test client that drives functions in-process. No server to spin up.
- Steps are memoized by ID, so a resumed run is deterministic and assertable.
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"); });
import ( "testing" "github.com/sahina/ironflow-go/ironflow" "github.com/sahina/ironflow-go/ironflow/ironflowtest" ) func TestFulfilOrder(t *testing.T) { tc := ironflowtest.NewClient(t, ironflowtest.Config{ Functions: []ironflow.Function{FulfilOrder}, }) // The handler gets this value in place of the real step body. tc.MockStep("reserve-stock", func() (any, error) { return "sku-1", nil }) // Queue the event that WaitForEvent receives. Send it before Emit. tc.SendEvent("payment.confirmed", map[string]any{"orderId": "ord_1"}) // Run the function that order.placed triggers, in memory. run := tc.Emit(t, "order.placed", map[string]any{"orderId": "ord_1"}) if run.Status != "completed" { t.Fatalf("status %s: %v", run.Status, run.Error) } if run.StepOutput("reserve-stock") != "sku-1" { t.Fatal("step output missing") } }
import asyncio from functions import fulfil_order from ironflow.testing import TestClient def test_fulfil_order(): t = TestClient([fulfil_order]) # The handler gets this value in place of the real step body. t.mock_step("reserve-stock", lambda: "sku-1") # Queue the event that wait_for_event receives. Send it before emit(). t.send_event("payment.confirmed", {"orderId": "ord_1"}) # emit() is async, so asyncio.run waits for the run to finish. run = asyncio.run(t.emit("order.placed", {"orderId": "ord_1"})) assert run.status == "completed" assert run.step_output("reserve-stock") == "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 pushchecks 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.
Dashboard
Inside the binary at localhost:9123. More than twenty pages, shown below.
CLI
serve, emit, secret, outbox dlq, and more. The whole platform is scriptable.
Run inspector
ironflow inspect <run_id> opens a TUI debugger that time-travels a run.
MCP server
ironflow mcp exposes runs and events to an AI assistant. Read-only by default.
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.
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.
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
Analytics page
Run throughput, error rate, p50 and p95 duration, event ingest, and the functions that fail or run slow. No setup.
Prometheus and OpenTelemetry
Metrics and traces, off until enabled. The Docker Compose monitoring profiles ship Grafana dashboards.
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.
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
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
Ironflow Cloud
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
apiVersion: ironflow/v1 kind: Server spec: port: 9123 storage: driver: postgres # ${VAR} reads the environment; startup fails if it is not set url: ${IRONFLOW_DATABASE_URL} # Pool limits; minConns cannot be more than maxConns pool: maxConns: 25 minConns: 5 maxIdleTime: 30s nats: # JetStream data directory; required with PostgreSQL and embedded NATS storeDir: /data/nats auth: # Encrypts stored secrets. If you rotate it, old secrets are unreadable masterKey: ${IRONFLOW_MASTER_KEY} observability: metrics: # Prometheus metrics at /metrics enabled: true
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
Single node
One engine container with embedded NATS. SQLite, or PostgreSQL through a Compose profile.
Starter, Cluster, HA
One node; three nodes behind nginx; three nodes with PostgreSQL failover.
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
# Get the Compose files git clone --depth 1 https://github.com/sahina/ironflow-releases.git cd ironflow-releases/deploy/docker-compose/cluster # Write .env with generated keys. Keep a copy: it holds the master key. # The script never overwrites an existing .env. ./init-env.sh # Starter: one node, NATS, PostgreSQL and SeaweedFS. API on :9123. # Or use docker-compose.cluster.yml (3 nodes behind nginx) or .ha.yml. docker compose -f docker-compose.starter.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.
Durable AI agents
Long tool-using loops with approval gates. Survive a redeploy mid-run.
Order fulfillment
Commerce flows with compensating actions and an event-sourced order.
Human-in-the-loop
Park for days waiting on a person, without holding a worker.
Webhook fan-out
Verify a provider webhook, fan out with retries and a DLQ.
ETL and batch
Pull-mode pipelines with no platform timeout. Fan out with step.map.
Media processing
Transcoding and transcription that run for hours on an open stream.
Real-time UIs
The browser SDK subscribes to wildcards. UIs update as runs move.
CQRS services
Commands append, projections build the query side, rebuilds are routine.
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.
Pull mode, so no platform timeout applies to a twenty-minute run.
Writing tool calls so a re-run is safe, which is the one discipline required.
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.
Sagas plus a circuit breaker, so a dead API stops being called.
A projection folds the stream into the summary the UI queries.
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.
Serverless cannot wait three days. A held thread is an outage waiting.
The approver's page updates the moment the run moves.
KYC, contract sign-off, and agent approval gates all use the same primitive.
In the dashboard
[ 22 ] managed offering
Ironflow Cloud.
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.
- 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.
- Internal use
- Personal projects
- Evaluation, up to 90 days
- Non-commercial education
- Non-commercial research
- Professional services
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}'
Compare and evaluate
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