@flow-state-dev/core
v0.2.0
Published
Isomorphic builders, type contracts, and item taxonomy for the flow-state-dev block framework.
Maintainers
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/coreWhat 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 schemaA 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 blockgenerator(config)— LLM call with framework-managed tool loop, streaming, and structured output repair (deterministicjsonrepairthen LLM coercion that reshapes off-schema output to the schema; on by default, configured viarepair.coerce/repair.coerce.model, defaulting tointent/utility)- Provider-native web search: set
search: true(or aGeneratorSearchConfig). This is the model provider's built-in search, distinct from the@flow-state-dev/toolstools.searchtool — the toolstierknob does not apply, and the generator'ssearchDepth("low" | "medium" | "high", OpenAIsearchContextSize) is a different field from the toolssearchDepth("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 withrunOnce), one approval gate per model turn (first-suspension-wins), and a gated tool can't becacheable(the cache short-circuits before the tool body). See Generator and router suspend/resume.
- Provider-native web search: set
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) => blockin place of a block. A block built that way does not exist whendefineFlowwalks the graph, so declare what the factory can produce withblocks: [...]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 throwsRouteUnavailableErroron a mismatch — and completed work inside the branch replays instead of re-executing. A router whose branch can suspend needs a pureexecuteselector (no side effects, no ambient state reads); see Control-flow determinismdispatcher(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, anddispatcher()
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 atool_outputitem 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:
defineFlow(definition)— Create a flow type with actions,internalandtaskentries, scopes, resources, and per-scopeclientblocks. See Dispatches, entries, anddispatcher()
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 fordistill,denoise, orcompresscontext 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 focusobjectivesand a default{ summary, keyPoints? }output contractutility.decomposer(config)— Generator factory that breaks broad requests into executable tasks using a default{ tasks: [{ id, goal, deps?, priority? }] }output contractutility.analyzer(config)— Generator factory for artifact critique/evaluation with configurablecriteriaand a default{ findings, score?, recommendation? }output contractutility.combiner(config)— Handler factory for deterministic artifact merging via concatenation, deduplication, and structural normalization with default{ combined, mergeNotes? }outpututility.intentClassifier(config)— Generator factory for bounded intent classification with required category descriptions and default{ category, confidence, reasoning? }output contractutility.intentRouter(config)— Sequencer factory that composesintentClassifier+routerinto classification-driven branching with category descriptions, handlers, optionalconfidenceThreshold, and optional fallback routingutility.keyedRouter(config)— Router factory for the "pick a block from aRecord<string, Block>by string key" case. Throws with the registered keys (or routes tofallback) 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 (type∈fact | 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. Requiresscope: "session" | "user" | "org". Register on a flow's or block'sresourcesmap.- Supports optional
content/contentFile(mutually exclusive),render,llmReadable, andllmWritablefor resource content workflows.contentFileand file-pathcontentTemplateaccept a bare string (resolved from the working directory) or anAnchoredPath—{ path, importerUrl: import.meta.url }— resolved relative to the declaring module first, with a working-directory fallback stateSchemamay 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 withValidationErrorrather 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 itsref.stategetter is synchronous. Declaring'lazy'on a flow-level single resource throws at build time (no per-block load trigger).sharedToLineage?: boolean(defaultfalse,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 byexpectedVersion.trueatuser/orgscope throws at build time (those scopes already span every session the principal touches). Also accepted bydefineResourceCollection, applying to every instance. NamedsharedToWorkstreambefore 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 aResourceChangepayload (key,ref,kind,state,prevState,evicted), typed withresourceChangeSchema(stateSchema). AcontentUpdatedbinding runs after a server-side content write with a minimalResourceContentChangepayload (key,ref,kind), typed withresourceContentChangeSchema(); the blockreadContent()s for the fresh body. The block runs blocking inside the originating turn. See the Reactive blocks reference.- Runtime
ResourceRefprovidesstate(sync getter) pluspatchState,setState,updateState,incState/pushState(below), andgetOrPatchState(key, compute)— get-or-compute over state: returnsstate[key]if present, else runscompute, patches the result underkey, 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 storednullis a hit; acomputeresolving toundefinedstores 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 onecompute, 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 tovoid; read the result offref.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 toTStateon aResourceRef<TState>/ResourceContext<TState>whose state type is written out — number fields forincState, array fields and their element type forpushState. A handle read offctx.resources.<name>isResourceRef<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, throwsFlowError(code: "resource_delta_refused",retryable: false) and leaves the stored value alone. A multi-fieldincStateapplies wholly or not at all. An absent ornullfield is that field's empty state, not a wrong kind of value — it starts from0/[].incStatealso refuses a result that is not finite, since two finite operands can overflow toInfinity, whichz.number()accepts but the stores disagree about how to persist. Both honorwritable: false, and a delta that commits is validated againststateSchemalike 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 (orundefinedif none completed).withOutcometakes any mutation runner —ref.updateState, a scope'satomicState, or your own wrapper — so one helper covers every retry entry point.scripts/validate-updater-purity.mjs(inpnpm typecheck) rejects the common outward-write forms as a backstop — it catches the naive shapes, not every possible one.
- Supports optional
defineResourceCollection(config)— Dynamic resource collection with pattern-based keys (files/*,files/**,[topic]/observations), requiredscope: "session" | "user" | "org", optionalmaxInstances/eviction, lifecycle hooks, andreactTo(same{ created?, stateUpdated?, deleted?, contentUpdated? }shape asdefineResource; supersedes theonInstance*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/countall return Promises (alwaysawaitthem), and the mutationscreate/getOrCreate/upsert/deletewere already async. FlippingprefetchModeneeds no call-site changes.'lazy'requireseviction: 'none'(a partial cache can't drive eviction) and throws at build time otherwise.- Runtime
ResourceCollectionRefprovidescreate(),get(),getOrCreate(),upsert(),list(),delete(),count() create(key, initial, { replace: true })— overwrites an existing instance instead of throwing.setStatesemantics; Zod.default(null)fills nullables on both the create and replace branches.maxInstancesonly checked when adding a new instance. Use for setup/reset paths. Awritable: falsecollection refuses that overwrite the same waysetStatedoes; a missing key creates.upsert(key, update, createOnly?)— patch-or-create. On exists: appliesupdateviapatchStatesemantics (other fields preserved). On missing: creates with{ ...createOnly, ...update }(update wins on overlap). ThecreateOnlyextras 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:
createthrows /create({ replace })replaces /getOrCreatereturns as-is /upsertpatches — all four create on missing; replace-on-existing anddeleterefuse whenwritable: 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 viauses: [cap]and the framework merges everything transitively.fns: (ctx) => ({ ... })— Helper functions exposed atctx.cap.{name}.{fn}, memoized on first accesspresets— Named opt-in/opt-out bundles of any block config surface. Use.presets({ name: true/false })to configureconfig: { 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/.presetsremain the underlying primitives; a preset name colliding with a config field is adefineCapability()erroruses— Capabilities can depend on other capabilities (transitive composition with diamond dedup)- Preset tool slots (generator-only):
toolsis a grant a consuming generator can withhold — a generator that declares its owntools:gets that list and nothing else.controlToolsreaches 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 declaringcacheableserves 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 customkeyFns 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)clienton scope configs — Per-scope client view:expose: string[](verbatim passthrough by field name) andderived: { name: fn }(compute functions receive{ state, resources }). State without aclientblock is private. The formerclientDatakey on scope configs has been removed —defineFlowthrows 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.sectiontakes a string title (default##) or{ title, level }to nest under another section;tablerenders an array of records as a Markdown table. The samekeyValues/list/tableshapes are auto-registered asfsd_*filters inside.mdprompt 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 (.parallelfans out blocks, not in-handler async).xmlTag(name, content),renderTaggedContext(tagged, order)— XML tag rendering used by object-form generator contextvalidateTagName(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 typedinputin the callback.
Type helpers:
StateOf<T>— Extract state type from schema or resourceContextOf<T, Kind>— Get context handle type for scope/resourceResourceContext<T>— Resource context typeBlockInput<T>/BlockOutput<T>— Infer block I/O typesBlockDefinition— The fully-typed return interface ofhandler(),generator(),sequencer(), androuter(). Generics default toZodTypeAny, so unparameterizedBlockDefinitionis 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 onblock.kind.BlockContext— The full block-context interface (the type ofctxinexecute). Generic over the four scope-state types, declared resources, sequencer state, and parent input.BlockResult<TOutput>— The handlerexecutereturn-value union.SessionScopeHandle<TState>/UserScopeHandle<TState>/OrgScopeHandle<TState>/RequestScopeHandle<TState>— The scope handlesctx.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 forBlockContext: typed on session scope, permissive on resources. Use for helper functions that take a block'sctxas a parameter. The fullBlockContext'sTResourcesgeneric is invariant onResourceRegistry, so a handler's narrow inferredResourceRegistry<{ memos: ... }>can't widen to aBlockContext's default.LooseBlockContextsidesteps that by leavingresourcespermissive — 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
PromptFilestraight topromptinstead of spreadingdefinePromptFile(pf).generator({ prompt: loadPromptFile(...), model })expands the file'suser/caching/maxTokens/temperature/name/descriptioninto the config; any sibling field you set explicitly wins (same precedence as...definePromptFile(pf), <overrides>). createPromptLoader(baseDir, options?)(Node subpath) captures an absolutebaseDirplus sharedpartialsDir/filtersonce and returns aload(relPath)function, so call sites drop the repeatedimport.meta.urlargument. Per-callfiltersmerge 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:
createCompositeVoiceProviderbuilds 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, theblockPath*builders, and theModelIdentity/SuspensionReason/SuspensionStatus/RequestStatusleaf types) now live in the zero-dependency@flow-state-dev/contractspackage and are re-exported from these same@flow-state-dev/corepaths. Import them fromcoreexactly as before — nothing changes for consumers. Browser packages can value-import the canonical helpers fromcontractswithout 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 writtenSuspension 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/contractsand are re-exported from these same@flow-state-dev/core/helperspaths. Import them fromcoreexactly as before; browser packages can value-import them fromcontractswithout core's heavy runtime.
cloneValue(value)— structural deep copy via the platformstructuredClone, 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 inoverridereplace; nested plain objects merge;baseis never mutated.deepEqual(a, b)— structural equality powering the state-write no-op guard. Primitives compared byObject.is(NaN-equal-NaN,+0 != -0); plain objects and arrays compared recursively. Rejects non-JSON shapes (Map, Set, functions) with aTypeError.looseDeepEqualis the throw-free variant.toError(value, fallback?)— coerce an unknown value toError. AnErroris returned as-is. A non-empty string becomesnew Error(value). Anything else, including""and objects with amessage, becomesnew Error(fallback).fallbackdefaults 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-positivetimeoutMsmeans no deadline and arms no timer — the same threescope-lockdisables on, andInfinityis guarded rather than passed through because Node coercessetTimeout(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 anAbortSignalwhen the work itself is cancellable. PassonTimeoutto reject with your own error type instead of a plainError. It lives here rather than incontractsbecause 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_changeitems on the SSE stream. - Do not appear in
state_snapshotpayloads, 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
