Skip to main content

DevTool

The DevTool is a visual inspector for flow-state-dev. It connects to your running flows over HTTP and SSE, giving you a live view of sessions, items, state, and execution traces. No SDK integration required.

Getting started

Install the DevTool package alongside the CLI:

pnpm add -D @flow-state-dev/devtool

Then run fsdev dev from your project root:

fsdev dev

This starts an HTTP server that serves both your flow API and the DevTool UI. Open the printed URL (default: http://localhost:4200) in your browser. The DevTool discovers all flows in your project and is ready to use.

How fsdev dev works

The command does three things:

  1. Discovers flows from src/flows/ and flows/ (same convention as fsdev run).
  2. Starts an HTTP server that routes /api/flows/* to the flow API router and serves the DevTool UI for everything else.
  3. Opens your browser pointing at the DevTool.

Flow data persists to .fsdev/data/ on disk, so sessions survive restarts.

For production, fsdev serve runs the same flow API and MCP endpoints with no DevTool UI and binds 0.0.0.0:$PORT. See the CLI API for its options and the loopback-bind guard.

Options

FlagDefaultDescription
-p, --port <port>4200Port to listen on
--flow-dir <path>auto-discoverOverride flow discovery root (repeatable)
-m, --model <model>Override model for generator blocks that run in this process. See Model overrides
--no-openDon't open the browser automatically

Example

# Start with a custom port and model override
fsdev dev --port 3000 --model openai/gpt-5.4-mini

# Point at a specific flow directory
fsdev dev --flow-dir ./my-flows

Flow overview

Choose a flow instance by its exact ID. Its kind describes the flow; its ID identifies the copy you inspect.

The navigator lists every registered instance, one row each. For most flows there is a single copy and the two are the same string, so a row just reads reports. A flow declared with cardinality: "collection" is registered once per copy, each under its own ID, and every copy gets its own row:

> engineer-a  (engineer)
> engineer-b (engineer)
> reports

The ID is the label; the kind sits beside it, muted, when the two differ. A long ID is shortened to fit — hover the row for the full value, or use the copy button beside Sessions to put it on your clipboard. That is the string you address the instance by, so copy it rather than retyping what the row shows.

Expand a row to see that copy's sessions and the actions it declares. Two copies of a kind declare the same action names but can run entirely different blocks, so the actions shown are the selected copy's.

Session management

Create, browse, and switch between sessions. Sessions belong to one flow instance and one user, so expanding a copy shows that copy's sessions and no other's. The DevTool displays the open session's ID and lets you create a new session or switch to an existing one.

Selecting a different instance closes the session you had open. The two are one choice: the requests, state and resources on screen all belong to the session under the copy you selected, and carrying a session across would show one copy's work under another. Pick a session again after switching.

The DevTool remembers the last session you had open under each copy and offers it back when you return, including after a reload. It checks with the server first — if that session no longer exists, is no longer yours, or belongs to a different copy, the workspace opens empty and waits for you to choose. It will never substitute another copy's session for the one you saved.

Action dispatch

Invoke actions directly from the DevTool. Select an action, paste or edit JSON input, and send. The response (request ID, status) appears immediately. Use this to trigger flows without wiring up a UI or writing curl commands.

The dispatch goes to the instance selected in the navigator, under the session you have open. If two copies of a kind are registered, check which row is highlighted before you send — engineer-a and engineer-b accept the same action name and do different work with it.

Item stream

Watch items arrive in real-time as blocks execute. Messages, tool calls, state changes, and custom components stream in as they're emitted. The Stream tab shows a chronological list of items for the active request.

Trace view

Visualizes the execution tree. Every item carries provenance: block name, instance ID, parent block, phase, step index. The trace view assembles this into a timeline, grouping items by block and showing parent-child relationships.

Pick a block from the tree and the detail panel opens with two symmetric sections, Input and Output. Each is a BlockValue: it can be inline content, a ref to an upstream item that the panel resolves and renders, or a structure of refs (for fan-in steps like stepAll or parallel). Clicking a ref jumps to the source block.

Blocks that are still running show their live status. A block_trace row appears as soon as the block starts, with input filled in and output empty. As patches arrive — connector input, generator bundle, model usage — the panel updates in place. The Output section becomes live once the block returns.

A block that runs more than once gets one row per execution, not a single row with everything stacked underneath. This shows up most in loops: a Plan & Execute or supervisor pattern drains its task list through one worker that runs the executor once per task. Each pass is a distinct row, tagged with an iter N label so you can tell the iterations apart, and each row carries a compact preview of the input it received and the output it produced. Tool calls cluster under the iteration that actually issued them, so you can read per-task behavior at a glance instead of untangling a merged list.

For generator blocks, the panel also shows what the model actually saw on that turn: the resolved system prompt, the user-slot messages for this turn, and the conversation history that came in alongside them. Tools and the resolved model identifier appear in the same panel. This is observability data — gated by FSDEV_TRACE_OBSERVABILITY (on by default in development) — so you can leave it on while iterating and switch it off in production.

Error details

When a block fails, the detail panel surfaces enough context to diagnose without re-running. The error message renders at the top with the code as a small mono-text label. When the runtime captures details on the failure — generator output-validation errors carry the raw model text and the Zod issues, author-thrown FlowErrors carry whatever was attached — the panel renders them as dedicated sections: a "Raw output" pane for the model's text, a typed "Validation issues" list for Zod issues, and a "Details" JSON panel for the rest. For tool-invoked blocks that fail, the panel also surfaces the originating tool call's arguments and the block's resolved input, so the failure stops requiring a hunt through sibling rows for the missing context. See Error handling.

Child sessions

Some work leaves the session you are watching. A dispatcher block, or a task board seat that hands its rows off, runs work in a child session — a session of its own that keeps going after the request which started it has returned — and none of that work shows up in the conversation's own stream or trace.

The Children tab lists it. One row per child session, with the labels it carries, the state its runs reached, and its session id.

Click a row and the workspace opens that session. It is a session like any other, so Stream, Trace, Tasks and Suspensions all read it, and a child that dispatched work of its own has a Children tab too. A breadcrumb above the tabs shows how deep you are and takes you back.

Work dispatched into another flow instance produces a child that instance owns, so opening a row can move you to a different copy as well as a different session. The breadcrumb remembers which copy each step was under, and going back returns you to that copy, not to whichever one you ended up in.

A few things worth knowing about a row:

  • Status is coarse on purpose. active means the work has not finished. It does not distinguish queued from running from paused waiting for someone. A row with no status has not run anything yet.
  • Labels can be missing. The topic and the entry label are stamped when the child is created, and either can be absent. The row still renders — the session id is the address.
  • The task link is a match, not a foreign key. Where a row lines up with a task on a board in this session, the Tasks tab shows a link on that task's row and the Children tab names the tasks. Where a task cannot be matched to exactly one child, no link is shown rather than a guessed one.

The list is read when you open a session and when you refresh; it does not update on its own while you watch. See Work that outlives the turn.

Session state

Inspect current state at every scope level. View session-level state, user-level state, and org-level state. Resources and their content are visible. ClientData values appear in the detail panel.

The Resources panel reads from a privileged debug endpoint and shows the full server-side state. Each entry can be toggled between the raw server view, the client view (what production clients receive after client.data projection), and a diff between the two. Production clients see only what each resource's client config allows. See Debug vs client state for the mental model and how to enable the endpoint locally.

Replay

Re-stream a previous request to reproduce behavior. Select a completed request and choose replay full or replay from cursor. The DevTool reconnects to the SSE stream and replays the events.

Interrupted runs and crash recovery

An interrupted run is a request that stopped abruptly — the server crashed, the process restarted, a deploy rolled out mid-execution — but is still resumable. The DevTool lists every interrupted run for a session, not just the most recent one, so you can find and continue an older request even after newer ones have started and finished.

Spotting an interrupted run. Each request in the stream and trace views shows a status pill in its header. A request the server marked interrupted carries that literal status, distinct from completed or failed. You don't need a dedicated panel to find it — scroll the session's request list and read the pill.

The Continue action. An interrupted request's overflow menu (the ... button in its header) has a Continue entry. Continue re-enters the same request, under the same request id: completed blocks are restored from the durable log rather than re-executed, and only the block that was running when the crash hit re-runs. This is different from retry, which creates a brand-new request id and re-runs the original action from scratch. Reach for Continue when you want to pick up exactly where a run left off; reach for retry when you want a clean re-attempt.

Today, that replay granularity stops at the sequencer's own steps. If the crash happened mid-generator or mid-router (rather than between two sequencer steps), the whole generator or router block re-runs on continue rather than resuming from wherever it was internally. Sequencer-level continue is solid; validating richer flows down to the generator/router boundary is tracked separately.

Reading the boundary. When a continued run streams back in, the DevTool draws a small divider — "continued here", with a count of prior items — at the point where the crash-recovery re-entry began. Everything above the divider is the prior log; everything below is the live continuation. Be careful how you read "prior" here: it is not a list of every item replayed. The prior log can include rows that were only partially written or still in progress when the crash happened, sitting there as-is, not just the outputs of blocks that had cleanly finished. The divider marks a seam in time, not a guarantee that everything above it finished successfully.

Background work on continue. Background work (.sideChain(), .forEachSideChain()) follows the same memoization rule as foreground blocks: a background task that completed and retained its trace before the crash is restored from the log, not re-run. A background task that was still in flight when the crash hit has no completed trace to restore, so it re-runs from the top on continuation — the guarantee is at-least-once, not exactly-once, so any non-idempotent side effect in that task needs its own runOnce guard. See Durability of background work for the full contract, including the failed-work-under-a-completed-parent case.

Example. Start a flow that does enough work to take a few seconds, kill the server mid-run, and restart it. Reopen the DevTool, find the request — its pill now reads interrupted — and click Continue from its overflow menu. Watch the stream: the earlier items appear immediately (restored from the log), then the divider, then new items streaming in live as the in-flight block re-runs and the request finishes.

Observing resource loads

The block detail panel also records what each block cost in resource loads: which keys hit the store versus the in-memory cache, how long each fetch took, and which prefetch wave or accessor triggered it. It's the signal you need to tune a collection's prefetchMode instead of guessing. See Observing resource loads.

Embedding the panel

The same UI ships as a React component you can mount inside any framework app. Useful for inspecting flows on Vercel previews and other deployed environments without leaving the app's origin. See Embedding DevTool.