A tiny, fully-typed, bounded and self-correcting agentic tool-use loop for the Anthropic Messages API. No framework. No orchestration engine. No API key needed to run the tests - the whole loop is exercised against a scripted mock client.
Most "agents" are a single LLM call that emits one tool call. That is not an agent - it is a function call with extra steps. A real agentic loop lets the model:
- call multiple tools across several turns,
- see its own tool failures and self-correct instead of aborting,
- stop when it decides it is done (
end_turn), not after a fixed number of steps,
...while the harness enforces hard guards so it can never run forever, never hangs on a safety refusal, and never sends a malformed follow-up request.
agentic-tool-loop is ~120 lines that get those four things right.
pnpm add agentic-tool-loop @anthropic-ai/sdkimport Anthropic from '@anthropic-ai/sdk';
import { runAgentLoop } from 'agentic-tool-loop';
const client = new Anthropic();
const result = await runAgentLoop({
client,
model: 'claude-sonnet-4-6',
system: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'What is (2 + 3) x 4?' }],
tools: [
{
name: 'add',
description: 'Add x and y',
input_schema: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
},
{
name: 'multiply',
description: 'Multiply x and y',
input_schema: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
},
],
// The one seam you own: run the tool, report the outcome.
async executeTool(name, input) {
const x = Number(input.x),
y = Number(input.y);
if (name === 'add') return { ok: true, result: { value: x + y } };
if (name === 'multiply') return { ok: true, result: { value: x * y } };
return { ok: false, error: `unknown tool: ${name}` };
},
onEvent: (e) => e.type === 'text_delta' && process.stdout.write(e.text),
});
// result.status is 'completed' | 'refused' | 'max_iterations' | 'incomplete'
console.log(result);Run the offline demo (no API key):
pnpm example ┌───────────────────────────────────────────┐
│ for iteration in 0..maxIterations (=6) │
└───────────────────────────────────────────┘
│
stream one model turn
│
┌────────────┴────────────┐
stop_reason? │
┌───────────┬─────────────┬──────────────┴─────────┐
end_turn tool_use refusal (max_tokens / null)
│ │ │ │
▼ ▼ ▼ ▼
completed run ALL tools refused incomplete
+ text in parallel
│
feed each result back as tool_result
(failures -> is_error: true)
│
loop again ──────────────────────────────┐
▼
never finished in maxIterations -> max_iterations
- Parallel tool execution. When the model requests several tools in one turn, they
run with
Promise.alland each gets a matchingtool_result. This is not just speed: the API rejects any follow-up where atool_useid has no correspondingtool_result, so the 1:1 mapping is a correctness invariant, not an optimization. - Failure feedback drives self-correction. A failed tool (or a thrown executor) is
returned to the model as a
tool_resultwithis_error: trueand a message - so the model reads the reason and retries, instead of the loop throwing. - Explicit refusal guard.
stop_reason === 'refusal'resolves a distinctrefusedresult rather than falling through to a generic error. - The loop is bounded. No
end_turnwithinmaxIterationsresolvesmax_iterations- an agent that cannot be trusted to terminate is a bug, not a feature.
- Zero domain coupling. Tool execution is injected via
executeTool; inputs can be rewritten viatransformToolInput(e.g. stamp a trusted tenant id so it can never be set from model output). The loop knows nothing about your tools.
function runAgentLoop(config: AgentLoopConfig): Promise<AgentResult>;AgentLoopConfig field |
Type | Notes |
|---|---|---|
client |
AnthropicClientLike |
A real new Anthropic() or any structural mock. |
model |
string |
e.g. claude-sonnet-4-6. |
system |
string | TextBlockParam[] |
Plain string or cacheable blocks. |
messages |
MessageParam[] |
Copied, never mutated. |
tools |
Tool[] |
Empty array = tool-free single turn. |
executeTool |
(name, input) => Promise<ToolOutcome> |
The one required seam. |
transformToolInput? |
(name, input) => input |
Rewrite input before execution. |
maxIterations? |
number |
Default 6. |
maxTokens? |
number |
Default 8192. |
thinking? |
ThinkingConfigParam |
Adaptive-thinking passthrough. |
onEvent? |
(event: AgentEvent) => void |
Streaming: iteration, text_delta, tool_call, tool_result. |
AgentResult is a discriminated union on status:
completed (with text + toolCalls), refused, max_iterations, or incomplete
(with reason).
pnpm install
pnpm run build # tsup -> dist (ESM + .d.ts)
pnpm run typecheck # tsc --noEmit
pnpm run test # vitest
pnpm run test:coverage
pnpm run lintMIT