npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flow-state-dev/core

v0.2.0

Published

Isomorphic builders, type contracts, and item taxonomy for the flow-state-dev block framework.

Readme

@flow-state-dev/core

The building blocks. Define handlers, generators, sequencers, routers, and flows — all with end-to-end type safety.

This is the foundation package. Every other package depends on it. It's isomorphic — runs in Node, the browser, edge runtimes, anywhere JavaScript runs.

Installation

pnpm add @flow-state-dev/core

What you can build

import { defineFlow, generator, handler, sequencer, router } from "@flow-state-dev/core";
import { z } from "zod";

A generator that calls an LLM with tools, history, and streaming:

const agent = generator({
  name: "agent",
  model: "intent/chat",
  prompt: "You are a helpful assistant.",
  inputSchema: z.object({ message: z.string() }),
  history: true,
  user: (input) => input.message,
  tools: [readDoc, writeDoc],
  itemVisibility: { client: true, history: true },
});

The prompt (and user) slots can also be authored in a separate .md file with YAML frontmatter and a LiquidJS body. Load it with loadPromptFile(...) and spread definePromptFile(pf) into the generator config. See the Prompts as Markdown reference.

A handler that validates and transforms:

const counter = handler({
  name: "counter",
  sessionStateSchema: z.object({ count: z.number().default(0) }),
  execute: async (input, ctx) => {
    await ctx.session.incState({ count: 1 });
    return input;
  },
});

A sequencer that composes them into a pipeline with error recovery:

const pipeline = sequencer({ name: "chat-pipeline", inputSchema })
  .step(analyzeInput)
  .stepIf((result) => result.needsContext, enrichWithContext)
  .step(agent)
  .step(counter)
  .rescue([{ when: [ModelError], block: fallback }]);

.rescue() is also a method on any block. someBlock.rescue([{ block: fallback }]) returns a block that recovers from its own failure and returns the handler's output instead of throwing — so a single step (or one forEach element, parallel branch, or router route) can fail in isolation while the rest of the chain continues. The chain-level .rescue() above is the same operation applied to the whole sequencer.

A later step can check whether an earlier one was recovered with ctx.wasRescued(blockName | blockDef) — without the recovered value carrying any marker.

Sequencers can optionally declare an outputSchema as a runtime contract on the composed output of the whole chain — validated on every exit path (tail, exitIf, rescue). Call .validate() at build time to catch structural drift early.

const summarize = sequencer({
  name: "summarize",
  inputSchema: z.object({ text: z.string() }),
  outputSchema: z.object({ summary: z.string(), wordCount: z.number() }),
}).step(summarizeBlock);

summarize.validate(); // throws if the tail shape drifts from the declared schema

A router that dispatches to different pipelines at runtime:

const dispatch = router({
  name: "mode-router",
  routes: [chatPipeline, planPipeline, reviewPipeline],
  execute: (input, ctx) => {
    const mode = ctx.session.state.mode;
    if (mode === "plan") return planPipeline;
    if (mode === "review") return reviewPipeline;
    return chatPipeline;
  },
});

A flow that ties it all together:

export default defineFlow({
  kind: "my-app",
  requireUser: true,
  actions: {
    chat: {
      inputSchema: z.object({ message: z.string() }),
      block: dispatch,
      userMessage: (input) => input.message,
    },
  },
  resources: {
    artifacts: defineResource({
      scope: "session",
      stateSchema: artifactSchema,
      writable: true,
    }),
  },
  session: {
    stateSchema: z.object({ mode: z.string().default("chat"), count: z.number().default(0) }),
    client: {
      derived: {
        artifactsList: (ctx) => /* derive list from resource state */,
      },
    },
  },
})();

Calling the factory with no argument yields the flow's one instance, whose id is its kind. A definition that should run as several configured copies declares cardinality: "collection" and gives each instance its own id (reviewFlow({ id: "review-east" })); a singleton refuses any other id. See Flow options.

Copies that differ by more than their name declare configSchema on the definition and pass config per copy (reviewFlow({ id: "review-east", config: { model: "opus" } })). The bag is parsed against the schema and frozen at that call; blocks read it as ctx.flow.config, and a block declares what it needs of any flow that installs it with flowConfigSchema. Both options are definition-time and instance-time halves of the same thing — see Flow options.

Exports

Main (@flow-state-dev/core)

Block builders:

  • handler(config) — Synchronous/async logic block
  • generator(config) — LLM call with framework-managed tool loop, streaming, and structured output repair (deterministic jsonrepair then LLM coercion that reshapes off-schema output to the schema; on by default, configured via repair.coerce / repair.coerce.model, defaulting to intent/utility)
    • Provider-native web search: set search: true (or a GeneratorSearchConfig). This is the model provider's built-in search, distinct from the @flow-state-dev/tools tools.search tool — the tools tier knob does not apply, and the generator's searchDepth ("low" | "medium" | "high", OpenAI searchContextSize) is a different field from the tools searchDepth ("basic" | "advanced"). See Web search.
    • Human-in-the-loop inside the tool loop: a generator tool can call ctx.suspend() to gate its own call. The request suspends like any sequencer gate, and on a durable resume the tool re-enters past the approval — prior turns and completed sibling tools replay from the item log, so the model is not re-called for them. Constraints: gate before side effects (the tool re-enters from the top on resume, so guard pre-gate work with runOnce), one approval gate per model turn (first-suspension-wins), and a gated tool can't be cacheable (the cache short-circuits before the tool body). See Generator and router suspend/resume.
  • sequencer(config) — Fluent composition DSL (21 methods: step, stepIf, parallel, forEach, forEachSideChain, doUntil, doWhile, map, tap, tapIf, rescue, branch, sideChain, sideChainIf, waitForSideChain, waitForCondition, loopBack, stepAll, stepAny, race, exitIf)
    • .forEach() / .forEachSideChain() take a per-item factory (item, index, ctx) => block in place of a block. A block built that way does not exist when defineFlow walks the graph, so declare what the factory can produce with blocks: [...] in the trailing options (IterationOptions / SideChainIterationOptions); declared blocks count as the step's children for the dispatcher address check and resource merging. Redundant on a call that passes a block directly.
  • router(config) — Runtime block selection from declared routes. Route names must be unique per router (validated at build). The selected branch dispatches through the same replay seam as sequencer children, so on a durable resume the branch decision stays stable — the framework validates the re-run selection against the recorded decision and throws RouteUnavailableError on a mismatch — and completed work inside the branch replays instead of re-executing. A router whose branch can suspend needs a pure execute selector (no side effects, no ambient state reads); see Control-flow determinism
  • dispatcher(config) — Send one dispatch to one entry the flow declares, in a child session derived from a key or in an existing session named by id. Returns { sessionId, requestId, adopted }. See Dispatches, entries, and dispatcher()

