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.
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}`),);65 typed events fire across 18 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; final iteration's text becomes the result)- 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.
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.
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.
Dynamic ReAct
Context that adapts mid-loop. The on-tool-return trigger fires the iteration after a specific tool ran — recency-first injection without hand-rolled state.
