{"slug": "building-an-agentic-coding-server-in-go", "title": "Building an Agentic Coding Server in Go", "summary": "Developer FirePing32 released harness-api, an OpenAI-compatible server written in roughly 12,000 lines of Go with 11,000 lines of tests and one runtime dependency, that moves the agentic coding loop behind an HTTP endpoint so existing clients can point at it. The server exposes a deliberately small tool set — read, write, edit, glob, grep and bash — and uses exact byte-for-byte string replacement in its edit tool, returning instructive errors such as naming the line numbers when a match is ambiguous. The project's stated premise is that a harness cannot make a weak model strong but controls how much capability is left on the table.", "body_md": "# Building an Agentic Coding Server in Go\n\n# What if the agent loop lived behind the API instead of in your client?\n\nEvery coding agent you’ve used — Claude Code, Cursor, Aider — is the same shape underneath. The model asks to read a file. Something reads it. The result goes back. The model asks to edit. Repeat until it stops asking. That loop is where most of the engineering lives, and it almost always lives in a client on your laptop.\n\nWhat happens if you move it to the other side of an HTTP endpoint? Not a\nframework, not an SDK — an **OpenAI-compatible server** you can point any\nexisting client at. You send one chat completion request; the server\nruns fifteen model calls and a pile of tool invocations; you get one answer\nback.\n\nThe result is [**harness-api**](https://github.com/FirePing32/harness-api): about\n12,000 lines of Go, another 11,000 of tests, and exactly one runtime dependency.\nWhat follows are the design decisions that turned out to matter, most of which\nwere not the obvious ones.\n\n## Getting started\n\n### Running it\n\n```\nexport HARNESS_UPSTREAM_BASE_URL=https://api.openai.com/v1\nexport HARNESS_UPSTREAM_API_KEY=sk-...\nexport HARNESS_UPSTREAM_MODEL=gpt-4.1\n\n./harness-api\n```\n\nThen use the OpenAI SDK you already have:\n\n``` python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:8080/v1\", api_key=\"unused\")\n\nresp = client.chat.completions.create(\n    model=\"gpt-4.1\",\n    messages=[{\"role\": \"user\", \"content\": \"rename oldName to newName everywhere\"}],\n    extra_body={\"harness\": {\"workspace\": \"/path/to/your/project\"}},\n)\nprint(resp.choices[0].message.content)\n```\n\nNo new client library. No new protocol. The agent is a server.\n\n### The shape of the system\n\n## Why a harness matters at all\n\nStart with the uncomfortable framing: **a harness cannot make a weak model\nstrong.** Agentic capability is dominated by the model, full stop.\n\nWhat a harness controls is how much capability gets *left on the table* — and\nthat gap is large. The same model, same weights, same prompt, can swing\ndramatically on agentic benchmarks depending on whether its tools return\ninstructive errors, whether its context gets managed sensibly, and whether it\ncan tell “you did something wrong” apart from “the tool is broken.”\n\nThat reframing changes what you optimise. You’re not building intelligence. You’re building the thing that stops intelligence being wasted.\n\n## The tools\n\nThe tool set is deliberately small: `read`, `write`, `edit`, `glob`, `grep`,\n`bash`. Adding more tools sounds like adding capability; mostly it adds ways for\nthe model to pick the wrong one.\n\n### `edit` decides whether the whole thing works\n\nIt does exact byte-for-byte string replacement — no fuzzy matching, no\nwhitespace normalisation, no regex. That sounds unhelpfully strict, and it is the right\ncall, because a fuzzy match that picks the *wrong* location produces a change\nnobody asked for and nobody notices.\n\nThe strictness only works if failure is instructive. So when a match fails:\n\n```\nNo match for that text in src/config.go.\n\nThe closest similar line is 42:\n    42 |     timeout = 30\n                ^^^^ you passed 4 spaces here, the file has a tab\n```\n\nAnd when it matches too many times, it names the line numbers rather than saying “ambiguous”:\n\n```\nThat text appears 3 times (lines 12, 45, 89). Include more surrounding\ncontext so the match is unique.\n```\n\nThe principle underneath: **tool error messages are implementation, not\ndecoration.** They are the model’s only recovery signal. A message that says\nwhat went wrong but not what to do next turns into a retry loop, and retry loops\nare where agent runs go to die.\n\n### Read-before-edit is a version check, not a flag\n\nThis is the detail most worth stealing.\n\nThe obvious way to stop a model editing a file it hasn’t looked at is a boolean:\n*has the model read this path?* It catches the common failure and misses a\nnastier one.\n\nThe model reads a file, runs a build script that rewrites that file, then edits it based on what it remembers. With a boolean, the edit sails through and the script’s work is silently destroyed. Nothing throws. The edit succeeds.\n\nSo instead of a flag, the server records a **content hash** per path. An edit is\nauthorised only if the file is still byte-for-byte what was observed. Any change\nfrom any source — a shell command, a formatter, a concurrent process —\ninvalidates it and forces a re-read.\n\nThere’s a subtle follow-on. When the conversation gets compacted (more on that\nbelow) and the summariser drops the turn that held a file’s contents, the model\nno longer *has* those bytes — but the hash entry survives and keeps saying yes.\nThe invariant quietly degrades into a rubber stamp. So compaction marks affected\nobservations stale, and the error says *“you read this, but that part of the\nconversation was summarised away”* rather than *“you never read this”*. Telling a\nmodel it didn’t do something it plainly did invites it to argue with the tool\ninstead of retrying.\n\n### The path jail, and a race worth designing out\n\nFile tools must not escape the workspace. The traditional approach is: resolve symlinks, compare against the root prefix, reject if outside.\n\nThat approach is a time-of-check-to-time-of-use race **by construction**.\nBetween your check and your `open()`, anything can swap a directory component\nfor a symlink pointing at `/etc`.\n\nGo 1.24 added [`os.Root`](https://pkg.go.dev/os#Root), which resolves every path\ncomponent with `openat` relative to a held directory descriptor. It’s a kernel\ncheck, not a string comparison, and the race disappears — not through\ncarefulness, but because there is no window left to exploit.\n\nThe sharpest test plants a symlink *after* the path has been validated and\nasserts the open still fails.\n\nOne honest caveat that belongs in any post like this: `bash` is not jailed.`cd /etc && cat passwd` works. The path jail covers the file tools and nothing\nelse. That’s documented loudly rather than implied away, because a security\nboundary you *think* you have is worse than one you know you don’t.\n\n### Truncation should be asymmetric\n\nBoth file reads and command output need capping. The interesting bit is that\nthey should be capped from **opposite ends**.\n\n- **File views drop the tail.** You want the top of the file, and a footer\ntelling the model how to continue (`offset=2001` , or “search with grep first”).\n- **Shell output drops the head.** The error in a failed build is at the*bottom* , under four hundred lines of compilation progress.\n\nCap both the same way and you throw away the one thing that mattered. The full output spills to a file in the workspace so the model can page through it.\n\n### `bash` is stateless, and that deleted the hardest code\n\nThe obvious design is a persistent shell — keep a `bash` process alive, feed\nit commands, read results back. This is genuinely hard: you need sentinel framing\nto find command boundaries in a shared stream, detection and respawn when the\nshell dies, pipe bookkeeping, and a story for interleaved output.\n\n[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) does\nsomething simpler: a fresh `bash -c` per call, with a `workdir` argument instead\nof `cd`.\n\nAdopting that deleted the hardest code in the project. What a persistent shell would preserve — environment variables, working directory — either gets passed explicitly or lives in the filesystem, which persists anyway.\n\nOne measurement worth sharing. On timeout, you must kill the whole process\ngroup, or `npm test` orphans node workers that hold ports for hours. SIGINT\nis the obvious signal. It doesn’t work: **POSIX requires a non-interactive shell to\nset SIGINT to *ignored* in background children**, so `something &` inside\n`bash -c` cannot be interrupted by SIGINT at all — bash dies, the child keeps\nrunning. SIGTERM, then SIGKILL after a grace period. That came out of a\ncontrolled experiment against a real process group, rather than from adjusting\nthe test until it passed.\n\n## Keeping the agent in bounds\n\n### Guards can only deny, never permit\n\nEvery tool call passes a chain of guards. The contract is one line:\n\n```\ntype Guard interface {\n    Name() string\n    Check(Execution) string  // \"\" means no opinion\n}\n```\n\nThere is **no allow result**. A guard can refuse or abstain, nothing else.\n\nThat sounds like a limitation and it’s the entire point: it makes the chain\n*monotonic*. No guard can undo another’s refusal, so adding one can only ever\nmake the system more cautious, and ordering affects which *message* the model\nsees and nothing else. It removes a whole bug class where “is this permitted”\nhas a different answer depending on registration order.\n\n### The denylist is ergonomics, not security\n\nThe built-ins are a repeated-identical-call detector, a destructive-command\ndenylist, and a timeout policy. The denylist is labelled in the docs as\n**ergonomics, not a security boundary** — command text has unlimited ways to say\nthe same thing, and anything *trying* to get past it will. What it catches is\nthe accident: a path built badly so `rm -rf /tmp/build/` becomes `rm -rf /`.\n\nA false positive here is worse than a miss, incidentally. A model can’t tell a policy refusal from a bug, so it rephrases the same command instead of adapting. Patterns are anchored to command position so a dangerous word inside a quoted string doesn’t trip them.\n\n## Managing the context window\n\n### Prune before you summarise\n\nLong runs overflow the context window. The obvious fix is to summarise older turns with an extra model call.\n\nThat’s the *second* thing you should do. A long agent run overflows the window\nnot because the conversation is long, but because **tool results are large**.\nTwenty file reads at 40 KB each is most of a context window, and almost none of\nit is still needed — the model read the file, made its edit, moved on.\n\nSo the first pass just drops the *bodies* of old tool results, replacing them\nwith a stub that names the file and says it can be re-read. It costs nothing, no\nmodel call, and on a typical run it’s enough. Only when that fails does the\nsummariser run.\n\nGetting the order wrong spends a model call and loses detail to solve a problem that deleting stale file contents would have solved for free.\n\nTwo details that are easy to get wrong:\n\n**Never cut between an assistant’s `tool_calls` and the results answering them.**\nStrict providers reject the *entire request*, which surfaces as a 400 on the turn\n*after* compaction with nothing pointing at compaction as the cause.\n\n**Keep the original task verbatim.** The system prompt and the first user message\nsurvive untouched. If a summary paraphrases the goal loosely, the agent carries\non with no idea what it was asked to do, and the run fails in a way that looks\nlike model incompetence rather than context loss.\n\n### Token estimation that corrects itself\n\nCompaction needs to know how close you are to the window. That needs a token\ncount, and this server talks to *whatever you point it at* — OpenAI’s tokenizer\ntells you nothing useful about Llama or Qwen.\n\nVendoring a tokenizer would be wrong for most providers. So instead: estimate\nfrom byte count, and correct the ratio against reality. Every response carries\n`usage.prompt_tokens` for a request whose byte count you know exactly — a free\nlabelled sample, every single turn.\n\nAn exponentially-weighted moving average over those samples converges on\nwhatever tokenizer the provider actually uses, within a few turns, with zero\ndependencies. Implausible samples get discarded (a cached prompt, a provider\ncounting images), and while it’s still unsure it deliberately reads *high* —\nbecause underestimating means overflowing, which is a failed request, while\noverestimating costs one unnecessary summarisation.\n\n## Talking to any provider\n\n### Quirks are data, not code\n\n“Works with any OpenAI-compatible model” is a sentence that hides a lot of pain:\n\n- `max_tokens` vs`max_completion_tokens`\n- `system` vs`developer` role, or no system role at all\n- empty content with tool calls: `null` ,`\"\"` , or omitted\n- `parallel_tool_calls` accepted, or a 400\n- JSON Schema dialect gaps that break vLLM and Ollama\n- `reasoning_content` that must**never** be echoed back (DeepSeek 400s on it)\n- SSE vs NDJSON framing, missing `[DONE]` terminators\n\nThere are more providers than anyone will write structs for. So compatibility is\na `Profile` struct — plain data — selected by configuration, with per-field JSON\noverrides. A provider nobody has heard of is a config change, not a code change.\n\nThere’s also an autodetect safety net: on a 400 matching a known pattern, apply the corresponding fix, log what to pin, and retry. Capped at three adjustments per model so a rejection nothing can be inferred from fails once instead of looping forever.\n\n### Streaming, and what it actually buys\n\n`\"stream\": true` works, with a limitation worth stating plainly.\n\nAn agent run has several generations and only the last one is the answer. The others are the model saying “let me check that file” before a tool call. Concatenating them reads like a transcript of someone thinking out loud, so only the final turn gets emitted.\n\nThe consequence: the final turn isn’t known to be final until it arrives without\ntool calls. So its content is produced *before* streaming begins, then sent in\npieces so progressive renderers behave normally. There is **no time-to-first-token\nbenefit** over a non-streaming request. What streaming buys is the connection\nstaying open, and structured progress events if you opt in:\n\n```\n{\"type\": \"tool_start\", \"turn\": 3, \"tool\": \"edit\", \"call_id\": \"c7\",\n \"args\": {\"path\": \"main.go\"}, \"summary\": \"edit main.go\"}\n```\n\nThose ride on `choices[0].delta.harness`. Standard SDKs ignore unknown keys\n*inside* `delta` — verified against `openai-python` — so it’s safe to leave on\nwith a client that’s never heard of it.\n\n## Measuring any of it\n\nEverything above is an argument. Arguments are cheap. So the repo includes an eval harness, and its design choices are more opinionated than the server’s.\n\n### Checks are programs, not LLM judges\n\nA judge lets a task grade prose, which is tempting and wrong — judges disagree with themselves across runs, and a regression detector that is itself noisy detects noise. Every task exits zero or it doesn’t.\n\n### A failed measurement is not a failed task\n\nA run that never reached the model — rate limited, server down — is excluded from the pass-rate denominator and reported separately, loudly. Otherwise a bad afternoon on your provider looks exactly like a capability regression, and those call for opposite responses.\n\n### It refuses to call small differences real\n\nThe comparison runs a Fisher exact test before declaring a winner. At three repetitions per task:\n\n| Config A | Config B | p | Verdict | \n|---|---|---|---|\n| 20/30 | 24/30 | 0.38 | nothing | \n| 18/30 | 24/30 | 0.16 | nothing | \n| 0/30 | 30/30 | 10⁻¹⁷ | real | \n\nA six-run gap at n=30 is *still not significant*. That’s not pessimism about the\ntool; it’s what thirty runs buys. A comparison tool that can’t say so gets used\nto justify changes that did nothing — and, worse, to reject changes that helped.\n\n### The metric that matters most\n\nIt isn’t pass rate. It’s **tool error rate, per tool**.\nIf `edit` errors on a third of its calls, the fix is in `edit.go`, not in the\nprompt — and without that number there’s no way to tell those two hypotheses\napart.\n\n## Five things worth knowing\n\nIn rough order of how surprising they were:\n\n1. **Error messages are product surface.** Not logging, not diagnostics.\nThe model’s recovery behaviour is downstream of your error text.\n2. **Matching a good reference beats inventing.** Reading DeepSeek Harness at\nsource deleted more code than it added.\n3. **Measure the platform instead of trusting the docs.** SIGINT vs SIGTERM,`setrlimit` not existing where the documentation implied,`ulimit` block\nsizes differing between shells — three separate times, the assumed answer\nwas wrong.\n4. **Monotonic beats configurable.** Deny-only guards can’t be misordered into a\nsecurity hole.\n5. **Build the measurement before you believe the design.** Especially your own.\n\nThe code is on GitHub: [**FirePing32/harness-api**](https://github.com/FirePing32/harness-api).\nMIT licensed. Go 1.26, one runtime dependency, `net/http` routing, `log/slog`\nlogging, no OpenAI SDK — the quirks layer needs byte-level request control and\nthe official SDKs fight unknown fields.\n\nIf you build something with it, or find a place where the reasoning above is wrong, open an issue.", "url": "https://wpnews.pro/news/building-an-agentic-coding-server-in-go", "canonical_source": "https://prakhargurunani.com/blog/building-an-agentic-coding-server-in-go/", "published_at": "2026-09-20 19:04:17+00:00", "updated_at": "2026-09-20 19:23:04.288202+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["harness-api", "FirePing32", "Go", "OpenAI", "Claude Code", "Cursor", "Aider", "gpt-4.1"], "alternates": {"html": "https://wpnews.pro/news/building-an-agentic-coding-server-in-go", "markdown": "https://wpnews.pro/news/building-an-agentic-coding-server-in-go.md", "text": "https://wpnews.pro/news/building-an-agentic-coding-server-in-go.txt", "jsonld": "https://wpnews.pro/news/building-an-agentic-coding-server-in-go.jsonld"}}