Block methods (available on every BlockDefinition):

  • .connectInput(mapper) — adapt input shape at the call boundary
  • .connectOutput(mapper) — transform output shape at the call boundary
  • .mapModelOutput(mapper) — when the block is used as a generator tool, supply a model-visible string representation of its output
  • .asTool(opts?) — wrap the block so it emits a tool_output item when run from a sequencer step (same envelope and lifecycle as the AI SDK tool-loop path)

Background work lifetime: .sideChain(), .sideChainIf(), and .forEachSideChain() queue tasks on a per-request pool, not the sequencer that dispatched them. Inner sequencers do not auto-await their own background work before returning; sibling sequencers run their tasks concurrently. The request executor drains the pool before terminal status — on every outcome, and repeatedly until no task is left, so a task that queues more background work is waited on too. Use .waitForSideChain() when an inner step depends on a queued task completing first — it drains only the calling sequencer's contributions.

Event-driven waits: .waitForCondition(predicate, { timeoutMs, wakeOn? }) suspends the sequencer until a synchronous predicate over the request's item stream returns true (or the timeout fires). Yields { timedOut: boolean }. Use it to coordinate with side-channel state — a worker writing an artifact, a task-board flipping a status, a projected actor resuming a paused review. Predicate helpers ship in @flow-state-dev/core/items: whenResourceChanged({ scope, path, changeType? }), whenResourceMatching({ scope, pattern }) (tiny glob with * and **), and whenAnyItem(predicate) as the generic escape hatch. The optional wakeOn filter lets high-fanout patterns skip predicate re-evaluation on irrelevant item types; @flow-state-dev/orchestration ships onTaskChangeFor(collectionId) for collection-bound waiters.

Flow:

Concurrency policy:

Any entry can declare a concurrency policy that decides what happens when two requests collide on the same key (the session by default). Set it on the entry — an action, an internal entry, a task-board entry, or a webhook / schedule binding — or set a flow-wide default via RequestConfig.concurrency (flow.request.concurrency); resolution is entry.concurrency ?? flow.request.concurrency ?? "allow", the same ladder for every dispatch type.

ConcurrencyConfig is either a bare policy name ("allow" | "queue" | "reject") or { policy, key }, where key is "session" (default), "user", "none", or a (ctx) => string | undefined function. A key that resolves to undefined means no arbitration — the request runs as allow. The default is allow (run concurrently).

defineFlow({
  kind: "support-chat",
  request: { concurrency: "queue" },                              // flow-wide default
  actions: {
    respond:     { block: respondPipeline },                      // inherits "queue"
    syncInvoice: { block: invoicePipeline,
                   concurrency: { policy: "reject", key: "user" } },
  },
});

Exported types: ConcurrencyConfig, ConcurrencyKey, ConcurrencyKeyContext, ConcurrencyPolicyName, plus the validateConcurrencyConfig validator. The policy is enforced once at the host dispatch seam, so every transport inherits it. See the Concurrency policies reference.

Utility block factories (utility.*):

  • utility.contextReducer(config) — Generator factory for distill, denoise, or compress context transformation modes with mode-specific default output schemas ({ distilled, keyPoints }, { cleaned, removedCategories? }, { compressed, compressionRatio?, dropped? })
  • utility.summarizer(config) — Generator factory for brief, detailed, or executive summaries with optional focus objectives and a default { summary, keyPoints? } output contract
  • utility.decomposer(config) — Generator factory that breaks broad requests into executable tasks using a default { tasks: [{ id, goal, deps?, priority? }] } output contract
  • utility.analyzer(config) — Generator factory for artifact critique/evaluation with configurable criteria and a default { findings, score?, recommendation? } output contract
  • utility.combiner(config) — Handler factory for deterministic artifact merging via concatenation, deduplication, and structural normalization with default { combined, mergeNotes? } output
  • utility.intentClassifier(config) — Generator factory for bounded intent classification with required category descriptions and default { category, confidence, reasoning? } output contract
  • utility.intentRouter(config) — Sequencer factory that composes intentClassifier + router into classification-driven branching with category descriptions, handlers, optional confidenceThreshold, and optional fallback routing
  • utility.keyedRouter(config) — Router factory for the "pick a block from a Record<string, Block> by string key" case. Throws with the registered keys (or routes to fallback) when the selected key is unregistered. Input adaptation belongs on the routed blocks via .connectInput (BP-013)
  • utility.memoryExtractor(config) — Generator factory for stateless durable-memory extraction with a default { memories: Array<{ type, content, confidence?, source? }> } output contract (typefact | preference | constraint | decision)

Every generator-based utility above accepts an optional itemVisibility ({ client: boolean; history: boolean }) to control whether output is surfaced to the client/history. All default to unset (silent — output flows only via graph edges). Set explicitly to opt in when the utility should be user-facing.

