mastra-ai/mastra · error · Error
${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" can
Error message
${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" cannot be stored: option "${key}" is a ${hint} that does not round-trip. Remove it or move that logic outside the persisted workflow. What it means
Agent and tool step entries carry an options bag; only JSON-safe options (`retries`, `metadata`) round-trip through storage. If any of the forbidden options — `onFinish`, `onChunk`, `onError`, `onStepFinish`, `onAbort`, or a function-valued `toolChoice` — is a function (callback closure), `pickSerializableStepOptions` throws, because the callback would silently disappear after save/reload.
Source
Thrown at packages/core/src/workflows/dynamic/serialize.ts:221
entryId: string,
kind: 'agent' | 'tool',
): SerializedStepOptions | undefined {
if (!options || typeof options !== 'object') return undefined;
// Closure-valued options don't round-trip. Fail loudly at serialize time so
// the workflow author immediately learns their step won't persist rather
// than discovering it in production when the callback silently no-ops.
const forbidden: Array<{ key: string; hint: string }> = [
{ key: 'onFinish', hint: 'callback closure' },
{ key: 'onChunk', hint: 'callback closure' },
{ key: 'onError', hint: 'callback closure' },
{ key: 'onStepFinish', hint: 'callback closure' },
{ key: 'onAbort', hint: 'callback closure' },
{ key: 'toolChoice', hint: 'may be a function' },
];
for (const { key, hint } of forbidden) {
if (typeof options[key] === 'function') {
throw new Error(
`${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" cannot be stored: option "${key}" is a ${hint} that does not round-trip. Remove it or move that logic outside the persisted workflow.`,
);
}
}
if (typeof options.scorers === 'function') {
throw new Error(
`${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" cannot be stored: "scorers" is a function; only the static array form round-trips.`,
);
}
const out: SerializedStepOptions = {};
if (typeof options.retries === 'number') out.retries = options.retries;
if (options.metadata && typeof options.metadata === 'object') {
out.metadata = options.metadata as Record<string, any>;
}
return Object.keys(out).length > 0 ? out : undefined;
}
View on GitHub (pinned to 75dd419e61)
Solutions
- Remove the function-valued option from the step's options before persisting.
- Move callback logic outside the persisted workflow: run it in the caller via run-level events, or place it in a wrapper plain step.
- Replace function `toolChoice` with the static form (a named tool or fixed choice).
- Pre-validate: `['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof options[k] !== 'function')`.
Example fix
// before
agent('myAgent', { onFinish: (res) => log(res), retries: 2 })
// after
agent('myAgent', { retries: 2 }) // log via run-level onFinish in the host app Defensive patterns
Strategy: validation
Validate before calling
const FORBIDDEN = ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'];
for (const e of stepFlow) {
if (e.type === 'agent' || e.type === 'tool') {
for (const k of FORBIDDEN) {
if (e.options && typeof e.options[k] === 'function') throw new Error(`${e.type} "${e.id}" option "${k}" is a function`);
}
}
} Type guard
const hasSerializableOptions = (e: StepFlowEntry): boolean => !(e.type === 'agent' || e.type === 'tool') || !e.options || ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof (e.options as any)[k] !== 'function');
Try / catch
try {
storable = toStorableGraph(stepFlow);
} catch (e) {
if (/option ".*" is a .* that does not round-trip/.test(e.message)) {
// strip or relocate the callback named in the message, then retry
} else throw e;
} Prevention
- Keep persisted step options limited to retries and metadata.
- Handle streaming/lifecycle callbacks at run level in the host app, not in step options.
- Share a validated options builder for agent/tool steps so closures never sneak in.
When it happens
Trigger: Persisting a workflow whose `.agent(...)` or `.tool(...)` entry options include streaming/lifecycle callbacks (e.g. `onFinish: (result) => ...`, `onStepFinish`, `onChunk`, `onError`, `onAbort`) or `toolChoice` given as a function.
Common situations: Authors copy agent-generation options (callbacks for streaming UIs, per-step logging, abort handling) into workflow steps; those callbacks work live but break persistence. Common when reusing the same options object for direct generation and workflow steps.
Related errors
- SleepUntil step "${entry.id}" cannot be stored: dynamic date
- Conditional (branch) step cannot be stored: closure predicat
- Loop step "${getSingleStepEntryId(entry.step)}" cannot be st
- Mapping step "${entry.id}" cannot be stored: the function fo
- Mapping step "${entry.id}" key "${key}" cannot be stored: so
AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30).
Data as JSON: /api/errors/c4fac9447dc7ba21.
Report an issue: GitHub.