A CLI coding agent and harness, built from scratch in TypeScript. Runs against local models through Ollama.
I wanted to understand what actually happens inside a coding agent, not just use one. The fastest way to find out was to build the three pieces by hand: a streaming LLM client, an agent loop that executes tool calls, and a harness that wires them to a terminal and the filesystem. No framework, no copy-paste.
It runs on local models on purpose. Iterating on an agent loop means a lot of throwaway calls, and I didn't want to pay per token to learn. Ollama exposes an OpenAI-compatible endpoint, so the provider is a thin layer over the openai SDK pointed at localhost. If you have never hosted a model locally, llm/README.md walks through the setup.
Three packages, one for each layer. The dependency direction is strict: the harness knows about the loop, the loop knows about the provider, and the provider knows nothing above it.
packages/ai- the provider.OllamaProvider.stream()is an async generator. It yields text chunks as they arrive, accumulates any tool calls from the stream, appends the finished assistant message to the history, and returns the collectedtoolCallsandreasoningas its final value.packages/agent- the loop.runAgentLoop()is also an async generator. It drains the provider stream and forwards the chunks, then looks at the returned tool calls. No tool calls means the turn is over. Otherwise it executes each one, pushes atoolmessage with the result, and goes round again.packages/coding-agent- the harness. Owns the system prompt, the tool implementations, the map from tool name to function, and the REPL. Each user prompt goes into the shared message history and the loop's output is written to stdout as it streams.
The loop does not know which provider it is talking to. It only relies on the shape of the stream: yield chunks, return { reasoning, toolCalls }. That is what makes adding a second provider a contained change.
Four tools ship today.
bash- runs a shell command. Input is tokenised withshlexand checked against an allowlist (ls,cat,grep,find,head,tailand a few others). Any shell operator in the arguments rejects the whole command. It is a read-only shell for now.read- reads a file at a path.write- writes content to a path.get_weather- hits Open-Meteo for a temperature. It's the tool I used to prove tool calling worked before the filesystem tools existed. It stayed because it is a nice sanity check that costs nothing.
Tool schemas live in tools/all.ts and are sent to the model on every call. The harness validates argument types before dispatching, so a model that sends the wrong shape gets an error instead of a crash.
You need Node 22 or newer (it runs .ts files directly) and Ollama serving a model that supports tool calling.
ollama pull glm-4.7-flash
cd packages/coding-agent && npm install
cd ../ai && npm install
node packages/coding-agent/harness.ts
The model name is set in harness.ts. Swap it for whatever you have pulled.
Working end to end: streaming responses, multi-step tool use, a REPL that keeps history across prompts. MIT licensed. Progress is tracked in CHANGELOGS.md.
Not built yet, and why:
- Anthropic provider. The loop is provider-agnostic on paper. It has only ever run against one provider, so that claim is untested. Adding Claude is the test.
- Context compaction. Nothing trims the history yet. Long sessions will eventually hit the model's context window and fall over. The plan is to summarise once usage crosses 80%.
- Tool permissions.
writeandbashrun without asking. The allowlist keepsbashharmless, but the real answer is a permission prompt before anything destructive, and that isn't there yet. - Reasoning display. The provider already collects reasoning tokens. The harness throws them away. Surfacing them is a UX change, not a plumbing one.
- Tests,
grep, sessions, a TUI. All on the list. None block the core loop, so they wait.