Resources:

  • defineResource(config) — Portable resource definition. Requires scope: "session" | "user" | "org". Register on a flow's or block's resources map.
    • Supports optional content/contentFile (mutually exclusive), render, llmReadable, and llmWritable for resource content workflows. contentFile and file-path contentTemplate accept a bare string (resolved from the working directory) or an AnchoredPath{ path, importerUrl: import.meta.url } — resolved relative to the declaring module first, with a working-directory fallback
    • stateSchema may normalize (fill a .default(), strip an undeclared key, map a retired enum value) but has to settle: parsing its own output must yield that same output. A .transform() that returns something new on each pass would move the stored value on every write, so a write that isn't a fixed point of the schema is refused with ValidationError rather than stored. See the schema has to settle.
    • prefetchMode?: 'eager' | 'lazy' (default 'eager') — 'lazy' defers the load until the declaring block dispatches. Once the resource is resolved its ref.state getter is synchronous. Declaring 'lazy' on a flow-level single resource throws at build time (no per-block load trigger).
    • sharedToLineage?: boolean (default false, scope: "session" only) — give the resource ONE identity across a session lineage, so a session, every session dispatched under it, and their own children resolve the same resource through the ordinary resource API. Session state is never shared, and sharing does not serialize writes — two children writing one shared resource is ordinary same-resource contention fenced by expectedVersion. true at user/org scope throws at build time (those scopes already span every session the principal touches). Also accepted by defineResourceCollection, applying to every instance. Named sharedToWorkstream before 0.5.
    • reactTo?: { created?, stateUpdated?, deleted?, contentUpdated? } — bind a block (handler/generator/sequencer) to a mutation. Each entry is a bare block or { block, when }. A state binding (created/stateUpdated/deleted) runs with a ResourceChange payload (key, ref, kind, state, prevState, evicted), typed with resourceChangeSchema(stateSchema). A contentUpdated binding runs after a server-side content write with a minimal ResourceContentChange payload (key, ref, kind), typed with resourceContentChangeSchema(); the block readContent()s for the fresh body. The block runs blocking inside the originating turn. See the Reactive blocks reference.
    • Runtime ResourceRef provides state (sync getter) plus patchState, setState, updateState, incState / pushState (below), and getOrPatchState(key, compute) — get-or-compute over state: returns state[key] if present, else runs compute, patches the result under key, and returns it (callback runs only on a miss, so a fetch happens at most once per stored key and downstream readers reuse the stored copy). A stored null is a hit; a compute resolving to undefined stores nothing. No TTL — a per-resource data spine, not a cache. Concurrent misses on the same key within a request are single-flighted (they share one compute, so a fanned-out read issues one upstream fetch); distinct keys still compute in parallel.
    • incState(increments) / pushState(field, value) — add to number-valued state fields (incState({ calls: 1 })), or append one value to an array-valued one (pushState("errors", "rate_limited")), under the same names scope state uses. Both resolve to void; read the result off ref.state. Each call is a single guarded mutation, so two callers incrementing one counter both land — on the memory, SQLite and Postgres stores, which compare and swap inside the store; the filesystem store holds the guard per key on the store instance, which covers every context sharing that instance but does not coordinate two stores pointed at one directory. Both are keyed to TState on a ResourceRef<TState> / ResourceContext<TState> whose state type is written out — number fields for incState, array fields and their element type for pushState. A handle read off ctx.resources.<name> is ResourceRef<any>, so only the delta-is-a-number check survives there. They refuse rather than corrupt: incrementing a field that holds something other than a number, or appending to one that holds something other than a list, throws FlowError (code: "resource_delta_refused", retryable: false) and leaves the stored value alone. A multi-field incState applies wholly or not at all. An absent or null field is that field's empty state, not a wrong kind of value — it starts from 0 / []. incState also refuses a result that is not finite, since two finite operands can overflow to Infinity, which z.number() accepts but the stores disagree about how to persist. Both honor writable: false, and a delta that commits is validated against stateSchema like any other state write.
    • updateStateWith(ref, updater) / withOutcome(run, updater) (@flow-state-dev/core/helpers) — run a state update whose callback returns what it did, as { state, result }, instead of assigning it to a variable outside the callback. On the CAS path an updater can run more than once, so an outward-assigned value can describe an attempt that never committed; these return the result belonging to the invocation that did (or undefined if none completed). withOutcome takes any mutation runner — ref.updateState, a scope's atomicState, or your own wrapper — so one helper covers every retry entry point. scripts/validate-updater-purity.mjs (in pnpm typecheck) rejects the common outward-write forms as a backstop — it catches the naive shapes, not every possible one.
  • defineResourceCollection(config) — Dynamic resource collection with pattern-based keys (files/*, files/**, [topic]/observations), required scope: "session" | "user" | "org", optional maxInstances/eviction, lifecycle hooks, and reactTo (same { created?, stateUpdated?, deleted?, contentUpdated? } shape as defineResource; supersedes the onInstance* callbacks for the block case)
    • prefetchMode?: 'eager' | 'lazy' (default 'eager') — a loading-cost knob, not an API-shape knob. Eager preloads the whole prefix into a per-request cache so reads resolve instantly; 'lazy' reads per access from the store. The call shape is identical in both modes: get/getOptional/list/count all return Promises (always await them), and the mutations create/getOrCreate/upsert/delete were already async. Flipping prefetchMode needs no call-site changes. 'lazy' requires eviction: 'none' (a partial cache can't drive eviction) and throws at build time otherwise.
    • Runtime ResourceCollectionRef provides create(), get(), getOrCreate(), upsert(), list(), delete(), count()
    • create(key, initial, { replace: true }) — overwrites an existing instance instead of throwing. setState semantics; Zod .default(null) fills nullables on both the create and replace branches. maxInstances only checked when adding a new instance. Use for setup/reset paths. A writable: false collection refuses that overwrite the same way setState does; a missing key creates.
    • upsert(key, update, createOnly?) — patch-or-create. On exists: applies update via patchState semantics (other fields preserved). On missing: creates with { ...createOnly, ...update } (update wins on overlap). The createOnly extras fill fields you only need to supply at creation time. Use for incremental-update paths that need to handle first-touch in a single call.
    • "If-exists / if-missing" summary: create throws / create({ replace }) replaces / getOrCreate returns as-is / upsert patches — all four create on missing; replace-on-existing and delete refuse when writable: false.
  • isDefinedResourceCollection(value) — Type guard for collection definitions

Capabilities:

  • defineCapability(config) — Bundle resources, state schemas, targets, and helper functions under a single name. Blocks declare capabilities via uses: [cap] and the framework merges everything transitively.
    • fns: (ctx) => ({ ... }) — Helper functions exposed at ctx.cap.{name}.{fn}, memoized on first access
    • presets — Named opt-in/opt-out bundles of any block config surface. Use .presets({ name: true/false }) to configure
    • config: { schema?, resolve } — Open, typed configuration. The resolver maps a validated value onto a block surface (like a preset, but value-carrying). Consumers pass it with .config(value), which composes with .presets() in either order
    • .with(bag) — The normalized consumer builder. Collapses .config() and .presets() into one flat call: preset-named keys become preset toggles, the rest become the config value. cap.with({ allowed: ["x"], dynamicActivation: true })cap.config({ allowed: ["x"] }).presets({ dynamicActivation: true }). .config/.presets remain the underlying primitives; a preset name colliding with a config field is a defineCapability() error
    • uses — Capabilities can depend on other capabilities (transitive composition with diamond dedup)
    • Preset tool slots (generator-only): tools is a grant a consuming generator can withhold — a generator that declares its own tools: gets that list and nothing else. controlTools reaches the model whatever the generator declares, for a control the generator's own configuration asked for (a loader the capability builds and never exports, say)
    • Factory pattern: wrap defineCapability() in a function for parameterized capabilities

Capability schema forwarding:

When a block lists a capability in uses, the capability's declared schemas flow into the block's ctx types at factory time. No re-declaration on the block is needed. The forwarded axes are sessionStateSchema, resources (resource handles), targetStateSchemas, sequencerStateSchema (from presets), and stateSchema (the block's own state, ctx.self — valid on any block kind). Block-own declarations merge in; for most axes the block wins on key collision, but stateSchema requires a shared field to be the same schema reference instead (matching the resources/targetStateSchemas merges) — a duplicate field with a different reference throws at build time rather than one side silently winning.

const myCap = defineCapability({
  name: "my-cap",
  sessionStateSchema: z.object({ ticker: z.string() }),
  fns: (ctx) => ({ currentTicker: () => ctx.session.state.ticker }),
});

const myHandler = handler({
  name: "my-handler",
  uses: [myCap],
  execute: async (_input, ctx) => {
    // ctx.session.state.ticker — string, from the capability
    const t = ctx.session.state.ticker;
  },
});

Forwarding is direct-only: inner capabilities used by myCap do not propagate to myHandler. Dynamic uses entries (functions) contribute at runtime but not to types.

Tool-result memoization:

  • createToolCacheCapability(options?) — Capability that installs a per-request LRU store on the active context so any tool block declaring cacheable serves identical calls from cache. Errors are never cached; identical in-flight calls in the same request coalesce.
  • createInMemoryToolCacheStore(options?) — Standalone store factory for advanced wiring (e.g. binding a per-board run-scoped store directly).
  • bindToolCacheStore(ctx, store) — Attach a store to a context without going through the capability path.
  • canonicalizeToolArgs(value) — Deterministic JSON canonicalizer for custom keyFns that want the substrate's default normalization.
  • Types: ToolCacheStore, ToolCacheEntry, ToolCacheAccessor, CreateToolCacheCapabilityOptions.
  • See Flow policy for the full guide, including when to mark a tool cacheable and how Task Board auto-installs the capability.

Context & client data:

  • contextFn(schemas, fn) — Typed context function for generators (scope-aware, portable)
  • client on scope configs — Per-scope client view: expose: string[] (verbatim passthrough by field name) and derived: { name: fn } (compute functions receive { state, resources }). State without a client block is private. The former clientData key on scope configs has been removed — defineFlow throws if it is still set.

Prompt formatters (@flow-state-dev/core/prompt):

  • section, list, keyValues, table, entries, codeBlock, join, when — Composable text formatters for building clean LLM context. section takes a string title (default ##) or { title, level } to nest under another section; table renders an array of records as a Markdown table. The same keyValues / list / table shapes are auto-registered as fsd_* filters inside .md prompt templates.

Concurrency (@flow-state-dev/core):

  • mapLimit(values, maxConcurrency, mapper) — bounded-concurrency async fan-out preserving input order. Use it for async work inside a handler (.parallel fans out blocks, not in-handler async).
  • xmlTag(name, content), renderTaggedContext(tagged, order) — XML tag rendering used by object-form generator context
  • validateTagName(name), RESERVED_TAG_NAMES — Reserved-tag list and validator for object-form context keys

Object-form generator context:

generator({ context: { ... } }) accepts an object whose keys become XML tag names. Multiple sources (the generator itself plus capabilities installed via uses) that contribute to the same key aggregate inside a single tag, instead of producing scattered sections.

generator({
  prompt: "You are a research assistant.",
  context: {
    documents: [doc1, doc2],
    userPreferences: () => loadPrefs(),
    memory: { shortTerm: items, longTerm: () => loadLongTerm() },
  },
  uses: [capA, capB], // each may also contribute `documents`
});
// Renders as one combined system message:
//   You are a research assistant.
//
//   <documents>
//     ...all documents from the generator + capA + capB...
//   </documents>
//   <user-preferences>...</user-preferences>
//   <memory>
//     <short-term>...</short-term>
//     <long-term>...</long-term>
//   </memory>

Keys may be authored as camelCase, snake_case, or kebab-case (all normalize to kebab-case). Values may be strings, string arrays, nested objects (recursive — produces nested tags), functions resolved at render time, or null placeholders that reserve order but emit nothing if unfilled. String leaves are HTML-escaped so < / > / & in user data don't get read as tags. The original array form is unchanged. See the blocks reference for the full contract.

Slot types:

  • ToolsSlot — Tools accepted by generators: static array or (ctx) => tools[]
  • UsesSlot — Capabilities accepted by blocks: static array or (ctx) => caps[]
  • InstructionsSlot<TInput> — Pattern-level instructions: static string or (input, ctx) => string | Promise<string>. Parameterize with the pattern's input type to recover a typed input in the callback.

Type helpers:

  • StateOf<T> — Extract state type from schema or resource
  • ContextOf<T, Kind> — Get context handle type for scope/resource
  • ResourceContext<T> — Resource context type
  • BlockInput<T> / BlockOutput<T> — Infer block I/O types
  • BlockDefinition — The fully-typed return interface of handler(), generator(), sequencer(), and router(). Generics default to ZodTypeAny, so unparameterized BlockDefinition is the unconstrained "any block" form — useful when an app-level factory needs to accept or return a block without restating the framework's generics.
  • BlockKind"handler" | "generator" | "sequencer" | "router" union — useful when writing dispatchers that switch on block.kind.
  • BlockContext — The full block-context interface (the type of ctx in execute). Generic over the four scope-state types, declared resources, sequencer state, and parent input.
  • BlockResult<TOutput> — The handler execute return-value union.
  • SessionScopeHandle<TState> / UserScopeHandle<TState> / OrgScopeHandle<TState> / RequestScopeHandle<TState> — The scope handles ctx.session / ctx.user / etc. resolve to. Use to type a ctx slice (e.g. (input, ctx: { session: SessionScopeHandle<MySessionState> }) => …) instead of hand-rolling a { session: { patchState: ... } } shape.
  • ScopeStateOps<TState> — The state-mutation interface every scope handle exposes (patchState, setState, setStateRecord, etc.).
  • LooseBlockContext<TSessionState> — Variance-friendly alias for BlockContext: typed on session scope, permissive on resources. Use for helper functions that take a block's ctx as a parameter. The full BlockContext's TResources generic is invariant on ResourceRegistry, so a handler's narrow inferred ResourceRegistry<{ memos: ... }> can't widen to a BlockContext's default. LooseBlockContext sidesteps that by leaving resources permissive — helpers accept any block's ctx, call sites retain their narrower typing internally.

handler.withDefaults({...})

Partially-applied handler constructor. Bakes in common config so a family of sibling handlers can share scaffolding without restating it per call.

import { handler } from "@flow-state-dev/core";
import { z } from "zod";

const memoHandler = handler.withDefaults({
  sessionStateSchema,
  resources: { memos: memosCollection },
  outputSchema: z.void(),
});

export const commitBullMemo = memoHandler({
  name: "commit-memo-p2-bull",
  inputSchema: bullThesisSchema,
  execute: async (thesis, ctx) => {
    // ctx.session.state is typed from sessionStateSchema
    // ctx.resources.memos is typed from the resources default
    await ctx.resources.memos.get("p2/bull").patchState({ ... });
  },
});

// Per-call overrides win: pass `outputSchema` again to replace the default.
export const markError = memoHandler({
  name: "mark-error",
  inputSchema: z.unknown(),
  outputSchema: z.object({ status: z.literal("error"), text: z.string() }),
  execute: async (_, ctx) => ({ status: "error" as const, text: "..." }),
});

Defaultable fields: sessionStateSchema, userStateSchema, orgStateSchema, requestStateSchema, sequencerStateSchema, resources, outputSchema, uses. name, inputSchema, execute, and description are excluded — those vary per block.

Prompt files (@flow-state-dev/core/prompt-file, @flow-state-dev/engine/prompt-file)

Author a generator's prompt as a .md file. The isomorphic subpath exports parsePromptFile(text, options?), definePromptFile(pf), isPromptFile(value), and the PromptFile / PromptFileConfig / PromptFileParseError / PromptFileLoadError types. The Node-only subpath exports loadPromptFile(specifier, importerUrl, options?), which reads the file and auto-registers sibling .md files as partials; only this subpath imports node:fs, so browser/bundled consumers use parsePromptFile with raw text plus an explicit partials map.

Resolution rule. Relative specifiers resolve against the caller's import.meta.url; absolute specifiers are used as-is (importerUrl ignored); createPromptLoader joins every relPath onto its absolute baseDir. Resolution never consults the process working directory — compute baseDir with resolveBaseDir(candidates, { expect? }) (first candidate dir that exists and contains the expect probe; throws listing all candidates when none qualifies) composed with moduleDir(importerUrl, relative?) (the module's directory, or undefined when a bundler has rewritten import.meta.url to a non-file: URL). Module-relative candidate first, process.cwd()-derived fallback for bundled runtimes that pin cwd (Next.js dev/build).

Two ergonomic shortcuts cut the boilerplate:

  • Pass the PromptFile straight to prompt instead of spreading definePromptFile(pf). generator({ prompt: loadPromptFile(...), model }) expands the file's user / caching / maxTokens / temperature / name / description into the config; any sibling field you set explicitly wins (same precedence as ...definePromptFile(pf), <overrides>).
  • createPromptLoader(baseDir, options?) (Node subpath) captures an absolute baseDir plus shared partialsDir / filters once and returns a load(relPath) function, so call sites drop the repeated import.meta.url argument. Per-call filters merge over the loader's shared filters.
import { generator } from "@flow-state-dev/core";
import {
  createPromptLoader,
  moduleDir,
  resolveBaseDir,
} from "@flow-state-dev/engine/prompt-file";

const PROMPT_ROOT = resolveBaseDir(
  [moduleDir(import.meta.url, "./prompts"), path.resolve(process.cwd(), "src/prompts")],
  { expect: "_partials" },
);
const load = createPromptLoader(PROMPT_ROOT);
const analyst = generator({ name: "analyst", model, prompt: load("analyst.prompt.md") });

Resource content templates. The same .md format can render resource content against state. The Node subpath exports loadResourceTemplate(specifier, importerUrl, options?) and the isomorphic subpath exports parseResourceTemplate(text, options?), renderResourceTemplate(template, state), and the isResourceTemplate(value) guard. Wire them via contentTemplate (build-time file — a parsed template, a working-directory-relative string path, or an AnchoredPath resolved relative to the declaring module) or contentTemplateRef (live-editable resource) on defineResource() and defineResourceCollection(). See the Resource content from Markdown templates reference.

Voice Provider

VoiceProvider is a single, ability-flagged interface a flow wires to handle one or more voice surfaces: speak (batch TTS), speakStream (streaming TTS), transcribe (STT), and listVoices (catalog). Each provider declares which abilities it supports via the abilities field; runtime type guards (canSpeak, canSpeakStream, canTranscribe, canListVoices) narrow the provider so the matching method is callable without !. Errors thrown by providers carry a discriminated VoiceError.kind so callers can branch on category instead of parsing messages.

This surface replaces the previous resolver-factory pattern (createAiSdkSpeechResolver, createAiSdkTranscriptionResolver) — those helpers and their SpeechResolver / TranscriptionResolver types are removed from core. The field is named abilities (not capabilities) to avoid colliding with the framework's first-class Capability concept (defineCapability, uses: [cap]).

import { canSpeak, type VoiceProvider } from "@flow-state-dev/core";

async function maybeSpeak(provider: VoiceProvider, text: string) {
  if (canSpeak(provider)) {
    const { audio, mediaType } = await provider.speak({ text });
    return { audio, mediaType };
  }
  return null;
}

Exports from the main package and @flow-state-dev/core/types:

  • Core contract: VoiceProvider, VoiceAbilities, SpeakOptions, SpeakResult, SpeakChunk, TranscribeOptions, TranscribeResult, VoiceInfo
  • Narrowing interfaces: SpeakCapable, SpeakStreamCapable, TranscribeCapable, ListVoicesCapable
  • Type guards: canSpeak, canSpeakStream, canTranscribe, canListVoices
  • Errors: VoiceError, VoiceErrorKind
  • Composite factory: createCompositeVoiceProvider builds a synthetic provider that delegates each ability to a different underlying provider

Per-provider implementations live in separate packages — @flow-state-dev/voice-openai is the first, with @flow-state-dev/voice-elevenlabs to follow.

Types (@flow-state-dev/core/types)

Block, flow, resource, scope, streaming, and model type definitions. Use this subpath for type-only imports.

defineResourceCollection accepts writable?: boolean (default true) and llmReadable?: boolean / llmWritable?: boolean (default false). Declared once, they apply to every instance. writable: false refuses instance patchState / setState / updateState / incState / pushState, collection upsert on an existing key, create(key, initial, { replace: true }) when that key already exists, delete(key) (including when the key is already absent), and instance writeContent; those writes throw Error with Resource "<storageKey>" is read-only (state) or Resource "<storageKey>" content is read-only (content). create of a key that does not exist succeeds, including create(key, initial, { replace: true }) when the key is missing. getOrCreate of a missing key creates; of an existing key it returns the instance and does not write. llmReadable exposes instance content to readResourceContentTool() and content search (grepResourceContent / searchResources); llmWritable lets writeResourceContentTool() overwrite an instance body. The write tool is available when llmWritable is true. The write fails when writable is false. The generic tools address resources by scope-qualified uri (e.g. session/files/readme.md). A content-bearing collection uses those tools; keep collection-specific tools for domain logic they do not cover.

defineResourceCollection accepts a prefetchWindow?: number (default 0) that inlines the first N items in the snapshot's prefetched window in lexicographic storage-key order. Per-item clientData in the window appears only when client.state.read: true is also set. CollectionStateClientConfig controls per-item state visibility separately from content; single resources don't accept client.state (state visibility is governed by client.data on those).

Set client: { live: true } to stream each mutation's projected clientData as an inline delta that the client merges mid-stream without a refetch (the resource-side analog of state_change). It requires the resource's clientData to be client-visible (state.read: true or a projection on collections; a projection on single resources). lifecycleSchema(statuses) is a convenience export that returns a status enum plus nullable startedAt / completedAt / errorMessage fields to spread into a status-bearing stateSchema.

defineResource and defineResourceCollection carry a derived client-projection type alongside the state type. ClientDataOf<typeof def> extracts it — the Pick from expose, the Omit from exclude, the return type of data, or the full state for the identity default. Pass it to the React hooks (useResource<T>, useResourceCollectionItem<T>, …) so clientData is typed instead of unknown. This is a type-level brand only; the runtime payload stays JsonValue. For data projections, annotate the function's return so the type is captured precisely.

Projected resource collections. defineProjectedResourceCollection({ pattern, scope, stateSchema, read, search, ... }) defines a read-only view of records the app already owns. The framework does not store a copy: ctx.resources.<coll>.get(key) / .getOptional(key) and the client state/content routes call the required read({ key, ctx }) hook and validate the result through stateSchema. search({ query, ctx }) backs list, searchResources (when llmReadable), and the list route. The runtime ref has no create / upsert / delete; client write routes are closed; client.content.create / update / delete is a build-time error. Patterns are wildcard-only (* or **). Scope is session | user | org. The hook ctx carries a server-derived userId, scope, and tenantId. Content templates and client projection work the same as on a store-backed collection.

The harness contract. A harness is a coding agent driven as a block: you hand it a prompt, it runs its own agentic loop, and it hands back a handle describing the run.

  • Runtime schemas, from the package root: harnessRunInputSchema, harnessRunHandleSchema, harnessRunEnvelopeSchema
  • Types, from @flow-state-dev/core/types: HarnessBlock, HarnessRunInput, HarnessRunHandle, HarnessRunEnvelope, HarnessSource, HarnessRunStatus, HarnessRunOutcome, HarnessRunUsage, HarnessRunCost, HarnessCostBasis, HarnessResolver, HarnessSessionHook, HarnessCallbackContext

Two packages implement it: @flow-state-dev/claude-code (claude-code/sdk) and @flow-state-dev/codex (codex/sdk). @flow-state-dev/harness-manager drives either. Full guide: Coding agents.

Items (@flow-state-dev/core/items)

Output item unions, content types, and stream event helpers. Item types: message, reasoning, component, container, tool_output, status, source, state_change, resource_change, error.

The item taxonomy and its pure helpers (resolveItemVisibility, collapseToCanonicalLog, resolveBlockValue / buildItemLookup, the blockPath* builders, and the ModelIdentity / SuspensionReason / SuspensionStatus / RequestStatus leaf types) now live in the zero-dependency @flow-state-dev/contracts package and are re-exported from these same @flow-state-dev/core paths. Import them from core exactly as before — nothing changes for consumers. Browser packages can value-import the canonical helpers from contracts without pulling core's heavy authoring dependencies.

state_change and resource_change share an exported InvalidationItem base (common scope/delta/version fields) for consumers that react to "something changed in a scope" generically. It is a base type, not a member of the OutputItem union — only the two leaves are.

BlockValue<T>block_output.output is a discriminated union with three cases: inline (novel content on the emitter), ref (pointer to another item's content), and structure (container of nested BlockValues, used by aggregators like .stepAll). Use resolveBlockValue(value, lookup) to recover the typed payload T; ctx.getBlockOutput() resolves transparently. Refs may also point at MessageItems — streaming-text generators emit a ref to their just-emitted message instead of duplicating the text inline. buildItemLookup(items) indexes every item by id so the resolver can follow either kind of ref.

Running a step under an extra abort signal

.step(block, { abortSignal }) and .stepIf(cond, block, { abortSignal }) run one step under an additional abort signal, resolved per dispatch from the running context:

sequencer({ name: "work" })
  .tap(startSomethingCancellable)
  .step(worker, { abortSignal: (ctx) => currentCancellation(ctx)?.signal })
  .tap(recordResult);

The signal is composed with the request's, never substituted for it, so a step cannot be made to outlive a cancelled request. Return undefined and the step runs exactly as it would without the option. The whole descendant tree sees the composed signal, so a model call several blocks down aborts with it.

Reach for it when the thing that should stop the step is known only at runtime — a lease the step's claim depends on, a projected cancellation the block itself has no way to see. A block that can decide for itself should just read ctx.signal.

Releasing what a step was holding

The same bag takes onSettled, called once when the step's dispatch leaves by every path — and told which one: "returned", "threw" or "suspended".

sequencer({ name: "work" })
  .tap(startSomethingCancellable)
  .step(worker, {
    onSettled: (_ctx, outcome) => {
      if (outcome === "suspended") stopSomethingCancellable();
    },
  })
  .tap(recordResult); // stops it itself, once the result is written

Suspension is why it exists. .rescue() is deliberately never run for a SuspensionError — suspension is control flow, not a failure — and a suspended request does not abort its signal either, so a step that parks on ctx.suspend() reaches no handler you can compose. Whatever the leading .tap started then outlives the request, silently.

Read the outcome before you release. The hook fires before recordResult above, so releasing on "returned" would stop the thing while the step that still needs it is running. Release on "suspended" — the exit with nothing downstream — and let the downstream handler release the other two when it is finished.

It runs in a finally and cannot change the step's outcome, and it is skipped whenever nothing was dispatched — a stepIf condition that skipped the step, or a step replayed from a durable resume rather than executed (so cleanup does not re-run on every re-entry). For recovery, use .rescue().

Helpers (@flow-state-dev/core/helpers)

Helpers shared across the framework. cloneValue, deepMerge, and deepEqual operate on the same JSON-serializable state trees and live here as the single canonical home — no per-package copies.

The pure, dependency-free helpers deepEqual / looseDeepEqual, mapLimit, toError, and the string-case utilities (camelToKebab, normalizeTagName) now live in @flow-state-dev/contracts and are re-exported from these same @flow-state-dev/core/helpers paths. Import them from core exactly as before; browser packages can value-import them from contracts without core's heavy runtime.

  • cloneValue(value) — structural deep copy via the platform structuredClone, falling back to a JSON round-trip. Stores clone records on read/write so callers can't mutate stored state through a retained reference.
  • deepMerge(base, override) — recursive merge returning a new object. Scalars and arrays in override replace; nested plain objects merge; base is never mutated.
  • deepEqual(a, b) — structural equality powering the state-write no-op guard. Primitives compared by Object.is (NaN-equal-NaN, +0 != -0); plain objects and arrays compared recursively. Rejects non-JSON shapes (Map, Set, functions) with a TypeError. looseDeepEqual is the throw-free variant.
  • toError(value, fallback?) — coerce an unknown value to Error. An Error is returned as-is. A non-empty string becomes new Error(value). Anything else, including "" and objects with a message, becomes new Error(fallback). fallback defaults to "Unknown block execution error".
  • withTimeout(promise, timeoutMs, label, onTimeout?) — bound a promise with a deadline. Rejects with "<label> timed out after <ms>ms" once the deadline passes; undefined, Infinity, or a non-positive timeoutMs means no deadline and arms no timer — the same three scope-lock disables on, and Infinity is guarded rather than passed through because Node coerces setTimeout(fn, Infinity) to 1ms. The timer is cleared on every settle path, so a bounded call that finishes in time leaves nothing holding the event loop open. The bounded work is not cancelled — pair it with an AbortSignal when the work itself is cancellable. Pass onTimeout to reject with your own error type instead of a plain Error. It lives here rather than in contracts because it arms a timer — runtime behaviour, unlike the pure helpers above it.

Graph (@flow-state-dev/core/graph)

A reusable typed-edge primitive for relational state. edgeSchema describes a directed, typed, bi-temporal Edge (from/to/type/confidence/validFrom/validUntil/source), and pure traversal helpers walk a plain Edge[]: egoGraph, shortestPath, neighbors, traverse, activeAt, plus nodeRef/parseNodeRef for "namespace:key" node ids. All traversals are depth-bounded and cycle-safe.

Resources opt into a first-class edge graph with defineResource({ edges: true }) (or { vocabulary, maxEdges }): the framework stores an edges array in the resource's state and exposes an .edges API (add, supersede, remove, all, neighbors, egoGraph, shortestPath, pruneDangling) on the live resource reference. Resources without edges are unaffected.

Block state (and its sequencer special case)

Any block — handler, generator, router, or sequencer — can declare its own request-scoped stateSchema and read/write it via ctx.self. A child block reaches its immediate parent's state the same way, via ctx.parent, when it declares parentStateSchema. Sequencer instance state below is the common case of this same primitive: ctx.sequencer is ctx.self addressed by "nearest enclosing sequencer" instead of "this block." See Block State for the full addressing model (ctx.self, ctx.parent, ctx.sequencer, ctx.targets) and the fan-out/loop isolation contract.

A sequencer can declare a stateSchema that gives every step in the pipeline a shared, typed state container. State is read via ctx.sequencer.state and written via the seven helpers on ctx.sequencer: patchState, setState, incState, pushState, setStateRecord, deleteStateRecord, and atomicState.

import { sequencer, transientSlot } from "@flow-state-dev/core";
import { z } from "zod";

const counter = sequencer({
  name: "counter",
  stateSchema: z.object({
    count: z.number().default(0),
    // Worker-local scratch. Stays in memory but never appears on the SSE
    // stream, never writes to the durable checkpoint, and resets to its
    // schema default on resume.
    lastClaimed: transientSlot(z.boolean().default(false)),
  }),
}).step(/* ... */);

