Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Getting Started

Start the server, scaffold a project, emit events, derive state, and time-travel through your system’s Continuous History — all in about five minutes.

Skip the walkthrough and paste this into your coding agent. It installs the engine and the skills, showing you each command first:

Read https://ironflow.run/start.md and set up Ironflow in this project.

To evaluate before you install the engine, install only the skills, then ask “does Ironflow fit this codebase?”:

Terminal window
npx skills add sahina/ironflow-releases

See AI Skills for the full route, and the AI Quickstart to build this same tutorial with an agent.

Terminal window
brew install sahina/tap/ironflow
# --dev bypasses auth — no dashboard login, no API key
ironflow serve --dev

The server starts at http://localhost:9123 with:

  • Dashboard at /
  • API at /api/v1/*
  • Health check at /health

The --dev flag disables authentication so you can start building immediately. No API keys or passwords needed.


Terminal window
ironflow init my-app
cd my-app

This scaffolds a working project with a function, projection, and worker — ready to run. ironflow init runs pnpm install automatically (falling back to npm install); pass --skip-install to opt out.


Open worker.ts — this single file contains a function, a projection, and a worker:

import {
createFunction,
createProjection,
createWorker,
type IronflowProjection,
} from "@ironflow/node";
// ── Types ───────────────────────────────────────────────────────
interface OrderData {
orderId: string;
total: number;
email: string;
}
// ── React: A function that processes orders ─────────────────────
// Every step is memoized. If the process crashes, it resumes
// from the last completed step. With recording enabled, every
// step is also permanently recorded for time-travel debugging.
const processOrder = createFunction(
{
id: "process-order",
triggers: [{ event: "order.placed" }],
recording: true,
},
async ({ event, step }) => {
const data = event.data as OrderData;
const order = await step.run("validate-order", async () => {
return {
valid: true,
orderId: data.orderId,
total: data.total,
};
});
const payment = await step.run("process-payment", async () => {
return {
charged: true,
amount: order.total,
transactionId: `txn_${Date.now()}`,
};
});
await step.run("send-confirmation", async () => {
return { sent: true, email: data.email };
});
return { order, payment };
},
);
// ── Derive: A projection that computes order statistics ─────────
// Projections are pure reducers. Every time an "order.placed"
// event is recorded, this reducer runs and the derived state
// is automatically persisted and queryable.
const orderStats = createProjection({
name: "order-stats",
events: ["order.placed"],
initialState: () => ({ totalOrders: 0, totalRevenue: 0 }),
handler: (
state: { totalOrders: number; totalRevenue: number },
event: { name: string; data: unknown },
) => ({
totalOrders: state.totalOrders + 1,
totalRevenue: state.totalRevenue + ((event.data as OrderData).total ?? 0),
}),
});
// ── Start the worker ────────────────────────────────────────────
const worker = createWorker({
functions: [processOrder],
projections: [orderStats as IronflowProjection],
});
worker.start().then(() => {
console.log("Worker started — listening for events");
});

Start the worker in a second terminal:

Terminal window
pnpm start

You should see the worker connect and register:

[ironflow-worker] Starting worker worker-… with 1 functions
[ironflow-worker] Registered function: process-order
[ironflow-worker] Connected to server at http://localhost:9123
[ironflow-worker] Projection runner started (streaming): order-stats

The scaffold’s own Worker started — listening for events line is inside worker.start().then(...), and start() is the poll loop — it resolves only when something calls worker.stop(). Do not wait for that line; the four above mean the worker is live.


With the server and worker running, emit an event:

Terminal window
ironflow emit order.placed --data '{"orderId": "order-1", "total": 99.99, "email": "customer@example.com"}'

Watch the worker terminal — you’ll see the function pick up the event and execute each step.

Emit a few more to build up history:

Terminal window
ironflow emit order.placed --data '{"orderId": "order-2", "total": 49.50, "email": "another@example.com"}'
ironflow emit order.placed --data '{"orderId": "order-3", "total": 149.00, "email": "third@example.com"}'

The CLI is the fastest way to emit while you’re following along, but in a real app you’ll emit from your own code. The client is separate from the worker — emit from an API route, a background job, or anywhere else you have a server.

import { createClient } from "@ironflow/node";
const client = createClient({ serverUrl: "http://localhost:9123" });
const result = await client.emit("order.placed", {
orderId: "order-4",
total: 75.25,
email: "fourth@example.com",
});
console.log(result.eventId, result.runIds);

Use emitSync() instead when you need the triggered runs’ results before responding — it blocks until every matched run completes and returns one result each. To target a single function by ID and get one result back, use invoke(functionId, { data }). See the Node SDK reference for idempotency keys, event versions, and metadata.


The order-stats projection has been processing every order.placed event and maintaining a running total. Query it:

Terminal window
curl -s -X POST http://localhost:9123/ironflow.v1.ProjectionService/GetProjection -H 'Content-Type: application/json' -d '{"name":"order-stats"}' | jq '.stateValue // .state'
{
"totalOrders": 3,
"totalRevenue": 298.49
}

That’s the three CLI emits. If you also ran the SDK emit from the previous section, you’ll see 4 orders and 373.74 instead.

You didn’t write any aggregation queries. The projection derived this state automatically from the recorded events. Emit another event and query again — the state updates in real time.

Same query, from the SDK client:

interface OrderStats {
totalOrders: number;
totalRevenue: number;
}
const stats = await client.projections.get<OrderStats>("order-stats");
console.log(stats.state); // { totalOrders: 3, totalRevenue: 298.49 }
console.log(stats.lastEventSeq, stats.status);

The TypeScript and Go clients return the materialized state plus the projection’s position in the event stream (lastEventSeq) and health (status), so you can tell fresh state from stale without a second call. Both accept a partition key to read one slice of a partitioned projection.

You can also see the projection in the Dashboard at http://localhost:9123 — navigate to Projections to see its status and current state.


What happens when something goes wrong mid-execution? Ironflow memoizes every completed step. If a function crashes, it resumes from the last successful step — not from scratch.

Try it: emit an event, then stop your worker mid-execution (Ctrl+C). Restart it:

Terminal window
pnpm start

The worker picks up the interrupted run and completes it from where it left off. Check the Runs page in the dashboard — you’ll see the run completed successfully despite the restart.

This is memoized execution: each step.run() result is recorded as the function proceeds and checkpointed to the server on a ~1s debounce (checkpointInterval on createWorker, 0 to disable). Crash at step 3 of 5? Steps 1 and 2 aren’t re-executed — the resumed run replays them from cache and picks up at step 3.


Every step of every function run was permanently recorded. You can rewind to any moment.

  1. Open http://localhost:9123 and navigate to Runs
  2. Click any completed run
  3. Use the timeline scrubber at the top to drag back in time
  4. Watch the step outputs change as you scrub — you’re seeing the exact state of the run at that moment
  5. With a point selected, click Compare with Final State to see a diff of what changed between that moment and the run’s end
Terminal window
# List your runs
ironflow run list
# Replay a run frame-by-frame (replace with your run ID)
ironflow inspect <run_id> --replay

In replay mode:

  • → or l — next frame
  • ← or h — previous frame
  • g — first frame, G — last frame
  • j/↓ — navigate steps within current frame
  • k/↑ — navigate steps up
  • Tab — switch between Steps and Details panels
  • q — quit

In five minutes, you built a system with Continuous History:

  1. Emit — You recorded events (order.placed). These are permanent, immutable facts.
  2. React — A function processed each event with durable, memoized steps. If the process had crashed mid-execution, it would have resumed from the last completed step — not restarted.
  3. Derive — A projection automatically computed order statistics from the event stream. No queries, no batch jobs — the state is always up to date.
  4. Rewind — This tutorial enabled recording: true, so you scrubbed through its recorded execution timeline and inspected step state.

This is Continuous History: events and recorded execution steps form an append-only history. Inspect what happened, replay when needed, and rebuild derived state without guesswork. Event history is inherent to entity streams. Execution-step history requires recording to be enabled.

Continuous History: Entity Lifecycle — showing how events, workflows, projections, and time-travel connect in one unified history