Build

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.

Tools do the work. Act decides about the work. Watch remembers both — and nothing can act without being watched.

Act and watch attend the same moments. The difference is what they may DO there: watch reports, act changes what happens next. A moment with an act seam always has a watch event; a moment with only a watch event is one you can see and cannot steer.

One agent turn drawn as a circle. The message enters through the input gate, the loop runs clockwise past the window gate, the model call, the before-tool gate, the tool running, the after-tool gate and the end of the iteration; the answer leaves through the output gate. Five amber gates are what act() can change; purple watch-dots sit at every moment, including those with no gate.

The whole steering wheel

import { Agent, slidingWindow } from 'agentfootprint';

const agent = Agent.create({ provider, model })
  .act({
    input:      [scrubSSNs],                          // the message, before the run commits it
    beforeTool: [refundCeiling],                      // every call, before it is dispatched
    afterTool:  [stripPII],                           // every result, before the model reads it
    window:     slidingWindow({ keepRecentTurns: 12 }), // what the live window keeps
    output:     [noCodenames],                        // the answer, before the caller gets it
  })
  .build();

Five optional keys, one per moment, in the order the loop reaches them. Autocomplete on an empty {} is the loop.

It is sugar, and provably so. Each key is forwarded to the door that already owned it, so the agent above is byte-for-byte the agent five separate calls build — same requests on the wire, same rows in the ledger. That equivalence is pinned per key by tests.

Call it once. A second .act() throws: two posture blocks put the answer to "what does this agent do at each moment?" in two places, with the later one silently winning. Adding one rule to an agent somebody else built is what the individual doors are for.

The table

Every moment of one turn, what watch reports there, and what act may change:

momentwhat happenswatch eventact seam
inputthe user's message arrives, before anything commits itagent.turn_start, middleware.decision.act({ input })
context slotsthe three slots are composed for this iterationcontext.slot_composed, context.injected, tools.offeredobservation only
windowthe live window is measured and trimmedcontext.evicted.act({ window })
the model callthe request goes out and the answer streams backstream.llm_start, stream.token, stream.llm_endobservation only
the routethis turn either calls tools or answersagent.route_decidedobservation only
before-toola call is about to be dispatchedstream.tool_start, permission.check, middleware.decision.act({ beforeTool })
the tool runsthe work itself — credentials resolve, the tool executescredential.requested, credential.acquiredobservation only
after-toolthe result exists, before it enters historystream.tool_end, middleware.decision.act({ afterTool })
the iteration endshistory is committed and the loop turnsagent.iteration_endobservation only
outputthe final answer, before the caller receives itagent.turn_end, middleware.decision.act({ output })
output — schema enforcementthe answer is judged against .outputSchema(); a failure with retries left sends a correction and turns the loop againagent.route_decided (chosen: 'output-retry'), agent.output_schema_retrydeclared, not a rule — { retries }

Schema enforcement sits INSIDE the output moment rather than beside it, and has no .act() key. It is not a sixth place to intervene — it is what a declaration you already made does when the answer arrives, and its decision is fixed: the shape either matched or it did not. A rule there would be a rule about whether to honour your own contract.

Two moments are worth naming as deliberately observation-only. The model call has no seam because a rule that could rewrite the response would be a rule that could answer for the model, which is the fabrication this library removes everywhere else. The tool run has none for the same reason, one layer down: beforeTool decides whether it happens and afterTool decides what is read of it, and neither can stand in for it.

What each moment can answer

verbinput / outputbeforeToolafterTool
allow()
allow(value, why)✅ the text✅ the args✅ the result the model reads
allow(undefined, why)✅ pass through, with a reason on the row
deny(reason)✅ raises MessageDeniedError✅ the model reads the reason✅ the model reads the reason instead of the result
ask({ question })❌ no pause at this boundary✅ suspends for a person❌ the tool has already run