No-op write guard. A state-write helper that produces a value structurally equal to the current state is suppressed: no persist call, no state_change SSE item, and the helper returns false instead of true. Callers don't need their own identity check before a repeated write. The comparison uses Object.is for primitives (NaN-equal-NaN; +0 != -0) and recursive structural equality for plain objects and arrays.

Transient slots. transientSlot() marks a top-level field on stateSchema as in-memory only. Transient slots:

  • Hold their value across a sequencer's run, readable by later steps via ctx.sequencer.state.
  • Do not emit state_change items on the SSE stream.
  • Do not appear in state_snapshot payloads, so they never enter the durable checkpoint store and reset to their schema default on resume.

Apply transientSlot() LAST in the schema chain — after .optional(), .default(), etc. — so the marker sits on the outermost schema instance referenced by the parent z.object shape.

Dispatches, entries, and dispatcher()

Every arrival at a flow is a dispatch of one type, delivered to one entry addressed by (type, name). Each type has its own map on the flow definition, and a dispatch resolves only that map. There is no fallback: an internal dispatch named wake resolves flow.internal.actions.wake or is refused, whatever flow.actions.wake is.

| Type | Map on the definition | Sent by | |---|---|---| | public | actions | A caller over HTTP, MCP, voice, or a custom transport | | internal | internal.actions | A dispatcher() block in one of the flow's own running requests | | task | task.actions | A task board handing a claimed row to a child session, from a dispatcher({ action, session }) seat (stamped type: "task") | | webhook | webhooks.<provider>.on | The webhook adapter | | schedule | schedules.static | The host scheduler |

