We are going to build an agent that answers questions about a project's deploy history — "why did the checkout service fail to deploy on Tuesday, and did anything change in its config?" — by calling real tools against a real database, and streaming each step to the browser as it happens.
The interesting part is not the model call. It is the plumbing: keeping the loop bounded, keeping credentials on the server, turning tool activity into something a user can watch, and making sure a client component sees the smallest possible slice of all that. By the end you will have four files and a working endpoint.
This assumes an App Router project on Next.js 16 and React 19. If the underlying concepts are unfamiliar, the complete guide to AI agents covers the loop in isolation.
The shape of the thing
app/
agent/page.jsx # Server Component, renders the client leaf
api/agent/route.js # POST: runs the loop, streams NDJSON
lib/agent/
tools.js # tool definitions + executors
run.js # the bounded loop
components/agent/
AgentConsole.jsx # 'use client' — the only client fileOne rule guides the whole layout: the model API key, the database client and the loop never leave the server. The browser receives a stream of already-sanitised events. That is not just a security preference — it is what makes the client component small enough to be trivially correct.
Environment and the trust boundary
Two variables, both server-only. Anything that must reach the browser needs the
NEXT_PUBLIC_ prefix, and neither of these does.
ANTHROPIC_API_KEY=sk-ant-...
AGENT_MODEL=claude-sonnet-4-5
DATABASE_URL=postgres://...Reading process.env.ANTHROPIC_API_KEY inside a file that is imported by a Client
Component is the single most common way to leak a key in an App Router codebase. Keep
the model client in lib/agent/ and never import that directory from anything carrying
'use client'.
Defining tools a model can pick correctly
Tool descriptions are not documentation. They are the input to a selection decision the model makes dozens of times per run, and they deserve more editing than your prompt.
import { db } from '@/lib/db';
export const tools = [
{
name: 'list_deploys',
description:
'List deploys for one service, newest first. Includes failed deploys. ' +
'Use this to find WHEN something happened. It does not return logs — ' +
'call get_deploy_log with a deploy id for that.',
input_schema: {
type: 'object',
properties: {
service: { type: 'string', description: 'Service name from the registry' },
limit: { type: 'integer', minimum: 1, maximum: 25, default: 10 },
},
required: ['service'],
},
async execute({ service, limit = 10 }) {
const rows = await db.deploy.findMany({
where: { service },
orderBy: { startedAt: 'desc' },
take: limit,
select: { id: true, status: true, startedAt: true, commitSha: true },
});
if (rows.length === 0) {
return { note: `No service named "${service}" has any deploys recorded.` };
}
return { deploys: rows };
},
},
{
name: 'get_deploy_log',
description:
'Return the last 200 log lines for one deploy id, obtained from list_deploys. ' +
'Output is truncated; it is a diagnostic sample, not the complete log.',
input_schema: {
type: 'object',
properties: { deployId: { type: 'string' } },
required: ['deployId'],
},
async execute({ deployId }) {
const log = await db.deployLog.findUnique({ where: { deployId } });
if (!log) return { note: `No log stored for deploy ${deployId}.` };
return { lines: log.body.split('\n').slice(-200) };
},
},
];
export const toolRegistry = new Map(tools.map((tool) => [tool.name, tool]));Three deliberate choices. Line 10 tells the model what this tool is not for and names
the tool that is — the cheapest fix for wrong tool selection there is. Line 16 caps
limit in the schema, so a model asking for ten thousand rows fails validation instead
of the database. Line 35 returns a sentence rather than an empty array; a model reading
[] frequently decides the call failed and retries it, burning a step.
The loop
The loop calls the model, executes any requested tools, appends the results, and repeats until the model stops asking for tools or the step budget runs out. It is an async generator so the route handler can stream each step without the loop knowing anything about HTTP.
import { tools, toolRegistry } from './tools';
const SYSTEM = `You are a deploy investigator. Answer using only what the tools return.
If the tools do not contain the answer, say so plainly and name what is missing.
Never speculate about a cause you have not seen evidence for in a log.`;
async function callModel(messages, signal) {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
signal,
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: process.env.AGENT_MODEL,
max_tokens: 2048,
system: SYSTEM,
messages,
tools: tools.map(({ name, description, input_schema }) => ({
name,
description,
input_schema,
})),
}),
});
if (!response.ok) {
throw new Error(`Model request failed: ${response.status}`);
}
return response.json();
}
export async function* runAgent({ question, maxSteps = 8, signal }) {
const messages = [{ role: 'user', content: question }];
for (let step = 1; step <= maxSteps; step += 1) {
const reply = await callModel(messages, signal);
messages.push({ role: 'assistant', content: reply.content });
const toolUses = reply.content.filter((block) => block.type === 'tool_use');
if (toolUses.length === 0) {
const text = reply.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('');
yield { type: 'answer', text };
return;
}
const results = [];
for (const use of toolUses) {
yield { type: 'tool_call', step, name: use.name, input: use.input };
const tool = toolRegistry.get(use.name);
try {
const value = tool
? await tool.execute(use.input)
: { error: `Unknown tool: ${use.name}` };
results.push({
type: 'tool_result',
tool_use_id: use.id,
content: JSON.stringify(value),
});
yield { type: 'tool_result', step, name: use.name, ok: true };
} catch (error) {
results.push({
type: 'tool_result',
tool_use_id: use.id,
content: `Error: ${error.message}`,
is_error: true,
});
yield { type: 'tool_result', step, name: use.name, ok: false };
}
}
messages.push({ role: 'user', content: results });
}
yield { type: 'aborted', reason: 'step_budget_exhausted' };
}Line 23 keeps the model identifier in configuration, so upgrading is a deploy variable
rather than a code change. Line 44 is the exit condition — no tool blocks means the
model considers itself finished. Line 58 is the one most first implementations get
wrong: a tool that throws must become a tool_result the model can read, not an
exception that ends the run. Given the error text, a model will usually correct its
arguments and try again. Given a 500, it cannot do anything.
Note what is not streamed: raw tool output. The client is told a tool ran and whether it succeeded. Row contents stay on the server.
Streaming the step log
Token streaming is well covered elsewhere. What users actually want from an agent is different — they want to know it is doing something, and what. Newline-delimited JSON is the least ceremonious way to send that.
import { runAgent } from '@/lib/agent/run';
export const runtime = 'nodejs';
export const maxDuration = 60;
export async function POST(request) {
const { question } = await request.json();
if (typeof question !== 'string' || question.trim().length < 3) {
return Response.json({ error: 'A question is required.' }, { status: 400 });
}
const encoder = new TextEncoder();
const controller = new AbortController();
request.signal.addEventListener('abort', () => controller.abort());
const stream = new ReadableStream({
async start(streamController) {
try {
for await (const event of runAgent({
question: question.slice(0, 2000),
signal: controller.signal,
})) {
streamController.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
}
} catch (error) {
const payload = { type: 'error', message: 'The agent run failed.' };
streamController.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`));
} finally {
streamController.close();
}
},
});
return new Response(stream, {
headers: {
'content-type': 'application/x-ndjson; charset=utf-8',
'cache-control': 'no-store',
},
});
}Line 7 validates before spending a token. Line 15 wires browser disconnects to the abort signal, so closing the tab actually stops the run instead of leaving it billing against a socket nobody is reading. Line 29 sends a generic message to the client while the real error stays in your server logs — model errors routinely echo request bodies.
The client leaf
This is the only file with 'use client', and it does one job: read the stream and
render it.
'use client';
import { useState } from 'react';
export default function AgentConsole() {
const [question, setQuestion] = useState('');
const [events, setEvents] = useState([]);
const [running, setRunning] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setEvents([]);
setRunning(true);
const response = await fetch('/api/agent', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question }),
});
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) setEvents((prev) => [...prev, JSON.parse(line)]);
}
}
setRunning(false);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="agent-question">Ask about a deploy</label>
<input
id="agent-question"
value={question}
onChange={(event) => setQuestion(event.target.value)}
disabled={running}
required
/>
<button type="submit" disabled={running || question.trim().length < 3}>
{running ? 'Investigating…' : 'Ask'}
</button>
<ol aria-live="polite" aria-busy={running}>
{events.map((event, index) => (
<li key={index}>{describe(event)}</li>
))}
</ol>
</form>
);
}
function describe(event) {
if (event.type === 'tool_call') return `Calling ${event.name}…`;
if (event.type === 'tool_result') {
return event.ok ? `${event.name} returned` : `${event.name} failed`;
}
if (event.type === 'answer') return event.text;
if (event.type === 'aborted') return 'Stopped: step budget reached.';
return 'Something went wrong.';
}Line 12 resets state before the request so a second question does not append to the first. Line 26 is the part people skip: a chunk boundary can land mid-line, so the trailing fragment must be carried into the next read. Dropping it produces the classic "works locally, JSON parse errors in production" bug, because local responses arrive in one chunk and real ones do not.
The aria-live="polite" list matters as much as the parsing. An agent that streams
progress no screen reader announces is an agent that appears frozen. Pair it with a
prefers-reduced-motion guard on any spinner you add.
The page itself stays a Server Component:
import AgentConsole from '@/components/agent/AgentConsole';
export const metadata = {
title: 'Deploy investigator',
description: 'Ask questions about deploy history and configuration changes.',
};
export default function AgentPage() {
return (
<main>
<h1>Deploy investigator</h1>
<AgentConsole />
</main>
);
}The wider pattern — server work streaming into a small interactive leaf — is worth understanding properly, and Server Components and streaming AI interfaces goes into the rendering model behind it.
Guardrails before this meets a user
Cap the steps and the duration. maxSteps bounds the loop; maxDuration bounds
the function. Without both, a model that loops on a failing tool will run until the
platform kills it.
Scope the tools per run. These two tools are read-only, which is why this example is comfortable. The moment you add a tool that writes, decide which sessions may see it — and read why prompt injection is an architecture problem first, because log lines are attacker-influenced text heading straight into context.
Rate-limit by user, not by IP. An agent request can cost many model calls. One impatient user hammering the submit button is a genuine cost incident.
Persist the transcript. When a user says the answer was wrong, the tool calls and arguments are the only thing that will tell you why.
Make writes idempotent. Not needed here, but the moment a tool has side effects, a retried step must not duplicate the effect.
What to test first
Write four fixed questions with known correct answers before you tune anything: one the tools can answer in a single call, one needing two chained calls, one whose answer is genuinely absent from the data, and one naming a service that does not exist. Assert on the tool sequence and the final answer, not on wording.
The third and fourth cases are the ones that catch regressions. A model that invents a cause when the log is empty, or hallucinates a service rather than saying it cannot find one, will pass every happy-path test you write. If you want to standardise this tool layer so other clients can reuse it, the Model Context Protocol is the next thing to read.