Overview — Project 4: askr
Project 3 · apilite had you serve HTTP — an async API with axum and sqlx.
Project 4 flips the arrow: now you’re the client, calling someone else’s HTTP API (Claude’s), and
doing something useful with the answer. You’ll build askr — a terminal program that takes a
question, calls the Claude Messages API, and streams the answer back word by word. Then you’ll add a
tiny RAG mode that searches your own local notes before asking. It’s the capstone of Phase 1: every
muscle you’ve built — ownership, errors, traits, async, HTTP — gets used at once.
What you’ll build
Section titled “What you’ll build”askr is a real, runnable crate in rust/askr/. By Day 15 it does this:
$ askr "why does Rust move values instead of copying them?" Moving transfers ownership so there's exactly one owner responsible for freeing the value — which makes a double-free impossible to write...
$ askr --rag ./notes "what did I decide about the cache layer?" retrieved 3 chunk(s) from ./notes: - design.md (score 0.612) - meeting-2026-06-10.md (score 0.488) - design.md (score 0.301) You decided to put the cache in front of the store, with a 5-minute TTL...The answer streams — the first words print before the model has finished thinking — and in --rag
mode the program first retrieves the most relevant passages from your files and stuffs them into the
prompt, so the model answers from your text, not just its training.
The four days
Section titled “The four days”| Day | You add | The Rust / systems idea it forces |
|---|---|---|
| 12 | a one-shot ask | HTTP with reqwest + serde, the Messages API shape, a network trait + a mock, secrets from env |
| 13 | streaming + --rag | Server-Sent Events for low perceived latency; chunk → embed → retrieve |
| 14 | a real retriever | a hand-written cosine similarity + an in-memory vector store, tested with mock embeddings |
| 15 | production hardening | config, error handling, a GitHub Actions CI, profiling — then a bridge to Phase 2 |
The one architectural decision that makes the rest easy
Section titled “The one architectural decision that makes the rest easy”Every network call in askr hides behind a trait, not a concrete type:
trait LlmClient trait Embedder / \ / \ ClaudeClient MockLlm HashingEmbedder MockEmbedder (real HTTP) (offline) (real, offline) (test fixture)Code that needs an answer depends on LlmClient — complete_streaming(..) — and never on how the
answer arrives. That one choice buys three things at once:
- Tests run with no API key and no network.
cargo testswaps inMockLlm/MockEmbedder, so the whole suite is hermetic and fast. (Try it:cd rust/askr && cargo test— 19 tests, zero network.) - The binary runs out of the box. No key?
askrfalls back to the mock and tells you so. - The real client is just one more implementor. Swapping providers, or stubbing failures, is a new
impl, not a rewrite.
The thread, in this project
Section titled “The thread, in this project”What does building this force you to understand — and what is Rust’s compiler protecting you from? An AI
CLI is mostly untrusted, asynchronous bytes: a network stream that can stall, truncate, split a
character in half, or hand you an error where you expected text. Rust won’t let you pretend those bytes
are a clean String — you parse the stream deliberately, every await is a place the borrow checker has
already proven your data is safe to hold, and every fallible step returns a Result you must handle. The
compiler is protecting you from the same things it always was — use-after-free, data races, ignored
errors — but now across a network boundary where the failure modes are loudest.
Start with Day 12 · Calling the Claude API →