Build Your First AI Agent (Safely, With Code)

Start tiny: one tool, hard budgets, and a kill switch. This is the fastest path to an agent that won’t embarrass you in production.
On this page
  1. The problem
  2. Why this matters in real systems
  3. The playbook (20 minutes, no heroics)
  4. What “one tool” really means
  5. Step 0: pick a task that won’t bankrupt you
  6. Step 1: budgets (the thing that turns a loop into a system)
  7. Step 2: log the action trace
  8. Step 2.5: make tools deterministic (or you can’t debug)
  9. Code (minimal but production-shaped)
  10. Make it real (a tiny runnable skeleton)
  11. Step 3: a tiny “tool policy” (even for read-only)
  12. Tool contracts (make the model play inside the lines)
  13. Step 4: a rollout that doesn’t hurt
  14. Step 4.5: cancellation (stop spending when the user leaves)
  15. Step 5: success metrics (so you know it’s getting better)
  16. Where to run it (serverless vs workers)
  17. Hardening v2 (still small, still safe)
  18. Add args hashing + dedupe
  19. Add per-tool budgets
  20. Add a kill switch
  21. Add a “stop reason”
  22. The first write tool (how to not screw it up)
  23. Common first-agent mistakes (we’ve done them)
  24. Real failure
  25. When NOT to build an agent (yet)
  26. Next

The problem

Your first agent will try to do everything.

It’ll browse the web, write to databases, “optimize” configs, and generally behave like an intern with root access.

Don’t start there.

Start with one tool, read-only, with budgets and logs.

Why this matters in real systems

The first time you ship tool calling:

  • you will discover missing timeouts
  • you will discover you didn’t have idempotency
  • you will discover your logs are useless

Better to find that with one tool than with twelve.

The playbook (20 minutes, no heroics)

  1. Pick a single read-only tool (web.search or kb.search).
  2. Add budgets: steps + time (+ $ if you can).
  3. Log every tool call (name, args hash, duration, status).
  4. Add loop detection on repeated tool args.
  5. Do not add “send” or “write” tools yet.

What “one tool” really means

“One tool” is not “one tool function plus a hidden escape hatch”.

It means:

  • you can point at the allowlist and count them on one hand
  • there is no generic http.request tool with full internet access
  • there is no run_shell tool “for debugging”

If your first agent can browse arbitrary URLs, you’ve already skipped the safe phase.

Step 0: pick a task that won’t bankrupt you

Good first tasks:

  • “find 5 relevant docs from our KB”
  • “summarize recent incidents from the internal postmortem folder”
  • “draft a reply using known templates”

Bad first tasks:

  • “browse the web until you’re confident”
  • “fix prod config”
  • “run commands on servers”

Your first agent should be boring and cheap.

Step 1: budgets (the thing that turns a loop into a system)

Budgets are not “nice to have”. They’re the difference between:

  • a request that stops
  • a request that keeps paying

We use at least:

  • max steps
  • max seconds

If you can estimate $ spend, add it early. It will pay for itself.

Step 2: log the action trace

You don’t need a perfect tracing system on day one. You need enough to answer:

  • what tools did it call?
  • with what arguments?
  • how long did each call take?
  • why did it stop?

If you can’t answer those, you can’t ship it.

Step 2.5: make tools deterministic (or you can’t debug)

Your agent is a loop. Loops are hard enough. Now add tools that return different results every run (timeouts, flaky APIs, changing pages). Congrats, you’ve built a nondeterministic system and you’re about to debug it at 03:00.

The trick we use early: record/replay. For a given run, record:

  • tool name
  • args hash
  • response (or error)

Then you can replay the same “world” to test:

  • prompt changes
  • model changes
  • loop guard tweaks

Minimal conceptual code:

PYTHON
class TapeTools(SafeTools):
  def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
      super().__init__(allow=allow, impl=impl)
      self.tape = tape if tape is not None else []

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      try:
          out = super().call(name, args=args, request_id=request_id)
          self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
          return out
      except Exception as e:
          self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
          raise


def replay(tape: list[dict]):
  # Replace real tools with deterministic playback.
  i = 0
  def impl(name: str, **args):
      nonlocal i
      item = tape[i]
      i += 1
      assert item["tool"] == name
      if not item["ok"]:
          raise RuntimeError(item["err"])
      return item["out"]
  return impl
JAVASCRIPT
export class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
  super({ allow, impl });
  this.tape = tape || [];
}

async call(name, { args, requestId }) {
  try {
    const out = await super.call(name, { args, requestId });
    this.tape.push({ tool: name, args, ok: true, out });
    return out;
  } catch (e) {
    this.tape.push({
      tool: name,
      args,
      ok: false,
      err: String(e && e.message ? e.message : e),
    });
    throw e;
  }
}
}

export function replay(tape) {
let i = 0;
return async function call(name, args) {
  const item = tape[i];
  i += 1;
  if (!item || item.tool !== name) throw new Error("tape mismatch");
  if (!item.ok) throw new Error(item.err);
  return item.out;
};
}

