State Targets and Parents
Targets give a block typed access to the state of named ancestor blocks in the execution tree. A block running inside a sequencer can reach up and read or write the sequencer's state — without knowing exactly where in the flow it lives.
This is a power-user surface. Most flows don't need it. When you do — typically a leaf block that reports progress to an enclosing sequencer, or a worker that writes findings back to a coordinator — targetStateSchemas is what you reach for.
targetStateSchemas
Add targetStateSchemas to any handler, generator, or router config:
const progressReporter = handler({
name: "progress-reporter",
inputSchema: z.object({ step: z.number(), total: z.number() }),
outputSchema: z.number(),
targetStateSchemas: {
research: z.object({ progress: z.number() }),
},
execute: async (input, ctx) => {
const pct = Math.round((input.step / input.total) * 100);
// ctx.targets.research is StateRef<{ progress: number }> | undefined
await ctx.targets.research?.patchState({ progress: pct });
return pct;
},
});
Each entry in targetStateSchemas declares:
- The name of the ancestor block (its
nameconfig field). - The partial state schema this block expects to read or write on that ancestor.
The framework infers ctx.targets.<name> as StateRef<...> | undefined. All the standard scope state operations are available on the ref: state for reads, patchState, incState, pushState, setStateRecord, deleteStateRecord, atomicState.
Why ?.
Target handles are always | undefined. If the block runs outside the expected topology — in a test, in a different flow, or with the ancestor not yet executed — the target may not exist. Guard every access:
// Read
const progress = ctx.targets.research?.state.progress ?? 0;
// Write
await ctx.targets.research?.patchState({ progress: 75 });
The undefined handle is by design. Blocks declare what they want via targetStateSchemas; the framework provides what's actually available at runtime. This keeps blocks reusable across topologies — a progress reporter can run inside a research pipeline or standalone, and it degrades gracefully when no target is present.
Resolution: siblings first, then ancestors
ctx.targets.<name> and the dynamic ctx.getTarget(name) resolve in two passes:
- Siblings — already-dispatched blocks at the current execution level, most-recent dispatch wins. This is how a later block in a sequencer finds an earlier one.
- Ancestors — walks up the parent execution chain. This is how a deeply nested block finds an enclosing sequencer or a block from an outer sequence.
Returns undefined if no block with that name exists in either pass.
Ambiguity errors
If multiple ancestors share the same name, the framework throws AmbiguousBlockNameError:
throw new AmbiguousBlockNameError(
`getTarget("research") is ambiguous from block instance "process-chunk[3]". Matching instances: research[1], research[7]`
);
This forces you to be explicit. The fix is usually to give one of the blocks a more specific name — a duplicate-named ancestor chain is almost always a sign that the topology has drifted and someone needs to disambiguate.
Ambiguity is intentional safety
The framework refuses to guess which ancestor you mean. In a deeply nested pipeline, picking "the closest one" silently is a bug factory — a refactor that adds an outer sequencer with the same name would change behavior with no test failure to catch it. Failing loudly at the first cross-block read is the right tradeoff.
ctx.targets vs ctx.sequencer
Both reach into ancestor state. Different use cases:
ctx.sequencer | ctx.targets.<name> | |
|---|---|---|
| What it points to | Nearest enclosing sequencer | Specific named ancestor |
| Typing | Inferred from the sequencer's stateSchema | Inferred from targetStateSchemas entry |
| Use case | Access the direct parent pipeline | Cross-sequencer coordination |
Use ctx.sequencer when a block cooperates with its immediate parent — a chain of steps sharing per-run state. Use ctx.targets.<name> when a block needs to communicate with a specific ancestor, possibly across multiple nesting levels.
Dynamic access via ctx.getTarget
When you don't know the target name at compile time, use ctx.getTarget:
const dynamic = ctx.getTarget<{ progress: number }>("some-block");
await dynamic?.patchState({ progress: 50 });
getTarget is the runtime escape hatch:
- It accepts an optional type parameter for ergonomic casting.
- It uses the same sibling-then-ancestor resolution as
ctx.targets.<name>. - It throws
AmbiguousBlockNameErroron the same conditions.
For well-known relationships, prefer targetStateSchemas — the type flows through without a manual cast, and the block self-documents which ancestors it expects. getTarget is the right tool when the target name is computed (a routing key, a configuration value) or when writing generic utilities.
A worked example
A research pipeline reports progress to its outermost sequencer from a deeply nested chunk processor:
import { handler, sequencer } from "@flow-state-dev/core";
import { z } from "zod";
// Outer sequencer carries progress state on its own instance state.
const researchPipeline = sequencer({
name: "research-pipeline",
inputSchema: z.object({ query: z.string() }),
stateSchema: z.object({ progress: z.number().default(0) }),
});
// Inner block declares which ancestor it needs.
const processChunk = handler({
name: "process-chunk",
inputSchema: z.object({ chunk: z.string(), total: z.number(), index: z.number() }),
outputSchema: z.string(),
targetStateSchemas: {
"research-pipeline": z.object({ progress: z.number() }),
},
execute: async (input, ctx) => {
const pct = Math.round(((input.index + 1) / input.total) * 100);
// Fully typed: StateRef<{ progress: number }> | undefined
await ctx.targets["research-pipeline"]?.patchState({ progress: pct });
return `processed:${input.chunk}`;
},
});
// Nested sequencer uses the reporter.
const chunkProcessor = sequencer({ name: "chunk-processor" })
.then(splitIntoChunks)
.forEach(processChunk, { maxConcurrency: 3 });
// Wire into the outer pipeline.
researchPipeline
.then(fetchSources)
.then(chunkProcessor) // process-chunk inside reaches up to research-pipeline
.then(synthesize);
The point: processChunk doesn't know how many sequencers wrap it, only that an ancestor named "research-pipeline" carries a { progress: number } shape. It works regardless of nesting depth, and the type system catches schema drift on either side.
Where to next
- State Operations — the full operation reference shared by every scope and target.
- Sequencer State — the per-execution scope
ctx.sequencerpoints to. - Blocks — block configuration, including the
targetStateSchemasfield.