Back to blog
◆ Tier 1 ● HIGH priority 17 min read2026-04-05

Tool-Calling Agents and the ReAct Pattern — Building AI That Takes Action

How tool-calling agents work, the ReAct reasoning loop, when to use agents vs chains, and how to design approval gates and rollback for production agent systems.

AI agents frontendReAct patterntool calling LLMLangChain.js agentsagent architectureAI agent approval gates

Beyond chat: AI that does things

Most AI features today are conversational — the user asks, the AI answers. But the next wave is agentic: the AI can take actions in your app. Search for data, create records, update settings, send notifications — all through natural language.

This is not science fiction. GitHub Copilot writes code. Cursor edits files. Claude Code runs terminal commands. These are all tool-calling agents. The architecture is the same whether the agent is writing code or managing a project board.

The ReAct loop

Every tool-calling agent follows the same reasoning pattern, called ReAct (Reason + Act):

┌─────────────────────────────────────────────┐
│                 USER REQUEST                 │
│  "Move all overdue tasks to next sprint"     │
└──────────────────┬──────────────────────────┘
                   ▼
┌─────────────────────────────────────────────┐
│  1. THINK  — What do I need to do?           │
│  "I need to find overdue tasks, then move    │
│   each one to the next sprint."              │
└──────────────────┬──────────────────────────┘
                   ▼
┌─────────────────────────────────────────────┐
│  2. ACT   — Call a tool                      │
│  Tool: searchTasks({ status: 'overdue' })    │
└──────────────────┬──────────────────────────┘
                   ▼
┌─────────────────────────────────────────────┐
│  3. OBSERVE — Process the tool result        │
│  "Found 7 overdue tasks. Next I'll get the   │
│   next sprint ID."                           │
└──────────────────┬──────────────────────────┘
                   ▼
         (loop back to THINK)
                   ▼
┌─────────────────────────────────────────────┐
│  4. RESPOND — Final answer to user           │
│  "Done. Moved 7 tasks to Sprint 24."         │
└─────────────────────────────────────────────┘

The LLM does not execute anything directly. It decides which tool to call and with what arguments, then your code executes the tool and feeds the result back. The LLM reasons about what to do next based on the observation.

Implementing tools with the Vercel AI SDK

import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const result = await generateText({
  model: openai('gpt-4o'),
  tools: {
    searchTasks: tool({
      description: 'Search for tasks by status, assignee, or sprint.',
      parameters: z.object({
        status: z.enum(['overdue', 'in-progress', 'done', 'blocked']).optional(),
        assignee: z.string().optional(),
        sprint: z.string().optional(),
      }),
      execute: async ({ status, assignee, sprint }) => {
        // Your actual database query
        return await db.tasks.findMany({ where: { status, assignee, sprint } });
      },
    }),
    moveTask: tool({
      description: 'Move a task to a different sprint.',
      parameters: z.object({
        taskId: z.string(),
        targetSprint: z.string(),
      }),
      execute: async ({ taskId, targetSprint }) => {
        return await db.tasks.update({
          where: { id: taskId },
          data: { sprint: targetSprint },
        });
      },
    }),
    notifyTeam: tool({
      description: 'Send a notification to the team about a change.',
      parameters: z.object({
        message: z.string(),
        channel: z.enum(['slack', 'email', 'in-app']),
      }),
      execute: async ({ message, channel }) => {
        return await notifications.send({ message, channel });
      },
    }),
  },
  maxSteps: 10, // Maximum ReAct loops before stopping
  prompt: 'Move all overdue tasks to next sprint and notify the team.',
});

The key decisions in this code:

  1. Tool descriptions matter enormously. The LLM chooses tools based on descriptions, not code. A vague description means wrong tool selection.
  2. Parameters are validated with Zod. The LLM generates arguments that must match the schema. Invalid arguments are caught before execution.
  3. maxSteps prevents infinite loops. Without a cap, a confused agent can loop forever.

Implementing tools with LangChain.js

import { ChatOpenAI } from '@langchain/openai';
import { DynamicStructuredTool } from '@langchain/core/tools';
import { AgentExecutor, createOpenAIFunctionsAgent } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { z } from 'zod';

const searchTool = new DynamicStructuredTool({
  name: 'search_tasks',
  description: 'Search for tasks by status, assignee, or sprint.',
  schema: z.object({
    status: z.enum(['overdue', 'in-progress', 'done', 'blocked']).optional(),
  }),
  func: async ({ status }) => {
    const tasks = await db.tasks.findMany({ where: { status } });
    return JSON.stringify(tasks);
  },
});

const moveTool = new DynamicStructuredTool({
  name: 'move_task',
  description: 'Move a task to a different sprint.',
  schema: z.object({
    taskId: z.string(),
    targetSprint: z.string(),
  }),
  func: async ({ taskId, targetSprint }) => {
    const result = await db.tasks.update({
      where: { id: taskId },
      data: { sprint: targetSprint },
    });
    return JSON.stringify(result);
  },
});

const model = new ChatOpenAI({ modelName: 'gpt-4o' });
const tools = [searchTool, moveTool];
const prompt = ChatPromptTemplate.fromMessages([
  ['system', 'You are a project management assistant. Use tools to help users manage tasks.'],
  ['human', '{input}'],
  ['placeholder', '{agent_scratchpad}'],
]);

