summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-04-25 11:39:25 -0600
committerT <t@tjp.lol>2026-04-26 09:51:40 -0600
commitd3d5a6295a67bdc99e35e5bfd77359d98a850c3f (patch)
tree345f68ea32be6a3a63d7cab12953269ca74ad549
initial documentation
-rw-r--r--docs/overview.md97
-rw-r--r--docs/phase-1.md447
-rw-r--r--docs/phase-2.md155
-rw-r--r--docs/phase-3.md318
-rw-r--r--docs/phase-4.md582
-rw-r--r--ideas.md38
6 files changed, 1637 insertions, 0 deletions
diff --git a/docs/overview.md b/docs/overview.md
new file mode 100644
index 0000000..962516d
--- /dev/null
+++ b/docs/overview.md
@@ -0,0 +1,97 @@
+# awl — Overview
+
+`awl` is a minimal coding agent built for performance, efficiency, correctness, and a small core that can be extended deliberately.
+
+## Ethos
+
+**Batteries optional.** A full-featured coding agent experience ships by default — but everything can be deactivated. The standard distribution includes a curated set of coding-oriented tools and settings, all of which can be turned off. Strip out every coding tool and `awl` becomes a general-purpose LLM chat client. Additional capabilities may ship in the base but remain deactivated by default, waiting to be opted into. Nothing is mandatory; everything is intentional.
+
+**Small core, deliberate extension.** The core runtime does as little as possible. Features that would be built-ins in other agents are extensions in `awl` — including the fundamental tools like `read`, `write`, `edit`, and `bash`. The extension system is the primary mechanism for adding capability.
+
+**Conservative provider support.** Provider integrations are careful and complete rather than broad and broken. `awl` supports Anthropic-shaped and OpenAI-shaped APIs with arbitrary base URLs. A provider integration that partially works is worse than no integration at all.
+
+**Own your data model.** `awl` defines its own internal conversation representation and maps to/from provider wire formats. No provider's API shape is treated as canonical. This ensures that adding a new provider never requires contorting the core model.
+
+**Lean on the terminal.** The TUI does not try to be a full application framework. Scrollback, selection, and search are handled by the surrounding terminal (ghostty, tmux, etc.). The TUI's job is to present output clearly and offer targeted enhancements — like expanding or collapsing tool-call blocks — by clearing and re-rendering its own output region. This keeps the TUI simple while still providing a much nicer experience than a raw CLI.
+
+## Architecture
+
+### Data model
+
+The conversation model is provider-agnostic. It uses a flat message list where each message has a role and a list of typed content blocks:
+
+```
+Conversation = ordered list of Messages
+Message = { role: system | user | assistant, content: []ContentBlock }
+ContentBlock = Text | Thinking | ToolUse | ToolResult
+```
+
+- `Text` and `Thinking` use a shared `TextualBlock` type that grows incrementally via an internal `ArrayList(u8)` — amortized O(1) appends during streaming, no O(n²) re-copying.
+- `ToolUse` and `ToolResult` arrive complete (not streamed incrementally) and store their data as owned byte slices.
+- System messages may contain multiple `Text` blocks, which are concatenated when a provider expects a single system prompt string (e.g., Anthropic).
+
+### Library structure
+
+`awl` is a library first. The core agent functionality lives in `libawl`, a Zig module. The CLI is a thin consumer of the library. A C ABI build of `libawl` will be produced when the extension system needs it (for Lua interop), implemented as thin `export fn` wrappers around the Zig API.
+
+### Provider abstraction
+
+Providers implement a streaming interface: given a conversation, stream a response message back via a Receiver (callback-based). The Receiver delivers incremental content deltas for real-time display and a complete assembled message when the stream ends. Adding a new provider means implementing this interface and writing the serialization for the provider's wire format.
+
+### Extension system
+
+Extensions will initially be written in Lua, requiring a C ABI surface on `libawl`. Future support for shared-object extensions (Zig, Rust, C, C++) will use the same C ABI. Core tools like `read`, `write`, `edit`, and `bash` are extensions — individually disableable, included in the standard distribution but not hardcoded into the runtime.
+
+### Server/proxy mode
+
+In a future phase, `awl` will be able to run as a server exposing OpenAI-compatible and Anthropic-compatible APIs, acting as a lightweight provider router/proxy to its configured backends. This is not yet planned in detail.
+
+## Phase Roadmap
+
+| Phase | Deliverable | Doc |
+|-------|-------------|-----|
+| 1 | libawl — minimal chat library, OpenAI provider, streaming, minimal CLI | [phase-1.md](phase-1.md) |
+| 2 | Anthropic provider — second provider, validates the abstraction | phase-2.md |
+| 3 | Extension API — Lua runtime, extension loading, tool registration | phase-3.md |
+| 4 | Conversation serialization — JSONL event log, session save/resume, crash recovery | [phase-4.md](phase-4.md) |
+| 5 | Core tools — read/write/edit/bash as distributable extensions | phase-5.md |
+| 6 | Rounded coding agent — slash commands, TOML config, extended TUI | phase-6.md |
+
+### Phase 1: libawl
+
+A Zig library that holds a streaming conversation with an LLM via an OpenAI-compatible API. No tools, no extensions — just chat. Ships a minimal CLI (`awl` binary) for live testing: readline, send, print streamed response, repeat. The conversation model is established with all four ContentBlock variants defined (ToolUse and ToolResult exist in the type but are never produced in this phase).
+
+### Phase 2: Anthropic Provider
+
+A second provider implementation targeting Anthropic's API shape. Validates that the Provider abstraction and internal conversation model are genuinely provider-agnostic — not just OpenAI in disguise. This is a focused phase: if the abstraction is right, it's mostly serialization work. If it's wrong, we find out here rather than later.
+
+### Phase 3: Extension API
+
+Introduces a Lua extension runtime and the extension loading mechanism. Extensions can register tools, access configuration, and participate in the agent loop. This phase also produces the C ABI build of `libawl` needed for Lua interop. Tools exist but none ship yet — the extension system is the deliverable, not the tools.
+
+### Phase 4: Conversation Serialization
+
+Save and resume conversations. Sessions are stored as append-only JSONL event logs — recording every message and model change — and fully rebuilt from the log on resume. Disk persistence so a coding agent can survive restarts and be reviewed later. See [phase-4.md](phase-4.md).
+
+### Phase 5: Core Tools as Extensions
+
+The fundamental coding tools — `read`, `write`, `edit`, `bash` — are implemented as extensions (initially Lua, eventually native). They live under the `std` namespace: `std.read`, `std.write`, `std.edit`, `std.bash`. The `std` package is a curated set of coding-oriented extensions — some enabled by default, some available but deactivated — embodying the "batteries optional" ethos. They ship with the standard distribution but are individually disableable. This is where `awl` becomes a functional coding agent rather than just a chat client.
+
+### Phase 6: Rounded Coding Agent
+
+Polish and capstone features that make `awl` a well-rounded coding agent experience:
+
+- **Slash commands** — an extensible framework for `/`-prefixed commands (e.g., `/help`, `/model`, `/clear`)
+- **TOML configuration** — a config file for persistent settings (default model, enabled/disabled extensions, provider configs, system prompt templates)
+- **Extended TUI** — smarter output rendering while remaining lightweight: expand/collapse tool call blocks (via clear-and-reprint), structured display of thinking content, prompt decoration
+- **Compaction with custom compaction prompts** — LLM-based context pruning for long conversations. Older messages are summarized to free context window space. The `compaction` entry type in the event log records the summary and a reference to the first kept message, so the log remains append-only and the full history is never destroyed — only omitted from the LLM context. Extension hooks allow custom compaction prompts, so users can guide how their history is summarized (e.g., preserving details about a specific task, emphasizing code changes over conversation).
+
+## Future (Unplanned)
+
+These are recorded from the initial ideas but do not yet have phase documents or detailed plans:
+
+- **Server/proxy mode** — run `awl` as a server exposing OpenAI-compatible and Anthropic-compatible APIs, routing to configured backends
+- **Shared-object extensions** — extend the extension system beyond Lua to support native shared libraries via the C ABI (Zig, Rust, C, C++)
+- **System prompt construction framework** — opinionated system for assembling system prompts from composable parts (templates, project context, extension contributions)
+- **Google API provider** — native integration with Google's Gemini API (rather than their OpenAI-compatibility layer), unlocking richer capabilities specific to that API shape. Low priority compared to Anthropic and OpenAI support.
+- **C ABI distribution of libawl** — `export fn` wrappers exposing libawl functionality through a C calling convention, enabling external programs to embed or build on awl from C, Rust, or other native languages. Not a separate library — the C ABI is a second interface on the same `libawl` artifact, compiled from `export fn` shims that translate between Zig types and C types. Needed eventually for shared-object extensions (Zig, Rust, C, C++) beyond Lua.
diff --git a/docs/phase-1.md b/docs/phase-1.md
new file mode 100644
index 0000000..d9a8450
--- /dev/null
+++ b/docs/phase-1.md
@@ -0,0 +1,447 @@
+# Phase 1: libawl — Minimal Chat Library
+
+## Goal
+
+A Zig library that can hold a streaming conversation with an LLM via an OpenAI-compatible API. No tools, no extensions — just chat. Includes a minimal CLI for live testing.
+
+## Deliverable
+
+A `libawl` Zig module importable by other Zig code, plus a `awl` binary that wires it into a basic read/print loop. At the end of this phase, you can:
+
+- Start `awl`, type a message, and receive a streamed response from an OpenAI-compatible LLM.
+- Send follow-up messages that include full conversation history.
+- See thinking tokens and text tokens stream in as they arrive.
+- Have the complete conversation available in memory for the duration of the session.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Open a conversation | `awl.conversation.Conversation.init(allocator)` |
+| Add a user message | `conversation.addUserMessage("hello")` |
+| Run an agent step (streaming) | `agent.runStep(conversation, &receiver)` |
+| See streamed output | CLI prints thinking/text chunks as they arrive |
+| Conversation persists across turns | Follow-up messages include prior history |
+
+## What is explicitly out of scope
+
+- Tools and tool-use (phase 3+)
+- Extensions and extension API (phase 3+)
+- C ABI (phase 3+, when needed for Lua extensions)
+- Anthropic provider (phase 2)
+- Disk persistence / session save (later phase)
+- Server/proxy mode (future, undefined phase)
+- System prompt construction framework (later phase — the model supports system messages, but no opinionated assembly system yet)
+
+---
+
+## Data Model
+
+### TextualBlock (shared streaming buffer)
+
+```
+TextualBlock = struct {
+ buf: std.ArrayList(u8),
+ allocator: std.mem.Allocator,
+
+ pub fn init(allocator: std.mem.Allocator) TextualBlock
+ pub fn content(self: *const TextualBlock) []const u8 // self.buf.items
+ pub fn append(self: *TextualBlock, delta: []const u8) !void
+ pub fn deinit(self: *TextualBlock) void // self.buf.deinit()
+}
+```
+
+`Text` and `Thinking` blocks both use `TextualBlock` as their payload. They share the same append-based streaming behavior — deltas arrive incrementally and are appended via an internal `ArrayList(u8)`, giving amortized O(1) appends and avoiding the O(n²) re-copying that would result from storing `[]const u8` slices.
+
+The `TextualBlock` stores its own allocator reference so that `deinit()` needs no external context. Each `TextualBlock` has an `init()`/`deinit()` pair. This also means a `ContentBlock` can clean itself up without the caller providing an allocator.
+
+### ContentBlock (tagged union)
+
+```
+ContentBlock = union(enum) {
+ Text: TextualBlock,
+ Thinking: TextualBlock,
+ ToolUse: ToolUseBlock, // phase 3+
+ ToolResult: ToolResultBlock, // phase 3+
+
+ pub fn deinit(self: *ContentBlock) void {
+ switch (self.*) {
+ .Text, .Thinking => |*b| b.deinit(),
+ .ToolUse => |b| { /* free id, name, input */ },
+ .ToolResult => |b| { /* free tool_use_id, content */ },
+ }
+ }
+}
+
+ToolUseBlock = struct {
+ id: []const u8, // owned copy
+ name: []const u8, // owned copy
+ input: []const u8, // raw JSON bytes, owned copy
+}
+
+ToolResultBlock = struct {
+ tool_use_id: []const u8, // owned copy
+ content: []const u8, // owned copy
+}
+```
+
+`ToolUse` and `ToolResult` are defined in the model now but not populated or processed until the extensions phase. This avoids a model refactor later — the types exist, we just never encounter them in phase 1.
+
+The `input` field of `ToolUse` is stored as raw JSON bytes (`[]const u8`) rather than a parsed structure. We are not in the business of understanding tool input schemas; we pass them through.
+
+`ToolUse` blocks also stream incrementally — both providers send tool input as JSON fragments across multiple deltas. Therefore `ToolUse.input` also uses `TextualBlock` for assembly. `id` and `name` arrive at block-start time and are stored as owned `[]const u8` copies.
+
+`ToolResult` blocks are constructed by `awl` itself (not streamed from a provider), so `content` could be a simple `[]const u8`. However, for consistency and to allow progressive construction of results, it also uses `TextualBlock`.
+
+Updated types:
+```
+ToolUseBlock = struct {
+ id: []const u8, // owned copy, from onBlockStart metadata
+ name: []const u8, // owned copy, from onBlockStart metadata
+ input: TextualBlock, // accumulated from onContentDelta
+}
+
+ToolResultBlock = struct {
+ tool_use_id: []const u8, // owned copy
+ content: TextualBlock, // accumulated content
+}
+```
+
+**Memory discipline**: When a `ContentBlock` is moved into a `Message`'s content list (stored in `std.ArrayList(ContentBlock)`), the TextualBlock's internal ArrayList buffer pointer remains valid — it points to the same heap allocation. The caller must ensure each block's `deinit()` is called exactly once, and must not copy a ContentBlock without clearing the source (standard Zig move semantics).
+
+### Message
+
+```
+Message = {
+ role: enum { system, user, assistant },
+ content: []ContentBlock,
+}
+```
+
+A system message may contain multiple `Text` blocks. When serializing to Anthropic's API (phase 2), these are concatenated into the single system prompt string.
+
+An assistant message is assembled incrementally during streaming. A user message containing tool results (phase 3+) naturally groups multiple `ToolResult` blocks.
+
+### Conversation
+
+```
+Conversation = {
+ messages: std.ArrayList(Message),
+ allocator: std.mem.Allocator,
+}
+```
+
+Ordered list of messages. Methods:
+
+- `init(allocator)` → Conversation
+- `addSystemMessage(text)` → appends `Message{ .system, [TextBlock(text)] }`
+- `addUserMessage(text)` → appends `Message{ .user, [TextBlock(text)] }`
+- `addAssistantMessage(blocks)` → appends `Message{ .assistant, blocks }` (called by agent loop after streaming completes)
+- `deinit()` → frees all owned memory
+
+All `[]const u8` fields in ContentBlocks and Messages are owned by the Conversation and freed on `deinit()`. Content is stored as copies, not slices into external buffers. `TextualBlock` fields back their content with a heap-allocated `ArrayList(u8)` that grows incrementally during streaming and is freed on `deinit()`.
+
+---
+
+## Module Structure
+
+```
+src/
+ root.zig // public API re-exports
+ conversation.zig // Message, ContentBlock, Conversation
+ provider.zig // Provider interface, StreamEvent, StreamResult
+ provider_openai.zig // OpenAI-compatible implementation
+ sse.zig // SSE line parser
+ agent.zig // Agent loop: runStep, Receiver interface
+ config.zig // Config struct (api_key, base_url, model)
+ json.zig // Serialization helpers (model → wire JSON, deltas → ContentBlocks)
+```
+
+### `conversation.zig`
+
+Defines `Message`, `ContentBlock`, `Conversation`. All serialization to/from provider wire formats lives in `json.zig` — conversation.zig is pure data structure.
+
+Tests: create conversations, add messages, verify content, free without leaks.
+
+### `provider.zig`
+
+Defines the `Provider` interface:
+
+```
+Provider = struct {
+ ptr: *anyopaque,
+ vtable: *const VTable,
+
+ VTable = struct {
+ streamStep: *const fn(*anyopaque, conversation: *Conversation, receiver: *Receiver) anyerror!void,
+ deinit: *const fn(*anyopaque) void,
+ };
+
+ pub fn streamStep(self, conversation, receiver) !void
+ pub fn deinit(self) void
+};
+```
+
+And the `Receiver` interface for streaming callbacks:
+
+```
+Receiver = struct {
+ ptr: *anyopaque,
+ vtable: *const ReceiverVTable,
+
+ ReceiverVTable = struct {
+ onMessageStart: *const fn(*anyopaque, role: MessageRole) void,
+ onBlockStart: *const fn(*anyopaque, block_type: ContentBlockType, index: usize, meta: ?BlockMeta) void,
+ onContentDelta: *const fn(*anyopaque, block_index: usize, delta: []const u8) void,
+ onBlockComplete: *const fn(*anyopaque, block_index: usize, block: ContentBlock) void,
+ onMessageComplete:*const fn(*anyopaque, message: Message) void,
+ };
+
+ pub fn onMessageStart(self, role) void
+ pub fn onBlockStart(self, block_type, index, meta) void
+ pub fn onContentDelta(self, block_index, delta) void
+ pub fn onBlockComplete(self, block_index, block) void
+ pub fn onMessageComplete(self, message) void
+};
+
+BlockMeta = struct {
+ // Only populated for ToolUse blocks. Null for Text/Thinking.
+ tool_id: ?[]const u8,
+ tool_name: ?[]const u8,
+};
+```
+
+**Callback contract:**
+
+- Callbacks are always invoked in this order for every block, regardless of which provider is active.
+- `onMessageStart` is called when the stream begins delivering a new message.
+- `onBlockStart` is called when a new content block begins. `meta` carries block-type-specific metadata (tool id/name for ToolUse, null for Text/Thinking).
+- `onContentDelta` is called zero or more times per block with raw byte fragments. For Text/Thinking these are word fragments; for ToolUse these are JSON fragments. The receiver does not need to interpret them. `delta` is a `[]const u8` — libawl does not parse tool input content, it passes bytes through.
+- `onBlockComplete` is called when a block is finished. The `block` parameter contains the fully assembled ContentBlock. The receiver that only needs complete content can ignore deltas and use this.
+- `onMessageComplete` is called when the stream ends. The `message` parameter contains the fully assembled Message with all blocks.
+- Providers guarantee that `onBlockComplete`'s `block` and `onMessageComplete`'s `message` are always fully assembled and valid.
+
+This uniform callback sequence means the TUI and agent loop don't need to know which provider is active. Anthropic (phase 2) maps its structured events directly to these callbacks; OpenAI synthesizes block boundaries from delta field transitions (see below).
+
+### `provider_openai.zig`
+
+Implements `Provider` for OpenAI-compatible APIs.
+
+- Converts `Conversation` → OpenAI wire JSON (see [OpenAI serialization](#openai-serialization) below)
+- Makes HTTP POST to `{base_url}/chat/completions` with `stream: true`
+- Reads SSE events, parses each `data: {...}` line as complete JSON
+- Synthesizes block boundaries from delta field transitions (OpenAI has no explicit block boundary events)
+- Calls the full Receiver callback sequence (onMessageStart → onBlockStart → onContentDelta → onBlockComplete → onMessageComplete)
+- Accumulates deltas into ContentBlocks via TextualBlocks
+
+Construction:
+
+```
+OpenAIProvider.init(allocator, config) !OpenAIProvider
+```
+
+### OpenAI block boundary synthesis
+
+OpenAI's streaming deltas have no explicit block boundaries. The provider tracks a state machine to infer when blocks start and end:
+
+```
+StreamingState = struct {
+ active_block_type: enum { none, thinking, text, tool_use },
+ active_block_index: usize,
+ // assembly buffers per block
+}
+
+On each SSE event:
+ 1. If delta.role == "assistant" → emit onMessageStart(.assistant)
+ 2. If delta.reasoning_content present:
+ - If active_block_type != .thinking:
+ - If active_block_type != .none → emit onBlockComplete for prior block
+ - Emit onBlockStart(.Thinking, index, null)
+ - active_block_type = .thinking
+ - Emit onContentDelta(index, delta.reasoning_content)
+ 3. If delta.content present:
+ - If active_block_type != .text:
+ - If active_block_type != .none → emit onBlockComplete for prior block
+ - Emit onBlockStart(.Text, index, null)
+ - active_block_type = .text
+ - Emit onContentDelta(index, delta.content)
+ 4. If delta.tool_calls present:
+ - If active_block_type != .tool_use:
+ - If active_block_type != .none → emit onBlockComplete for prior block
+ - Emit onBlockStart(.ToolUse, index, .{ .tool_id = ..., .tool_name = ... })
+ - active_block_type = .tool_use
+ - Emit onContentDelta(index, delta.tool_calls[].function.arguments)
+ 5. If finish_reason != null:
+ - If active_block_type != .none → emit onBlockComplete for current block
+ - Emit onMessageComplete(assembled_message)
+```
+
+A transition in `active_block_type` means the previous block is done and a new one has started. The state machine also handles the case where the same block type appears again after an intervening type (e.g., thinking → text → thinking), which would open a new Thinking block at a new index.
+
+### `sse.zig`
+
+Incremental SSE line parser. The HTTP client delivers arbitrary-sized read buffers; this module reassembles them into complete `data: ...\n\n` events.
+
+```
+SSEParser = struct {
+ buf: std.ArrayList(u8),
+
+ pub fn init(allocator) SSEParser
+ pub fn feed(self, chunk: []const u8) ![]const []const u8 // returns slice of complete event strings
+ pub fn deinit(self) void
+};
+```
+
+`feed()` may return zero events (partial line buffered) or multiple events (chunk contained several). The caller does not need to worry about line boundaries.
+
+Tests: feed partial chunks, verify events emitted at correct boundaries; multi-event in single chunk; empty lines; `data: [DONE]`.
+
+### `json.zig`
+
+Two responsibilities:
+
+1. **Serialize Conversation → OpenAI request body** — Convert our `Message`/`ContentBlock` model into the JSON shape OpenAI expects. See below.
+2. **Parse SSE chunk deltas → ContentBlock updates** — Each SSE event's JSON contains a `choices[0].delta` object. Extract text/thinking content from it.
+
+### `agent.zig`
+
+The agent loop. In phase 1, it's simple:
+
+```
+Agent = struct {
+ provider: Provider,
+ allocator: std.mem.Allocator,
+
+ pub fn init(allocator, provider) Agent
+ pub fn runStep(self, conversation: *Conversation, receiver: *Receiver) !void
+ pub fn deinit(self) void
+};
+```
+
+`runStep` does:
+1. Call `provider.streamStep(conversation, receiver)` — this streams the response and calls the full Receiver callback sequence on the receiver
+2. The `onMessageComplete` callback appends the finished Message to the conversation (the agent itself can wire this, or the caller handles it — TBD during implementation)
+
+In later phases, `runStep` gains the tool-call loop: check for ToolUse blocks, execute them, feed results back, call provider again. But the shape stays the same — one `runStep` invocation carries the conversation through a full agent turn.
+
+### `config.zig`
+
+```
+Config = struct {
+ api_key: []const u8,
+ base_url: []const u8, // e.g. "https://api.openai.com/v1"
+ model: []const u8, // e.g. "gpt-4o"
+};
+```
+
+Populated from environment variables (`AWL_API_KEY`, `AWL_BASE_URL`, `AWL_MODEL`) with defaults for base_url and model.
+
+### `root.zig`
+
+Public API. Re-exports the types and functions that external Zig code needs:
+
+```
+pub const conversation = @import("conversation.zig");
+pub const provider = @import("provider.zig");
+pub const agent = @import("agent.zig");
+pub const config = @import("config.zig");
+```
+
+Does not re-export provider_openai, sse, or json — those are internal.
+
+---
+
+## OpenAI Serialization
+
+### Request
+
+Our `Conversation` → OpenAI `chat/completions` request body:
+
+```
+{
+ "model": config.model,
+ "stream": true,
+ "messages": [
+ // For each Message in conversation:
+ //
+ // role=.system → { "role": "system", "content": "<concatenated text blocks>" }
+ // role=.user → { "role": "user", "content": "<concatenated text blocks>" }
+ // (ToolResult blocks pulled out into separate role:tool messages in phase 3+)
+ // role=.assistant → { "role": "assistant", "content": [
+ // ...text blocks as { "type": "text", "text": "..." },
+ // ...thinking blocks as { "type": "thinking", "thinking": "..." },
+ // ...tool_use blocks become function_call/function entries in phase 3+
+ // ] }
+ ]
+}
+```
+
+For phase 1, all content blocks we encounter are `Text` or `Thinking`, so serialization is straightforward.
+
+### Response (streaming)
+
+Each SSE event is a complete JSON object:
+
+```
+data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
+data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
+data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}
+data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
+data: [DONE]
+```
+
+We parse each event's `choices[0].delta` and drive the block boundary state machine:
+- `delta.role == "assistant"` → emit onMessageStart, marks the start of a new assistant message
+- `delta.reasoning_content` → transition to Thinking block if needed, append via onContentDelta
+- `delta.content` → transition to Text block if needed, append via onContentDelta
+- `delta.tool_calls` → transition to ToolUse block if needed, append arguments via onContentDelta (phase 3+)
+
+The `finish_reason: "stop"` signals stream end → emit onBlockComplete for any active block, then onMessageComplete.
+
+---
+
+## Minimal CLI
+
+```
+awl/
+ src/
+ main.zig // CLI entry point
+```
+
+Behavior:
+1. Read `AWL_API_KEY`, `AWL_BASE_URL`, `AWL_MODEL` from environment
+2. Create a Conversation, add a system message (default: "You are a helpful assistant.")
+3. Print a prompt (`> `), read a line from stdin
+4. Add user message, call `agent.runStep()`, print streamed deltas to stdout
+5. Repeat step 3 until EOF (Ctrl+D)
+
+There is no line editing, no scrolling, no syntax highlighting. Just `readline` → `print`. The sole purpose is exercising libawl against a real API.
+
+---
+
+## Testing Strategy
+
+### Unit tests (automated, per module)
+
+| Module | What to test |
+|---|---|
+| `conversation.zig` | Create conversation, add messages of each role, verify content block storage, free without leaks |
+| `sse.zig` | Feed partial chunks, verify event boundaries; multi-event chunks; `data: [DONE]`; empty lines between events |
+| `json.zig` | Serialize conversation → OpenAI JSON; parse delta JSON objects → content updates |
+| `config.zig` | Parse from env vars; defaults for missing optional fields |
+
+### Integration test (manual)
+
+- Run `awl` binary with a real API key
+- Hold a multi-turn conversation
+- Verify responses stream to stdout
+- Verify follow-up messages include prior context (ask the model "what did I just say?")
+
+---
+
+## Open Questions (to resolve during implementation)
+
+1. **Thinking token support in OpenAI API**: OpenAI's `reasoning_content` field in streaming deltas is not universally present across models/endpoints. We need to handle its absence gracefully (just skip it, don't crash).
+2. **Error handling in streams**: Mid-stream HTTP errors, rate limiting, truncated responses. How do we represent these to the caller? An `onError` callback on the Receiver seems likely.
+3. **HTTP connection lifecycle**: Does `std.http.Client` support long-lived streaming connections cleanly? We may need to manage connection pooling or timeouts.
+4. **Memory strategy for long conversations**: We're storing full message content in memory. For phase 1 this is fine, but we should define the interface so a later phase can introduce message summarization or offloading without changing the agent loop.
diff --git a/docs/phase-2.md b/docs/phase-2.md
new file mode 100644
index 0000000..877f8bd
--- /dev/null
+++ b/docs/phase-2.md
@@ -0,0 +1,155 @@
+# Phase 2: Anthropic Provider
+
+## Goal
+
+Add Anthropic as a second provider, validating that the Provider abstraction and internal conversation model are genuinely provider-agnostic — not just OpenAI in disguise.
+
+## Deliverable
+
+A working `provider_anthropic.zig` that can hold a streaming conversation via Anthropic's API. At the end of this phase, you can:
+
+- Switch between OpenAI and Anthropic providers with no changes to the agent loop or conversation model.
+- Stream responses from either provider and see the same callback sequence (onMessageStart, onBlockStart, onContentDelta, onBlockComplete, onMessageComplete).
+- Observe thinking and text content streaming correctly from Anthropic models.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Create an Anthropic provider | `AnthropicProvider.init(allocator, config)` |
+| Run a chat via Anthropic | Same `agent.runStep()` call, different provider |
+| Stream Anthropic responses | Same Receiver interface, same callback sequence |
+| System prompts with Anthropic | System messages extracted and sent as top-level system field |
+
+## What is explicitly out of scope
+
+- Tools and tool-use (phase 3+)
+- Extensions (phase 3+)
+- Conversation serialization / disk persistence (phase 4)
+- Server/proxy mode (future)
+- Google API provider (future)
+
+---
+
+## Receiver Interface
+
+The Receiver interface with the full 5-callback lifecycle is defined in phase 1. Both providers must produce the same callback sequence:
+
+- onMessageStart → onBlockStart → onContentDelta(s) → onBlockComplete → ... → onMessageComplete
+
+See `phase-1.md` for the full definition and contract.
+
+### How OpenAI synthesizes the callbacks
+
+Defined in phase 1. OpenAI has no explicit block boundaries; the provider infers them via a state machine that tracks the active block type and emits start/complete callbacks on transitions.
+
+### How Anthropic maps to the callbacks
+
+Anthropic's structured events map directly:
+
+| Anthropic event | Callback |
+|---|---|
+| `message_start` | onMessageStart(.assistant) |
+| `content_block_start` | onBlockStart(type, index, meta if ToolUse) |
+| `content_block_delta` | onContentDelta(index, delta bytes) |
+| `content_block_stop` | onBlockComplete(index, assembled block) |
+| `message_delta` + `message_stop` | onMessageComplete(assembled message) |
+
+No inference needed — Anthropic gives us explicit boundaries.
+
+---
+
+## Anthropic Request Serialization
+
+### Wire format differences from OpenAI
+
+| Aspect | OpenAI | Anthropic |
+|---|---|---|
+| System prompt | Messages with `role: "system"` | Top-level `system` field (string) |
+| Content shape | String or array of parts | Always array of content blocks |
+| Tool results | Separate `role: "tool"` messages | Content blocks on `role: "user"` messages |
+| Auth | `Authorization: Bearer <key>` | `x-api-key: <key>` + `anthropic-version` header |
+| Streaming | `stream: true` in request body | `stream: true` in request body |
+
+### Serialization rules
+
+**System messages**: Extract all `role=.system` messages from the conversation. Concatenate their Text block contents into a single string. Set as the top-level `system` field. Do not include them in the messages array.
+
+**User messages**: Emit as `role: "user"`. Content blocks become Anthropic content block format:
+- Text → `{ "type": "text", "text": "..." }`
+- ToolResult → `{ "type": "tool_result", "tool_use_id": "...", "content": "..." }` (phase 3+)
+
+**Assistant messages**: Emit as `role: "assistant"`. Content blocks:
+- Text → `{ "type": "text", "text": "..." }`
+- Thinking → `{ "type": "thinking", "thinking": "..." }`
+- ToolUse → `{ "type": "tool_use", "id": "...", "name": "...", "input": {...} }` (phase 3+)
+
+Note: Anthropic expects ToolUse's `input` as a parsed JSON object, not a string. Since we store `input` as raw bytes in a TextualBlock, we will need to parse it into a `std.json.Value` during Anthropic serialization. This is the one place where libawl does parse tool input JSON — it's a serialization requirement, not an interpretation of the tool schema. The round-trip guarantee is: the bytes we stored serialize back to equivalent JSON when sent to Anthropic.
+
+---
+
+## Anthropic Streaming Event Parser
+
+Each SSE event is a complete JSON object. The event type is in a top-level `type` field.
+
+### Event types and handling
+
+| Event type | What we extract | Action |
+|---|---|---|
+| `message_start` | `message.role`, `message.id`, `message.model` | Emit onMessageStart; begin assembling Message |
+| `content_block_start` | `content_block.type`, `content_block.index`, `content_block.id`, `content_block.name` | Emit onBlockStart; create new TextualBlock or ToolUseBlock |
+| `content_block_delta` | `delta.type` (text_delta, thinking_delta, input_json_delta), `delta.text` or `delta.thinking` or `delta.partial_json` | Append to current block's buffer; emit onContentDelta |
+| `content_block_stop` | `index` | Emit onBlockComplete with assembled block |
+| `message_delta` | `delta.stop_reason`, `usage` | Track stop reason for onMessageComplete |
+| `message_stop` | (none) | Emit onMessageComplete with fully assembled Message |
+
+The parser is a separate concern from the SSE line parser (`sse.zig`). The SSE parser reassembles byte chunks into complete `data: {...}` events. The Anthropic event parser interprets the JSON of each event. They compose: `HTTP read → SSE parser → event strings → Anthropic event parser → callbacks`.
+
+---
+
+## Module Changes
+
+### New files
+
+```
+src/provider_anthropic.zig // Anthropic provider implementation
+```
+
+### Modified files
+
+- `provider.zig` — ReceiverVTable expanded to the 5-callback lifecycle
+- `provider_openai.zig` — No changes needed (block synthesis already built in phase 1)
+- `agent.zig` — Any changes needed for expanded Receiver (should be minimal since Receiver is an interface)
+
+### Config
+
+```
+AnthropicConfig = struct {
+ api_key: []const u8,
+ base_url: []const u8, // e.g. "https://api.anthropic.com"
+ model: []const u8, // e.g. "claude-sonnet-4-20250514"
+ api_version: []const u8, // e.g. "2023-06-01"
+};
+```
+
+Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them; libawl treats them as distinct.
+
+---
+
+## Testing Strategy
+
+### Unit tests
+
+| What | How |
+|---|---|
+| Anthropic serialization | Create conversations with system/user/assistant messages, serialize to Anthropic JSON, verify structure and system prompt extraction |
+| Streaming event parser | Feed canned Anthropic SSE events (message_start, content_block_start, etc.) and verify correct callback sequence and assembled output |
+| Block boundary synthesis | Feed OpenAI-style deltas (reasoning → content transitions) and verify onBlockStart/onBlockComplete emitted correctly |
+| Receiver contract | Verify that both providers produce the same callback sequence for equivalent conversations |
+
+### Integration test (manual)
+
+- Run `awl` binary against Anthropic API with a real API key
+- Hold a multi-turn conversation with thinking model
+- Verify thinking and text stream correctly
+- Switch to OpenAI provider, verify same experience
diff --git a/docs/phase-3.md b/docs/phase-3.md
new file mode 100644
index 0000000..51e1341
--- /dev/null
+++ b/docs/phase-3.md
@@ -0,0 +1,318 @@
+# Phase 3: Extension System
+
+## Goal
+
+Introduce a Lua extension runtime and tool registration/execution, transforming `awl` from a chat client into an agent that can act on the world. The extension system is the primary mechanism for adding capability — tools are extensions, not built-ins.
+
+## Deliverable
+
+A working extension system where Lua scripts can register tools and handle tool-use requests. The agent loop detects ToolUse blocks in LLM responses, executes the corresponding tool handlers, and feeds ToolResult blocks back. At the end of this phase, you can:
+
+- Write a Lua extension that registers a tool and handles invocations.
+- Place it in `~/.config/awl/extensions/` (or `.awl/extensions/`) and have `awl` discover and load it.
+- Have a conversation where the LLM calls your tool and receives the result.
+- See tool calls execute in parallel when the LLM returns multiple ToolUse blocks.
+- See a meaningful error message when an extension crashes, instead of a process abort.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Write a tool extension | Create `~/.config/awl/extensions/mytool.lua` calling `awl.register_tool(...)` |
+| Discover extensions | Place `.lua` files or directories in extension directories; `awl` loads them on startup |
+| LLM calls a tool | Ask the LLM to use a registered tool; it emits a ToolUse block; awl executes the handler |
+| Tool result fed back | The tool handler's return value becomes a ToolResult block sent back to the LLM |
+| Parallel tool calls | LLM returns multiple ToolUse blocks; they execute concurrently |
+| Extension crash handling | A crashing tool handler prints `the "mytool" extension crashed: <trace>` and aborts the turn |
+
+## What is explicitly out of scope
+
+- Core tools as extensions (phase 5 — `std.read`, `std.write`, etc.)
+- Conversation serialization / disk persistence (phase 4)
+- C ABI distribution of libawl for external consumers (future)
+- GitHub or luarocks extension loaders (future — local filesystem only in phase 3)
+- Shared-object extensions (future)
+- Extension sandboxing beyond `xpcall` crash protection (future)
+- Config file for specifying which extensions to load (phase 6 — phase 3 loads everything it discovers)
+
+---
+
+## Extension Discovery
+
+### Directory locations
+
+`awl` scans two directories in order:
+
+1. `.awl/extensions/` — project-local extensions (relative to current working directory)
+2. `~/.config/awl/extensions/` — user-level extensions
+
+### Naming and structure
+
+Extensions are identified by name, derived from their path. Two formats:
+
+- **Single-file**: `<name>.lua` → extension name is `<name>`
+- **Directory**: `<name>/init.lua` → extension name is `<name>`
+
+Names can be hierarchical using dots as separators, mapping to directory nesting:
+
+- `utils/json.lua` → extension name is `utils.json`
+- `coding/edit/init.lua` → extension name is `coding.edit`
+
+This convention mirrors Lua's `require("a.b.c")` path resolution. Extension sub-modules (e.g., `coding/edit/helpers.lua`) are the extension's internal business — awl only loads the top-level entry point (`init.lua` or the single file).
+
+### Loading behavior
+
+- Scan both directories recursively.
+- Construct extension names from relative paths using dot separators.
+- Load each discovered entry point file into a fresh `lua_State`.
+- After loading, the extension's top-level code runs, which should call `awl.register_tool(...)` to register its tools.
+- Duplicate extension names: project-local takes precedence over user-level.
+
+---
+
+## Lua Bridge
+
+The Lua bridge is a Zig module (`lua_bridge.zig`) that registers awl functions into the Lua state and handles translation between Zig types and Lua types. It is compiled into the `awl` binary — it is not a separate library.
+
+### Functions exposed to Lua
+
+#### `awl.register_tool(name, schema, handler)`
+
+Registers a tool with the agent.
+
+- `name` (string) — tool name, e.g. `"bash"`
+- `schema` (table) — tool input schema as a Lua table (JSON Schema format). Serialized to JSON bytes for inclusion in provider requests.
+- `handler` (function) — called when the LLM invokes this tool. Receives a single argument: a Lua table parsed from the tool input JSON. Must return a string (the tool result content).
+
+Example:
+
+```lua
+awl.register_tool("echo", {
+ type = "object",
+ properties = {
+ message = { type = "string", description = "The message to echo back" }
+ },
+ required = { "message" }
+}, function(input)
+ return input.message
+end)
+```
+
+### Input parsing at the bridge boundary
+
+Tool input arrives in libawl as raw JSON bytes (stored in the ToolUseBlock's TextualBlock). At the Lua bridge boundary, libawl parses these bytes into a Lua table using `std.json`, then passes the table to the handler. This is a convenience service for extension authors — internally, libawl still treats tool input as opaque bytes. The round-trip guarantee: the JSON bytes the provider sent are faithfully represented in the Lua table.
+
+### Output from handlers
+
+The handler returns a string. This string becomes the `content` of a ToolResult block. It is stored as raw bytes; libawl does not interpret or parse it.
+
+---
+
+## Tool Registration (Internal)
+
+When `awl.register_tool()` is called from Lua, the bridge stores:
+
+```
+RegisteredTool = struct {
+ name: []const u8, // owned copy
+ input_schema: []const u8, // JSON bytes, owned copy (serialized from the Lua table)
+ lua_handler_ref: i32, // Lua registry reference to the handler function
+};
+```
+
+A global tool registry maps tool names to `RegisteredTool` entries. The agent loop consults this registry when it encounters ToolUse blocks.
+
+The `input_schema` bytes are included verbatim in provider requests when tools are listed. Both OpenAI and Anthropic accept JSON Schema objects for tool input definitions.
+
+---
+
+## Tool Execution
+
+### Agent loop extension
+
+The agent loop gains tool-call handling after each provider response:
+
+```
+runStep(conversation, receiver):
+ 1. Call provider.streamStep(conversation, receiver)
+ 2. Examine the completed message for ToolUse blocks
+ 3. If ToolUse blocks present:
+ a. For each ToolUse block, look up the tool in the registry
+ b. Execute all tool handlers (see parallel execution below)
+ c. Collect ToolResult blocks
+ d. Construct a user Message containing the ToolResult blocks
+ e. Append to conversation
+ f. Go to step 1 (call provider again with the updated conversation)
+ 4. If no ToolUse blocks: done — the turn is complete
+```
+
+A single `runStep` may invoke the provider multiple times if the LLM chains tool calls.
+
+### Parallel execution
+
+Multiple ToolUse blocks in a single response are executed concurrently. This is a documented part of the extension API: **tool handlers may be called concurrently in separate threads.** Extension authors must ensure their handlers are thread-safe.
+
+Implementation: on-demand `lua_State` pool.
+
+```
+LuaStatePool = struct {
+ states: std.ArrayList(*lua_State),
+ available: std.BitSet, // which states are free
+ allocator: std.mem.Allocator,
+ extension_dirs: []const []const u8,
+
+ pub fn acquire(self) *lua_State // returns an existing free state, or creates a new one
+ pub fn release(self, *lua_State) // returns state to the pool
+ pub fn deinit(self) void // destroys all states
+};
+```
+
+- `acquire()`: if a free state exists, return it. Otherwise, create a fresh `lua_State`, load all discovered extensions into it (so the handler function references are valid), and return it.
+- States are created lazily, not pre-allocated.
+- Each state has all extensions loaded identically, so any state can handle any tool.
+- When tool execution completes, the state is returned to the pool for reuse.
+
+### Crash protection
+
+Every tool handler invocation is wrapped in `xpcall` with a traceback handler:
+
+```lua
+xpcall(handler_fn, function(err)
+ return debug.traceback(err)
+end, input_table)
+```
+
+If the handler crashes:
+
+- The error and stack trace are captured as a string.
+- awl prints: `the "<tool_name>" extension crashed: <trace>`
+- The current LLM turn is aborted — no ToolResult is generated for this tool call.
+- Other concurrent tool calls in the same batch are not affected (they run in separate `lua_State` instances).
+
+---
+
+## Tool Serialization in Provider Requests
+
+When tools are registered, the provider requests must include them. Both providers have a `tools` field in the request body.
+
+### OpenAI
+
+```
+{
+ "model": ...,
+ "stream": true,
+ "messages": [...],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "description": "...", // not yet supported, phase 5+ when we add descriptions
+ "parameters": { <input_schema> }
+ }
+ }
+ ]
+}
+```
+
+### Anthropic
+
+```
+{
+ "model": ...,
+ "system": ...,
+ "stream": true,
+ "messages": [...],
+ "tools": [
+ {
+ "name": "echo",
+ "description": "...",
+ "input_schema": { <input_schema> }
+ }
+ ]
+}
+```
+
+The `input_schema` bytes stored in the registry are emitted verbatim into the `parameters` (OpenAI) or `input_schema` (Anthropic) field.
+
+### ToolUse in responses
+
+Both providers return tool-call information in their streaming responses. This is already handled by the existing Receiver callback sequence:
+
+- OpenAI: `delta.tool_calls` triggers `onBlockStart(.ToolUse, ...)` with `meta.tool_id` and `meta.tool_name`, then `onContentDelta` with JSON argument fragments.
+- Anthropic: `content_block_start` with `type: "tool_use"` triggers `onBlockStart(.ToolUse, ...)`, then `content_block_delta` with `input_json_delta` fragments.
+
+The assembled ToolUseBlock contains `id`, `name`, and `input` (TextualBlock with the full JSON string). The agent loop reads `name` to look up the registered tool, reads `input.content()` to get the JSON string, and passes it through the Lua bridge.
+
+### ToolResult in requests
+
+After tool execution, a user Message containing ToolResult blocks is appended to the conversation. Serialization differs by provider:
+
+**OpenAI**: Each ToolResult block becomes a separate message:
+```json
+{ "role": "tool", "tool_call_id": "<tool_use_id>", "content": "<result string>" }
+```
+
+**Anthropic**: ToolResult blocks are content blocks on a user message:
+```json
+{ "role": "user", "content": [
+ { "type": "tool_result", "tool_use_id": "...", "content": "..." }
+] }
+```
+
+---
+
+## Module Changes
+
+### New files
+
+```
+src/lua_bridge.zig // Zig functions registered into Lua state, type translation
+src/tool_registry.zig // RegisteredTool storage, lookup by name
+src/lua_state_pool.zig // On-demand pool of lua_State instances
+src/extension_loader.zig // Directory scanning, extension discovery and loading
+```
+
+### Modified files
+
+- `agent.zig` — runStep gains the tool-call loop (detect ToolUse → execute → feed results → repeat)
+- `provider_openai.zig` — request serialization includes `tools` array when tools are registered
+- `provider_anthropic.zig` — request serialization includes `tools` array when tools are registered
+- `json.zig` — serialization for ToolResult blocks (OpenAI and Anthropic formats)
+- `config.zig` — extension directories added to config
+
+### External dependency
+
+Lua interpreter linked into the `awl` binary. Zig's build system can fetch and compile Lua from source (Lua is a small C codebase, ~30KLOC). No system dependency required.
+
+---
+
+## Testing Strategy
+
+### Unit tests
+
+| What | How |
+|---|---|
+| Extension discovery | Create temp directory with single-file and directory extensions, verify names constructed correctly |
+| Tool registration | Load a Lua extension that registers a tool, verify entry appears in registry with correct name and schema |
+| Lua bridge input parsing | Feed JSON strings through the bridge, verify correct Lua tables produced |
+| Lua bridge output | Call a tool handler that returns a string, verify it becomes a ToolResult with correct content |
+| Crash protection | Load an extension whose handler throws an error, verify xpcall catches it and returns trace |
+
+### Integration test (manual)
+
+- Write a simple `echo.lua` extension, place it in extension directory
+- Start `awl`, ask the LLM to use the echo tool
+- Verify the tool is called, result is fed back, LLM continues
+- Write a `crash.lua` extension that throws an error
+- Verify the crash is caught and printed with context, turn aborts gracefully
+- Ask the LLM to use two tools in one response, verify both execute
+
+---
+
+## Open Questions (to resolve during implementation)
+
+1. **Lua version**: Lua 5.4 is current. Luau (Roblox's fork) has performance improvements but diverges. Stick with standard Lua 5.4 for compatibility with luarocks and existing ecosystem?
+2. **Handler timeout**: Should tool handlers have a timeout? A hung tool call blocks the agent loop. Could add a configurable timeout with abort.
+3. **Streaming tool results**: Some tools (e.g., `bash` running a long command) produce output incrementally. Phase 3 handlers return a single string. Streaming results would require a different handler interface — possibly a callback the handler calls to emit partial output. Defer to a later phase?
+4. **Tool description field**: The `awl.register_tool()` call in phase 3 includes a schema but no explicit description string. Provider APIs require a description. Options: add a `description` parameter, or extract it from the schema. Probably simplest to add it as a parameter.
diff --git a/docs/phase-4.md b/docs/phase-4.md
new file mode 100644
index 0000000..268467a
--- /dev/null
+++ b/docs/phase-4.md
@@ -0,0 +1,582 @@
+# Phase 4: Conversation Serialization
+
+## Goal
+
+Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, awl replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later.
+
+## Deliverable
+
+A session persistence system where awl saves and resumes conversations. At the end of this phase, you can:
+
+- Start `awl`, hold a conversation, and find the session saved to disk.
+- Restart `awl --resume` and continue the conversation exactly where you left off.
+- Restart `awl --resume <id>` to resume a specific session.
+- Inspect the JSONL session file to see every event that occurred, including which provider/model handled each turn.
+- Have awl recover gracefully from a crash — the corrupted trailing line is removed, and the session loads from the last valid entry.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Auto-save session | Start `awl`, converse, quit; session is in `~/.local/share/awl/sessions/` |
+| Resume most recent | `awl --resume` — continues the most recent session for the current working directory |
+| Resume specific session | `awl --resume <id>` — opens the session with the given ID (or unique prefix) |
+| List sessions | `awl sessions` — lists sessions for the current working directory |
+| Crash recovery | Kill `awl` mid-turn; restart with `--resume`; corrupted trailing line is removed, session loads from last valid entry |
+
+## What is explicitly out of scope
+
+- Compaction / context pruning (phase 6 — the format is designed to allow future entry types like `compaction` without breaking older readers, but phase 4 does not implement any form of compaction)
+- Branching / tree navigation (the format supports it via `id`/`parentId`, but no CLI commands for branching exist yet)
+- Session forking or cloning
+- Custom entry types (for extensions — future phase)
+- Labels, bookmarks, session naming
+- Session export or format migration beyond version 1
+- In-memory-only sessions (every session is persisted)
+
+---
+
+## Event Log Format
+
+Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Entries form a tree structure via `id`/`parentId` fields, enabling in-place branching in future phases without creating new files.
+
+The event log is the authoritative record of a session. It captures everything that happened — not just the conversation content, but the full context of how it happened (which model was active, when it changed, etc.). On load, awl rebuilds all state from the log alone.
+
+### File Location
+
+```
+~/.local/share/awl/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
+```
+
+Where `<encoded-cwd>` is the working directory with `/` replaced by `--`. This groups sessions by project directory, making it easy to find sessions for a given project.
+
+The base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if unset (per the XDG Base Directory specification).
+
+Example:
+```
+~/.local/share/awl/sessions/--Users-travis-Code-awl--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
+```
+
+### Crash Recovery
+
+On loading a session, if the last line of the file does not deserialize as a complete JSON object, it is deleted. The process does not refuse to start — it trims the corruption and loads from the last valid entry. This handles the common case where a process is killed mid-write.
+
+---
+
+## Entry Types
+
+### SessionHeader
+
+First line of the file. Metadata only — not part of the tree (no `id`/`parentId`).
+
+```json
+{
+ "type": "session",
+ "version": 1,
+ "id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572",
+ "timestamp": "2026-04-25T17:40:15.990Z",
+ "cwd": "/Users/travis/Code/awl",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514"
+}
+```
+
+| Field | Type | Purpose |
+|---|---|---|
+| `version` | number | Format version (1 for phase 4). Future format changes increment this. |
+| `id` | string | Session UUID. |
+| `timestamp` | string | ISO 8601 timestamp of session creation. |
+| `cwd` | string | Working directory where the session was started. |
+| `provider` | string | Initial provider (`"openai"` or `"anthropic"`). |
+| `model` | string | Initial model name. |
+
+The `provider` and `model` fields serve as the baseline for model tracking. If no user messages have been submitted yet, the header's values are the active provider/model.
+
+### EntryBase
+
+All entries (except the header) extend this base:
+
+```
+EntryBase = {
+ type: string, // entry type discriminator
+ id: string, // 8-char hex ID, random
+ parentId: string | null, // parent entry ID (null for first entry)
+ timestamp: string, // ISO 8601
+}
+```
+
+The first entry has `parentId: null`. Each subsequent entry points to its parent, forming a linked path from root to leaf. In phase 4 this is always a linear chain; the tree structure exists to support branching in future phases.
+
+### MessageEntry
+
+A message in the conversation. The `message` field contains role, content blocks, and (for assistant messages) provider metadata.
+
+**System message:**
+```json
+{
+ "type": "message",
+ "id": "s1s2s3s4",
+ "parentId": null,
+ "timestamp": "2026-04-25T14:00:00.000Z",
+ "message": {
+ "role": "system",
+ "content": [
+ { "type": "text", "text": "You are a helpful assistant." }
+ ]
+ }
+}
+```
+
+**User message (human prompt):**
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+**Assistant message (with tool call):**
+```json
+{
+ "type": "message",
+ "id": "b2c3d4e5",
+ "parentId": "a1b2c3d4",
+ "timestamp": "2026-04-25T14:00:02.000Z",
+ "message": {
+ "role": "assistant",
+ "content": [
+ { "type": "thinking", "thinking": "The user wants to list files..." },
+ { "type": "text", "text": "I'll check that for you." },
+ { "type": "toolUse", "id": "tool_abc123", "name": "bash", "input": "{\"command\":\"ls\"}" }
+ ],
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "stopReason": "toolUse",
+ "usage": { "input": 1500, "output": 85 }
+ }
+}
+```
+
+**User message (tool results):**
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+Note that tool results are content blocks on a user message, consistent with awl's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in awl, tool results are content blocks that happen to live on user messages, just as they do in the Conversation model.
+
+### Content Block Serialization
+
+The on-disk content block types correspond directly to awl's internal `ContentBlock` tagged union:
+
+| Internal type | On-disk `type` | Fields |
+|---|---|---|
+| `Text` (`TextualBlock`) | `"text"` | `text: string` |
+| `Thinking` (`TextualBlock`) | `"thinking"` | `thinking: string` |
+| `ToolUse` (`ToolUseBlock`) | `"toolUse"` | `id: string`, `name: string`, `input: string` (raw JSON bytes) |
+| `ToolResult` (`ToolResultBlock`) | `"toolResult"` | `toolUseId: string`, `content: string` |
+
+Notable decisions:
+
+- **`input` is a string, not a parsed object.** Consistent with awl's internal model where tool input is stored as raw JSON bytes. The on-disk format does not parse or interpret tool schemas.
+- **`content` in `toolResult` is a string.** Consistent with awl's model where tool result content is a single `TextualBlock`. If structured content (e.g., images) is needed later, the format can evolve.
+- **`thinking` stores full thinking content.** The event log records everything that happened. Thinking blocks are never omitted.
+
+### Assistant message metadata
+
+Assistant messages carry additional fields beyond `role` and `content`:
+
+| Field | Type | Purpose |
+|---|---|---|
+| `provider` | string | Which provider handled this request |
+| `model` | string | Which model was used |
+| `stopReason` | string | Why the model stopped: `"stop"`, `"length"`, `"toolUse"`, `"error"` |
+| `usage` | object | Token counts: `{ "input": number, "output": number }` — optional, omitted if unavailable |
+
+These fields are metadata about the provider response. They are recorded in the event log but do **not** become part of the in-memory `Conversation` model. The `Conversation` stores only `role` and `content` — the fields needed for provider serialization. Metadata is accessed through the session entry layer when needed (display, review, debugging).
+
+The assistant metadata is a response-side confirmation of what the user message's `provider`/`model` already declared on the request side. It's useful for quick inspection — you can see which model produced a response without scanning backward to the preceding user message — and for detecting discrepancies (e.g., a provider routing layer returned a different model than requested).
+
+### Model tracking on user messages
+
+Every entry with `type: "message"` and `message.role: "user"` carries top-level `provider` and `model` fields recording which provider/model the request was submitted to. This includes both human-authored prompts and user messages containing tool results — both are submissions to a provider API.
+
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "provider": "openai",
+ "model": "gpt-4o",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+The model history is self-evident from the log: scan user messages in order and you see exactly which provider/model handled each request. The header's `provider`/`model` fields serve as the baseline for the first turn — if no user messages have been submitted yet, the header's values are the active model.
+
+---
+
+## Tree Structure
+
+Entries form a tree via `id`/`parentId`:
+
+- First entry has `parentId: null`
+- Each subsequent entry points to its parent
+- In phase 4, the tree is always a simple linear chain (one path from root to leaf)
+- Branching creates new children from an earlier entry (future phase)
+- The "leaf" is the current position — always the last entry
+
+```
+[system] ─── [user] ─── [assistant] ─── [user (tool results)] ─── [assistant] ─┬─ [user] ← current leaf
+ │
+ └─ (future branch point)
+```
+
+The tree structure is defined now so that the on-disk format supports branching from day one. Phase 4 simply doesn't exercise it — every session is a linear chain.
+
+---
+
+## Rebuilding State from the Log
+
+On resume, awl fully rebuilds conversation state by replaying the event log:
+
+1. **Read the file line by line.** Parse each line as a JSON object.
+2. **Crash recovery.** If the last line doesn't parse as a complete JSON object, delete it from the file.
+3. **Extract the header.** First line must be a `session` header. Extract version, id, cwd, initial provider/model.
+4. **Build the entry index.** Map entry `id` → entry, track `leafId` (the last entry's id).
+5. **Walk the tree.** From leaf to root, collect entries in root-to-leaf order.
+6. **Reconstruct the Conversation.** For each `message` entry on the path:
+ - Construct a `Message { role, content }` from the entry's `message` field.
+ - For each content block in the JSON, create the corresponding `ContentBlock` variant.
+ - `TextualBlock` fields (Text, Thinking) are initialized with their complete content in a single append — no streaming involved.
+ - `ToolUseBlock.input` is initialized with the raw JSON string from the `input` field.
+ - `ToolResultBlock.content` is initialized with the string from the `content` field.
+7. **Reconstruct the active model.** Walk the path and find the last user message entry with `provider`/`model` fields. Those fields (or the header defaults if no user message exists) determine the active provider/model.
+8. **Result:** A `Conversation` ready for the agent loop, plus the active provider/model for configuration.
+
+The Conversation stores only `role` and `content` per message — the data needed for provider serialization. Entry metadata (provider, model, usage, stopReason, timestamps) lives in the session entry layer and is accessible for display or review without polluting the Conversation model.
+
+### Handling incomplete turns on resume
+
+If the session was interrupted mid-turn, the log may contain an assistant message with `ToolUse` blocks but no corresponding user message with `ToolResult` blocks. On resume, awl does **not** automatically re-execute the tools. The conversation is rebuilt as-is and presented to the user. The agent loop waits for user input — the user decides how to proceed.
+
+---
+
+## Persistence Strategy
+
+**Append-only.** Entries are appended to the JSONL file as they occur. No full-file rewrite during normal operation. Each agent turn appends one or more entries:
+
+- A user message entry (when the user submits a prompt, or when tool results are assembled)
+- Provider/model stamped on user message entries (top-level fields on every user message)
+- An assistant message entry (when the provider response is complete)
+
+A single agent turn with tool calls appends multiple rounds of these entries.
+
+**File creation.** The session file is created when the session is initialized (header written). If `awl` starts and the user immediately quits without the session being initialized, no file is created.
+
+**No explicit save command.** Persistence is automatic. Every event is written to disk as it happens.
+
+---
+
+## Module Changes
+
+### New files
+
+```
+src/session.zig // On-disk entry types, JSON serialization/deserialization
+src/session_manager.zig // SessionManager: file management, append, load, tree traversal, crash recovery
+```
+
+### `session.zig`
+
+Defines the on-disk types and their JSON serialization. These are separate from the in-memory `Message`/`ContentBlock` types — the on-disk types are the schema of what goes in the JSONL file, and include metadata that doesn't belong in the Conversation model.
+
+```
+SessionHeader = struct {
+ version: u32,
+ id: []const u8, // UUID string, owned
+ timestamp: []const u8, // ISO 8601, owned
+ cwd: []const u8, // owned
+ provider: []const u8, // owned
+ model: []const u8, // owned
+
+ pub fn deinit(self) void
+};
+
+EntryBase = struct {
+ id: []const u8, // 8-char hex, owned
+ parent_id: ?[]const u8, // owned
+ timestamp: []const u8, // ISO 8601, owned
+
+ pub fn deinit(self) void
+};
+
+SessionEntry = union(enum) {
+ message: MessageEntry,
+ pub fn base(self) EntryBase
+ pub fn deinit(self) void
+};
+
+MessageEntry = struct {
+ base: EntryBase,
+ message: DiskMessage,
+ // Present on user messages only. Null for system/assistant messages.
+ provider: ?[]const u8, // owned
+ model: ?[]const u8, // owned
+};
+
+DiskMessage = struct {
+ role: enum { system, user, assistant },
+ content: []DiskContentBlock,
+ // Assistant-only metadata (null for system/user messages):
+ provider: ?[]const u8,
+ model: ?[]const u8,
+ stop_reason: ?[]const u8,
+ usage: ?Usage,
+
+ pub fn deinit(self) void
+};
+
+DiskContentBlock = union(enum) {
+ text: DiskTextBlock,
+ thinking: DiskThinkingBlock,
+ tool_use: DiskToolUseBlock,
+ tool_result: DiskToolResultBlock,
+
+ pub fn deinit(self) void
+};
+
+DiskTextBlock = struct {
+ text: []const u8, // owned
+};
+
+DiskThinkingBlock = struct {
+ thinking: []const u8, // owned
+};
+
+DiskToolUseBlock = struct {
+ id: []const u8, // owned
+ name: []const u8, // owned
+ input: []const u8, // raw JSON bytes, owned
+};
+
+DiskToolResultBlock = struct {
+ tool_use_id: []const u8, // owned
+ content: []const u8, // owned
+};
+
+Usage = struct {
+ input: u64,
+ output: u64,
+};
+
+FileEntry = union(enum) {
+ header: SessionHeader,
+ entry: SessionEntry,
+};
+```
+
+Serialization functions:
+
+- `serializeHeader(allocator, header) ![]const u8` — header → JSON line
+- `serializeEntry(allocator, entry) ![]const u8` — entry → JSON line
+- `parseLine(allocator, line: []const u8) !FileEntry` — JSON line → header or entry
+
+Conversion functions (bridge between on-disk and in-memory types):
+
+- `contentBlockToDisk(allocator, block: ContentBlock) !DiskContentBlock` — in-memory → on-disk
+- `diskContentBlockToInternal(allocator, disk: DiskContentBlock) !ContentBlock` — on-disk → in-memory
+
+All `[]const u8` fields in on-disk types are owned copies. `deinit()` frees them. The conversion from `ContentBlock` to `DiskContentBlock` extracts the content from `TextualBlock` buffers as owned string copies. The reverse conversion creates new `TextualBlock` instances initialized with the complete content.
+
+### `session_manager.zig`
+
+```
+SessionManager = struct {
+ session_id: []const u8,
+ session_file: ?[]const u8, // path to JSONL file
+ session_dir: []const u8, // directory containing session files for this cwd
+ cwd: []const u8,
+ entries: std.ArrayList(SessionEntry),
+ by_id: std.StringHashMap(*const SessionEntry),
+ leaf_id: ?[]const u8,
+ allocator: std.mem.Allocator,
+
+ // ── Creation ──
+
+ /// Create a new session. Writes the header to disk.
+ pub fn init(allocator, cwd, provider, model) !SessionManager
+
+ /// Open an existing session file, replay the log, rebuild state.
+ pub fn open(allocator, path) !SessionManager
+
+ /// Find and open the most recent session for the given cwd.
+ /// Returns null if no sessions exist.
+ pub fn continueRecent(allocator, cwd) ?SessionManager
+
+ pub fn deinit(self) void
+
+ // ── Appending (writes to disk immediately) ──
+
+ /// Append a message entry as child of the current leaf.
+ /// Returns the new entry's id.
+ pub fn appendMessage(self, message: DiskMessage) ![]const u8
+
+
+
+ // ── Tree traversal ──
+
+ pub fn getLeafId(self) ?[]const u8
+ pub fn getEntry(self, id: []const u8) ?*const SessionEntry
+
+ /// Walk from an entry to root, returning entries in root-to-leaf order.
+ pub fn getBranch(self, from_id: ?[]const u8) []const SessionEntry
+
+ // ── Rebuilding ──
+
+ /// Rebuild a Conversation from the event log.
+ /// Walks from leaf to root, collects message entries,
+ /// converts DiskContentBlocks → ContentBlocks.
+ pub fn rebuildConversation(self) !Conversation
+
+ /// Determine the active provider/model by walking the path
+ /// and finding the last user message with provider/model fields,
+ /// falling back to header defaults.
+ pub fn activeModel(self) struct { provider: []const u8, model: []const u8 }
+
+ // ── Session listing ──
+
+ /// List sessions for a given cwd.
+ pub fn listSessions(allocator, cwd) ![]SessionInfo
+
+ // ── Crash recovery ──
+
+ /// Remove the last line of the file if it doesn't parse as complete JSON.
+ /// Called during open().
+ fn trimCorruptedTrailingLine(file_path: []const u8) !void
+};
+
+SessionInfo = struct {
+ path: []const u8, // owned
+ id: []const u8, // owned
+ cwd: []const u8, // owned
+ created: []const u8, // ISO 8601, owned
+ modified: []const u8, // from file mtime, ISO 8601, owned
+ message_count: usize,
+};
+```
+
+### Modified files
+
+- **`agent.zig`** — Receives a `SessionManager` reference. After each message is assembled, calls `appendMessage()` with the current provider/model (user messages get stamped; assistant/system messages pass null). The agent loop is now a producer of session entries.
+- **`config.zig`** — Session directory path added. Default: `$XDG_DATA_HOME/awl/sessions/` (falling back to `~/.local/share/awl/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `AWL_SESSION_DIR` environment variable.
+- **`main.zig`** — `--resume` and `--resume <id>` flags. `sessions` subcommand. On startup with `--resume`, load session, rebuild conversation, continue agent loop. Without `--resume`, create a new session.
+
+---
+
+## CLI Changes
+
+### `--resume`
+
+Resume the most recent session for the current working directory. If no sessions exist, create a new one.
+
+```
+awl --resume
+```
+
+### `--resume <id>`
+
+Resume a specific session by its ID (or a unique prefix of the ID). Errors if no session matches, or if the prefix is ambiguous.
+
+```
+awl --resume 019dc5ba
+```
+
+### `sessions` subcommand
+
+List sessions for the current working directory. Shows session ID (short), creation time, and message count.
+
+```
+awl sessions
+```
+
+Output:
+```
+019dc5ba 2026-04-25 17:40 14 messages
+019dc80a 2026-04-26 04:27 38 messages
+```
+
+---
+
+## Testing Strategy
+
+### Unit tests
+
+| What | How |
+|---|---|
+| Entry serialization round-trip | Create entries of each type, serialize to JSON, parse back, verify equivalence |
+| Content block conversion | Convert each `ContentBlock` variant to/from `DiskContentBlock`, verify content preserved |
+| Crash recovery | Write a JSONL file with a corrupted trailing line, load it, verify the line is removed and valid entries are intact |
+| Session rebuild | Create a session with system, user, assistant, tool result messages; rebuild conversation; verify messages match original content |
+| Model tracking on user messages | Create a session with multiple turns, change model mid-session, verify `provider`/`model` fields appear on every user message entry and reflect the model used for that request |
+| Tree traversal | Create entries with parent chain, walk from leaf to root, verify correct root-to-leaf order |
+
+### Integration test (manual)
+
+- Start `awl`, hold a multi-turn conversation with tool calls, quit
+- Inspect the JSONL file — verify entries are present and well-formed
+- Run `awl --resume` — verify conversation continues from where it left off
+- Change the model mid-session, send a prompt, verify the user message entry in the log carries the updated `provider`/`model` fields
+- Kill `awl` mid-turn (e.g., Ctrl+C during streaming), run `awl --resume` — verify session loads from last valid entry
+- Run `awl sessions` — verify sessions are listed with correct metadata
+
+---
+
+## Open Questions (to resolve during implementation)
+
+1. **Entry ID generation**: 8-char hex IDs give ~4 billion possibilities. Collision probability within a single session is negligible, but should we verify uniqueness against existing entries, or trust the randomness?
+2. **Timestamp precision**: ISO 8601 with millisecond precision is sufficient. Zig's `std.time` provides nanosecond precision — we format to milliseconds.
+3. **File locking**: If two `awl` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4?
+4. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt Conversation state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? Or defer this optimization?
+5. **Session directory creation**: Should `awl` create the session directory tree eagerly on startup, or lazily when the first session is created?
diff --git a/ideas.md b/ideas.md
new file mode 100644
index 0000000..731e994
--- /dev/null
+++ b/ideas.md
@@ -0,0 +1,38 @@
+# awl Ideas
+
+`awl` is a minimal coding agent inspired by `pi`, with a focus on performance, efficiency, correctness, and a small core that can be extended deliberately.
+
+## Core architecture
+
+- The core agent loop will be written in Zig for performance, efficiency, and a small runtime footprint.
+- In addition to the CLI/application, the project should expose a `libawl` library through a C ABI so other programs can embed or build on the agent functionality.
+
+## Extension system
+
+- Extensions will initially be written in Lua, chosen for speed, simplicity, and low overhead.
+- Because poorly written extensions can easily destabilize the host, `awl` should explore ways to improve extension correctness and crash protection.
+- Future extension support should include loading shared object libraries through a C ABI, allowing extensions to be written in Zig, Rust, C, C++, or other native languages.
+
+## Minimal built-in feature set
+
+- `awl` should follow `pi`'s philosophy of avoiding unnecessary built-ins such as native subagents, MCP support, and permission systems.
+- `awl` will go further by not building in AGENTS.md automation, skills, or customizable `/prompts`.
+- Those features should be possible to implement as extensions rather than being part of the core runtime.
+
+## Provider API support
+
+- Provider support should be careful and conservative.
+- The core should support Anthropic-shaped and OpenAI-shaped APIs with arbitrary base URLs.
+- The goal is to avoid the common failure mode where provider integrations exist but only partially work, break important agent features, or crash the process.
+
+## Server/proxy mode
+
+- `awl` should support running as a server that exposes OpenAI-compatible and Anthropic-compatible APIs.
+- In this mode, `awl` can act as a lightweight provider router/proxy to its configured backends.
+- This is intended to provide the useful parts of tools like `omniroute` while avoiding excessive memory usage, fragile integrations, and runtime instability.
+
+## Core tools as extensions
+
+- Basic tools such as `read`, `write`, `edit`, and `bash` should be supplied by extensions rather than hardcoded into the core.
+- These tools can be included in the standard distribution but should be individually disableable.
+- Once shared object extension support exists, the standard core tools should be ported to Zig/native extensions.