internal and task are definition-only, like the transport maps: passing either to the instance call (defineFlow({ ... })({ internal })) throws. Each nests its entries under actions: internal: { actions: { wake: { block } } }. The flat internal: { wake } spelling is refused by name. Every entry of every type has the same core shape as an action, { block, inputSchema?, concurrency?, durable?, tokenBudget?, onCompleted?, onErrored?, userMessage? }. Only actions adds the caller-facing description and mcp fields.

A task entry is declared as a plain block, but a task dispatch does not run the entry's block as-is. Before the block runs, the row is re-read and the claim verified; a claim that is no longer current throws StaleTaskClaimError. The block then receives the worker input the row was claimed with. defineFlow throws, naming the block and the entry, for a task entry no reachable board hands off to, a task dispatcher no board holds, and two boards handing off to one entry.

resolveEntry(flow, type, name, coordinate?) is the lookup itself, exported for hosts and adapters, alongside DispatchType, DISPATCH_TYPES, BlockDispatchType, EntryMaps, EntryCoordinate, InternalEntry, and TaskEntry.

dispatcher(config)

A dispatcher is a handler that sends one dispatch to one declared entry instead of doing the work itself. Its address (type and action) is fixed on the block; the session and the payload are computed per call from the block's input. It comes in two shapes: an internal dispatcher (InternalDispatcherConfig) sends this request's own authority to flow.internal.actions[action], and a task dispatcher (TaskDispatcherConfig) is a seat on a task board that hands the board's rows to flow.task.actions[action]. Omit type in both cases — ordinary dispatchers default to internal, and a task-board seat is a dispatcher whose session is "per-task", "per-worker", or { key }. The stamped address on a seat is still type: "task".