const agent = await createOpenAIFunctionsAgent({ llm: model, tools, prompt });
const executor = new AgentExecutor({ agent, tools, maxIterations: 10 });

const result = await executor.invoke({
  input: 'Move all overdue tasks to next sprint',
});

When to use agents vs chains

Not everything needs an agent. If you know the exact sequence of steps, a chain (pipeline) is simpler, cheaper, and more predictable.

Factor Chain (pipeline) Agent (ReAct)
Steps Fixed, predefined Dynamic, decided at runtime
Cost 1 LLM call 3-15 LLM calls per request
Latency Predictable Variable (depends on reasoning)
Reliability High (deterministic) Lower (LLM may choose wrong tool)
Use when You know the workflow upfront User intent is open-ended
Example "Extract name and email from this text" "Help me reorganize my project"

The decision rule: if you can draw the flowchart ahead of time, use a chain. If the user's request could require different tools in different orders, use an agent.

Approval gates: the safety layer

Agents that take actions need guardrails. The most important pattern is the approval gate — pausing before destructive or high-impact actions to get user confirmation.

// Action classification
type ActionRisk = 'low' | 'medium' | 'high' | 'critical';

interface ActionClassification {
  risk: ActionRisk;
  requiresApproval: boolean;
  reason: string;
}

function classifyAction(toolName: string, args: Record<string, any>): ActionClassification {
  // Read-only tools are always safe
  const readOnly = ['search_tasks', 'get_sprint', 'list_members'];
  if (readOnly.includes(toolName)) {
    return { risk: 'low', requiresApproval: false, reason: 'Read-only operation' };
  }

  // Single-item mutations are medium risk
  const singleMutation = ['move_task', 'update_task', 'assign_task'];
  if (singleMutation.includes(toolName)) {
    return { risk: 'medium', requiresApproval: false, reason: 'Single item change' };
  }

  // Batch operations and notifications need approval
  const batchOrExternal = ['bulk_move', 'notify_team', 'delete_task', 'close_sprint'];
  if (batchOrExternal.includes(toolName)) {
    return {
      risk: 'high',
      requiresApproval: true,
      reason: 'Batch operation or external notification — requires user confirmation',
    };
  }

  // Unknown tools always need approval
  return { risk: 'critical', requiresApproval: true, reason: 'Unknown action' };
}

On the frontend, the approval gate appears as a confirmation card showing what the agent wants to do, why, and what will change:

// Approval gate UI structure
interface ApprovalRequest {
  action: string;         // "Move 7 tasks to Sprint 24"
  tool: string;           // "bulk_move"
  args: Record<string, any>;
  impact: string;         // "7 tasks will be reassigned"
  reversible: boolean;    // Can this be undone?
  risk: ActionRisk;
}

// User sees:
// ┌──────────────────────────────────────┐
// │ 🤖 Agent wants to:                   │
// │                                      │
// │ Move 7 overdue tasks to Sprint 24    │
// │                                      │
// │ Impact: 7 tasks will be reassigned   │
// │ Reversible: Yes                      │
// │                                      │
// │  [Approve]  [Modify]  [Cancel]       │
// └──────────────────────────────────────┘

Action history and transparency

Users must be able to see what the agent did. Every tool call should be logged and visible.

interface AgentAction {
  id: string;
  timestamp: number;
  tool: string;
  args: Record<string, any>;
  result: any;
  status: 'pending' | 'approved' | 'executed' | 'rolled_back' | 'failed';
  approvedBy?: string;
}

class ActionLog {
  private actions: AgentAction[] = [];

  record(action: Omit<AgentAction, 'id' | 'timestamp'>): string {
    const entry: AgentAction = {
      ...action,
      id: crypto.randomUUID(),
      timestamp: Date.now(),
    };
    this.actions.push(entry);
    return entry.id;
  }

  async rollback(actionId: string): Promise<void> {
    const action = this.actions.find(a => a.id === actionId);
    if (!action || action.status !== 'executed') return;

    // Execute the inverse operation
    await this.executeRollback(action);
    action.status = 'rolled_back';
  }

  getHistory(): AgentAction[] {
    return [...this.actions].reverse();
  }

  private async executeRollback(action: AgentAction): Promise<void> {
    // Each tool needs a registered inverse operation
    // move_task → move_task back to original sprint
    // create_task → delete_task
    // etc.
  }
}

The UI should show a collapsible action log — users can see the agent's reasoning and actions, expand individual steps, and roll back specific actions.

Cost awareness

Agents are expensive. Each ReAct loop is an LLM call. A complex multi-step request can cost 10-15 calls. At $0.01-0.03 per call (GPT-4o), that is $0.10-0.45 per user request.

Design decisions that control cost:

  1. Limit maxSteps — cap the reasoning loops (8-12 is usually enough)
  2. Use cheaper models for tool selection — GPT-4o-mini for choosing tools, GPT-4o only for complex reasoning
  3. Cache tool results — if the same search is called twice, return cached results
  4. Show cost to power users — let them see token usage per conversation

Practice designing this

For the foundation on AI integration, see 5 AI Patterns Every Frontend Engineer Will Build in 2026.

LLM-friendly summary

A practical guide to building tool-calling AI agents for frontend applications, covering the ReAct reasoning loop, tool registration, approval gates, action history, and rollback design.