Build

Patterns

Reflection, Tree of Thoughts, Self-Consistency, Debate, Map-Reduce and Swarm — each a small factory over the same primitives, so they compose with each other and with your own flows.

Every named pattern in the agent literature is a composition of the 2 primitives + 3 compositions from the concepts taxonomy. Not a new Agent class. Not a new runtime. A recipe.

PRIMITIVES: LLM, Agent
COMPOSITIONS: Sequence, Parallel, Conditional

Each page below opens with a "Built from" line showing the recipe, plus a paper citation. Patterns ship either as a loop-mode flag on Agent or as a thin factory over existing concepts — same narrative, same recorders as hand-wired flows.

Two categories:

  • Loop patterns — reactMode: 'classic' vs reactMode: 'dynamic'. Controls when the three API slots (SystemPrompt, Messages, Tools) re-evaluate inside the ReAct loop.
  • Composition patterns — selfConsistency, reflection, debate, mapReduce, tot, swarm. Factories from the root agentfootprint import that wire primitives + compositions into a named shape.

Import:

import { Agent } from 'agentfootprint';
import { selfConsistency, reflection, debate, mapReduce, tot, swarm } from 'agentfootprint';

Runner — the shared interface every primitive (LLMCall, Agent), every composition (Sequence, Parallel, Conditional), and every pattern factory returns. Each exposes .run(input), .getSpec(), .on()/.off(), .attach(), and .enable. Because they all share it, they plug into each other.


Loop patterns — reactMode

Built from: Agent (the ReAct primitive). The loop-mode flag changes where the loop target lands; the shape stays the same.

Paper: ReAct (Yao et al. 2022, ICLR — "Reasoning and Acting").

Every Agent runs a ReAct loop: some SystemPrompt / Messages / Tools preamble, then CallLLM → Parse → Route → ExecuteTools, loop. The reactMode flag controls where the loop jumps back to, which determines which slots re-run each iteration. Set it on Agent.create({ ... }):

reactModeLoop targetRe-evaluates each turn
'dynamic' (default)InjectionEngine (all three slots)All three slots + loop body
'classic'Messages onlyMessages slot + loop body (context engineered once)

Dynamic ReAct (default)

Built from: Agent (default). Paper: ReAct, Yao et al. 2022.

[SystemPrompt] → [Messages] → [Tools] → AssemblePrompt
  → CallLLM → Parse → Route → ExecuteTools → loopTo(InjectionEngine)
 ↑                                                   |
 └───────────────────────────────────────────────────┘

All three slot subflows re-run each turn. Strategies see the updated messages array (now includes tool results) and the iteration count, so they can return a different prompt, a different message window, or a different tool set based on what just happened.

Best for:

  • Progressive authorization — unlock admin tools after verify_identity succeeds
  • Adaptive prompts — tighten the system prompt if the LLM starts looping
  • Context-dependent tooling — swap search tools in once a document class is known
  • Skill-gated agents — .skills(registry) with auto-activating skills, since activation is re-evaluated each turn
const agent = Agent.create({ provider, model })   // reactMode defaults to 'dynamic'
  .system('You are a research assistant.')
  .toolProvider(myDynamicTools)   // can add/remove tools each turn
  .build();

Classic ReAct

Built from: Agent with context engineered once, looping only Messages. Paper: ReAct, Yao et al. 2022 (the original fixed-preamble form).

[SystemPrompt] → [Messages] → [Tools] → AssemblePrompt
  → CallLLM → Parse → Route → ExecuteTools → loopTo(Messages)
                ↑                                 |
                └─────────────────────────────────┘

Context is engineered once before the loop starts. Subsequent iterations reuse the same system prompt and tool list — only the Messages slot re-runs to add the new tool results to the conversation.

Best for: most agents with a fixed persona, fixed tool set, and standard history.

const agent = Agent.create({ provider, model, reactMode: 'classic' })
  .system('You are a research assistant.')
  .tool(searchTool)
  .build();

Skills footgun: 'classic' caches the system-prompt and tools slots after the first turn, so a skill or dynamic injection that activates mid-run won't surface into the cached slots. If you rely on mid-run activation, stay on the default 'dynamic' mode.

Cost vs. adaptability trade-off