import { defineFlow, dispatcher, handler } from "@flow-state-dev/core";
import { z } from "zod";

const summarize = handler({
  name: "summarize",
  inputSchema: z.object({ documentId: z.string() }),
  execute: async (input) => {
    // runs in the child session, on its own request
  },
});

const acknowledge = handler({
  name: "acknowledge",
  inputSchema: z.object({ reason: z.string() }),
  execute: async (input) => {
    // runs in the coordinator's existing session
  },
});

// One child session per document. The same documentId from the same parent
// session lands on the same child, adopted rather than created.
const summarizeInBackground = dispatcher({
  name: "summarize-in-background",
  action: "summarize",
  inputSchema: z.object({ documentId: z.string() }),
  session: { key: (input) => input.documentId },
});

// Deliver into a session that already exists. An unknown id is refused, never created.
const wakeCoordinator = dispatcher({
  name: "wake-coordinator",
  action: "acknowledge",
  inputSchema: z.object({ coordinatorSessionId: z.string(), reason: z.string() }),
  session: { id: (input) => input.coordinatorSessionId },
  payload: (input) => ({ reason: input.reason }),
});

export default defineFlow({
  kind: "documents",
  actions: {
    upload: { block: summarizeInBackground },
    nudge: { block: wakeCoordinator },
  },
  internal: {
    actions: {
      summarize: { block: summarize },
      acknowledge: { block: acknowledge },
    },
  },
})();

