case study · 02
italki-cli
verifiedThe API that doesn't exist, built anyway.
A personal CLI + MCP server for italki — teacher search, scheduling, reviews, booking — reverse-engineered from the public web API. Local software, in daily use, built so agents can operate it too.
the problem
A whole product behind a web UI
italki has no official API. Searching teachers, comparing prices, checking a schedule, booking a lesson — all of it lives behind the website, unscriptable. I use the platform for my own lessons, and I wanted my tools and agents to reach it the same way they reach everything else: structured calls, JSON out.
The catch with undocumented APIs: nothing behaves the way the payload names suggest.Filters nest inside unrelated keys. Sort parameters silently do nothing. The only honest way to build on it is to send real requests and write down what actually comes back.
the shape
Four layers, one direction
Each layer may only import from the ones beneath it. Add a new surface — REST, a TUI — and nothing below it changes.
services/
fetch italki, return raw — pure functions, no console, no classes
transforms/
raw codes → domain objects: cents→dollars, tag codes→names, 15-min units→minutes
presenters/
domain objects → ANSI text for humans
commands/ + mcp/
CLI args and JSON-RPC tools — thin surfaces only
the calls
Decisions, and what they beat
Zod schemas as the contract
rejected · Hand-written TypeScript interfaces
The undocumented API has 50+ nested fields — verified: 99 tag codes, 7 categories, 13 filters. Types are inferred from schemas, so the types can't drift from reality.
Architecture enforced by custom lint rules
rejected · Layer boundaries documented in AGENTS.md
Five oxlint rules (no cross-layer imports, no classes in services, no console in services…) make violations build errors. The diagram isn't aspirational — it cannot rot.
Every API behavior verified by request
rejected · Assuming documented-sounding behavior
No official docs exist. `sort_by` accepts 12 values — all return identical order, so sorting is client-side. `page_size` max is really 99, not 20. api-reference.md is curl-truth, not guesses.
Three runtime dependencies
rejected · The usual CLI pile: clack, chalk, hono, ora, playwright
zod + citty + the MCP SDK, that's all. Login is pure HTTP with an AES signature — no browser automation. Every rejected dep is a named call, not minimalism for its own sake.
MCP returns translated domain JSON
rejected · Raw API passthrough to agents
Agents need to reason in dollars, tag names, and minutes — not cents, CA005, and 15-min units. `text: true` opts into compact output for simple queries.
13
mcp tools on one service layer — search → book → confirm
50+
api response fields validated at runtime
5
architecture rules enforced as build errors
3
runtime dependencies: zod · citty · mcp sdk
proof
The architecture is checkable
One command proves the layering claims: TypeScript strict plus five custom oxlint rules. Violations aren't review comments — they're errors. Captured live while building this page.
$ bun run verify $ tsc --noEmit && oxlint Found 0 warnings and 0 errors. Finished in 83ms on 68 files with 101 rules using 14 threads.
honestly
What's verified vs. what isn't
- Booking actions are HAR-verified, not live-fired — the code matches captured booking/reschedule/confirm traffic exactly, but the real POST hasn't been sent from the CLI yet. That's on the record, not hidden.
- An unfiltered
--allsearch takes ~15s — 44 pages fetched concurrently, Zod parsing ~4,300 records. It needs progress output or caching; today it's just honest about the wait. - The REST layer the separation rule was designed for doesn't exist yet — it's additive by construction, but that claim is unproven until it ships. YAGNI said wait; the cost is the rule stays untested on that axis.
More case studies as the lab ships.
← back to selected work