Dynamic re-runs the prompt/messages/tools subflows every turn. If those subflows are pure functions over in-memory state the cost is trivial. If any of them touch a retriever, a remote store, or an LLM call, the multiplier is real — budget accordingly, or stay on 'classic' and update state via tools.


Composition patterns

All six are thin factories that express a named paper as a composition of primitives. Reading the source (src/patterns/) is the fastest way to learn how to build your own.

PatternBuilt fromPaperReturnsSource
reflectionLoop(Sequence(propose, critique))Madaan et al. 2023 (Self-Refine)RunnerReflection.ts
totLoop(Parallel(LLM × K) + prune)Yao et al. 2023 (Tree of Thoughts)RunnerToT.ts
selfConsistencyParallel(LLM × N) + majority voteWang et al. 2022 (Self-Consistency)RunnerSelfConsistency.ts
debateSequence(proposer, critic, judge)Du et al. 2023 (Multiagent Debate)RunnerDebate.ts
mapReduceSequence(split, Parallel(LLM × N) + reduce)Dean & Ghemawat 2004RunnerMapReduce.ts
swarmLoop(Conditional(route-to-agent))OpenAI Swarm 2024RunnerSwarm.ts

Hierarchy (Swarm) — also a pattern (an LLM-driven routing decision picks which agent handles the next turn). It ships as the swarm(...) factory like the others; the recipe is Loop(Conditional(route-to-agent)). See Concepts → Swarm (hand-off). Paper: OpenAI Swarm (2024).

Each returns a Runner, so patterns compose with each other — drop a reflection runner into a Sequence, a mapReduce into a Conditional branch, or wrap anything in Conditional for a route-by-quality variant.

Picking a quality pattern

reflection and tot both trade tokens for answer quality, but they target different failure modes:

ProblemPick
The LLM's first answer is usually in the right direction but has errors you can describereflection — critic finds the errors, the next iteration fixes them
The LLM's first answer is often in the wrong direction entirely (multiple reasonable paths exist)tot — generate alternatives, score and prune to the best
You want the most self-consistent answer across independent samplesselfConsistency — sample N, take the majority vote
Work is parallelizable across independent inputs (N documents, N shards)mapReduce
You want two personas to argue, then a judge to ruledebate

Rule of thumb: reflection helps on single-path reasoning; tot helps when the first path is the problem.


reflection — Propose → Critique → repeat

Like: writing a first draft, then handing it to an editor — over and over until it's good.

Built from: Loop(Sequence(propose, critique)). Paper: Self-Refine: Iterative Refinement with Self-Feedback (Madaan et al. 2023); the name comes from Reflexion (Shinn et al. 2023, NeurIPS).

Iterative self-refinement. Each iteration proposes a candidate, then a critic critiques it; the loop continues until the critic emits a stop marker (or maxIterations is hit).

Why: a self-review pass catches a surprising number of reasoning / code / plan errors — the "second look" effect. A cheap model for the critic while keeping a strong proposer is often a win.

Background: Honesty box: the shipped factory is propose-then-critique looped to a stop marker, closer to Self-Refine (Madaan et al. 2023) than to full Reflexion. Real Reflexion has long-term reflection memory across multiple attempts. To approximate the memory, persist critique transcripts via a memory definition.

import { mock } from 'agentfootprint/providers';
import { reflection } from 'agentfootprint';

const provider = mock();   // swap for a real provider from 'agentfootprint/providers'

const reviewer = reflection({
  provider,
  model: 'claude-sonnet-4',
  proposerPrompt: 'Draft (or revise) an answer.',
  criticPrompt: 'List weaknesses. When the answer is good enough, include the marker "DONE".',
  untilCritiqueContains: 'DONE',   // optional; defaults to 'DONE'
  maxIterations: 3,
});

const result = await reviewer.run({ message: 'Explain monads in plain English.' });

Options:

FieldTypeDefaultMeaning
providerLLMProvider(required)Provider for both proposer and critic
modelstring(required)Model id
proposerPromptstring(required)System prompt for the proposer / reviser
criticPromptstring(required)System prompt for the critic
untilCritiqueContainsstring'DONE'Stop marker the critic emits when satisfied
maxIterationsnumber3Max refinement iterations
temperature / maxTokensnumber—Forwarded to every LLMCall
name / idstring'Reflection' / 'reflection'Topology + narrative labels