The window moment speaks a different language — a WindowStrategy plans a removal and files a record — because it decides about the whole conversation rather than about one value.

The onion

The two tool moments are ONE chain, walked forwards for the call and backwards for the result. The first-declared rule gets the first word going in and the last word coming out:

                    ┌──────────────── audit ────────────────┐
                    │   ┌──────────── redact ───────────┐   │
                    │   │   ┌──────── ceiling ──────┐   │   │
   the model asks ──┼───┼───┼──────▶  THE TOOL      │   │   │
                    │   │   └───────────────────────┘   │   │
                    │   └───────────────────────────────┘   │
   the model reads ◀┴───────────────────────────────────────┘

   .act({ beforeTool: [audit, redact, ceiling] })

   before:  audit → redact → ceiling → the tool runs
   after:            the tool → ceiling → redact → audit

audit sees the call the model asked for, before anyone changed it, and the result after everyone finished with it. ceiling sits closest to the tool on both sides. That is the only order in which "wrap" means anything — and an order-sensitive test pins it.

The after-tool moment

onToolResult runs once the tool has executed and before its result enters the history or reaches the model:

const stripPII: ToolMiddleware = {
  name: 'strip-pii',
  onToolResult: (call) => {
    const record = call.result as Record<string, unknown>;
    if (!('ssn' in record)) return allow();
    const { ssn, ...safe } = record;
    return allow(safe, 'removed the SSN before the model read it');
  },
};

It takes the same context the call moment gets — including toolSource — plus result, and args as the tool actually ran with them, every before-transform applied.

The moment is after-tool; the hook is onToolResult. A moment is named for WHEN it happens — which is what .act()'s afterTool key is named after — and a hook is named for WHAT IT RECEIVES, which is how it pairs with onToolCall.

A refusal here hides an answer; it does not undo a side effect. deny(reason) sends the reason to the model instead of the result, and the run still commits the real result, because it happened. A record that dropped it would describe an agent that called a tool and got nothing back, which is not what occurred. (If that value must not survive in the commit log, that is a redaction question, and the row survives the scrub.)

onToolResult never runs for a call that never executed. Denied before dispatch, waiting on a person, rejected by arg validation, blocked on a credential, or naming a tool that does not exist — none of those have a result to decide about, and asking a rule about a result that does not exist is the fabrication the outcome union removes.

It DOES run for a call a person finished. A tool that called askHuman() / pauseHere() has run — it started, it may have done half its work, and the value you hand agent.resume() becomes that tool's result: it lands in the history under the same toolCallId, stream.tool_end reports it, and it fires on-tool-return triggers. So the after-tool moment runs there too, with the args the tool was actually running with. Before 8.13.0 it did not, which left every onToolResult rule — redaction first among them — unapplied to the one channel where a person can paste a secret. Rules that only govern calls are unaffected; an agent with no onToolResult anywhere behaves exactly as before.

There is no ask at the after-tool moment, and that is a refusal rather than an omission. The machinery is right there — the dispatch loop pauses perfectly well — but the tool has already run, so a person woken to answer cannot prevent anything. The honest verbs at that moment are "let it through" and "the model does not read this", and both ship. The business cases this was measured against (authorize before · hide from the model · annotate the result · attach a fact through a trigger) needed none of them. Bring one that genuinely needs a person there and the arm can be added on the pause machinery that already exists.

A rule with only onToolResult takes no part in dispatch — no walk, no ledger row at the call moment. It did not decide anything there, and a row saying it allowed would be inventing a decision.

Every row says which moment it came from

const rows = agent.getLastSnapshot()?.sharedState.middlewareDecisions;
// [{ middleware: 'scrub-ssns',  moment: 'input',       outcome: 'allow', changed: true, ... },
//  { middleware: 'refund-ceiling', moment: 'before-tool', outcome: 'allow', changed: true, ... },
//  { middleware: 'strip-pii',   moment: 'after-tool',  outcome: 'allow', changed: true, ... }]

