- Start Here
- Getting Started
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.
Prefer a GUI?
Ironflow Desktop is a free app for macOS, Windows, and Linux that bundles the engine — download, open, and explore without the CLI. This tutorial uses the CLI.
Prefer an AI Agent?
Skip the walkthrough and ask your coding agent instead. This installs Ironflow’s skills into Claude Code, Codex, Cursor, and 75+ other agents — no Ironflow install needed:
npx skills add sahina/ironflow-releasesThen ask “does Ironflow fit this codebase?” or “set up Ironflow in this project”. See AI Skills for the full route, and the AI Quickstart to build this same tutorial with an agent.
1. Start the Server
brew install sahina/tap/ironflow
# --dev bypasses auth — no dashboard login, no API keyironflow serve --devscoop bucket add ironflow https://github.com/sahina/scoop-ironflowscoop install ironflow/ironflow
# --dev bypasses auth — no dashboard login, no API keyironflow serve --devWindows is experimental. See Installation for what that means.
docker run -p 9123:9123 ghcr.io/sahina/ironflow-releases:latest serve --devFor persistent data, mount a volume and point the SQLite DB at it (NATS storage is derived from --db):
docker run -p 9123:9123 \ -v ironflow-data:/data \ ghcr.io/sahina/ironflow-releases:latest serve --dev --db /data/ironflow.dbSee Self Hosting for Docker Compose with PostgreSQL.
Save this as docker-compose.yml:
name: ironflowservices: ironflow: image: ghcr.io/sahina/ironflow-releases:${VERSION:-latest} command: serve --dev --db /data/ironflow.db ports: - "${IRONFLOW_PORT:-9123}:9123" volumes: - ironflow-data:/data # Persists NATS JetStream and SQLite data healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:9123/health"] interval: 10s timeout: 5s retries: 3
volumes: ironflow-data:Then start it:
docker compose upFor a production setup with PostgreSQL and monitoring profiles, see Self Hosting — it renders the full docker-compose.single-node.yml, which also lives in the public release repo.
Download the latest release from GitHub Releases for your platform, then:
./ironflow serve --devThe 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.
Gitignore .ironflow/
Run as a local binary, serve keeps its state in .ironflow/ under the current directory: the SQLite DB, the embedded NATS store, blobs/, plus .ironflow_bootstrap_key.json (an admin API key) and .ironflow_jwt_secret. Add .ironflow/ to .gitignore before your first commit so those credentials never reach the repo — ironflow init projects already do. Passing --db moves all of it to that file’s directory; the Docker tabs above keep it inside the container or the mounted volume.
Production Mode
When you’re ready for real workloads, drop the --dev flag. Ironflow will auto-bootstrap an admin account and API key on first boot — see Security for details.
2. Create Your Project
ironflow init my-appcd my-appThis 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.
If you pass --skip-install
The install step is also what removes the template’s pnpm-workspace.yaml and pnpm-lock.yaml, which point at monorepo paths that don’t exist in your project. If you skip it, delete both files before running pnpm install yourself.
ironflow init my-app --template go-quickstartcd my-appThis scaffolds main.go and go.mod, then runs go get github.com/sahina/ironflow-go/ironflow@latest and go mod tidy. The module path defaults to the project name — pass --module github.com/you/my-app to set a real one.
Install ironflow-py, not ironflow
pip install ironflow-pyThe distribution name is ironflow-py; the import name stays ironflow. The
bare name ironflow on PyPI belongs to an unrelated third-party project, so
pip install ironflow installs the wrong package. Available from v0.33.0.
Experimental
The Python SDK is experimental and incomplete. It is not at feature parity with the TypeScript and Go SDKs.
The Python SDK provides REST and ConnectRPC clients for emitting events, querying runs and projections, and managing resources. It does not include a worker runtime (step.run, push/pull mode). For durable step execution, follow the TypeScript or Go track above. See the Python SDK reference for the full surface and the SDK comparison matrix for feature parity.
Manual setup
You can also install the SDK directly: npm install @ironflow/node (TypeScript), go get github.com/sahina/ironflow-go/ironflow (Go), or pip install ironflow-py (Python) — see the Installation guide for details.
3. Understand the Code
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:
pnpm startYou 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-statsThe 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.
The Go SDK supports both push mode (Serve()) and pull mode (NewWorker()). The go-quickstart template uses pull mode and mirrors the TypeScript scaffold — ProcessOrder function with three steps plus an OrderStats projection. Open main.go:
package main
import ( "context" "fmt" "log" "os" "os/signal" "syscall"
"github.com/sahina/ironflow-go/ironflow")
type OrderData struct { OrderID string `json:"orderId"` Total float64 `json:"total"` Email string `json:"email"`}
// React: A function that processes orders. Every step is memoized// and permanently recorded for time-travel debugging.var ProcessOrder = ironflow.CreateFunction( ironflow.FunctionConfig{ ID: "process-order", Name: "Process Order", Mode: ironflow.PullMode, Recording: true, Triggers: []ironflow.Trigger{{Event: "order.placed"}}, }, func(ctx ironflow.Context) (any, error) { var data OrderData if err := ctx.Event.Data(&data); err != nil { return nil, fmt.Errorf("parse order: %w", err) }
order, err := ironflow.Run(ctx, "validate-order", func() (map[string]any, error) { return map[string]any{"valid": true, "orderId": data.OrderID, "total": data.Total}, nil }) if err != nil { return nil, err }
payment, err := ironflow.Run(ctx, "process-payment", func() (map[string]any, error) { return map[string]any{"charged": true, "amount": data.Total}, nil }) if err != nil { return nil, err }
_, err = ironflow.Run(ctx, "send-confirmation", func() (map[string]any, error) { return map[string]any{"sent": true, "email": data.Email}, nil }) if err != nil { return nil, err }
return map[string]any{"order": order, "payment": payment}, nil },)
// Derive: pure reducer over order.placed events.var OrderStats = ironflow.CreateProjection(ironflow.ProjectionConfig{ Name: "order-stats", Events: []string{"order.placed"}, Mode: ironflow.ProjectionModeManaged, InitialState: func() map[string]any { return map[string]any{"totalOrders": 0.0, "totalRevenue": 0.0} }, Handler: func(state map[string]any, event ironflow.ProjectionEvent, ctx ironflow.ProjectionContext) (map[string]any, error) { total, _ := event.Data["total"].(float64) // float64, not int. The runner JSON round-trips state between events, // so every number comes back as a float64 — an `.(int)` assertion // silently yields 0 and the count never advances past 1. totalOrders, _ := state["totalOrders"].(float64) totalRevenue, _ := state["totalRevenue"].(float64) return map[string]any{ "totalOrders": totalOrders + 1, "totalRevenue": totalRevenue + total, }, nil },})
func main() { worker := ironflow.NewWorker(ironflow.WorkerConfig{ Functions: []ironflow.Function{ProcessOrder}, Projections: []ironflow.Projection{OrderStats}, })
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigChan cancel() worker.Drain() }()
log.Println("Worker started — listening for events") if err := worker.Run(ctx); err != nil { log.Fatalf("Worker error: %v", err) }}Go SDK fully supports projections — see sdk/go/ironflow/projection.go and the Projections guide. The go-quickstart example ships this scaffold.
There’s no Python equivalent for this step. The Python SDK is experimental
and client-only — it can emit events and read runs and projections, but it
can’t define the function and projection this step walks through. There is no
step.run, no worker runtime, and no push/pull handler.
Follow the TypeScript or Go tab for this step. You can still drive that
worker from Python: steps 4 and 5 below show emitting events and reading
derived state with IronflowClient.
See the SDK comparison matrix for exactly what each SDK implements today.
4. Emit Events
With the server and worker running, emit an event:
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:
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"}'From your application
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.
client := ironflow.NewClient(ironflow.ClientConfig{ ServerURL: "http://localhost:9123",})
result, err := client.Emit(ctx, "order.placed", map[string]any{ "orderId": "order-4", "total": 75.25, "email": "fourth@example.com",})if err != nil { log.Fatal(err)}
fmt.Println(result.EventID, result.RunIDs)Use EmitSync() instead when you need the triggered runs’ results before
returning — it returns one result per matched run. To target a single function
by ID, use InvokeSync(). See the Go SDK reference for
idempotency keys, event versions, and metadata.
from protobuf.wkt import Structfrom ironflow.rpc import v1from ironflow import IronflowRPCfrom ironflow import IronflowClient
client = IronflowClient(server_url="http://localhost:9123")
with IronflowRPC(server_url=client.server_url, api_key=client.api_key) as rpc: result = rpc.events.emit(v1.TriggerRequest(event='order.placed', data=Struct.from_python({'orderId': 'order-4', 'total': 75.25, 'email': 'fourth@example.com'})))
print(result.event_id, result.run_ids)The Python RPC client exposes events.emit. Its typed response has event_id and run_ids attributes.
5. See What Was Derived
The order-stats projection has been processing every order.placed event and maintaining a running total. Query it:
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.
From your application
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);stats, err := client.Projections().Get(ctx, "order-stats")if err != nil { log.Fatal(err)}
fmt.Printf("%+v\n", stats.State) // map[totalOrders:3 totalRevenue:298.49]fmt.Println(stats.LastEventSeq, stats.Status)from ironflow import AsyncIronflowRPCfrom ironflow.rpc import v1
async with AsyncIronflowRPC(server_url="http://localhost:9123") as rpc: result = await rpc.projections.get(v1.GetProjectionRequest(name="order-stats")) state = result.state_value if result.state_value is not None else result.state print(state.to_python() if state is not None else None) print(result.registry.last_event_seq if result.registry else 0)Projection state is in state_value or state. Registry metadata is in registry.
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.
5.5 See Durability in Action
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:
This scaffold is too fast to interrupt
The quickstart’s three steps have no real work in them, so a run finishes in milliseconds and you will almost certainly hit Ctrl+C after it has already completed — nothing to resume, and nothing to wait for. Read this step for the mechanism. To actually catch a run mid-flight, follow Survive a Crash, whose first step sleeps 3 seconds specifically to give you a kill window.
pnpm startgo run .Durable step execution is a worker feature, so there’s nothing to restart on the Python track — the experimental Python SDK doesn’t run functions. Use the TypeScript or Go worker from step 3 to see crash resume.
Python can still observe it: poll rpc.runs.get(v1.GetRunRequest(id=run_id)) or
rpc.runs.list(v1.ListRunsRequest()) to watch the interrupted run move back to running and
then completed.
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.
Resume timing
Expect 90 seconds to 3 minutes, not instant. A worker killed mid-step still holds a concurrency lease, so the run is reclaimed by the capacity scanner once that lease expires (90s) plus a 30s recovery grace — not by the stale-claim sweep, whose threshold --dev does lower but which skips any run holding a lease. The lease timings are compile-time constants with no flag or environment variable. See Crash recovery for the full mechanics.
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.
6. Rewind Time
Every step of every function run was permanently recorded. You can rewind to any moment.
Dashboard
- Open http://localhost:9123 and navigate to Runs
- Click any completed run
- Use the timeline scrubber at the top to drag back in time
- Watch the step outputs change as you scrub — you’re seeing the exact state of the run at that moment
- Click any two points to see a diff of what changed between them
CLI
# List your runsironflow run list
# Replay a run frame-by-frame (replace with your run ID)ironflow inspect <run_id> --replayIn 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
What Just Happened?
In five minutes, you built a system with Continuous History:
- Emit — You recorded events (
order.placed). These are permanent, immutable facts. - 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.
- Derive — A projection automatically computed order statistics from the event stream. No queries, no batch jobs — the state is always up to date.
- 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.
Push and Pull Modes
This tutorial used Pull mode — a long-running worker that polls the server over HTTP for jobs. For serverless environments (Next.js, Lambda, Cloud Functions), Ironflow also supports Push mode — the server POSTs to your HTTP endpoint. Same functions, same SDK, different deployment model. See Workflows for details.