Connectors
Blocks have typed inputs and outputs. When adjacent steps don't match, you need a transform. Connectors are lightweight functions that sit between blocks and reshape data. They work across the sequencer DSL: .step(), .stepIf(), .parallel(), and more.
Connector functions
A connector receives the previous step's output and the block context, and returns the shape the next block expects:
const pipeline = sequencer({ name: "pipeline", inputSchema: inputSchema })
.step(blockA)
.step(
(output, ctx) => ({ query: output.text, limit: 10 }),
searchBlock
);
Here, blockA produces { text: string }. searchBlock expects { query: string, limit: number }. The connector bridges the gap.
Connectors in sequencer methods
step and stepIf
.step(connector, block)
.stepIf(condition, connector, block)
The connector runs before the block. Its return value is the block's input.
parallel
Parallel steps can use connectors when each branch needs different input shapes:
.parallel({
summary: summaryBlock,
tags: { connector: (input) => input.text, block: tagBlock },
metrics: {
connector: (input) => ({ docId: input.id, version: input.version }),
block: metricsBlock,
},
})
Steps without a connector receive the pipeline value directly. Steps with { connector, block } receive the connector output.
forEach
Use a connector to extract the array before iterating:
.forEach(
(output) => output.items,
processItemBlock,
{ maxConcurrency: 5 }
)
The connector returns the items. Each item is passed to the block.
Block-level connections
You can attach transforms directly to a block with connectInput and connectOutput:
const searchFromText = searchBlock.connectInput(
(text: string) => ({ query: text, limit: 10 })
);
pipeline.step(searchFromText);
The block always receives the adapted input. Useful when you reuse the same block in multiple pipelines with different upstream shapes.
connectOutput transforms the block's output before it reaches the next step:
const adapted = someBlock.connectOutput((out) => ({
...out,
normalized: out.raw.trim(),
}));
Model-visible tool output: mapModelOutput
When a block is installed as a tool on a generator, its output flows to two consumers: the LLM (as the tool result it sees on the next turn) and the framework's observation surfaces (tool_output items, devtool, replay, tests). For tools with rich structured envelopes, that's a bad tradeoff — fields the LLM can't reason about cost tokens, and trimming the result strips inspection value.
mapModelOutput declares a separate, model-visible representation. The structured TOutput keeps flowing through the framework. The mapper only fires at the AI SDK bridge boundary, producing the value the LLM observes.
const recallTool = recallSeq.mapModelOutput((result) => {
if ('error' in result) return `Recall failed: ${result.error}`
if (result.results.length === 0) return `No memories matched "${result.query}".`
return result.results.map((r) => `- ${r.content}`).join('\n')
})
The mapper returns a string. Both TInputSchema and TOutputSchema are preserved — downstream sequencer steps and tool_output items still see the full structured envelope.
How it differs from connectOutput
connectOutput | mapModelOutput | |
|---|---|---|
Rewrites TOutputSchema | yes | no |
| Visible to downstream block consumers | yes | no |
Visible in tool_output items | yes | no |
| Fires when the block is used as a non-tool step | yes | no — silently inert |
| Fires when the block is installed as a tool | yes | yes, at the bridge boundary |
connectOutput reshapes data flowing into downstream consumers. mapModelOutput is scoped to a single boundary — the AI SDK tool-result content the LLM sees — and preserves every existing type and observation contract.
The mapper is expected to be deterministic: history replay re-runs it on the persisted structured output rather than persisting the string itself.
Devtool inspection
When a tool block declares mapModelOutput, the model-visible string flows to the AI SDK via toModelOutput on the tool entry. The structured value lives on the regular tool_output item. Devtool reads both, so you can see what the LLM saw next to what the block actually produced. Gated by FSDEV_TRACE_OBSERVABILITY (on by default in dev/test, off in production).
State handoffs
Connectors can read from scope state or sequencer state when shaping input:
.step(
(output, ctx) => ({
query: output.text,
userId: ctx.user.identity.id,
preferences: ctx.user.state.preferences,
}),
personalizedSearch
)
Scope state (session, user, org) and sequencer state are available in the block context. Use them when the next block needs contextual data.
Why this matters for portability
Community or shared blocks often have fixed input shapes. Your pipeline may produce something different. Connectors let you adapt without wrapper blocks:
// Community block expects { query: string, limit: number }
import { communitySearchBlock } from "@vendor/search";
pipeline.step(
(output) => ({ query: output.userMessage, limit: 5 }),
communitySearchBlock
);
One-line connector. No new block, no inheritance. The block stays portable; the pipeline does the adaptation.
Type inference through connectors
TypeScript infers types through the chain. The connector's return type must match the next block's input schema. Mismatches surface at compile time:
// searchBlock expects { query: string }
.step(
(output) => ({ query: output.text }), // ✓
searchBlock
);
// This would error: missing required 'query'
.step(
(output) => ({ limit: 10 }),
searchBlock
);
Realistic examples
Reshaping for a generator
const agent = generator({
name: "agent",
inputSchema: z.object({ prompt: z.string(), context: z.array(z.string()) }),
user: (input) => input.prompt,
context: (input) => input.context,
});
const researchPipeline = sequencer({ name: "research" })
.step(searchBlock)
.step(
(results) => ({
prompt: `Summarize these findings: ${results.summary}`,
context: results.snippets,
}),
agent
);
Parallel with different shapes
.parallel({
sentiment: sentimentBlock,
entities: {
connector: (input) => input.entities,
block: entityExtractor,
},
summary: {
connector: (input) => ({ text: input.body, maxLength: 200 }),
block: summarizeBlock,
},
})
Conditional connector
.stepIf(
(output) => output.needsEnrichment,
(output, ctx) => ({
...output,
userContext: ctx.user.state.recentTopics,
}),
enrichBlock
)