Skip to content

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.

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.

DayYou addThe Rust / systems idea it forces
12a one-shot askHTTP with reqwest + serde, the Messages API shape, a network trait + a mock, secrets from env
13streaming + --ragServer-Sent Events for low perceived latency; chunk → embed → retrieve
14a real retrievera hand-written cosine similarity + an in-memory vector store, tested with mock embeddings
15production hardeningconfig, 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 LlmClientcomplete_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 test swaps in MockLlm / 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? askr falls 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.

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 →