A minimal, bring-your-own-LLM ReAct (Reason + Act) agent loop for TypeScript.
⚠️ ReAct = Reasoning and Acting — the LLM agent pattern of Think → Act → Observe (Yao et al., 2022). This is NOT React.js, the UI library. There is no JSX, no components, no DOM here — just an agent control loop.
react-agent-loop is a tiny, dependency-free control loop for building tool-using LLM agents. It implements the classic Reason + Act cycle — the model thinks, acts by calling tools, observes the results, and repeats until the task is done — while leaving every external concern (which LLM, which tools, how to persist) to you.
Most agent frameworks bundle a specific LLM SDK, a vector store, a prompt DSL, and a runtime you have to adopt wholesale. This one doesn't.
- A clean Think → Act → Observe loop you can drop any LLM into. Implement one method (
complete) over OpenAI, Anthropic, a local model, or a mock. - Pluggable tools via a small
ToolRegistry—name,description, JSON-schemaparameters, and an asynchandler. - Hooks for guardrails.
beforeToolCallis the seam where you plug in a loop detector, a policy engine, or a budget guard to block or rewrite actions before they run. - Zero runtime dependencies. Node ≥ 20, ESM, strict TypeScript, full type declarations.
- Bring your own everything — no SDK lock-in, no hidden network calls, no global state.
npm i github:Princeu3/react-agent-loopnpm registry release coming soon. For now install straight from GitHub.
import { runReActLoop } from "react-agent-loop";
import type { LlmClient, Tool } from "react-agent-loop";
// 1. Define a tool. `parameters` is a JSON Schema object.
const getWeather: Tool = {
name: "get_weather",
description: "Get the current weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
handler: async (args) => {
const city = String(args.city);
// ...call a real API here...
return `It is 22°C and sunny in ${city}.`;
},
};
// 2. Implement LlmClient over your provider (OpenAI shown; Anthropic works too).
// The library never imports an SDK — you own the network call.
const llm: LlmClient = {
async complete({ system, messages, tools }) {
// Map `messages` + `tools` to your provider's format, call it, then map
// the response back to { text?, toolCalls? }.
const res = await callYourProvider({ system, messages, tools });
return { text: res.text, toolCalls: res.toolCalls };
},
};
// 3. Run the loop. It registers a built-in `task_done` tool the agent calls
// to finish. Think → Act → Observe repeats until then (or maxTurns).
const result = await runReActLoop({
llm,
tools: [getWeather],
system: "You are a helpful weather assistant. Call task_done when finished.",
initialMessages: [{ role: "user", content: "What's the weather in Lisbon?" }],
maxTurns: 10,
});
console.log(result.stopped); // "task_done" | "shouldStop" | "maxTurns"
console.log(result.finalSummary); // summary the agent passed to task_done
console.log(result.turns); // number of turns executedconst result = await runReActLoop({
llm,
tools: [getWeather, deleteEverything],
system: "…",
hooks: {
// Block dangerous actions before they execute. Return { block, reason }.
beforeToolCall(call) {
if (call.name === "deleteEverything") {
return { block: true, reason: "Blocked by policy: destructive action." };
}
},
// Inject fresh context each turn (e.g. retrieved memory).
buildContext(state) {
// mutate state.messages here if needed
},
// Stop early on a custom condition.
shouldStop: (state) => state.turn > 3 && somethingHappened(state),
// Persist each completed turn.
persistTurn: (turn) => saveToDb(turn),
},
});A blocked tool is not executed; instead a tool message carrying your reason is appended so the model can react and try something else.
| Option | Type | Default | Description |
|---|---|---|---|
llm |
LlmClient |
— | Your provider adapter. Required. |
tools |
Tool[] | ToolRegistry |
— | Tools the agent may call. Required. |
system |
string |
— | System prompt. Required. |
initialMessages |
Message[] |
[] |
Seed conversation (e.g. the user request). |
hooks |
AgentHooks |
{} |
Lifecycle + guardrail hooks (all optional). |
maxTurns |
number |
25 |
Hard ceiling before the loop force-stops. |
stopToolName |
string |
"task_done" |
Name of the tool that ends the run. |
context |
Ctx |
— | Arbitrary value exposed on state.context. |
Each turn: build context → llm.complete → append assistant message → for each tool call: beforeToolCall guard (skip + record if blocked) else run the handler → append the tool result as an observation → onToolResult → persist the turn. The loop stops when the stopToolName tool is called, shouldStop returns true, or maxTurns is reached.
Returns AgentRunResult: { stopped, turns, messages, finalSummary? }.
Message—{ role: "system" | "user" | "assistant" | "tool"; content; toolCalls?; toolCallId?; name? }.ToolCall—{ id, name, arguments }.ToolResult—{ toolCallId, content, isError? }.Tool—{ name, description, parameters, handler(args, ctx) }. A handler may return aToolResultor a plain string.ToolRegistry—register/get/has/list/toSpecs.LlmClient— you implementcomplete({ system, messages, tools }): Promise<{ text?, toolCalls? }>.AgentHooks—buildContext,onTurnStart,beforeToolCall,onToolResult,onTurnEnd,shouldStop,persistTurn(all optional).
A built-in task_done tool is registered automatically (unless you supply one by that name); the agent calls it with a summary to finish.
beforeToolCall is a deliberate extension point. Compose it with:
- agent-loop-detector — detect repetitive tool-call patterns and break out of loops (wire it into
beforeToolCall/shouldStop). - agent-policy-engine — allow/deny/rewrite tool calls against a policy (wire it into
beforeToolCall). - agent-budget-guard — cap turns, tokens, or spend and stop when exhausted.
Distilled and authored by Prince Upadhyay (@Princeu3). The control-flow architecture is inspired by the MIT-licensed Conway-Research/automaton agent loop, re-authored here as a clean, framework-agnostic library with all project-specific coupling (SQLite, orchestration, provider SDKs) removed. See NOTICE.
ai-agents, react-agent, reasoning-and-acting, agent-framework, llm-agents, tool-use, agent-loop, autonomous-agents, think-act-observe, llm, typescript, agentic, tool-calling, openai, anthropic — a ReAct (Reason + Act, not React.js) agent loop for building tool-using LLM agents in TypeScript.
MIT © 2026 Prince Upadhyay