Middleware
A typed chain around every tool dispatch and around the message boundary. Three verbs — allow, deny, ask — and no way to return a result, so the answer the model reads is always the real tool's or a refusal.
Every agent framework lets you wrap a tool call. Most of them let the wrapper answer — return a canned string, a cached value, a "simulated" result — and the moment one does, the trace is fiction. The model was told a tool ran. Nothing ran.
Here the wrapper cannot answer. The outcome union has no arm for a result, so whatever a chain decides, what the model finally reads is the real tool's output or a refusal.
Where they go
Middleware is written into .act(), the one block that says what an agent does at each moment of its loop:
import { Agent, allow, deny, ask } from 'agentfootprint';
Agent.create({ provider, model })
.act({
input: [scrubSSNs], // the message, before the run commits it
beforeTool: [refundCeiling, fourEyes], // every call, before it is dispatched
afterTool: [stripPII], // every result, before the model reads it
output: [noCodenames], // the answer, before the caller gets it
})
.build();The individual doors (.toolMiddleware(), .messageMiddleware()) are still there and still supported — they are what .act() forwards to — and they are the right spelling when you are adding one rule to an agent somebody else built. See Composing incrementally.
Three verbs, one vocabulary
| verb | what it means | where the answer goes |
|---|---|---|
allow() | pass it through | the tool runs |
allow(value, why) | pass it through, changed | the tool runs on value; the ledger records both versions and your why |
allow(undefined, why) | pass it through, and say why you were comfortable | nothing moves; the row carries the reason |
deny(reason) | refuse | the reason reaches the model verbatim as the tool result, and the loop continues |
ask({ question }) | suspend for a person | the run pauses; the answer is a decision, and then the real tool runs |
Order is call order. Each link sees the previous one's output. The first non-allow answer wins and the rest of the chain does not run — a refusal a later rule could overturn is not a refusal.
A tool rule may speak twice: onToolCall about the CALL, and onToolResult about the RESULT once the tool has run. Those two halves are one chain walked in onion order, and the second has its own page section — the after-tool moment — because its laws are different: two verbs, no ask, and it never runs for a call that never executed.
Both spellings appear above, and they name different things. afterTool is a MOMENT — when it happens — which is why .act()'s key and the ledger's 'after-tool' share the word. onToolResult is a HOOK, named for what it receives, which is how it pairs with onToolCall.
Configure none of it and nothing changes: no chain walk, no extra committed key, the same request bytes as an agent that never heard of any of this.
The law: a middleware cannot answer for the tool
This is enforced by absence, not by review. There is no result arm to return:
// Does not compile. There is no spelling of this.
const fabricated: ToolOutcome = { kind: 'result', value: 'I already did it' };It is also what decides what ask resumes with. The human's answer is a decision, not a result: approve and the chain continues from the next link and the REAL tool runs; decline and it becomes a denial the model reads and adapts to. Nobody — not the middleware, not the person who approved it — gets to write the tool's answer.
A person approving is a person approving, so the answer uses the vocabulary check-ins already use. That sharing is deliberate: one word for one thing beats a synonym.
import { checkInApproved, checkInDeclined, isAskPause } from 'agentfootprint';
const out = await agent.run({ message });
if (isAskPause(out)) {
console.log(out.ask.middleware, out.ask.question);
await agent.resume(out.checkpoint, checkInApproved({ by: 'dana@ops' }));
}The checkpoint is JSON. It is the same pause machinery askHuman and checkIn ride — not a second one — which is why it already works everywhere they do, including hosting, where an outstanding question is refused as unfinished work rather than silently dropped.
One human question per resume. footprintjs's resumed dispatch has no second checkpoint to offer, so if a later link also asks — or the tool itself declares checkIn — the call is not executed and a named refusal reaches the model instead. Ask through one gate, not two.
A PII scrub, in eleven lines
/** * A PII scrub, as a message middleware. One plain regex — the seam is the * product, the classifier is yours. * * Placed at `'input'`, it runs BEFORE the message is committed, so the * window strategies, the injections, the request bytes and every slice * taken later all agree about what the user said. Transform any later and * the trace would show one message while the model answered another. */const scrubSSNs: MessageMiddleware = { name: 'scrub-ssns', onMessage: (msg) => { const clean = msg.content.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[ssn]'); return clean === msg.content ? allow() : allow(clean, 'masked a US SSN'); },};The regex is a plain regex on purpose. The seam is the product; the classifier is yours. Put a commercial content filter behind the same onMessage — a managed guardrails service, an in-house model, a DLP endpoint — and nothing else about the wiring changes.
Where 'input' runs, and why it has to be there
At the very top of the run, before the message is committed. Everything downstream reads the committed history: the window strategies, the injection engine, all three slots, the bytes on the wire, and every slice taken afterwards. Transform any later than this and those components disagree about what the user said — the trace would show one message while the model answered another.
That holds on every turn. A continued conversation — run({ message, continueFrom }), followUp(), a stored session behind standingAgent — appends the message the chain let through, so the stored history keeps the rewritten turn, the same string checkpoint().originalInput.message names. (Before 9.112.2 the chain ran on a continued turn but its output was discarded: the model was sent, and the conversation stored, the raw message, while the ledger row and userMessage held the rewrite.)
'output' runs when Route identifies a would-be final answer, before final capture.
Output policy holds drafts until the answer is accepted
A nonempty .act({ output: [...] }) chain holds provider draft tokens for the
whole run, including tool iterations and schema retries. stream.llm_end still
reports usage and timing, but its content is empty and contentWithheld is
true. That marker means text was withheld, not that the model returned nothing.
After the chain accepts an answer, final capture emits one stream.token with
the captured answer (unless it is empty), then agent.turn_end. The returned
answer, the token and the final assistant memory entry agree, including an
output rewrite. This also works with complete-only providers.
const agent = Agent.create({ provider: mock({ respond: () => 'Account PRIVATE-123 is active.' }), model: 'mock',}) .act({ output: [ { name: 'mask-account-id', onMessage: (message) => allow(message.content.replace('PRIVATE-123', '[account]'), 'masked account id'), }, ], }) .build();const delivered: string[] = [];agent.on('agentfootprint.stream.token', (event) => delivered.push(event.payload.content));agent.on('agentfootprint.stream.llm_end', (event) => { assert.equal(event.payload.content, ''); assert.equal(event.payload.contentWithheld, true); // usage and timing still available});const answer = await agent.run(input);assert.equal(answer, 'Account [account] is active.');assert.deepEqual(delivered, [answer]); // one captured answer, not raw provider chunksA refusal produces no final answer token, turn_end or final memory write.
Agent.run() / resume() surface the refusal, and their API boundary emits a
content-free error.fatal event so public streams close. The existing
middleware.decision event still identifies the rule and its verdict. A thrown
rule, an unsupported ask, or a non-text rewrite is also a refusal.
Generic .messageMiddleware(...) may inspect both phases, even if its callback
manually branches on phase. It therefore holds drafts too. Declare genuinely
input-only rules through .act({ input: [rule] }) to retain immediate streaming.
No message chain, an empty output list, and declared input-only rules keep their
existing streaming behavior.
This protects answer delivery, not every copy of the data. Raw model content,
rewrite pairs, earlier tool history and checkpoint audit state remain available
to recorders. It does not sanitize every typed event or undo earlier tool
effects. A denial after earlier iterations may be wrapped in
RunCheckpointError; its cause is MessageDeniedError. Composed pipelines
retain their existing error propagation behavior, so this is not a guarantee
that a refused child stops the entire pipeline.
Coverage and standing lines are still composed after the output rule: releasing
one captured answer does not mean that rule inspected those appended lines.
Likewise, policy acceptance does not set answerValidationCommitted, and a
runTyped fallback created after the run is outside this chart's output policy.
Protect raw records separately before storing or sharing them.
The ledger: a scrub that hides its scrubbing poisons every slice
A transform makes the trace and the wire disagree. Committing the pair — with the middleware's name and its why beside it — is what turns that disagreement from a lie into a record:
const rows = agent.getLastSnapshot()?.sharedState.middlewareDecisions;
// [{ middleware: 'scrub-ssns', moment: 'input', outcome: 'allow',
// changed: true, why: 'masked a US SSN',
// before: 'my ssn is 123-45-6789', after: 'my ssn is [ssn]',
// at: 'message', phase: 'input' }] // `at` / `phase`: the 7.18 spellingallow(value, why) requires the why. A transform that does not say why is exactly the thing this key exists to prevent.
Every decision files a row, including the pass-throughs — "the rule looked and was fine with it" and "the rule never ran" are different facts about a run.
Refused inputs do not create conversations
An input refusal raises MessageDeniedError without calling the model or
admitting conversation state. checkpoint() then returns undefined, and
followUp() raises NoConversationError with reason: 'last-input-refused'.
Keep the last accepted checkpoint and continue from it explicitly:
const agent = Agent.create({ provider, model: 'mock' }) .act({ input: [ { name: 'no-private-markers', onMessage: (message) => message.content.includes('PRIVATE:') ? deny('Remove the private marker before submitting.') : allow(), }, ], }) .build();await agent.run('Start a conversation.');const accepted = agent.checkpoint();assert.ok(accepted);await assert.rejects(agent.followUp('PRIVATE: not for the model'), MessageDeniedError);assert.equal(agent.checkpoint(), undefined); // the refused attempt is not a conversationawait assert.rejects(agent.followUp('Try again.'), NoConversationError);// Choose the earlier accepted conversation explicitly. A hosted session// already keeps its last accepted checkpoint under every durability mode.const answer = await agent.run({ message: input, continueFrom: accepted });With standingAgent, every durability mode keeps the last accepted session;
a refused first request creates no stored conversation. A refused
resumeOnError also produces no new checkpoint and does not change the
checkpoint you supplied. The runtime does not guess which part of a failed
turn's stored history might still be safe.
The refusal decision remains in the run's record, but no conversation clock or restored history is filed, and the integrity disposition reports that no model work existed. This is admission protection, not audit erasure: run inputs, rewrite evidence and middleware-authored reasons may retain sensitive text. Previously stored checkpoints are not rescanned or repaired; remove unsafe legacy conversations before continuing them. Allowed retries still replay their supplied history as stored; this change does not rescrub it.
Read this before you scrub secrets
An 'input' scrub keeps the text away from the model and the conversation — not from every record of the run. On every turn the chain lets through (first or continued, since 9.112.2) the model reads the scrubbed message, the committed history and checkpoint().history hold the scrubbed turn, and no agentfootprint.* event payload carries the original. The run still keeps what it replaced — that is the honest trade that makes the transform legible — so the pre-scrub text is not in one place. It is in these, and an app that must not keep it redacts each one itself before it persists or serves it:
| holds the pre-scrub text | where it shows up |
|---|---|
the ledger row, middlewareDecisions[i].before | every copy of the run's state: getLastSnapshot() (sharedState, commitLog, executionTree, subflowResults), getLastNarrativeEntries(), a recordRun(...) recording, a BoundaryRecorder subflow.entry payload — and a paused run's checkpoint, RunnerPauseOutcome.checkpoint (sharedState), which standingAgent itself stores for a paused session as a flowchart-v1 envelope under every durability, the default 'exit' included |
| the run's input, as passed | the run.entry payload every flow recorder receives — BoundaryRecorder, recordRun, a Lens — is { message } before the chain ran |
| a crash checkpoint | RunCheckpointError.checkpoint.originalInput.message is the message as passed (checkpoint().originalInput.message is the chain's verdict) |
The crash checkpoint's original-input retention remains a known gap. Replace
its originalInput.message with the scrubbed text before storing it when your
policy requires that (resuming runs the input chain again). A paused run's
checkpoint is a separate carrier and still needs the protection described below,
including under the default 'exit' durability.
If your threat model says the original must not survive in a record, that is a redaction question, not a middleware one, and Agent.create({ redact }) answers it for every record the library keeps or serves — see Keep personal data out of the record. Under conversationRedaction() (from agentfootprint/security) the middlewareDecisions rows are kept out of the snapshot, the narrative and every recording, and the run's input is served with message as the placeholder; the middleware.decision events still say a decision was made — which middleware, at which moment, its outcome and whether it changed the value — without holding what was scrubbed. A redaction policy never covers the resume checkpoints, because a run must resume on the real values, so a paused run's checkpoint still needs the strip before it is stored: if you store out.checkpoint yourself, strip before / after from the rows in its sharedState first; behind standingAgent the library stores it, so wrap the store you pass as sessions — its persist strips those rows from a flowchart-v1 envelope's data.checkpoint and then writes. The ledger is only ever appended to, so a checkpoint stripped this way resumes the same. For a local-observability Trace, serializeTrace(events, { redact: redactContent }) blanks the run.entry / subflow.entry payloads at the serialize boundary. Honesty and protection are both laws here, and that is where they meet.
For tool args and the output phase the point is moot — the pre-transform value is already in the committed history and in llmLatestContent, so the ledger adds no exposure. It just makes the change findable.
The 'after-tool' moment is the input phase's mirror image: a result the model was not allowed to read is kept in that row's before — and, by design, in the agentfootprint.stream.tool_end event, which reports what the tool returned while the history carries what the rules let through. Same trade, same answer as above: redact both where you keep the record and the refusal survives without the value.
Tool rules
/** A hard rule. The reason is written FOR THE MODEL — it is what it reads. */const refundCeiling: ToolMiddleware = { name: 'refund-ceiling', onToolCall: (call) => Number(call.args.amount) > 10_000 ? deny('refunds over $10,000 must go through the finance desk, not this agent') : allow(),};/** A soft rule: above $100, a person decides. */const fourEyes: ToolMiddleware = { name: 'four-eyes', onToolCall: (call) => Number(call.args.amount) > 100 ? ask({ question: `Approve a $${String(call.args.amount)} refund?`, detail: call.args }) : allow(),};Write a deny reason for the model, not for a log line — it is literally what the model reads next, and a good one gets you a corrected second attempt instead of a stuck loop.
const agent = Agent.create({ provider: llm, model: 'small-model' }) .system('You handle refunds.') .tool(refund) .act({ input: [scrubSSNs], // the message, before the run commits it // Order is call order, and each link sees the previous one's output. beforeTool: [refundCeiling, fourEyes], // every call, before dispatch }) .build();Where the chain sits, and what it is not
permission gate → MIDDLEWARE CHAIN → arg validation → check-in → credentials → executeAfter the permission gate, so an existing PermissionChecker still decides first — a call it denies never reaches a middleware, and every checker written before 7.18 behaves identically with or without a chain attached.
Before arg validation, so validation judges the args that will actually be sent. A middleware that transformed args into something the tool's schema rejects must be caught, not forwarded.
Three layers, three jobs, and they do not overlap:
| decides | |
|---|---|
gatedTools | which tools the model can see |
PermissionChecker | whether a call is permitted |
toolMiddleware | what happens when a permitted call is made |
Which server did this tool come from?
A tool NAME is not an identity. Two MCP servers can each serve a call_aws, and a rule matching the bare name governs whichever one answers — including the one it was never written about. So the context carries toolSource: the name of the server the tool came from.
const prodNeedsATicket: ToolMiddleware = {
name: 'prod-needs-a-ticket',
onToolCall: (call) =>
call.toolSource === 'aws-prod' ? deny('production AWS calls need a change ticket') : allow(),
};Absence is the other half of the fact. A tool you wrote with defineTool carries no toolSource — not undefined, absent — because "this agent's own" and "served by somebody I chose not to name" are different situations, and only one of them should match a rule about somebody else's server. It is filled from Tool.source, which mcpClient and mockMcpClient stamp with their client name, so a rule written against it is testable before the real server exists.
The same field appears on mcpServe's serving-side chain, carrying the served tool's provenance (present when you are re-serving another server's tool, absent when the tool is yours) — never the calling client's, since a client does not get to declare where a tool came from.
A middleware that throws is a denial
The thrown message becomes the reason and the tool does not run. This is the same deny-by-default the permission gate has always applied to a checker that throws: a governance layer whose failure mode is "allow" is not a governance layer.
Refusing a message
deny(reason) at either phase raises a MessageDeniedError rather than returning:
try {
await agent.run({ message });
} catch (e) {
if (e instanceof MessageDeniedError) {
console.log(`${e.middleware} refused this ${e.phase}: ${e.reason}`);
} else throw e;
}At 'input' there is no model to tell — the message never reached one. At 'output' the middleware has just declined to release what the model said, and handing the caller a string in its place is the one substitution they must never make without noticing. The error carries the reason, the phase and the middleware's name — never the refused content, since an error object that carried the answer out would undo the refusal.
Serving a governed tool over MCP
Middleware belongs to an agent, not to a Tool. mcpServe hands out the tool object you passed in, so an agent's chain does not travel inside it — there is nothing on the tool to detect, and nothing to strip. Rather than let the rule dead-end at that boundary, pass a chain to the served surface directly:
await mcpServe([refundTool], {
name: 'support-desk',
toolMiddleware: [refundCeiling],
});Both moments run there: the call half before execute and before credentials resolve, and the onToolResult half over the result before it is serialized for the client — exactly as they do inside an agent, on the same walker, under the same laws. ask cannot survive that boundary — MCP is request/response and there is no pause to carry the question — so a link that asks answers the client with a tool error naming it, rather than executing ungoverned. Same refusal wording checkIn gets, for the same reason.
What the package exports
Everything below comes from the package root, agentfootprint:
| export | what it is |
|---|---|
allow() / allow(value, why) | Pass through, optionally transformed. The why is required when the value changes. |
deny(reason) | Refuse. For a tool, reason is what the model reads. |
ask(payload) | Suspend for a person. { question, detail? }. The call moment only. |
ToolMiddleware | { name, onToolCall?(call), onToolResult?(call) } — at least one hook, or it does not compile. |
MessageMiddleware | { name, onMessage(msg) }. |
ToolMiddlewareContext | What a tool link is handed: toolName, toolSource?, toolCallId, iteration, args, history, identity?, signal?. |
ToolResultContext | The same, plus result and error? — what onToolResult is handed. args are what the tool actually ran with. |
MessageMiddlewareContext | What a message link is handed: phase, content, history, identity?, signal?. |
ToolOutcome | allow | deny | ask. No result arm. |
ToolResultOutcome | allow | deny. No ask — the tool has already run. |
MessageOutcome | allow | deny. No ask — the message boundary has no pause to carry one. |
MiddlewareDecision | One ledger row, stamped with the moment it came from. |
LoopMoment | 'input' | 'before-tool' | 'after-tool' | 'window' | 'output' — see the moments. |
MessageDeniedError | Raised when a message chain refuses. Carries reason, phase, middleware. |
isAskPause(result) | Narrows a paused run to one carrying ask. |
The three arms are exported individually too, for code that needs to name one: AllowOutcome is the pass-through-or-transform arm, DenyOutcome the refusal, and AskOutcome the suspension — whose payload is an AskPayload ({ question, detail? }). On a paused run that question surfaces as a MiddlewareAsk, which is the AskPayload plus the middleware name that asked. MessageDeniedContext is what MessageDeniedError is constructed from.
ToolCallMiddleware and ToolResultMiddleware are the two arms of the ToolMiddleware union, for code that needs to name one. LOOP_MOMENTS and actKeyFor (with ActKey) are the moment list and its key mapping; ActOptions and ACT_KEYS are .act()'s bundle and the keys it accepts.
One typed event, agentfootprint.middleware.decision, fires per row. It carries the fact — who, where, which outcome, whether the value changed — and deliberately not the values themselves: an event stream fans out to sinks the library does not control, and a scrubbed value should not leave the run through it.
Related
- Security —
PermissionChecker, which still decides first - Check in — evidence-carrying consent declared by a tool
- Pause / resume — the machinery
askrides - Serving over MCP — the governance law at the served boundary
- Connecting to an MCP server — where
toolSourcecomes from - The moments of the loop —
.act(), the after-tool moment, and the whole table - Window strategies — the other family that records what it changed
The moments of the loop
Every place in an agent turn where a rule may speak, what watch reports there, and what act may change. One block — .act({ input, beforeTool, afterTool, window, output }) — with keys locked to the moments at compile time.
Runbook as tool
Turn a written operational procedure into one agent tool whose every answer is evidence — coverage, provenance, rule version, and the recorded walk — instead of a pile of logs.
