← all posts

· Agents · 12 min read

Three ways to build an agent (and why I picked the middle one)

The thing I was building

I wanted a bot that behaves like a developer on my team. Assign it an issue, it writes the code and opens a merge request. Make it a reviewer, it leaves inline comments. @mention it in a thread, it answers. It lives inside my self-hosted GitLab (gitlab.dnsif.ca, in the home lab) and it's powered by Claude.

I call it GitBot. This post isn't really about GitBot, though — it's about a decision I had to make three times before I got it right: who runs the agent loop?

Because once you've picked your model, that's the actual architectural fork. Not "Haiku or Opus." Not "which framework." The question is whether you write the loop, whether a library writes it inside your process, or whether Anthropic runs it on their infrastructure. Those are the three ways, and they look deceptively similar on a whiteboard. The differences only show up when you ask what crosses the wire.

Let me set up the one mental model that makes all three legible, and then walk through them in the order I actually tried them.

First: there are two loops, not one

The single most useful idea I picked up building this is that an agent system has two loops, and people constantly conflate them.

The inner loop is the agent loop. It's the think → act → observe → repeat cycle:

The agent inner loop: send context to the model, the model responds with a tool call, execute the tool and capture the result, feed the result back, and repeat — until the model returns a final answer instead of a tool call and the loop is done.

That's it. Generic. It looks the same whether the agent is booking flights or refactoring Go. Every agent on earth runs this loop.

The outer loop is your product. For GitBot that's: receive a GitLab webhook, dedupe it, classify it (is this a question? a code task? a review?), pick a workflow, hold a per-issue lock so two events don't stomp each other, post a "working…" comment, manage gitbot::* labels, survive a container restart mid-task, ask a human when it's genuinely stuck. None of that is "AI." It's the domain. It's the part that makes the bot feel like a teammate instead of a chat box.

Here's the punchline that took me a while to internalize:

The three ways to build an agent are really three answers to one question: who owns the inner loop? The outer loop is always yours. Nobody can write it for you, because nobody else knows what a GitLab webhook means to your product.

Keep that split in your head. It's the lens for everything below.

Way 1: Write everything yourself

This is where I started, and honestly where most people start, because the inner loop looks trivial. It is trivial, at first. Here's the whole thing, more or less:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": task},
]

for _ in range(MAX_ROUNDS):
    resp = llm.chat(messages, tools=TOOL_SCHEMAS)   # via litellm
    msg = resp.choices[0].message
    messages.append(msg)

    if not msg.tool_calls:
        break                       # model produced prose → assume it's done

    for call in msg.tool_calls:
        result = execute_tool(call.name, call.args)  # I run the tool, my creds
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": result,
        })

~20 lines. You own it completely. I was routing through litellm, so the same loop spoke to Anthropic, Gemini, OpenAI, whatever — that multi-provider flexibility was the whole appeal.

The trap is that the 20-line loop is not the thing you end up maintaining. The moment real tasks arrive, you start "helping" the model, and that's when it gets baroque. My version grew a three-phase pipeline: a gather phase (cheap model fetches context), a plan phase (mid model writes explicit steps), and an execute phase (run each step). It felt principled. It was a slow-motion disaster, for three reasons that are all the same reason.

The plan is frozen before reality gets a vote. The planner wrote "commit README to main" as step 1 — but the repo was empty, so every step rammed into a 500 while a second workflow I'd accidentally triggered opened an empty MR. A model running a live loop replans after every tool result for free. My planner had committed to fiction before the first tool ran.

Context got passed between steps by regex. Each step was a fresh context window, so to carry an ID from step 1 to step 3 I literally parsed it out of the model's prose:

# yes, this was real, and there were five of these
for m in re.finditer(r"Created (\w+):\s*(.+?)\s*\(id=(\d+)\)", result):
    registry[f"{m.group(1)} {m.group(2)}"] = int(m.group(3))

In a single continuous loop, this problem doesn't exist. The tool results just stay in the conversation. The context window is the registry. I was hand-rolling memory because I'd thrown the conversation away at each step boundary.

Failure detection was vibes. With each step isolated, I had to guess whether a step "worked" by string-matching the model's summary for phrases like "i cannot" or "manual creation". This produced my favourite bug: the harness appended a cheerful "✅ Done!" to a task that had 500'd halfway through, because the summarizer never saw the errors — they happened in a sibling step's context it couldn't read.

None of these were bugs I could patch. They were the architecture. I was using application code — regexes, string matching, frozen plans — to reimplement, badly, things the model does natively if you just let it run one continuous loop. I was fighting my own tool.

The verdict I wrote in my own issue tracker at the time: about half the harness is exactly what I should own; the other half is fighting the model. The half I should own was the outer loop. The half fighting the model was my hand-rolled inner loop.

So: Way 1 gives you total control and provider independence, at the cost of owning — and getting wrong — a loop the labs have already solved. If you genuinely need multi-provider, or you're doing something the SDKs don't support, it's a legitimate choice. For me it had become a liability.

Way 2: Let a library run the inner loop

This is the Claude Agent SDK — the same harness that powers Claude Code, available as a library. The pitch is exactly the inner/outer split: the SDK takes the inner loop; you keep the outer loop.

Crucially, nothing about where things run changes. The loop is a library executing inside my own container. My tools still run locally with my credentials. Same trust boundary as Way 1 — I just stop hand-rolling the loop.

My ~900-line brain.py (gather/plan/execute, the ID registry, the string-matching failure detector) collapsed into this:

from claude_agent_sdk import query, ClaudeAgentOptions

