An agent is a language model that decides its own next action. That's powerful, and it's exactly why it needs boundaries. What I've learned from wiring agents into real systems.
We call it "agentic AI" as soon as a model stops merely answering and starts choosing an action: query a database, call an API, write a file, then look at the result and decide what comes next. In a demo, it's magic. In production, it's a program whose control flow is partly written by a probabilistic model. It has to be treated as such.
Start by asking whether you need an agent
Many "agentic" needs are really workflows: a sequence of steps known in advance, where the model classifies, extracts or drafts. A workflow is faster, cheaper, testable and predictable. An agent earns its place when the path truly depends on what is discovered along the way: diagnosing an incident, exploring a code base, answering a question that takes several chained searches.
The loop, without the magic
Under the hood, every agent is the same loop: send the model the conversation and the list of tools; it answers either with final text or with a tool call request; run the tool, send back the result, and repeat. Everything that matters is what you put around that loop.
export async function runAgent(task: string, tools: Tool[], opts = { maxSteps: 12, maxCostUsd: 0.5 }) {
const messages: Message[] = [{ role: "user", content: task }];
let cost = 0;
for (let step = 0; step < opts.maxSteps; step++) {
const reply = await model.complete({ messages, tools: tools.map((t) => t.schema) });
cost += reply.usage.costUsd;
trace.step({ step, reply }); // every decision is logged and replayable
if (reply.type === "final") return reply.text;
if (cost > opts.maxCostUsd) throw new BudgetExceeded(cost);
const tool = tools.find((t) => t.name === reply.call.name);
const args = tool?.input.safeParse(reply.call.args); // validate, never trust
if (!tool || !args?.success) {
messages.push(toolError(reply.call, "Unknown tool or invalid arguments"));
continue;
}
if (tool.irreversible) await requireHumanApproval(tool.name, args.data);
messages.push(toolResult(reply.call, await tool.run(args.data)));
}
throw new StepLimitReached(opts.maxSteps);
}Narrow, typed, idempotent tools
An agent is first and foremost as good as its tools. A "run SQL" tool gives the model every power and every chance to get it wrong. A "find a customer's orders by phone number" tool does one thing, with validated parameters and a compact result. The model chooses better when the menu is short and each dish is clearly described.
- A strict schema for every tool, checked on the server: the model proposes, the code decides.
- Short, structured results: return 5 useful rows rather than 5,000 that drown the context.
- Idempotent tools: if the agent replays a call, nothing must happen twice.
- Readable errors sent back to the model, so it corrects course instead of going in circles.
Guardrails that are not optional
An agent without limits always ends up finding the infinite loop or the surprise bill. I always set a maximum number of steps, a cost and time budget, and human approval for any irreversible action: sending an e-mail, paying, deleting, publishing. The agent's permissions are those of the user who started it, never more.
Measure before you believe
An agent that works on three examples has proved nothing. I build an evaluation set of about fifty real tasks with their expected outcome, and replay it on every prompt, tool or model change. I track success rate, average number of steps and cost per task. Each run's traces explain the failures: in the vast majority of cases, the problem is a poorly described tool, not the model.
In short
- Workflow first; agent when the path is truly unpredictable.
- Few tools: narrow, typed, idempotent.
- Step and budget limits, human approval for anything irreversible.
- Full traces and an evaluation set replayed on every change.
Read next
Idempotency: the property that protects your payments from double taps
The network drops, the user tries again, the client retries the request: in a payment system, any request may arrive twice. Idempotency guarantees it is only carried out once.

What are some rules to follow for good app architecture in Flutter?
Principles for structuring a maintainable, scalable Flutter app.