Under the hood: Loop.repeat(Sequence(propose → critique)).times(maxIterations).until(critic-output contains marker).

Observability: critic output surfaces as its own stage in the narrative, and each iteration emits composition.iteration_start / iteration_exit. This is how you measure whether the critique helped — compare proposals across iterations.

Failure modes: lenient critic never withholds the stop marker → reflection runs full maxIterations for zero quality gain. Before shipping, measure proposal-quality-with-critic vs proposer-alone on a labeled set; if the critic isn't moving the needle, drop the pattern.


tot — Breadth-first thoughts → score → prune

Like: a brainstorm — generate several ideas each round, keep the best, expand again.

Built from: Loop(Parallel(LLM × K) + score-and-prune). Paper: Tree of Thoughts: Deliberate Problem Solving with Large Language Models (Yao et al. 2023, NeurIPS).

Each level fans out branchingFactor parallel thoughts, scores them with a consumer-supplied scorer, keeps the top beamWidth, then expands again for depth levels.

Why: for problems where one-shot answers are often wrong, generating multiple independent thoughts each level catches errors a single chain-of-thought misses. The scoring step is a pure function, so the only LLM in the pattern is the thought generator — budget accordingly.

Background: Honesty box: the shipped factory is BFS with constant width (beam search), not full DFS with backtracking or adaptive branching. True DFS would need a runtime-variable Parallel. The scorer is synchronous so pruning is deterministic.

import { mock } from 'agentfootprint/providers';
import { tot } from 'agentfootprint';

const provider = mock();   // swap for a real provider from 'agentfootprint/providers'

const thinker = tot({
  provider,
  model: 'claude-sonnet-4',
  thoughtPrompt: 'Propose one solution step. Be concrete.',
  depth: 3,             // number of expansion levels
  branchingFactor: 3,   // K thoughts per level
  beamWidth: 1,         // survivors kept after each level (default 1 = greedy)
  score: (thought) => thought.length,   // your value estimate; higher is better
});

const result = await thinker.run({ message: 'What is the fastest sort for nearly-sorted data?' });

Options:

FieldTypeDefaultMeaning
providerLLMProvider(required)Provider for the thought generator
modelstring(required)Model id
thoughtPromptstring(required)System prompt for each thought LLMCall
depthnumber(required)Number of expansion levels (≥ 1)
branchingFactornumber(required)K thoughts generated per level (≥ 2)
score(thought: string) => number(required)Synchronous scorer; higher is better
beamWidthnumber1Survivors kept after each level
temperature / maxTokensnumber0.7 / —temperature drives thought diversity
name / idstring'ToT' / 'tot'Topology + narrative labels

Throws if depth < 1, branchingFactor < 2, or beamWidth < 1. For depth-only refinement without branching, use reflection.

Under the hood: Loop.repeat(Parallel(K thoughts).mergeWithFn(score-and-keep-top-beamWidth)).times(depth). Each level's surviving frontier becomes the next level's input.

Observability: each thought is a separate branch in the narrative; each level emits composition.iteration_start / iteration_exit. The merge function's scoring decides which thoughts survive.

Failure modes: a weak scorer prunes the wrong thoughts. Cost scales with depth × branchingFactor — at depth: 3, branchingFactor: 3 you pay ~9× the tokens of a single LLMCall. Measure quality lift vs single-shot before shipping.


mapReduce — Split → fan-out shards → reduce

Like: tearing a long report into chunks, handing each to a reader, then having them write a joint summary.

Built from: Sequence(split, Parallel(LLM × N) + reduce). Paper: Map-Reduce (Dean & Ghemawat 2004); LLM-flavored variants appear in summarization-tree literature.

A consumer-supplied split(input, shardCount) chops the input into exactly shardCount strings at run time. Those shards fan out across shardCount parallel LLMCalls (all sharing one mapPrompt), then a reducer combines the results — either an LLM synthesizer or a pure function.

Why: map-reduce is a common shape — summarize N chunks of a long document, compare N candidates, evaluate a prompt against N rubrics. Splitting at run time keeps the shard contents flexible while the fan-out width stays fixed at build time.

