Agent
Agent = ReAct. The loop primitive that thinks, acts (tool call), observes the result, and repeats until done.
A user asks "what's the weather in Paris?". Your code can't just hit a weather API — the LLM has to decide which tool to call, with what args, then read the result, then decide whether to call another tool or respond. That decide → act → observe → repeat is what makes an Agent an Agent, not a function call.
Agent = ReAct
The Agent primitive is the ReAct loop (Yao 2022). One iteration:
LLM call → route → [tool calls → loop] OR [final answer]Each iteration: the LLM produces text + optional tool calls. If tools were called, the framework executes them, appends results to the message history, and starts another iteration. If no tools were called (or maxIterations is hit), the loop exits with the final text.
If it doesn't loop-with-tools, it isn't an Agent — it's an LLMCall.
Build an agent
Agent.create({ provider, model }) → builder. .system(...) sets the system prompt. .tool(...) registers tools (each with a JSON schema). .build() finalizes:
const agent = Agent.create({ provider: provider ?? exampleProvider('feature', { respond: weatherRespond }), model: 'mock', maxIterations: 5, // reactMode: 'dynamic-grouped' wraps the LLM turn in an sf-llm-call subflow, // so Lens renders the agent's reasoning as an LLM group with its context // slots (system-prompt / messages / tools) nested inside — the SAME shape // the LLMCall primitive shows — instead of a bare "Final · RUNNER" card. reactMode: 'dynamic-grouped',}) .system('You answer weather questions using the `weather` tool.') .tool({ schema: { name: 'weather', description: 'Get current weather for a city.', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, execute: async (args) => `${(args as { city: string }).city}: sunny, 72°F`, }) .build();The framework owns the iteration loop. You declare what tools the agent has; the LLM decides when (and with what args) to call them; the framework dispatches and feeds results back.
What run() takes
A message, in either spelling:
await agent.run('what is the weather in Paris?'); // a bare string IS the message
await agent.run({ message: 'what is the weather in Paris?' }); // identical, byte for byte
await agent.run({ message: '…', identity: { tenant: 'globex', conversationId: 'c2' } });Anything that is not a message is refused before the run starts, with an InvalidRunInputError naming the door and what arrived — {}, { message: 42 }, null, and an empty or whitespace-only message. Nothing is billed and no half-run has to be explained.
An empty message is refused rather than sent: it is not a shorter question, it reaches a provider as a turn with no content, and real wires reject it. To run on the system prompt alone, say so in the message (run({ message: 'begin' })).
The same rule holds for LLMCall, Sequence, Parallel, Conditional and Loop — one door, one answer.
Observe the loop
Because the framework owns the loop, observability is just attaching listeners — no SDK, no agent-instrumentation wrapper:
agent.on('agentfootprint.stream.tool_start', (e) => console.log(`→ tool ${e.payload.toolName}(${JSON.stringify(e.payload.args)})`),);agent.on('agentfootprint.stream.tool_end', (e) => console.log(`← tool result: ${e.payload.result}`),);109 typed events fire across 24 domains during a single agent.run(). See the Observability guide for the full taxonomy.
maxIterations
Every Agent has a maxIterations cap (default 10). The loop exits when:
- The LLM returns text with no tool calls (normal completion)
maxIterationsis reached (forced exit — see Running out of budget below)- A tool throws (propagates as an error; subject to
withRetryif wrapped) - The agent pauses via
askHuman/pauseHere(run()returns aRunnerPauseOutcomeinstead of a string — narrow it with theisPaused()guard)
Tune maxIterations to your tool budget. Tool-heavy agents (research, code-gen) commonly run 15–30; chat agents 3–5.
Running out of budget
maxIterations is a cap on actions, and the model does not know it is about to be hit.
Through 9.55.0, a turn that reached the cap while the model was still asking for tools handed
back whatever text happened to ride that last call. Mid-task, that is a fragment:
"The third finding focus is not settling… Let me check what's on screen now:"
That sentence reached the person as if it were the answer, with nothing on the record saying the budget had run out.
Since 9.56.0 the run spends one more LLM call with the tools withheld, carrying one instruction, and hands back what comes back:
[budget exhausted — the action budget was exhausted before the wrap-up call this message opened, so no tools were offered on that call. That call was for the final answer, from what the messages above already hold: what was completed, what remained undone, and anything the person should know.]
The bracketed opening is not decoration: it is the marker that tells the window's refusal engine
and a routing rule reading history that the library wrote this message, not the person (see
isSaidByPerson). And every clause reports the call it was written for in the past tense,
because the message stays in history and is re-read on every later call of the turn.
That call is exempt from maxIterations by construction, not by exception: a model offered no
tools has nothing to ask for, so it can only answer. It costs one call, and it is on the record
like any other turn — its own iteration_start, llm_start and cost.tick.
The fact is on the record three ways — agent.stoppedEarly(),
agentfootprint.agent.budget_exhausted, and a stoppedEarly field on
agentfootprint.agent.turn_end — so a dashboard can tell answered from answered after the
budget ran out:
const answer = await agent.run({ message: 'Audit the three findings' });
agent.stoppedEarly();
// { reason: 'max-iterations', iteration: 8, pendingToolCalls: 1,
// answerWasEmpty: false, wrappedUp: true }
agent.on('agentfootprint.agent.budget_exhausted', (e) => e.payload);
// { reason: 'max-iterations', iteration: 8, limit: 8,
// pendingToolCalls: 1, action: 'wrapped-up' }
agent.on('agentfootprint.agent.turn_end', (e) => e.payload.stoppedEarly);
// { reason: 'max-iterations', iteration: 8, pendingToolCalls: 1, wrappedUp: true }It rides the iteration budget only. A halting costBudget keeps the old behaviour, because
there you capped the money and one more call would spend past the cap — an action cap says
nothing about a call that takes no action.
Turn it off with wrapUpAtMaxIterations: false (the fact is still recorded, as
action: 'cut-short'). A turn that never runs out of budget is byte-identical either way —
same calls, same events, same committed state.
const seen: { tools: number }[] = [];const agent = Agent.create({ provider: provider ?? busyModel(seen), model: 'demo-sonnet', maxIterations: 2, // a deliberately small action budget}) .tool(inspect as never) .build();let exhausted: Record<string, unknown> | undefined;agent.on('agentfootprint.agent.budget_exhausted', (e) => { exhausted = e.payload as unknown as Record<string, unknown>;});const answer = await agent.run({ message: input });const cut = agent.stoppedEarly();Identity for multi-tenant memory
If your agent uses memory (.memory(...)), every .run() call must include an identity so memory is scoped per tenant / principal / conversation:
await agent.run({
message: 'How long do refunds take?',
identity: { tenant: 'acme', principal: 'alice', conversationId: 'thread-42' },
});Without an identity, memory falls back to a global namespace — fine for single-user prototypes, dangerous in production multi-tenant apps. See Memory guide.
Per-run config — .configure()
An agent is built once and run many times, but not every run wants the same model or the same house rules. A long message may deserve the bigger model; a tenant may have its own policy text; a canary may want last week's prompt.
Rebuilding the whole agent per request works and is wasteful. Reaching in and mutating one is worse — the trace then describes an agent that no longer exists.
.configure((ctx) => ({ model?, instructions? })) resolves once per run, at
the start of the run:
const agent = Agent.create({ provider: llm, model: 'small-model' }) .system('You answer support questions.') .configure(({ message, identity, defaults }) => ({ // Route to the bigger model only when the question is big. ...(message.length > 40 ? { model: 'big-model' } : {}), // Tenant rules land on top of the built-in prompt. `defaults` carries // what the agent was BUILT with, so nothing has to be restated here. instructions: `${defaults.instructions}\n${HOUSE_RULES[identity?.tenant ?? ''] ?? ''}`.trim(), })) .build();ctx is a RunConfigContext: the run's message, its identity (when
run({ identity }) supplied one), its runId, and defaults — what the agent
was built with, so a resolver can decide relative to it rather than restating
it. The return value is a RunConfig; the resolver's own type is RunConfigFn.
Typing a resolver that lives somewhere else
The inline form above needs no annotations — TypeScript infers ctx from
.configure(). A resolver you keep in its own module does need them, so
RunConfigFn, RunConfigContext and RunConfig are all exported from
agentfootprint. Import them rather than re-declaring the shape by hand: a
hand copy stops matching the moment the context grows a field, and it stops
matching silently.
import type { RunConfig, RunConfigContext, RunConfigFn } from 'agentfootprint';
// A named resolver, testable on its own and shared across agents.
export const pickBrain: RunConfigFn = (ctx) =>
ctx.message.length > 500 ? { model: 'big-model' } : {};
// A factory naming its own return type.
export function houseRulesFor(tenant: string) {
return (ctx: RunConfigContext): RunConfig => ({
instructions: `${ctx.defaults.instructions}\n\n${rulesFor(tenant)}`,
});
}
const agent = Agent.create({ provider, model: 'small-model' })
.system('You answer support questions.')
.configure(pickBrain)
.build();What gets resolved gets committed
This is the part that matters. A run that changed its own model without recording it would be a trace that lies about its most expensive fact — you would read the recording, see the model the agent was constructed with, and be wrong about which one answered.
So the resolved values ride the same commit that already carries the run's other
run-level facts (identity, iteration budget, turn number): resolvedModel and
resolvedInstructions land in the commit log before the first LLM call, and
the LLM call reads them from there. One value, used and recorded:
await agent.run({ message, identity: { tenant: 'globex', conversationId: 'c2' } });
const state = agent.getLastSnapshot()?.sharedState;
state.resolvedModel; // 'big-model' — what actually answered
state.resolvedInstructions; // the rules that run actually ran underThe agentfootprint.stream.llm_start event reports the resolved model too, and
cost is priced against it — there is no second copy to drift.
Absent means unchanged
Omit .configure() and every run behaves, and records, exactly as it did before:
no extra scope writes, no extra scope reads, and the request bytes are identical.
The same is true of a resolver that returns {} or nothing — only what it
actually returned is committed, so "I looked and decided not to change anything"
costs nothing in the log.
This is the run axis only
Tools are the iteration axis and already have an owner: .toolProvider(),
consulted every iteration so gates can react to what just happened. .configure()
deliberately does not duplicate it, and does not reach past model and
instructions into the rest of the run — temperature, maxTokens and the tool
set stay where they were set.
Calling .configure() twice throws. A silently-overridden resolver is a config
that lies.
Composition with other Agents
An Agent is a runner, just like LLMCall, Sequence, Parallel, Conditional. Use them as steps in larger graphs:
import { Sequence } from 'agentfootprint';
const research = Sequence.create()
.step('plan', plannerLLM)
.step('execute', researchAgent)
.step('summarize', summarizerLLM)
.build();There is no separate "multi-agent" class — agents are the building blocks. See Patterns for Reflexion / ToT / Debate / Map-Reduce / Swarm recipes.
Anti-patterns
- Don't subclass Agent for a "smarter" agent — use
.skill()/.instruction()to inject behavior. Subclassing breaks composition. - Don't put async I/O in tool
execute's synchronous setup — it runs every iteration. Cache outside; pure dispatch inside. - Don't bypass
maxIterationsto "let the agent decide" — every loop has a cost ceiling. Set it explicitly; observeagentfootprint.agent.iteration_endto track.
Next steps
- Tools guide —
defineTool, MCP integration, permission gating - Memory guide —
.memory(defineMemory({...}))for cross-run state - Skills, explained — LLM-activated body + tools
vs Other Frameworks
How agentfootprint sits relative to LangChain, LangGraph, CrewAI, AutoGen, Mastra, Genkit, Pydantic AI, DSPy, and Inngest AgentKit. We didn't have to choose between them.
Recipes — an agent setup you can name
defineAgentRecipe declares an agent's whole setup as one named, versioned AgentRecipe, and .recipe() applies it. Every run then says on its manifest which composition produced the agent that answered.
