A chat completion answers once. An agent keeps going until a goal is met. The minimum agent is a loop with four parts: a model, a tool registry, a memory of the conversation so far, and a stop condition.
- Model: decides the next action. - Tools: the only way the agent can affect the world (HTTP calls, SQL, file writes). - Trace: the growing message list, which is the agent's short-term memory. - Stop: the model returns a final answer, or a max-step budget is hit.
ts
type Msg = { role: "system" | "user" | "assistant" | "tool"; content: string; tool_call_id?: string };
export async function runAgent(goal: string, tools: ToolRegistry, maxSteps = 8) {
const messages: Msg[] = [
{ role: "system", content: "You are a careful research agent. Use tools before answering." },
{ role: "user", content: goal },
];
for (let step = 0; step < maxSteps; step++) {
const res = await chat(messages, tools.schemas); // one model call
const calls = res.tool_calls ?? [];
messages.push(res.message);
if (calls.length === 0) return res.message.content; // final answer
for (const call of calls) {
const out = await tools.run(call.name, JSON.parse(call.arguments));
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(out) });
}
}
throw new Error("step budget exhausted");
}Everything else in agent engineering — planning, multi-agent teams, MCP, guardrails — is a refinement of this loop. Always build the loop first and add structure only when a real failure demands it.
Exercise: implement `runAgent` with two tools (`search`, `calculator`) and log every step. Ask a question that needs both. Confirm from the log that the model called the tools rather than guessing.