
Build a Model-Agnostic GitHub Coding Agent with the Claude Agent SDK
We run CodeMagpie — a GitHub App that writes
code and reviews PRs when you @mention it. Three agents share one backend:
- Reviewer —
@codemagpieai review→ inline review comments on a PR. - Implementer —
@codemagpieai createon an issue → opens a fresh-branch PR. - Resolver —
@codemagpieai resolveon a review thread → pushes a fix.
The interesting part isn't the agent loop — the Claude Agent SDK gives you that for free. It's that none of this is locked to a single model. In production our agents don't run on Claude at all — and the swap is one environment variable. This post shows the architecture and the one trick that makes it model-agnostic.
The shape of an "online" agent
A coding agent that lives on GitHub is not a chat loop. It's a webhook handler with a
durable job queue in front of an agent runtime. The interactive diagram below walks
through each stage — step through it to see how a single @mention travels from
GitHub to a merged PR.
Why the queue? Webhooks must return 200 in seconds, but an agent run takes minutes.
You ack the webhook immediately, persist the run, and let a worker pull the job. The
queue also gives you retries, a dead-letter path, and a natural place to enforce
per-user quota before you spend a single token.
The agent core
This is where the Claude Agent SDK earns its keep. Instead of hand-rolling the
tool-use loop — call the model, parse tool_use blocks, execute, feed results back,
repeat until done — you describe the tools and let the SDK drive. The Implementer,
reduced to its essentials:
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const fsTools = createSdkMcpServer({
name: "workspace",
tools: [
tool("write_file", "Create or overwrite a file in the repo", {
path: z.string(),
content: z.string(),
}, async ({ path, content }) => {
await writeFileInWorkspace(path, content);
return { content: [{ type: "text", text: `wrote ${path}` }] };
}),
// read_file, list_dir, run_tests ...
],
});
async function handleCreate(issue: Issue, repoDir: string) {
const run = query({
prompt: `Implement this issue, then stop:\n\n${issue.title}\n\n${issue.body}`,
options: {
model: process.env.ANTHROPIC_MODEL, // ← not pinned to Claude
cwd: repoDir,
mcpServers: { workspace: fsTools },
maxTurns: 40, // hard cap so a confused run can't burn budget
systemPrompt:
"You are a senior engineer. Make the smallest change that resolves the " +
"issue. Write files, run tests, then summarize what you did.",
},
});
for await (const msg of run) {
if (msg.type === "result") return msg; // final summary → PR body
}
}
The SDK handles the loop, the tool-result plumbing, and context management. You bring
the tools (a sandboxed workspace, git, the GitHub API) and the stop condition. After
the run finishes you open a PR from a fresh codemagpie/ branch — never push to
the user's branch from the Implementer.
Here's that division of labor — the parts the SDK owns, the parts you supply, and what happens once the run ends:
The model-agnostic trick
Here's the whole secret. The Claude Agent SDK — like the underlying Anthropic SDK —
talks to an HTTP endpoint defined by ANTHROPIC_BASE_URL. Point that at any
Anthropic-compatible gateway and the SDK neither knows nor cares what's behind it.
In production we point it at a third-party endpoint that speaks the Anthropic Messages API:
# Claude (default) — nothing to set
ANTHROPIC_API_KEY=sk-ant-...
# Any Anthropic-compatible provider, same code path
ANTHROPIC_BASE_URL=https://<anthropic-compatible-host>
ANTHROPIC_AUTH_TOKEN=$PROVIDER_API_KEY
ANTHROPIC_MODEL=<provider-model-id>
Our actual Python client is four lines — and the TS Agent SDK reads the same two env vars:
anthropic.Anthropic(
base_url=os.environ["ANTHROPIC_BASE_URL"], # provider host
api_key=os.environ["PROVIDER_API_KEY"],
)
Centralize this in one module so every agent resolves its model the same way and they can't drift apart. Swapping the whole fleet to a new model — Claude, a hosted open model, or a self-hosted model behind an Anthropic-compatible proxy like LiteLLM — is a config change and a redeploy, not a code change. That's the payoff: you pick the model per environment, benchmark candidates against the same agent, and never rewrite the loop.
One caveat worth stating plainly: "Anthropic-compatible" has to actually mean it. Tool-use semantics and streaming behavior vary between providers. Keep an eval set of real issues and re-run it whenever you change the backing model — a model swap can silently change how often the agent writes files versus spins on read-only exploration.
The guardrails that matter once it's public
A coding agent reachable from the internet needs more than a good prompt:
- Verify the webhook. HMAC-check
x-hub-signature-256and reject anything that fails. Without this, anyone can trigger runs. - Dedupe deliveries. GitHub redelivers webhooks. Key each run by delivery id so a retry doesn't open two PRs.
- Quota per user. Enforce a rate limit (we use 5 runs/hour/uid) before dispatch, not inside the agent.
- Cap the loop.
maxTurnsplus a nudge to start writing past the halfway point stops a confused run from exhausting its budget on a no-op. - Isolate the workspace. Each run gets a fresh clone in a throwaway directory, and the Implementer only ever pushes to its own branch.
Takeaways
The agent loop is a solved problem — the Claude Agent SDK gives you tool-use,
context management, and a clean stop condition out of the box. The two decisions that
actually shape your system are upstream (a webhook + queue so runs survive and stay
rate-limited) and sideways (ANTHROPIC_BASE_URL, so the model is a deployment
detail, not an architectural one). Build those two right and "which model" becomes the
easiest thing to change.
Get new posts in your inbox
No spam — just new posts and learning pages when they ship.