moment is the same word the key is named for, so the door you wrote and the row it produced read in one vocabulary. The 7.18 fields — at: 'tool' | 'message' and phase: 'input' | 'output' — are committed state and are still written; moment is the newer spelling and the one to narrow on.

The typed event carries it too:

agent.on('agentfootprint.middleware.decision', (e) => {
  console.log(e.payload.moment, e.payload.middleware, e.payload.outcome);
});

As before, the event carries the FACT and never the values — an event stream fans out to sinks the library does not control.

Session trust: ask once, remember the answer

The rule people actually want around a consequential tool is neither "ask every time" nor "never ask". It is ask once, then remember — and the remembering has to be legible, or "why did this run without asking?" has no answer in the record.

/** What a remembered approval is keyed on: the tool, its source, its args. */const keyOf = (call: { toolName: string; toolSource?: string; args: unknown }): string =>  `${call.toolSource ?? 'own'}::${call.toolName}::${JSON.stringify(call.args)}`;/** Ask a person once per distinct call; remember the answer for the session. */function approveOnce(): {  middleware: ToolMiddleware;  remember: (question: MiddlewareAsk, by: string) => void;} {  const approved = new Map<string, string>();  return {    middleware: {      name: 'approve-once',      onToolCall: (call) => {        const decided = approved.get(keyOf(call));        // A pass-through that says why it was comfortable. The row reads        // `changed: false` — nothing moved — and carries the decision.        if (decided) return allow(undefined, `approved earlier this session by ${decided}`);        return ask({          question: `Approve ${call.toolName}(${JSON.stringify(call.args)})?`,          // The key rides the question, so whoever answers it can hand the          // same key back — no second copy of the keying rule anywhere.          detail: { key: keyOf(call) },        });      },    },    remember: (question, by) => {      const key = (question.detail as { key?: string } | undefined)?.key;      if (key) approved.set(key, by);    },  };}

The why is what makes it honest: a call that sails through on a remembered decision files a row saying whose decision it sailed through on. allow(undefined, why) is a pass-through that carries a reason — the row still reads changed: false, because nothing moved.

The key is the safe default: tool + source + args. An approval is an approval of a THING — this refund, this amount, this order. The loosening is one line:

const keyOf = (call) => `${call.toolSource ?? 'own'}::${call.toolName}`;  // args dropped

…and it costs exactly what it looks like it costs: one approval of issue_refund then covers every refund for the rest of the session, including amounts nobody has seen. A reasonable trade for a read-only lookup; a bad one for anything destructive.

The whole wheel, running

const agent = Agent.create({ provider: llm, model: 'small-model' })  .system('You look customers up and answer briefly.')  .tool(lookup)  .act({    input: [scrubSSNs], //  the message, before the run commits it    beforeTool: [knownCustomersOnly], //  every call, before it is dispatched    afterTool: [stripPII], //  every result, before the model reads it    window: slidingWindow({ keepRecentTurns: 12 }), //  what the window keeps    output: [noCodenames], //  the answer, before the caller gets it  })  .build();

Run it with npm run example examples/features/38-act.ts and it prints what each moment decided.

Composing incrementally

Advanced. Use these when you are adding one piece to an agent you did not build — a plugin, a policy pack, a test harness. For an agent you own, .act() is the spelling.

The five doors .act() forwards to are unchanged and stay open:

doormoment(s)
.messageMiddleware(...)input and output — one rule at both halves, reading msg.phase
.toolMiddleware(...)before-tool and after-tool — whichever of onToolCall / onToolResult the rule has
.window(strategy)window
.compaction({...})window, with summarizeOldest already in it

They append, so a plugin can add a rule to an agent that already has a posture. Two details are worth knowing:

  • .act({ input }) restricts a rule to one phase; .messageMiddleware() does not. A rule named for one message moment is still a link in the chain at the other, where it passes through and files that pass-through row — byte for byte what the hand-written msg.phase === 'input' ? … : allow() spelling records. Name a rule under both input and output and it is attached once, unguarded: exactly .messageMiddleware(rule).
  • The tool buckets are for reading; the hooks decide. A rule with both onToolCall and onToolResult runs at both moments whichever key you wrote it under, and a rule named under both keys is the same object attached once. A governance rule that silently did not run because it was filed in the wrong bucket is the failure this door exists to prevent — so the bucket is checked for the hook it names, and refuses at build time if it is missing.

The completeness lock

.act() claims to be the whole wheel. A claim like that lasts exactly as long as somebody remembers it, so it is pinned by the compiler instead:

export type LoopMoment = 'input' | 'before-tool' | 'after-tool' | 'window' | 'output';

The bundle's keys are checked both ways against that list, camel-cased ('before-tool'beforeTool) by type-level string manipulation rather than a hand-written pair table. Ship a sixth moment without a key and our build fails naming it; add a key that is not a moment and it fails the other way. The runtime validator is derived from the same list, so a typo'd key is refused by a rule that cannot fall behind the type it validates.

LOOP_MOMENTS and actKeyFor are exported, so a UI that renders "what does this agent do at each moment?" can enumerate the moments rather than hard-code five strings.

The watch side — .watch()

The watch event column above is what an observer sees. .watch() is where you put the observer:

import { Agent, type Watcher } from 'agentfootprint';
import { routeRecorder, toolChoiceRecorder } from 'agentfootprint/observe';
import { staticEmbedder } from 'agentfootprint/providers';

const choices = toolChoiceRecorder({ embedder: staticEmbedder() });

const agent = Agent.create({ provider, model })
  .watch(routeRecorder(), choices)          // who is looking
  .act({ beforeTool: [refundCeiling] })     // what may change
  .build();

It is variadic, because observers come in sets, and it attaches at build time — before build() returns — so the observer sees the very first run. There is no window in which the agent has acted and nobody was watching.

Watcher is the type it takes: the plain name for footprintjs's CombinedRecorder, which is what every recorder factory on agentfootprint/observe returns. Write your own by handing .watch() any object with the recorder hook methods on it.

.watch() returns the builder. The runtime door is agent.attach(observer), which returns an Unsubscribe you own and call when the observer's life ends — a request scope, a UI unmount, a test teardown. Same mechanism underneath, so mixing them is fine and order is preserved.

There is deliberately no WATCH_MOMENTS to match LOOP_MOMENTS. .act()'s keys are a closed, compiler-pinned list because a rule has to be told where it may speak — a rule filed at a moment nobody reads is a governance hole. An observer is the opposite: it attends the entire event stream, and there is no closed set to enumerate. A list we published would be a vocabulary we then had to keep true against every event ever added, so the honest surface for watching is the door and the type.

.recorder() was the same door under its older, internals-flavoured name, taking one observer instead of many. Deprecated in 8.0.0 and removed in 9.0.0 — the name survives one major as a throwing stub that names .watch().

Two different observers may not share one id. footprintjs de-duplicates attached recorders by id, so of two objects carrying one name only the last would ever fire and the first would report nothing — which reads exactly like an observer whose events never happened. build() refuses it, naming the id. Handing the same object to .watch() twice is fine and stays one attachment: it is identity, not the id, that decides.

  • Middleware — the verbs, the ledger, and the laws they rest on
  • Window strategies — what the window moment can be
  • Check in — consent a TOOL declares, rather than a rule
  • SecurityPermissionChecker, which decides before any of this
  • Observability — the watch side of the same moments
  • Serving over MCP — both tool moments at the served boundary
  • Hosting — the doors a turn arrives through. A posture set here applies identically whether the turn came in as a request or as a frame on an open conversation; the door decides how it arrives, never what happens next.

On this page