options = ClaudeAgentOptions(
    system_prompt=IMPLEMENT_SYSTEM,         # per-workflow prompt — still mine
    mcp_servers={"gitlab": gitlab_mcp},     # my ~50 GitLab tools, unchanged
    allowed_tools=["Read", "Edit", "Bash", "Glob", "Grep", "mcp__gitlab__*"],
    model="sonnet",                          # tier alias, not a pinned model id
    max_turns=60,
    cwd=repo_checkout,                       # a real clone it can edit + test
)

async for message in query(prompt=task, options=options):
    stream_progress_to_gitlab(message)      # outer loop: mirror into a comment

Look at what survived and what died.

Died: the plan/execute phases, the step boundaries, the regex registry, the vibes-based failure detector. The loop replans itself after every tool result. Context just stays in context. ~900 lines gone, and the Docker image dropped from 1.05 GB to 844 MB when litellm went with it.

Survived, untouched: my GitLab tools. They were the most valuable code in the repo, and they carried straight over — I exposed them as an in-process MCP server and the SDK calls them exactly as before. Also surviving: the entire outer loop. Webhooks, dedupe, locks, labels, the "working…" comment, crash recovery. The SDK never sees any of that. It doesn't know what GitLab is.

Two things I'd flag if you go this way:

Make completion structural, not inferred. The fix for my "✅ Done!" bug wasn't a smarter string-matcher — it was deleting string-matching entirely. Now the model finishes a task, and then my code checks reality: for an implement task, does an open MR actually exist with commits on it? If not, it didn't succeed, no matter what the model said. Cheap API checks beat parsing prose. That's an outer-loop responsibility and it's worth being strict about.

Model aliases age better than IDs. Notice model="sonnet", not a pinned claude-sonnet-4-x. The tier alias resolves to the current model of that tier, so I don't chase Anthropic's release notes. A cheap classifier scores each incoming task's complexity and picks the tier — trivial @mentions land on Haiku, real implementation on Sonnet, reviews on Opus.

The thing that sold me: adopting the SDK is a code decision, not a trust decision. What I hand to Anthropic is identical to Way 1 — request payloads over the API, tools executing on my side, credentials never leaving my container. I just deleted the worst code I'd written. This is where GitBot lives today.

Way 3: Let Anthropic run the loop and the sandbox

The third way is Managed Agents (CMA). This is a genuinely different shape, and it's the one people miss when they think there are only two options ("DIY vs SDK"). Here the inner loop and the execution environment move to Anthropic.

You define an agent once, then open a session per task:

agent = client.beta.agents.create(
    name="gitbot",
    system_prompt=IMPLEMENT_SYSTEM,
    # tools / MCP config, persisted and versioned server-side
)

session = client.beta.agents.sessions.create(agent_id=agent.id)
session.define_outcome(                       # "iterate until this is true"
    "An MR exists that addresses the issue and the pipeline is green",
    max_iterations=20,
)

for event in session.stream():
    mirror_to_gitlab_comment(event)           # the outer loop shrinks to ~this

Notice how small the outer loop gets. You're not running a loop at all — you're streaming events out of theirs and reflecting them into your product. And that define_outcome with a rubric is a near-perfect match for "assign it work, let it iterate until done." Architecturally, for my use case, this was the best fit on paper.

Here's where it falls apart for me, and it's not a code problem:

Anthropic provisions a container on their infrastructure. To do anything, that container has to reach my GitLab. My GitLab resolves to a private 10.33.x address in my home lab. Their cloud has never heard of it and never will.

To make CMA work I'd have to either expose my GitLab to the public internet (no thanks), or run a self-hosted sandbox/worker fleet so execution happens on my side again — at which point I'm running infrastructure anyway and much of the "managed" benefit evaporates. So I parked it. Not because it's bad — because of network reality.

But parking it taught me the real lesson of this whole exercise, which is about the wire.

The deciding question: what actually leaves your network?

Forget the loop for a second. Here's the same task — "assign issue #7 to the bot" — traced through all three, by what crosses the trust boundary:

What crosses the trust boundary in each approach, compared across Way 1 (DIY), Way 2 (Agent SDK), and Way 3 (Managed Agents): issue and code text is sent to Anthropic in all three; tool execution and the repo checkout stay on your side for DIY and the SDK but move to Anthropic's container for Managed Agents; GitLab credentials and persistent state never reach Anthropic except under Managed Agents; and only Managed Agents requires an Anthropic-to-GitLab network path.

Stare at the first two columns. They're identical. Going from "I wrote the loop" to "a library writes the loop" changes who maintains the inner loop and nothing about what you hand Anthropic. Same data, same trust boundary, same blast radius.

Now look at the third column. Going from the SDK to Managed Agents is where the real step happens: your repo contents, a credential, the execution itself, and a durable history of every tool call all move to Anthropic's side — in exchange for them running the sandbox and the iterate-until-done loop.

That's the decision, stated cleanly:

Most self-hosted GitLab users chose self-hosting precisely so their code doesn't leave. For that audience, the SDK's "deploy a container, connect GitLab, paste your Anthropic key, done" is not just convenient — it's the only model that respects why they self-host in the first place. That's why I'm on Way 2.

Where this leaves the two loops

Pulling it back to the mental model, because it's the thing worth keeping:

I have a litmus test now for any code I write in this project:

The mistake I made for months was letting those two blur together — hand-rolling inner-loop machinery and calling it my application. Once I drew the line, the right architecture was obvious: take the maintained inner loop, pour all my energy into the outer one, and keep every byte of code on my own side of the wire.

Next up, the highest-value piece of outer loop still on my list: closing the CI feedback loop, so when a pipeline goes red on a branch the bot opened, it reads the job log and pushes a fix — iterate-until-green, but running on my infrastructure. Which is, of course, the whole point.