A simple AI chat application built with Gooey that connects to Anthropic's Claude API.
Inspired by chat-ai (GPUI/Rust version).
Status: Zig 0.16 / Gooey v0.1.0. Uses the new
std.Iostack end-to-end —std.Io.Groupfor fiber lifecycle,std.Io.Queue(T)for cross-thread result delivery,std.Io.Dir/std.Io.Filefor attachment reads, and theio-awarestd.http.Clientfor the Anthropic request path.
![]() |
![]() |
- 💬 Chat with Claude AI
- 🎨 Dark theme UI
- 📜 Virtual list for efficient message rendering
- 🔄 Async HTTP requests via
std.Io.Group+std.Io.Queue(non-blocking UI, zero heap per result) - 🧹 Structured cancellation — window close cleanly unwinds in-flight fetches
- ⌨️ Simple text input with send button
- Zig 0.16.0 — Install from ziglang.org. Older versions will not build; the code depends on
std.Io,std.process.Init, and the newstd.http.Client.iofield. - Anthropic API Key — Get one from Anthropic Console.
Gooey is pinned to v0.1.0 in build.zig.zon (tarball URL + hash), so no local checkout of Gooey is needed.
# Clone
git clone https://github.com/yourusername/chat-zig
cd chat-zig
# Set your API key
export ANTHROPIC_API_KEY="your-key-here"
# Build and run
zig build run
# Run the (offline) unit tests — JSON escaping, response parsing, base64, etc.
zig build testchat-zig/
├── build.zig # Build configuration
├── build.zig.zon # Dependencies (Gooey)
└── src/
├── main.zig # Entry point & app setup
├── state.zig # Application state & message handling
├── layout.zig # UI components (Header, MessageList, InputArea)
├── http.zig # Anthropic API client
└── theme.zig # Color definitions
This follows the Gooey pattern for stateful apps, using Zig 0.16's std.Io
primitives for all async work:
main(init: std.process.Init)— pullsio+environfrom the runtime-providedInitstruct and publishes them on module-level globals soAppState.initcan reach them without extra plumbing.- AppState — single source of truth. Owns a
std.Io.Group(for fiber lifecycle), astd.Io.Queue(WorkerResult)(for result delivery), and the staging buffers that workers fill. - Layout functions — render UI based on current state. The top of
rendercallss.drainResults(cx)to apply any completed worker outputs before anything else readsis_loadingormessages. - Command handlers — update state in response to user actions.
sendMessagelaunches an HTTP worker viaio_group.async(...)instead of spawning a rawstd.Thread+ dispatcher trampoline.
// sendMessage launches a fiber on the shared Io instance.
// The Io.Group owns the task — cancellation on window close unwinds it.
self.io_group.async(io, httpWorker, .{
io,
&self.http_client.?,
self,
&self.result_queue,
});
// httpWorker runs off the main thread, blocks on the HTTP call, writes
// the response text into the staging buffer, and signals completion.
fn httpWorker(
io: std.Io,
client: *http.AnthropicClient,
app: *AppState,
queue: *std.Io.Queue(WorkerResult),
) void {
var result = client.sendBlocking(request);
defer result.deinit(client.allocator);
// Write staging buffer BEFORE putOne — `putOne` is the happens-before
// edge that makes these writes visible to the render thread.
const outcome: WorkerResult = ...;
queue.putOne(io, outcome) catch {};
app.requestRenderFromWorker(); // nudge the event loop
}
// The render loop drains the queue each frame — non-blocking, bounded.
pub fn drainResults(self: *AppState, cx: *gooey.Cx) void {
var buf: [RESULT_QUEUE_CAPACITY]WorkerResult = undefined;
for (cx.drainQueue(WorkerResult, &self.result_queue, &buf)) |r| {
switch (r) {
.chat_success => |ok| self.applyChatSuccess(ok),
.chat_error => |err| self.applyChatError(err),
}
}
}No per-request heap allocation, no dispatcher trampoline, and the
std.Io.Group registration with Gooey guarantees that window close
cancels any in-flight fetch before AppState is torn down.
MIT

