At some point, writing an AI coding agent in Zsh became a perfectly reasonable thing to do. I am still prepared to accept that this says something about me.
The question behind zcoder.zsh was simple: how much of a useful coding agent could we build with the shell itself? HTTP, JSON, tool dispatch, conversation storage, streaming, a proper terminal interface-how far could native Zsh take us before we started fighting it?
The answer now includes an agent that can explore a workspace, read and edit files, apply patches, run approved commands, and continue through multiple rounds of model responses and tool results. It talks to Ollama, supports persistent sessions, and can run headlessly beside a remote workspace. The interface in the cover is the v0.13.0 screenshot; the implementation discussed here is v0.14.0.
Ollama still runs the model. Zsh runs the application around it. That application is where this experiment gets interesting.
A shell script often acts as a coordinator for other programs. Zsh also exposes facilities that let it do much more of the work internally. Its module system includes TCP and Unix-domain sockets, low-level descriptor I/O, file access, readiness polling, pseudo-terminals, and curses bindings.
Those facilities give zcoder a surprisingly substantial foundation:
| Job | What zcoder uses |
|---|---|
| Connect to Ollama | zsh/net/tcp and ztcp |
| Read and write descriptors | zsh/system, including sysread and syswrite |
| Read small text files | zsh/mapfile |
| Watch descriptor readiness | zsh/zselect |
| Run interactive command workers | zsh/zpty |
| Draw the interface | zdraw, with a stock zsh/curses fallback |
| Track elapsed time | zsh/datetime |
The application logic uses Zsh functions, arrays, associative arrays, parameter expansion, and these modules. Search uses rg; patches use git apply with a patch fallback; approved shell commands run through zsh -c. These programs provide the requested capability directly.
So the honest description is Zsh-first. There is no Python or Node application runtime underneath the agent, but there are dependencies: Zsh and its modules, Ollama, the model, and the tools needed for particular operations. The enhanced drawing module also contains native compiled code. Being precise about that makes the achievement easier to assess.
JSON was an early test of how serious we were about the idea. An Ollama response can contain nested objects, arrays of tool calls, escaped source code, Unicode, reasoning text, and usage counters. Extracting a field with a convenient regular expression would leave most of that problem unsolved.
We built a tokenizer and decoder in lib/json.zsh. It recognizes JSON punctuation, strings, numbers, booleans, and null. The parsers above it walk the structures they need, retain tool arguments, and skip other values through the same parsing machinery. Public object parsers require the input to end after the object, so a valid-looking prefix cannot hide a malformed suffix.
The details matter. JSON has a specific number grammar and permits exactly four whitespace characters outside strings. Literal control characters inside strings are invalid. Trailing commas are invalid. Unicode escapes can contain UTF-16 surrogate pairs, which need to be combined; unpaired surrogates become the replacement character. Those are ordinary protocol requirements, even when the implementation language is a shell.
The more instructive problem was performance. Repeatedly indexing a long multibyte scalar can require walking the string from its beginning. Do that for every character and a small, innocent-looking loop becomes expensive as responses grow.
The parser therefore creates a character array once:
JSON_CHARS=("${(@s::)1}")It then tracks its position in that array. Array searches locate the next quote or escape, and ordinary stretches of text can be copied together. Strings without escapes have a fast path; common escapes use bulk transformations; Unicode escapes and difficult cases go through the more detailed scanner.
Encoding needed similar attention. A coding conversation contains a lot of newlines, quotes, and backslashes. Building an escaped result one character at a time repeatedly copies growing strings. Our encoder instead uses native split/join operations for the common escapes, preserving empty fields so consecutive and trailing separators survive correctly. It also repairs malformed UTF-8 before emitting JSON strings.
This was one of the most useful lessons in the project: the choice of Zsh operation matters enormously. A shell-level loop and a parameter expansion may express the same transformation while doing very different amounts of work.
With JSON available, connecting to Ollama became a manageable protocol problem. The HTTP client opens a TCP connection with ztcp, constructs an HTTP/1.1 request, writes it through a checked descriptor-write helper, and reads the response with sysread. There is no curl process in that native request path.
The client handles model discovery through /api/tags, chat through /api/chat, and running-model information through /api/ps. It checks HTTP status and response framing, distinguishes connection failures from response timeouts, and detects incomplete bodies.
Even counting a request body has a trap: Content-Length means bytes. In a multibyte locale, a character count can differ from the number of bytes sent. The transport uses a locally scoped nomultibyte setting for byte-oriented operations. Text decoding and terminal display have different rules, and keeping those rules separate prevents subtle failures.
The current native transport handles plain HTTP. It explicitly rejects HTTPS. That boundary is deliberate: implementing TLS in shell code would be a very different project. There is an exploratory design for a libcurl bridge, but it is not a shipped transport feature. Local and trusted-network Ollama remain the native client's scope.
Streaming introduced another distinction: bytes arriving from a socket are not yet model messages.
Ollama's streaming format uses newline-delimited JSON. HTTP may carry those records in chunked transfer encoding. A socket read can end halfway through a chunk header, a JSON string, or a multibyte character. An HTTP chunk can contain several JSON records, or only part of one.
We split the work into explicit stages:
TCP bytes
→ incremental HTTP framing
→ response-body bytes
→ complete NDJSON records
→ accumulated assistant state
→ validated response and normal tool dispatchIn lib/stream.zsh, the HTTP decoder maintains states for headers, chunk sizes, chunk data, CRLF delimiters, trailers, and completion. It also supports content-length and connection-close framing. Incomplete data stays buffered for the next read. Declared limits bound headers, the total stream, and unfinished record storage.
A background Zsh worker owns the network exchange and appends decoded body bytes to private request storage. The parent reads that storage and owns the interface. Each drain processes at most 64 records and reads at most 32 KiB, leaving opportunities to handle keyboard input between batches.
That division is why the terminal can keep accepting draft edits and cancellation while the model generates. The network worker never takes ownership of curses. There is still real scheduling work to do in a shell application: a long native operation can delay the next input poll. Bounded work per poll makes the common streaming path behave much better.
As records arrive, zcoder accumulates answer text, reasoning, and tool calls separately. The interface can show a growing preview. Execution waits for the completed response, successful transport collection, and Ollama's final done:true record. An interrupted stream remains visibly partial and does not become an accepted tool batch.
That last point matters more than the animation. Streaming changes when a person sees text; it must preserve the conditions under which the agent is allowed to act.
The same concern for responsiveness shaped the interface. A terminal coding agent spends much of its time displaying long transcripts, switching between sessions, collecting input, and waiting. It needs predictable layout and readable state throughout those transitions.
This is where zdraw earns its place. zdraw is a terminal drawing and interaction module derived from Zsh's curses module, with its own module identity and API. It supplies terminal primitives and companion Zsh UI libraries. zcoder owns the application layout, theme, transcript, and event-loop policy.
The design uses a session sidebar, a main transcript, a multiline prompt, and status information that stays separate from the conversation. The drawing adapter assigns semantic roles such as surface, accent, warning, error, and muted text. At paint time those roles resolve to RGB, indexed colors, basic colors, or monochrome according to the available capabilities.
That gives the interface a coherent appearance without assuming every terminal supports the same colors. Rounded borders, styled spans, and clipping are also selected by capability, with fallback paths when an extension is unavailable.
Styled spans are particularly useful for a coding transcript. One row might contain a prefix, ordinary text, and several differently colored fragments. zdraw can draw those fragments in one call. With spansclip, they share one display-cell budget, so changing styles does not accidentally reset the available width.
Display cells deserve their own mention. A wide character may occupy two columns; a combining mark does not behave like an ordinary spacing character. Wrapping, clipping, and cursor positioning must agree about the geometry. We handle wide characters and combining marks, while complex emoji sequences still depend on terminal behavior. Counting bytes or characters alone would be insufficient.
The integration also uses zdraw's layout and list helpers for pickers, help-row widgets, and capability-gated native input features such as event polling and streaming paste handling. A pasted newline must remain paste data inside a dialog; accidentally treating it as an approval key would be a much more serious problem than an ugly border.
We also avoid repainting unchanged windows. Each window has a key describing its visible state. Changes invalidate the relevant view; refresh batches changed windows into one physical update. Ncurses still handles terminal-cell comparison underneath. An unchanged frame needs no curses calls, and a spinner can update the header without repainting the entire transcript.
The result is an interface whose visual design and runtime behavior reinforce each other. Headless server and ACP modes skip the curses interface and its input machinery altogether.
Under that interface, the coding loop remains straightforward: prepare messages and tool schemas, ask Ollama, validate the response, execute permitted tool calls, append results, and ask again. Multiple calls execute sequentially in the model's emitted order, allowing a write followed by a check to retain its intended sequence.
The difficult parts sit at the boundaries. Built-in workspace file tools canonicalize paths and resolve symlinks before accepting access. replace_text requires a unique exact match. Tool results are bounded before returning to the model. Shell execution passes through the command policy: it asks by default, with explicit allow and deny modes available. An approved shell command can have effects beyond the workspace, so file-tool confinement should not be confused with an operating-system sandbox.
We keep tool dispatch separate from Ollama and curses so these rules can be tested directly. A model emitting a convincing-looking request should not determine whether validation happens.
Persistence follows the same approach. Session saves write a new generation and publish it through an atomically replaced current pointer. Readers resolve a committed snapshot, and locking coordinates access. Stale writers are detected instead of silently overwriting a newer session. This protects against interrupted publication; it does not promise durability against power loss through fsync.
Remote operation builds on these separations. The server owns the workspace, model interaction, tools, and saved session. The client submits prompts and receives ordered events; command approvals travel back through the protocol. Keeping transport, orchestration, persistence, and presentation distinct made that extension possible without making the terminal interface the owner of everything.
The project targets Zsh 5.8 and newer, and verification deserves the same precision as the dependency claim. make test checks syntax and runs the shell-level suite. make compile produces Zsh wordcode for faster loading; it does not turn the agent into a standalone machine-code binary or prove compatibility with every supported shell version.
The v0.14.0 release notes report 2,651 passing assertions and 23 visual baselines on Zsh 5.9.2 with the bundled renderer. Focused Zsh 5.8 checks passed, but the full suite on the tested 5.8 installation still encountered asynchronous/PTY failures and a segmentation fault in MCP tests. That remains compatibility work to do.
There is plenty here to be pleased with without pretending the experiment has no rough edges. We have a JSON implementation shaped around Zsh's strengths, a native HTTP client, incremental streaming with a clear execution boundary, persistent state, and a terminal UI that makes substantial use of zdraw. All of it supports a coding agent that works with local models on real projects.
For me, the rewarding part has been discovering how much machinery was already available in the shell-and learning which ways of using it hold up once the responses, sessions, and transcripts become large.
The source is at ZaguanLabs/zcoder.zsh. If you want to see where the experiment is most visible, start with lib/json.zsh, lib/http.zsh, lib/stream.zsh, and lib/drawing.zsh. The interesting decisions are close enough to the surface that you can read them in an evening. Then open a terminal and see what the shell can do.
