Skip to main content

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 name config 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:

  1. 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.
  2. 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.sequencerctx.targets.<name>
What it points toNearest enclosing sequencerSpecific named ancestor
TypingInferred from the sequencer's stateSchemaInferred from targetStateSchemas entry
Use caseAccess the direct parent pipelineCross-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 AmbiguousBlockNameError on 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.sequencer points to.
  • Blocks — block configuration, including the targetStateSchemas field.