Background: the map-reduce shape predates LLMs (Dean & Ghemawat 2004). LLM-flavored variants appear in summarization-tree literature (e.g. LangChain's map_reduce chain, refine chains). Honesty box: this factory is the simple flat form with a build-time-fixed shardCount — no hierarchical reduce, no recursive splitting. For very large N build a tree of mapReduce calls.

import { mock } from 'agentfootprint/providers';
import { mapReduce } from 'agentfootprint';

const provider = mock();   // swap for a real provider from 'agentfootprint/providers'

const pipeline = mapReduce({
  provider,
  model: 'claude-sonnet-4',
  mapPrompt: 'Summarize this chunk in two sentences.',
  shardCount: 3,
  split: (input, n) => input.split('\n\n').slice(0, n),   // MUST return exactly n strings
  reduce: {
    kind: 'llm',
    opts: { provider, model: 'claude-sonnet-4', prompt: 'Combine the summaries into a single report.' },
  },
});

const result = await pipeline.run({ message: longDocument });

For a deterministic combiner, swap the reducer for a pure function:

reduce: { kind: 'fn', fn: (results) => Object.values(results).join('\n\n') }

Options:

FieldTypeDefaultMeaning
providerLLMProvider(required)Provider for the per-shard mappers
modelstring(required)Model id for the mappers
mapPromptstring(required)System prompt applied to every shard
shardCountnumber(required)Fan-out width (≥ 2), fixed at build time
split(input, shardCount) => readonly string[](required)Run-time splitter; must return exactly shardCount strings
reduce{ kind: 'fn'; fn } | { kind: 'llm'; opts }(required)Reducer — pure function or LLM synthesizer (opts: MergeWithLLMOptions)
temperature / maxTokensnumber—Forwarded to every mapper
name / idstring'MapReduce' / 'mapreduce'Topology + narrative labels

Throws if shardCount < 2 — use a single LLMCall for one shard. If split returns fewer than shardCount strings, the remaining shards receive empty strings; extra strings are truncated.

Under the hood: Sequence(split → Parallel(shard-0..N-1).mergeWithFn|mergeWithLLM). The reducer's pure-function form receives the per-branch results keyed by shard-i.

Observability: each shard appears as a labeled branch (shard-i) in the narrative. You can trace every claim in the final report back to which shard produced it.

Failure modes: one shard throws → the whole Parallel subflow rejects (strict-merge behavior). If your mappers hit external services, wrap the provider with withRetry(...) from agentfootprint/resilience. Reduce-step hallucination risk: the LLM reducer may drop or invent content — when the source-of-truth mappings matter, prefer reduce.kind === 'fn' with a deterministic combiner.


selfConsistency — Sample N → majority vote

Like: asking the same question several times and going with the most common answer.

Built from: Parallel(LLM × N) + majority-vote merge. Paper: Self-Consistency Improves Chain of Thought Reasoning in Language Models (Wang et al. 2022).

Run samples parallel LLMCalls with the same input (high temperature for diversity), extract a "vote token" from each, then return the most frequent one. Ties break toward the first sample.

import { mock } from 'agentfootprint/providers';
import { selfConsistency } from 'agentfootprint';

const provider = mock();

const voter = selfConsistency({
  provider,
  model: 'claude-sonnet-4',
  systemPrompt: 'Solve step by step, then end with "ANSWER: <value>".',
  samples: 5,
  extract: (response) => response.split('ANSWER:').pop()?.trim() ?? response.trim(),
});

const result = await voter.run({ message: 'What is 17 × 24?' });

Options:

FieldTypeDefaultMeaning
provider / modelLLMProvider / string(required)Sampler provider + model
systemPromptstring(required)Prompt for every sample
samplesnumber(required)Parallel samples (≥ 2)
extract(response: string) => stringtrimMaps a response to its vote token
temperature / maxTokensnumber0.7 / —Higher temperature = more diverse samples
name / idstring'SelfConsistency' / 'self-consistency'Topology + narrative labels

Throws if samples < 2 — use a single LLMCall for one sample.


debate — Proposer ↔ Critic → Judge

Like: two advocates argue, then a judge rules.

Built from: Sequence(proposer, critic, judge) — or Loop(Sequence(...)) when rounds > 1. Paper: Improving Factuality and Reasoning in Language Models through Multiagent Debate (Du et al. 2023).

A proposer asserts a position, a critic argues against it (rounds times), then a judge reads the transcript and renders the verdict.

import { mock } from 'agentfootprint/providers';
import { debate } from 'agentfootprint';

const provider = mock();

const panel = debate({
  provider,
  model: 'claude-sonnet-4',
  proposerPrompt: 'Argue FOR the proposition.',
  criticPrompt: 'Argue AGAINST the previous argument.',
  judgePrompt: 'Read the transcript and give the final verdict.',
  rounds: 2,   // propose+critique rounds before the judge weighs in (default 1)
});

const result = await panel.run({ message: 'Should this PR be merged?' });

Options:

FieldTypeDefaultMeaning
provider / modelLLMProvider / string(required)Shared by all three personas
proposerPrompt / criticPrompt / judgePromptstring(required)The three personas
roundsnumber1Propose+critique rounds before the judge
temperature / maxTokensnumber—Forwarded to every LLMCall
name / idstring'Debate' / 'debate'Topology + narrative labels

Throws if rounds < 1.


swarm — Route to a specialist each turn

Like: a switchboard that hands each turn to whichever specialist fits.

Built from: Loop(Conditional(route-to-agent)). Paper: OpenAI Swarm (2024).

A fixed roster of agents plus a synchronous route({ message }) function that picks which agent's id handles the next turn. The chosen agent's output feeds the next iteration; the loop halts when route returns undefined, 'done', or an unknown id — or when maxHandoffs is reached.

import { Agent } from 'agentfootprint';
import { mock } from 'agentfootprint/providers';
import { swarm } from 'agentfootprint';

const provider = mock();
const model = 'claude-sonnet-4';

const flow = swarm({
  agents: [
    { id: 'triage',  runner: Agent.create({ provider, model }).system('Triage the request.').build() },
    { id: 'billing', runner: Agent.create({ provider, model }).system('Handle billing.').build() },
  ],
  route: ({ message }) => (message.includes('refund') ? 'billing' : 'triage'),
  maxHandoffs: 10,
});

const result = await flow.run({ message: 'I want a refund.' });

Options:

FieldTypeDefaultMeaning
agentsSwarmAgent[](required)Fixed roster (≥ 2). SwarmAgent = { id, name?, runner }
route(input: { message }) => string | undefined(required)Picks the next agent's id; undefined/'done'/unknown halts
maxHandoffsnumber10Loop cap
name / idstring'Swarm' / 'swarm'Topology + narrative labels

Throws if there are fewer than 2 agents, or if any agent id is 'done' (reserved for the halt branch).


Composing patterns with primitives and compositions

Every pattern returns a Runner, so they plug into every composition (Sequence / Parallel / Conditional):

import { Sequence, Conditional } from 'agentfootprint';

// Pattern inside a Sequence:
Sequence.create({ name: 'research-then-write' })
  .step('research', mapReduce({ provider, model, mapPrompt, shardCount: 3, split, reduce }))
  .step('write',    reflection({ provider, model, proposerPrompt, criticPrompt }))
  .build();

// Pattern inside a Conditional (.when(id, predicate, runner, name?), .otherwise(id, runner)):
Conditional.create({ name: 'triage' })
  .when('complex', (input) => isComplex(input.message), tot({ provider, model, thoughtPrompt, depth: 2, branchingFactor: 3, score }))
  .otherwise('simple', simpleRunner)
  .build();

For dynamic specialist hand-offs inside a loop, use the swarm() factory shown above rather than hand-wiring the Conditional + Loop yourself.


Beyond the shipped patterns

The six factories cover the most common shapes (self-review, beam search, vote, debate, map-reduce, hand-off). When you need something outside that set, remember: every named pattern is a composition of 2 primitives + 3 compositions. Don't invent new Agent classes — compose primitives.

  1. Start with the primitives + compositions — Agent + Sequence / Parallel / Conditional already express most graph shapes.
  2. Give the Agent tools — registering tools on an Agent and letting the LLM choose covers most dynamic-control cases without leaving the Agent abstraction; for specialist hand-offs use swarm().
  3. Drop to footprintjs — the builder (flowChart(), addFunction, addDeciderFunction, addSubFlowChart, loopTo) is the same library agentfootprint is built on. No escape, no re-learning.

If you build a shape worth reusing, the source of the patterns in src/patterns/ is ~85–230 lines each — copy one and modify.

On this page