Is it glamorous? No. Does it let you run a “golden suite” of 20 tasks before shipping? Yes. That’s how you avoid learning about regressions from real customers.

Code (minimal but production-shaped)

PYTHON
from dataclasses import dataclass
import time
from typing import Any


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


class Tools:
  def __init__(self):
      self.allow = {"web.search"}

  def call(self, name: str, *, args: dict[str, Any]) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"tool not allowed: {name}")
      return tool_impl(name, args=args)  # (pseudo)


def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
  started = time.time()
  last = None

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return "stopped: time budget exceeded"

      action = llm_pick_action(question)  # (pseudo): either {"tool":..., "args":...} or {"finish":...}

      if action.get("finish"):
          return action["finish"]

      key = f"{action['tool']}:{action['args']}"
      if key == last:
          return "stopped: loop guard (same call twice)"
      last = key

      obs = tools.call(action["tool"], args=action["args"])
      question = update_state(question, action, obs)  # (pseudo)

  return "stopped: step budget exceeded"
JAVASCRIPT
export class Tools {
constructor() {
  this.allow = new Set(["web.search"]);
}

async call(name, { args }) {
  if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
  return toolImpl(name, { args }); // (pseudo)
}
}

export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) {
    return "stopped: time budget exceeded";
  }

  const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
  if (action.finish) return action.finish;

  const key = String(action.tool) + ":" + JSON.stringify(action.args);
  if (key === last) return "stopped: loop guard (same call twice)";
  last = key;

  const obs = await tools.call(action.tool, { args: action.args });
  question = updateState(question, action, obs); // (pseudo)
}

return "stopped: step budget exceeded";
}

Make it real (a tiny runnable skeleton)

The minimal example above is intentionally short. Here’s a slightly more complete “single file” skeleton that looks like something you could actually ship behind an API:

PYTHON
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


def args_hash(args: dict[str, Any]) -> str:
  raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
  return hashlib.sha256(raw).hexdigest()[:12]


class LoopGuard(RuntimeError):
  pass


class Monitor:
  def __init__(self, *, max_repeat: int = 2):
      self.max_repeat = max_repeat
      self.counts: dict[str, int] = {}

  def mark(self, tool: str, args: dict[str, Any]) -> None:
      key = f"{tool}:{args_hash(args)}"
      self.counts[key] = self.counts.get(key, 0) + 1
      if self.counts[key] >= self.max_repeat:
          raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")


class SafeTools:
  def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
      self.allow = allow
      self.impl = impl

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
      started = time.time()
      try:
          return self.impl[name](**args)
      finally:
          ms = int((time.time() - started) * 1000)
          print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")


def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
  request_id = uuid.uuid4().hex
  started = time.time()
  monitor = Monitor(max_repeat=2)
  state = {"question": question, "notes": []}

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return f"[{request_id}] stopped: time budget"

      action = llm_pick_action(state)  # (pseudo)
      if action.get("finish"):
          return action["finish"]

      tool = action["tool"]
      args = action["args"]
      monitor.mark(tool, args)

      obs = tools.call(tool, args=args, request_id=request_id)
      state = update_state(state, action, obs)  # (pseudo)

  return f"[{request_id}] stopped: step budget"
JAVASCRIPT
import crypto from "node:crypto";

function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}

export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}

export class LoopGuard extends Error {}

export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
  this.maxRepeat = maxRepeat;
  this.counts = new Map();
}

mark(tool, args) {
  const key = tool + ":" + argsHash(args);
  const next = (this.counts.get(key) || 0) + 1;
  this.counts.set(key, next);
  if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}

export class SafeTools {
constructor({ allow, impl }) {
  this.allow = allow;
  this.impl = impl;
}

async call(name, { args, requestId }) {
  if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
  const started = Date.now();
  try {
    return await this.impl[name](args);
  } finally {
    const ms = Date.now() - started;
    console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
  }
}
}

export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";

  const action = await llmPickAction(state); // (pseudo)
  if (action.finish) return action.finish;

  const tool = action.tool;
  const args = action.args;
  monitor.mark(tool, args);

  const obs = await tools.call(tool, { args, requestId });
  state = updateState(state, action, obs); // (pseudo)
}

return "[" + requestId + "] stopped: step budget";
}

This is not “the perfect framework”. It’s a small, bounded loop with:

  • allowlisted tools
  • budgets
  • loop guard
  • basic logs

That’s enough to start learning without torching production.

Step 3: a tiny “tool policy” (even for read-only)

Even read-only tools should be allowlisted explicitly. It forces you to make the surface area visible.

You can extend the toy Tools class above into something you can operate:

  • add args hashing
  • add timing
  • add error classification (retryable vs fatal)

Don’t over-engineer it. Just make it debuggable.

Tool contracts (make the model play inside the lines)

The model will happily invent args:

  • wrong field names
  • huge strings
  • weird nested objects

If your tool wrapper accepts “any dict”, your runtime becomes the validation layer. Which means you’ll debug validation errors in production.

