processOutputResult runs on stream(), and the raw text already reached the reader
A Mastra output processor that implements only processOutputResult does run on agent.stream(), but it runs after every chunk has been delivered. The redaction lands on result.text and never reaches the reader. Measured on @mastra/core 1.72.0 with no API key.
On this page
Quick answer
As of September 2026, a Mastra output processor that implements only processOutputResult does run when you call agent.stream(), but it runs after every chunk has already been handed to whoever is reading the stream. The value you meant to redact is on the client. result.text is clean.
That last sentence is the dangerous part. The hook is not skipped, so it does not look broken. result.text comes back redacted, so a test that asserts on result.text passes. Meanwhile the reader consuming textStream already had the whole reply, the last chunk landing a full twenty-eight milliseconds before the processor ran.
I measured it on @mastra/core 1.72.0 against a local fake model server, with no API key of any kind. Three things fall out of it:
agent.generate()is fine. Same processor, same agent, same model. Only the call method differs.- The obvious fix, moving the same logic into
processOutputStream, is also wrong if you write it the way the Mastra docs write it, because a token that straddles two chunks never appears whole in any single chunk. - Every built-in Mastra processor that redacts anything implements both hooks. Nothing tells you that you have to.
What you need
Node.js 24, and a scratch directory. No API key, no account, no network calls to a model provider.
mkdir mastra-processor-lab && cd mastra-processor-lab
npm init -y
npm install --save-exact @mastra/core@1.72.0 zod@4.1.13
zod is a peer dependency of @mastra/core; version 4 satisfies it.
a fake model server, so nothing here needs a key
Every measurement below runs against this. It is stateless, it answers both the streaming and the non-streaming shape of the OpenAI chat API, and it always returns the same sentence with a card number in the middle. It splits the streamed reply into twelve character pieces, which is what puts the card number across a chunk boundary later on.
// fake-model-server.js
// Stateless fake OpenAI-compatible chat server. No API key of any kind.
const http = require('http');
const VERSION = 'fake-model-server v1';
const REPLY = 'Customer card on file is 4111 1111 1111 1111 and the account is active.';
const CHUNKS = REPLY.match(/[\s\S]{1,12}/g);
const server = http.createServer(function (req, res) {
let body = '';
req.on('data', function (d) { body += d; });
req.on('end', function () {
let parsed = {};
try { parsed = JSON.parse(body || '{}'); } catch (e) { parsed = {}; }
const base = { id: 'chatcmpl-fake', object: 'chat.completion.chunk', created: 1700000000, model: 'fake-model' };
if (parsed.stream !== true) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: 'chatcmpl-fake', object: 'chat.completion', created: 1700000000, model: 'fake-model',
choices: [{ index: 0, message: { role: 'assistant', content: REPLY }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
}));
return;
}
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
function send(obj) { res.write('data: ' + JSON.stringify(obj) + '\n\n'); }
send(Object.assign({}, base, { choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }));
let i = 0;
const timer = setInterval(function () {
if (i !== CHUNKS.length) {
send(Object.assign({}, base, { choices: [{ index: 0, delta: { content: CHUNKS[i] }, finish_reason: null }] }));
i += 1;
return;
}
clearInterval(timer);
send(Object.assign({}, base, { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } }));
res.write('data: [DONE]\n\n');
res.end();
}, 8);
});
});
server.listen(8731, '127.0.0.1', function () { console.log(VERSION + ' listening on 8731'); });
Run it in its own terminal:
node fake-model-server.js
the same processor, on generate() and on stream()
One processor. It implements processOutputResult only, which is the hook the Mastra docs reach for first, and it replaces the card number with a marker. One agent shape, two call methods.
// probe-generate-vs-stream.mjs
import { Agent } from '@mastra/core/agent';
const SECRET = '4111 1111 1111 1111';
const model = { id: 'fake/fake-model', url: 'http://127.0.0.1:8731', apiKey: 'not-a-real-key', api: 'chat' };
function makeRedactor() {
const calls = [];
const processor = {
id: 'redactor',
name: 'redactor',
processOutputResult({ messages }) {
calls.push(1);
for (const m of messages) {
const parts = (m.content && m.content.parts) || [];
for (const p of parts) {
if (p.type === 'text' && typeof p.text === 'string') {
p.text = p.text.split(SECRET).join('[REDACTED]');
}
}
}
return messages;
}
};
return { calls, processor };
}
const r1 = makeRedactor();
const a1 = new Agent({ id: 'a1', name: 'a1', instructions: 'be brief', model, outputProcessors: [r1.processor] });
const gen = await a1.generate('show the card');
console.log('generate() processOutputResult calls : ' + r1.calls.length);
console.log('generate() text : ' + JSON.stringify(gen.text));
console.log('generate() contains raw card : ' + String(gen.text.includes(SECRET)));
console.log('');
const r2 = makeRedactor();
const a2 = new Agent({ id: 'a2', name: 'a2', instructions: 'be brief', model, outputProcessors: [r2.processor] });
const res = await a2.stream('show the card');
const seen = [];
for await (const piece of res.textStream) seen.push(piece);
const consumerSaw = seen.join('');
const finalText = await res.text;
console.log('stream() processOutputResult calls : ' + r2.calls.length);
console.log('stream() what the consumer read : ' + JSON.stringify(consumerSaw));
console.log('stream() consumer got raw card : ' + String(consumerSaw.includes(SECRET)));
console.log('stream() result.text : ' + JSON.stringify(finalText));
console.log('stream() result.text raw card : ' + String(finalText.includes(SECRET)));
generate() processOutputResult calls : 1
generate() text : "Customer card on file is [REDACTED] and the account is active."
generate() contains raw card : false
stream() processOutputResult calls : 1
stream() what the consumer read : "Customer card on file is 4111 1111 1111 1111 and the account is active."
stream() consumer got raw card : true
stream() result.text : "Customer card on file is [REDACTED] and the account is active."
stream() result.text raw card : false
Read the middle two lines of the stream block together. processOutputResult calls : 1 means the hook ran. consumer got raw card : true means it ran too late to matter. And result.text raw card : false is why this survives code review: the object you assert on is clean.
how late is too late
The same processor again, doing nothing but timestamping itself against the chunks as they arrive.
// probe-ordering.mjs
import { Agent } from '@mastra/core/agent';
const model = { id: 'fake/fake-model', url: 'http://127.0.0.1:8731', apiKey: 'not-a-real-key', api: 'chat' };
const log = [];
const t0 = Date.now();
function note(label) { log.push(String(Date.now() - t0).padStart(4) + ' ms ' + label); }
const resultOnly = {
id: 'result-only',
name: 'result-only',
processOutputResult({ messages }) { note('processOutputResult FIRES'); return messages; }
};
const agent = new Agent({ id: 'ord', name: 'ord', instructions: 'be brief', model, outputProcessors: [resultOnly] });
const res = await agent.stream('show the card');
let n = 0;
for await (const piece of res.textStream) {
n += 1;
note('consumer received chunk ' + n + ' ' + JSON.stringify(piece));
}
note('textStream ended');
await res.text;
note('await result.text resolved');
for (const line of log) console.log(line);
82 ms consumer received chunk 1 "Customer car"
85 ms consumer received chunk 2 "d on file is"
94 ms consumer received chunk 3 " 4111 1111 1"
102 ms consumer received chunk 4 "111 1111 and"
109 ms consumer received chunk 5 " the account"
117 ms consumer received chunk 6 " is active."
145 ms processOutputResult FIRES
149 ms textStream ended
149 ms await result.text resolved
Chunks three and four carry the card number. They reached the consumer at 94 ms and 102 ms. The processor fired at 145 ms. The absolute numbers move a few milliseconds between runs; the gap between the last chunk and the hook held at 28 to 30 ms across four runs, and the ordering never changed. There is no ordering here that a processOutputResult implementation could have won.
why moving it to processOutputStream is not automatically the fix
The Mastra documentation shows a StreamFilter for exactly this job. Its predicate tests one chunk in isolation and drops the chunk if part.payload.text includes the word it is looking for. That is the shape below, run twice against the same stream with two different tokens.
// probe-chunk-boundary.mjs
import { Agent } from '@mastra/core/agent';
const model = { id: 'fake/fake-model', url: 'http://127.0.0.1:8731', apiKey: 'not-a-real-key', api: 'chat' };
// The shape the Mastra docs use for "Filter streamed output":
// test one chunk in isolation, drop it if it contains the token.
function dropChunksContaining(token) {
return {
id: 'drop-' + token.length,
name: 'drop',
processOutputStream({ part }) {
if (part.type === 'text-delta' && part.payload && typeof part.payload.text === 'string') {
if (part.payload.text.includes(token)) return null;
}
return part;
}
};
}
async function run(label, token) {
const agent = new Agent({ id: 'cb', name: 'cb', instructions: 'be brief', model, outputProcessors: [dropChunksContaining(token)] });
const res = await agent.stream('show the card');
const seen = [];
for await (const piece of res.textStream) seen.push(piece);
const joined = seen.join('');
console.log(label);
console.log(' token : ' + JSON.stringify(token));
console.log(' chunks reaching reader: ' + seen.length);
console.log(' consumer read : ' + JSON.stringify(joined));
console.log(' token still present : ' + String(joined.includes(token)));
console.log('');
}
await run('TOKEN THAT FITS INSIDE ONE CHUNK', 'Customer');
await run('TOKEN THAT SPANS TWO CHUNKS', '4111 1111 1111 1111');
TOKEN THAT FITS INSIDE ONE CHUNK
token : "Customer"
chunks reaching reader: 5
consumer read : "d on file is 4111 1111 1111 1111 and the account is active."
token still present : false
TOKEN THAT SPANS TWO CHUNKS
token : "4111 1111 1111 1111"
chunks reaching reader: 6
consumer read : "Customer card on file is 4111 1111 1111 1111 and the account is active."
token still present : true
Same predicate, opposite outcomes. Customer happens to sit inside chunk one, so the whole chunk is dropped, and notice that this is destructive: the reader loses the word and everything else that shared that chunk, which is why the output now begins mid-word. The card number is split across chunks three and four, so no single chunk contains it, and it sails straight through.
A per-chunk includes() test is not a redactor. It is a redactor for tokens short enough and lucky enough to land inside one chunk.
a version that actually holds the line
To test a token that may be split, you have to keep the pieces together. Hold back a tail at least as long as the token, scrub the join, emit only the part that can no longer change, and flush what you held when the text ends.
// probe-fix.mjs
import { Agent } from '@mastra/core/agent';
const SECRET = '4111 1111 1111 1111';
const HOLD = SECRET.length;
const model = { id: 'fake/fake-model', url: 'http://127.0.0.1:8731', apiKey: 'not-a-real-key', api: 'chat' };
// Hold back a tail at least as long as the token, so a token split across
// two chunks is still whole when it is tested. Flush the tail on text-end.
const bufferedRedactor = {
id: 'buffered-redactor',
name: 'buffered-redactor',
processOutputStream({ part, state }) {
if (part.type === 'text-delta' && part.payload && typeof part.payload.text === 'string') {
const carry = typeof state.carry === 'string' ? state.carry : '';
const merged = (carry + part.payload.text).split(SECRET).join('[REDACTED]');
const cut = Math.max(0, merged.length - HOLD);
state.carry = merged.slice(cut);
state.textId = part.payload.id;
const emit = merged.slice(0, cut);
if (emit.length === 0) return null;
part.payload.text = emit;
return part;
}
if (part.type === 'text-end') {
const carry = typeof state.carry === 'string' ? state.carry : '';
state.carry = '';
if (carry.length === 0) return part;
return { type: 'text-delta', runId: part.runId, from: part.from,
payload: { id: state.textId, providerMetadata: undefined, text: carry } };
}
return part;
}
};
const agent = new Agent({ id: 'fix', name: 'fix', instructions: 'be brief', model, outputProcessors: [bufferedRedactor] });
const res = await agent.stream('show the card');
const seen = [];
for await (const piece of res.textStream) seen.push(piece);
const consumerSaw = seen.join('');
console.log('chunks reaching reader : ' + seen.length);
console.log('consumer read : ' + JSON.stringify(consumerSaw));
console.log('consumer got raw card : ' + String(consumerSaw.includes(SECRET)));
console.log('text is complete : ' + String(consumerSaw.endsWith('the account is active.')));
console.log('result.text : ' + JSON.stringify(await res.text));
chunks reaching reader : 6
consumer read : "Customer card on file is [REDACTED] and the account is active."
consumer got raw card : false
text is complete : true
result.text : "Customer card on file is [REDACTED] and the account is active."
Two things to check in that output, not one. consumer got raw card : false is the redaction. text is complete : true is the flush. My first attempt at this held back a tail and never flushed it, so the output was clean and silently lost the tail of the sentence, which is a worse bug than the one I was fixing.
The flush point is the text-end chunk. It carries no text of its own, so the held tail is emitted by returning a text-delta in its place, reusing the text id the deltas were using.
What the built-in processors do
This is the part that convinced me the behaviour is intended rather than broken.
// probe-builtin-hooks.mjs
import * as P from '@mastra/core/processors';
const names = ['PIIDetector', 'ModerationProcessor', 'SystemPromptScrubber', 'RegexFilterProcessor', 'TokenLimiter',
'PromptInjectionDetector', 'UnicodeNormalizer', 'LanguageDetector', 'BatchPartsProcessor'];
const hooks = ['processInput', 'processOutputStream', 'processOutputResult', 'processInputStep', 'processOutputStep'];
const rows = names.map(function (n) {
const C = P[n];
if (typeof C !== 'function') return [n, 'not a class'];
const proto = C.prototype;
const has = hooks.filter(function (h) { return typeof proto[h] === 'function'; });
return [n, has.join(' + ') || 'none'];
});
const width = Math.max.apply(null, rows.map(function (r) { return r[0].length; }));
for (const r of rows) console.log(r[0].padEnd(width) + ' ' + r[1]);
PIIDetector processInput + processOutputStream + processOutputResult
ModerationProcessor processInput + processOutputStream + processOutputResult
SystemPromptScrubber processOutputStream + processOutputResult
RegexFilterProcessor processInput + processOutputStream + processOutputResult
TokenLimiter processInput + processOutputStream + processOutputResult + processInputStep
PromptInjectionDetector processInput
UnicodeNormalizer processInput
LanguageDetector processInput
BatchPartsProcessor processOutputStream
Every built-in that has to suppress something on the way out, PIIDetector, ModerationProcessor, SystemPromptScrubber, RegexFilterProcessor and TokenLimiter, implements both processOutputStream and processOutputResult. The three that only ever inspect input implement neither of them, and BatchPartsProcessor, which only regroups chunks and suppresses nothing, needs only the stream hook.
So the people who wrote Mastra already know one hook is not enough. If you use the built-ins, you are not exposed to any of this. The gap is for the processor you write yourself, and the interface marks both methods optional.
What the documentation says, exactly
I checked this rather than assumed it, because the wording is not consistent.
The Processor interface, verbatim, from the comment above processOutputResult in @mastra/core 1.72.0:
Process the complete output result after streaming/generate is finished
Read that as a custom-processor author and you will conclude the hook covers streaming. It does, literally. It just finishes after the stream has been delivered.
The SystemPromptScrubber reference page, verbatim:
Processes final output results to detect and handle system prompts in non-streaming scenarios
That is the opposite framing, and it is attached to one specific built-in rather than to the interface.
On the main processors guide, the phrase non-streaming appears zero times. What that page does say, verbatim, is that The processOutputStream() method transforms or filters streaming chunks before they reach the client, and that A new state object is created for every new agent.generate() or agent.stream() call. Both are true, and neither tells you that a processOutputResult redactor is a no-op for the reader on the streaming path. The same page opens its output-processor section with a CustomOutputProcessor that implements processOutputResult alone.
What I checked and ruled out
- Is this the fake server? No. The server is stateless and identical for both arms;
agent.generate()redacts correctly against it. The only variable between the two arms in step 2 is the call method. - Is the hook being skipped on the stream path? No, and this matters. It runs exactly once, which is why the failure is quiet.
- Is
result.textalso wrong? No. It is correctly redacted on both paths. That is the trap, not a second bug. - Do the built-ins have this problem? No. All five output-side built-ins implement both hooks.
- Is holding back a fixed-length tail enough in general? It is enough for a fixed token, which is the case measured here. For a pattern of unbounded length you need to hold back until the pattern can no longer match, and the flush at
text-endstill has to happen.
The rule worth keeping
If a processor exists to stop something reaching a user, implementing processOutputResult alone is not a partial defence. On the streaming path it is no defence at all, and it produces a passing assertion while it fails. Implement both hooks, or use the built-in that already does.
Related reading: Mastra rewrites tool keys and toolChoice does not follow, and the AI SDK ships a comparable per-step filter in filterActiveTools if you are on that stack instead. The buffering problem itself is the ordinary streaming one that Node stream backpressure has always had. The processor source lives in the Mastra repository.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Does processOutputResult run at all when I call agent.stream()?
Yes. It runs exactly once, which is why the problem is quiet. The measurement in this article counts the calls: one on generate() and one on stream(). It simply runs after every chunk has been delivered to whoever is reading textStream.
If the hook runs, why does my redaction not reach the user?
Because processOutputResult operates on the finished message list, and on the streaming path that list is assembled after the chunks have already been emitted. In the timed run here the last chunk reached the consumer at 117 ms and the hook fired at 145 ms.
Why does my test still pass?
Because result.text is redacted correctly on both paths. An assertion on result.text passes while the reader of textStream has already received the raw value. Assert on what you actually stream, not only on the final object.
Is this a bug in Mastra?
It is better described as a hook that does less than its name suggests on one code path. Every built-in Mastra processor that suppresses output implements both processOutputStream and processOutputResult, so the framework's own components are not exposed. The gap is in custom processors, where both methods are optional.
Can I just move my logic into processOutputStream?
Only if you handle chunk boundaries. A per-chunk includes() test, which is the shape the Mastra documentation uses, misses any token split across two chunks. This article measures the same predicate passing for a token inside one chunk and failing for a card number spread over two.
What does a correct streaming redactor look like?
Hold back a tail at least as long as the token you are matching, scrub the joined text, emit only the part that can no longer change, and flush the held tail when the text-end chunk arrives. Skipping the flush produces clean but truncated output.
Do I need an API key to reproduce this?
No. Everything here runs against a local stateless fake server that speaks the OpenAI chat shape. Mastra accepts an OpenAI compatible model config with a url field, so no provider is ever contacted.
Does agent.generate() have the same problem?
No. On generate() there is no incremental delivery, so processOutputResult runs before anything is handed back and the redaction is effective. The call method is the only variable between the two arms.
Related tutorials
Mastra rewrites tool object keys, and toolChoice does not follow
Mastra documents that the object key, not the tool id, becomes the name the model sees. It does not document that the key is rewritten first: invalid characters become underscores and the result is cut to 63. The tools array goes through that rewrite. toolChoice does not.
tool_search finds nothing unless the path is the exact tool name
Measured on @openai/agents 0.18.0: the built-in client-side tool_search loader matches paths against tool names by exact string equality, and a miss returns zero tools with no error and no warning. The failure arrives one turn later as an error that blames the model.
toModelOutput is not called when your tool throws, and the raw error goes to the provider
On ai 7.0.114, whatever your tool execute function throws is serialised and sent to the model provider in a tool_result block marked is_error. The documented toModelOutput hook is skipped on that path. Measured first-party with a local capture server, no API key.