| Field | What it does | |---|---| | type | Omit it. Ordinary dispatchers send internal. A task-board seat stamps type: "task" from its session policy. An explicit "task" is still accepted. | | action | The entry name, resolved as flow.internal.actions[action] or flow.task.actions[action]. Checked when the flow is defined, unless flowKind names another flow. | | flowKind | The other flow the entry lives on, on an internal or task dispatcher. Omit to address this flow's own entry. Checked at run time, not when the flow is defined — see Dispatching to another flow. | | inputSchema | internal only. What the block accepts. Defaults to z.unknown(). | | session | internal: { key: (input, ctx) => string } derives a child of the running session; { id: (input, ctx) => string } names an existing one; { from: true } delivers into the seam-stamped sender (refuses no-sender when this request was not dispatched). task: a TaskSessionPolicy, one of "per-task" (one child per row), "per-worker" (one child per seat), or { key: (task, ctx) => string } read from the row's worker input. | | payload | internal only. (input, ctx) => unknown, the entry's input. Defaults to the input itself. Validated by the entry's own schema on arrival. | | transient | Hide the block's trace from clients. Default false. |

A task dispatcher's input is the claim envelope, taskDispatchInputSchema / TaskDispatchInput: { boardId, seat, taskId, attempt, createdAt, incarnationId?, payload }. Only a task board mints one, from the row it claimed. Put the block under a board's workers where an inline worker would go:

