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:
pnpm 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.
Themes
The DevTool has three themes: Paper (light), Draft (a warmer paper), and Night (dark). It opens in Night. Switch with the Paper / Draft / Night control at the right of the header.
The DevTool remembers your choice in that browser's local storage. It isn't stored in the devtool config or the URL, so a link you share opens in whatever theme the other person last picked.
How fsdev dev works
The command does three things:
- Discovers flows from
src/flows/andflows/(same convention asfsdev run). - Starts an HTTP server that routes
/api/flows/*to the flow API router and serves the DevTool UI for everything else. - Opens your browser pointing at the DevTool.
Flow data persists to .fsdev/data/fsdev.db on disk, the file fsdev run writes to as well, so sessions survive restarts and a run from fsdev run that paused for approval shows up here to answer.
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
| Flag | Default | Description |
|---|---|---|
-p, --port <port> | 4200 | Port to listen on |
--flow-dir <path> | auto-discover | Override flow discovery root (repeatable) |
-m, --model <model> | — | Override model for generator blocks that run in this process. See Model overrides |
--no-open | — | Don't open the browser automatically |
Example
# Start with a custom port and model override
pnpm fsdev dev --port 3000 --model openai/gpt-6-luna
# Point at a specific flow directory
pnpm 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 groups flows by kind, and how far a kind opens comes from how it was declared. A flow declared with cardinality: "singleton" has one copy, so its kind row opens straight into sessions. A flow declared with cardinality: "collection" is registered once per copy, each under its own ID, and its kind row opens into those copies first:
> reports
> engineer
engineer-a
engineer-b
Expanding a kind costs no request. Sessions are read when you open the row you are going to work in — a singleton's kind row, or one copy under a collection kind.
A long ID is shortened to fit, so each copy's row carries a copy button at its end. That is the string you address the instance by, so copy it rather than retyping what the row shows. A singleton's ID is its kind and is already shown whole.
Open a copy to see its sessions, re-read them, or start a new one. An open copy also shows how many actions it declares — click that count to list them. Two copies of a kind declare the same action names but can run entirely different blocks, so what you see there belongs to the copy you opened.
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.
Opening a session from a link
Add ?session=<id> to the DevTool's address to open one session directly, for example http://localhost:4200/?session=dsx_8f1c…. A tool that knows which run you care about can link straight to it this way; Shift Manager's Open trace does.
The DevTool reads the session, finds the flow copy that owns it, and opens both. If no listed copy owns it, it belongs to another user, or the server refuses the read, nothing opens and the header says why. The link only works against a DevTool that talks to the server holding that session, so a Lab with in-memory stores has to be served by the same process.
Linking to a session from your app
When you serve your app beside your flows with fsdev dev --app, each page can link to a session in the DevTool. Two helpers from @flow-state-dev/devtool/react build the link:
readDevtoolUrl()returns the DevTool's address, orundefinedwhen no DevTool is served, during server-side rendering, or when the address isn't http(s).traceHref(devtoolUrl, sessionId)adds?session=<id>to that address and keeps its existing path and query. Without a session id it returns the address unchanged.
import { readDevtoolUrl, traceHref } from "@flow-state-dev/devtool/react";
function TraceLink({ sessionId }: { sessionId: string }) {
const devtoolUrl = readDevtoolUrl();
if (devtoolUrl === undefined) return null;
return (
<a href={traceHref(devtoolUrl, sessionId)} target="_blank" rel="noreferrer">
Open trace
</a>
);
}
Hide the link when readDevtoolUrl() returns undefined, as above, since there's no DevTool for it to open.
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.
Dispatched runs
Some work leaves the session you are watching. A dispatcher block, or a task board worker that dispatches its rows, runs it in a dispatch run: a session of its own that keeps going after the request which started it has returned. None of that work shows up in the starting conversation's stream or trace. See Dispatched work for what the server records about one.
The rail lists those sessions with the rest of the flow's, indented one level under the session that started each one. Each carries a label saying the dispatcher started it and a link that opens the session it came from. A run started by another run indents once, beside its own parent.
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.
The Trace tab also lists the runs a session started, under the blocks that ran in it. Each is a collapsed node naming a separate session; expand one and that run's own block tree loads in place, without moving the workspace off the session you are on. Nothing about a run loads until you open it.
The section says so when it does not have the whole picture. A read that fails says so rather than rendering as a session that dispatched nothing, and if a refresh fails under rows you already have, those rows stay on screen marked as possibly out of date. When the list holds only the newest page, it says that older runs are not shown instead of looking complete.
Work dispatched into another flow instance produces a session that instance owns, so opening a row can move you to a different copy as well as a different session. The rail still lists every copy, and each remembers the session you last had open under it, so going back is picking it again.
A few things worth knowing about a row:
- Status is coarse on purpose. Each node in the Trace tab carries the run's status.
activemeans the work has not finished. It does not distinguish queued from running from paused waiting for someone. A run that has made no requests reads asnot started. - Labels can be missing. The topic and the entry label are stamped when the session 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 run lines up with a task on a board in this session, the Tasks tab shows a link on that task's row. Where a task cannot be matched to exactly one run, no link is shown rather than a guessed one.
The lists are read when you open a session and when you refresh; they do not update on their own while you watch. See Work that outlives the turn.
Task boards
The Tasks tab lists the task boards the open session emitted. A task board is the queue a flow files work into for workers to claim. Each task is one row: its status first, then its goal, then a short note about it when there is one, then its assignee.
Click a row, or focus it and press Enter, to open it in place. The open row shows what the task carries: the full goal, attempts, assignee, priority, labels, the note and any error, input and output, metadata, revision and timestamps, and the dispatch run working it. Fields the task doesn't have are left out. The raw record is folded at the bottom if you want the JSON. A row stays open while the task changes underneath it, and you can keep several open at once.
The note is the task's feedback field. A failure with retries left records the error there and sends the row back to pending. The note slot appears only on boards where some task carries one.
A note is not an error. A failure with no retries left writes the task's error field instead, and a task can hold both. A long goal or note is clipped to one line on the collapsed row, so hover it for the whole string or open the row.
Changing a task from its row
An open row lists the flow's actions that take a taskId, and the board's own getTask and updateTask, which name the task id. Pick one and its form opens in the row with the task's id filled in and locked. Run it and the DevTool sends the action the same way the action bar would, in the session you're looking at. The row updates when the task changes.
If the task can't move that way, say you cancel a task that already finished, the action refuses and the row shows why in the action's own words. Nothing is written. The row shows the request's outcome:
| The row reads | When |
|---|---|
Running… | The request hasn't finished. |
Done, with the return value | The request completed. |
Refused: and the reason | The request completed, and the action returned { ok: false } or { outcome: "declined" }. |
Failed: and the error | The request ended any other way. |
No result recorded for this request. | The request completed but has no saved result, as with history written by an older server. |
Finished, but its return value couldn't be recorded. | The return value wasn't saved because JSON can't hold it. See fsd.sessions.requests. |
The DevTool changes tasks only through your flow's actions. There is no way to edit a task's record directly, so every change is a transition the task can legally make. The action doesn't claim the task. It acts as a coordinator would, so it can settle a task a worker is still running: your change lands, and the worker's result is dropped when it arrives. If a row says the flow has no actions that take a taskId, see changing tasks from outside a run.
A row shows the changes made in the session you're looking at. When a worker dispatches a task to a run of its own, the row here shows the dispatch and the row's dispatch-run link opens the run where the task finished.
See A task waiting on a user for how a task waits on a person and picks up again.
Inventory
If your app writes a workforce inventory, the Inventory tab shows the workers registered in the organization. Open a session whose flow declares the inventory collection, such as a person's session on the workforce-roster flow, and pick the tab.
The tab has one section, Registered workers, with each worker's Id and Kind. When nothing is registered, the tab says so and points at the usual cause: the app never calls openInventory at boot.
Every row is labelled registered. The tab says the same: a row stays once it is written, so a worker that was fired is still listed as registered. The tab is a record of what has been registered here. It can't tell you which workers are working now.
The tab works with FSDEV_DEBUG_ENDPOINTS=0. Each request carries the DevTool's bearer token, from devtool.bearerToken in your config or the Settings sheet (see Connecting to a secured flow). It reads every page of the collection.
It shows the organization of the session you opened and no other. There is no organization picker.
A read that fails is never shown as an empty section. A refused read names its status, 403, and a collection the server doesn't know names 404. If a read fails partway, the section says how many pages were read before it stopped and shows none of the rows.
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, except for workforce roster workers owned by a user other than the session's user and any state field a resource names in secret. 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.