aporia
The study assistant I use for math. It picks a way of working for each conversation, keeps its own log, and takes my handwritten pages from a tablet.
- status
- active
- stack
- Go / React / SQLite / Claude Code / Tailscale
- topics
- software-engineering / ai
aporia is the study assistant I use for math problems I work by hand on a tablet. The name is the Greek word for being stuck with no way through, which is the state I usually open it in. A session is a sequence of conversations with a model, and each conversation runs under one move: a prompt with its own way of working. One gives me the next heuristic to try on a problem, never the answer. One attacks a solution I think is finished. One presses on a concept I can talk around but can’t use. One wraps a chapter or a lecture. Every conversation leaves a log, and the next session starts from what those logs say.
The moves are Claude skills I already used for studying, rewritten so they don’t read my notes or point at each other. Everything they need gets passed in.
The code isn’t public yet. It will be soon, about as rough as everything else here.
A session
flowchart LR accTitle: An aporia session accDescr: Pickup, by a small model from last session's digests; the opening, by me; the router, by a small model; the conversation, by me with a bigger model; the digest, by a small model; then back to the next opening. pick["Pickup<span class='who'>small model, from last session</span>"] open["Opening, maybe with pages<span class='who'>me</span>"]:::eu route["Router picks a move<span class='who'>small model</span>"] conv["Conversation<span class='who'>me + bigger model</span>"]:::par dig["Three-line digest<span class='who'>small model</span>"] pick --> open --> route --> conv --> dig --> open
Opening a session on a topic runs the pickup, which reads last session’s digests and proposes a few things to start with. I take one or type my own opening, with photos of pages if I have them. A small model reads the opening against the conditions written at the top of each move, picks one, and logs a sentence saying why. When the conversation ends, the digest records what was tackled, how it ended and the biggest gap. If the router picked wrong, I name the move I wanted and a new conversation opens under it with the same pages. The correction is kept, so I can fix the conditions later. Closing the session runs a fixed closing move, and then I mark by hand how well I hold each region of the topic.
How it’s put together
The server is one Go process. It serves a React app, an HTTP API and a WebSocket that only pushes, and keeps everything in SQLite, with page images as files next to it. Moves are markdown files in the repo, hashed at startup, and a conversation stays bound to the version it opened with, so editing a prompt never changes a conversation in progress.
The model sits behind a worker process that speaks newline-delimited JSON over stdio. The server restarts it with backoff if it dies, and a conversation open at that moment ends as interrupted. The one real worker drives the claude CLI headless on my subscription login: one long-lived process per open conversation, and a fresh one with a JSON schema for each router, digest or pickup call. It runs with no tools, no MCP servers, no skills and no settings, which is what keeps my own CLAUDE.md out of the prompt.
The tablet reaches my laptop through Tailscale. tailscale serve terminates HTTPS on the laptop’s tailnet name and forwards to the server on loopback with a header carrying my login, and the server answers anything without it with a 403. HTTPS is what lets the app install as a PWA with a share target, so a screenshot shared to aporia lands in the open conversation of the session I last had on screen, up to four pages at a time. Replies render as Markdown with LaTeX. The work that let the opening message carry pages went through ekky as four tasks.
An example
On the 16th I brought a handwritten solution to a problem from AoPS Intermediate Algebra and asked if it was correct. The router sent it to the move that attacks finished work, which by design doesn’t hand out verdicts. It took about ten turns to surface that I had copied the problem wrong: the book had 1/(√2 − 2) and I’d solved 1/(2 − √2). The model had both lines side by side long before that and still made me find it. The second conversation, on the right problem, came out correct, and it still asked me for three calculator values, because nothing in aporia can run anything.
What’s rough
- Moves are dialogue only, so when something needs computing, I compute it. The fix I’ve written down lets the checking move run code and say “correct” or “wrong” only when an execution settles it. It isn’t built.
- There’s one worker, and it needs a logged-in Claude Code CLI on my machine. The second worker the boundary was built for doesn’t exist.
- The tablet only works while the laptop is awake and on the tailnet. The service worker caches nothing, so there’s no offline mode.
- The digest is the model’s opinion of me, and the router and the pickup both re-read it. A bad digest skews the next pick. Digests are visible and can be rebuilt, which helps less than it sounds.
- So far I’ve spent more hours building it than studying with it.