import { defineFlow, dispatcher } from "@flow-state-dev/core";
import { taskBoard } from "@flow-state-dev/orchestration/task-board";

const board = taskBoard({
  name: "issue-work",
  boardId: "issue-work",
  collection: issues,                          // a defineTaskCollection()
  workers: {
    triage: triageWorker,                        // runs inline, in the drain
    implement: dispatcher({                      // hands off
      name: "hand-off-implement",
      action: "implement",                       // flow.task.actions.implement
      session: "per-task",
    }),
  },
});

export default defineFlow({
  kind: "issues",
  actions: { drain: { block: board.drain } },
  task: { actions: { implement: { block: implementWorker } } },  // what runs in the child session
})();

The block returns a DispatchHandle (dispatchHandleSchema): { sessionId, requestId, adopted }, the session the dispatch runs in, the request it became, and whether a key landed on a child that already existed. It returns once the runtime has accepted the request and does not wait for the work; read the child's progress from that session's own request history.

The same key from a different parent session, user, or tenant is a different child. A caller cannot address another user's child by key. The child's session record carries parentSessionId, topic (the key), and coordinate ("internal:summarize"). An id target must exist, belong to this flow kind, this principal, and this tenant, and not be bound to a different org.

defineFlow checks every block it can reach (sequencer steps, rescue handlers, a generator's tools, the blocks a forEach / forEachSideChain factory declares, and the blocks behind internal and task entries) and throws when a dispatcher names an entry the flow does not declare, naming the block and the address. A target chosen from data is a router over declared dispatchers, not a dynamic string. The one address it cannot check is a cross-flow one, which names an entry on a flow it does not hold.

Dispatching to another flow

An internal or task dispatcher can name a different flow with flowKind. Everything else is the same: the dispatch is fire-and-forget, the session policy still decides where it runs, and the block still returns a DispatchHandle.

const notifyBilling = dispatcher({
  name: "notify-billing",
  flowKind: "billing",                          // resolves on the billing flow
  action: "charge",                             // billing's flow.internal.actions.charge
  inputSchema: z.object({ orderId: z.string() }),
  session: { key: (input) => input.orderId },
});

The address is as declared as any other — a string on the block, not a value computed from data — but defineFlow holds one flow's entry maps and cannot resolve another's. That check moves to run time, against the flows the process has registered, and stays a refusal by name: flow-not-found when no such flow is registered, no-entry when the flow is registered and declares no such entry. Neither retries, queues, nor falls back to the sending flow's own map.

The key child belongs to the flow it was dispatched to: its session record carries that flow's flowKind, its session-state defaults come from that flow's schema, and it roots its own lineage rather than inheriting the sender's — a sharedToLineage resource is addressed by lineage with no flow in the key, so sharing one across flows would put two schemas on one durable cell. Data crosses in the payload, which the entry's own schema validates. Two flows addressed with the same key from the same parent get their own child each.

Getting an answer back is the same three-request shape it is within one flow: the recipient replies with its own { from: true } dispatcher, pointed at the sender's flow. { from: true } supplies the session id from the runtime's stamp and flowKind supplies the flow; a delivery happens only when they agree, and session-not-addressable names it when they do not.

// declared on the billing flow
const confirmToSender = dispatcher({
  name: "confirm-to-sender",
  flowKind: "orders",
  action: "confirm",
  inputSchema: z.object({ orderId: z.string() }),
  session: { from: true },
});

Cross-flow addressing resolves by flow instance id (flowKind on the block carries it: a singleton's kind, or a collection member's own id such as "review-east"; a collection's bare kind is flow-not-found), and both flows have to be registered in the same process — a flow served by some other host is flow-not-found, not a network hop. A task dispatcher may take flowKind the same way. See Background work.

At run time a refused dispatch throws DispatchRefusedError (code: "dispatch-refused"), carrying blockName, address, detail, and refused:

| refused | Meaning | |---|---| | no-entry | The addressed flow declares no entry at (type, action). | | flow-not-found | A flowKind names a flow instance this process has not registered. | | session-not-found | An id names a session that does not exist, or that belongs to another principal or another tenant. | | session-not-addressable | An id (or { from: true }) names a session on a flow other than the one addressed, or one bound to a different org. | | key-occupied | A key derived a child id already held by a record that is not this request's child. | | no-dispatch-operation | This process executes requests but was not wired to dispatch one. | | dispatch-rejected | The host refused before starting, such as a reject concurrency policy whose key is held. | | external-dispatcher | An id target on a host whose dispatcher runs requests in another process (a queue adapter); delivery into an existing session is refused there. |

Every refusal is decided before anything is dispatched, so a .rescue() on the dispatcher can branch on refused knowing nothing started. A key or id function that returns an empty string throws a plain Error naming the block. On a context no runtime wired (a hand-built test context), the block