Build

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}`),);

91 typed events fire across 21 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:

  1. The LLM returns text with no tool calls (normal completion)
  2. maxIterations is reached (forced exit; final iteration's text becomes the result)
  3. A tool throws (propagates as an error; subject to withRetry if wrapped)
  4. The agent pauses via askHuman / pauseHere (run() returns a RunnerPauseOutcome instead of a string — narrow it with the isPaused() 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.

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.

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 under

The 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 maxIterations to "let the agent decide" — every loop has a cost ceiling. Set it explicitly; observe agentfootprint.agent.iteration_end to track.

Next steps

On this page