Start simple:

  • define an args shape per tool
  • validate types and bounds
  • reject unknown fields

Conceptual example:

PYTHON
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
  q = str(args.get("q", "")).strip()
  if not q or len(q) > 200:
      raise ValueError("invalid q")

  k = int(args.get("k", 5))
  if k < 1 or k > 8:
      raise ValueError("invalid k")

  return {"q": q, "k": k}
JAVASCRIPT
export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");

const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");

return { q, k };
}

This looks dumb. It’s also what stops your agent from calling web.search with a 10,000 character prompt dump. And it gives you a clean error that you can treat as fatal (don’t retry forever).

Step 4: a rollout that doesn’t hurt

This is how we ship the first version:

  1. Internal only (you and one teammate)
  2. Read-only (no write tools)
  3. Canary (small traffic slice)
  4. Kill switch (operator stop)

If you skip canary, the first time it loops will be on your most important customer.

Step 4.5: cancellation (stop spending when the user leaves)

If you have a UI, users will abandon requests. If you don’t propagate cancellation, the agent keeps running anyway. It’ll keep calling tools. It’ll keep paying tokens. It’ll keep generating output nobody reads.

Make cancellation a first-class stop reason:

  • client disconnect → abort current run
  • abort propagates into model calls and tool calls
  • log stop_reason = client_cancel

Even a crude implementation is better than “keep spending until the budget dies”.

Step 5: success metrics (so you know it’s getting better)

Pick 3 numbers and track them:

  • completion rate (did it finish?)
  • average cost per run ($ or tokens or tool credits)
  • p95 runtime

If completion rate is high but cost is exploding, you didn’t build budgets properly. If cost is low but completion rate is terrible, your tool contract probably sucks.

Where to run it (serverless vs workers)

Your first agent will probably run as an API route. That’s fine… until it isn’t.

Two common gotchas:

  • cold starts: long cold starts + a loop = bad UX
  • time limits: serverless platforms have execution limits; your “90 second budget” might not fit

We often split it:

  • request comes in (fast)
  • agent run happens in a worker (budgeted)
  • UI polls / streams progress

If that sounds like “too much architecture”, keep it simple: just set budgets low enough that your platform can actually stop the run. The fastest way to get 500s is to run a long loop inside a request handler with no guardrails.

Hardening v2 (still small, still safe)

Once the “one tool” version is stable, here’s what we add next:

Add args hashing + dedupe

If the agent calls the same tool with the same args repeatedly, stop it. This catches the easiest infinite loops.

Add per-tool budgets

Global budgets stop the run. Per-tool budgets stop the flaky dependency from eating the run.

Example:

  • web.search max 3 calls

Add a kill switch

Even for a tiny agent. If it’s running in production, you want the ability to stop it without a deploy.

Add a “stop reason”

Make the stop reason visible in logs and UI:

  • time budget
  • step budget
  • loop detected
  • tool denied

This prevents users from hammering refresh.

The first write tool (how to not screw it up)

When you finally add a write tool:

  1. make it a separate tool (don’t reuse read tool name)
  2. add idempotency keys
  3. require approval by default
  4. log an audit event for the intended write

If you skip idempotency, you’ll ship duplicate writes. It’s not “maybe”. It’s “when”.

Common first-agent mistakes (we’ve done them)

  • “Just one more tool” creep. You start with web.search and end with http.request and an admin token.
  • Retries without limits. You “handle errors” and accidentally build an infinite loop.
  • No stop reason in the UI. Users refresh because they don’t know what happened, and you pay twice.
  • Logging only the final answer. Then you can’t explain why it called the tool 17 times.

If you avoid those four, you’re already ahead of most “agent demos”. Also: run it with a fake tool that fails once. If your loop survives without spamming retries, you’re fine. If it melts down, fix it now. Seriously, now.

Real failure

We’ve watched a “first agent” ship with 0 budgets. It got a weird prompt, started browsing, then got stuck.

Result:

  • user waited ~2 minutes
  • agent spent money
  • everyone blamed the model

It wasn’t the model. It was missing budgets.

When NOT to build an agent (yet)

  • If a script can do it, write the script.
  • If a workflow can do it, build the workflow.
  • If you can’t add tool permissions + audit logs, don’t ship tool calling.

Next

Not sure this is your use case?

Design your agent ->
⏱️ 13 min readUpdated Mar, 2026Difficulty: ★★☆
Integrated: production controlOnceOnly
Add guardrails to tool-calling agents
Ship this pattern with governance:
  • Budgets (steps / spend caps)
  • Tool permissions (allowlist / blocklist)
  • Kill switch & incident stop
  • Idempotency & dedupe
  • Audit logs & traceability
Integrated mention: OnceOnly is a control layer for production agent systems.
Author

This documentation is curated and maintained by engineers who ship AI agents in production.

The content is AI-assisted, with human editorial responsibility for accuracy, clarity, and production relevance.

Patterns and recommendations are grounded in post-mortems, failure modes, and operational incidents in deployed systems, including during the development and operation of governance infrastructure for agents at OnceOnly.