omnitutor service · omnitutor database · omnitutor-assets bucket. Zero shared state with Canvas A · Physolympiad · DhyanHQ · DreamBook · or any other agent on this infra.v1 was right for M1. v2 adds the seams that, if added later, would force migrations: multi-tenancy · canonical subjects/topics · plan revision history · asset dedup · idempotency · soft delete. Plus M2 stubs (subscriptions · mastery_records) so the FKs land cleanly when M2 starts.
users.org_id nullable · multi-tenancy seamsubjects + topics · canonical · FK from lessonsplan_revisions · history of plan iterate / adjustassets.content_hash unique · TTS dedupidempotency_keys · client retries on SSE reconnectdeleted_at on user-data tables · soft deletesubscriptions · Stripe foundationmastery_records · spaced repetition foundation| Layer | Choice | Why this |
|---|---|---|
| Backend | FastAPI · Python 3.12 | Matches Canvas A pattern · fast iteration · async streaming · type-checked with pydantic |
| Frontend | Vanilla HTML/CSS/JS | v12 mockup is already vanilla · zero build-step pain in M1 · React migration deferred to M2 if needed |
| Database | Postgres 16 | Local on devbox for M1 · RDS in M2 · ULID primary keys · JSONB for content blobs |
| Cache | Postgres tables | Same DB · single connection pool · upgrade to Redis only when latency demands it |
| Object store | S3 · own bucket | TTS audio · generated diagrams · Make-me artifacts · CDN via CloudFront |
| Streaming | Server-Sent Events | Simpler than WebSockets · works through every proxy · one-way is all we need |
| LLM | Anthropic API | Haiku 4.5 (first response) · Sonnet 4.6 (streaming beats) · Opus 4.7 (hard derivations only) |
| TTS | ElevenLabs streaming | ~200ms TTFB · streamed bytes drive owl mouth lip-sync amplitude |
| Auth | Anonymous + Cookie | M1: anon session token in HttpOnly cookie · email-only "save my work" optional · full Google sign-in in M2 |
| Hosting | EC2 (devbox-1) → ECS later | M1 on existing devbox · M2 moves behind ALB + ECS Fargate |
| Domain | omnitutor.ai | Already owned · MerakiLabs Cloudflare zone · own A-record |
| Deploy | GitHub Actions → SSH → systemd | Own pipeline · zero coupling to other agents' deploys |
| Logging | structlog → CloudWatch | JSON logs · trace_id per request · sampled to S3 for replay |
Minimal entity set for M1. Every entity has a ULID primary key (sortable, time-encoded) and created_at/updated_at timestamps.
| Entity | Owns | References | Notes |
|---|---|---|---|
| user | anon_id · email? · settings_json | — | Anonymous-by-default in M1. Email optional (for "save my work"). Full auth in M2. |
| session | started_at · ended_at · client_meta_json | user | A browser sitting at the page. Closes on tab-close or 30 min idle. |
| lesson | subject · topic · status · intent_json · plan_json · level_at_start | session, user | One lesson = one Plan modal lock + everything that follows. Status: draft · active · complete · abandoned. |
| beat | order · kind · content_json · narration_text · audio_url · viz_url | lesson | The atom. Kind: concept · derivation · problem · test · free. |
| interaction | type · payload_json · response_json · ts | lesson, beat | Anything the student does mid-lesson: ask, hint, adjust, visualize, animate, attempt, advance, etc. |
| cache | key (hash) · model · payload_json · hits · ttl_at | — | Model output cache. Key = hash(model + topic + intent_signature + level + …). |
| asset | type · url · mime · size_bytes | lesson?, beat? | TTS clips · diagrams · Make-me outputs (flashcards/notes/slides/video). On S3. |
| event | type · payload_json · ts | session, lesson? | Analytics stream. See §9 for type catalog. |
| model_run | model · prompt_tokens · completion_tokens · cost_usd · latency_ms · status | lesson, beat, interaction | One row per LLM call. Cost · latency · errors tracked from day one. Drives daily spend cap (§10). |
| magic_link | token · email · consumed_at · expires_at | user | Email-based auth for "save my work" in M1. 15-min TTL · single-use. |
| rate_bucket | scope (ip|user|key) · key · count · window_start | — | Sliding-window rate limit counters. See §8. |
| subject [v2] | code · name · archetype | — | Canonical subjects (physics · math · history · english · …). Lessons FK to here. |
| topic [v2] | name · canonical (bool) | subject | Canonical topics under a subject. canonical=false for user-typed free-form topics. |
| plan_revision [v2] | revision_num · plan_json · triggered_by · feedback_text | lesson | History of plan iterations. triggered_by: initial | iterate | adjust. Lets students undo + we replay decisions. |
| idempotency_key [v2] | key · request_hash · response_json · status_code · expires_at | — | Client-supplied Idempotency-Key header. Prevents double-charging on SSE reconnect / network retry. 24h TTL. |
| subscription [v2 · M2 stub] | plan_code · status · stripe_customer_id · stripe_sub_id · period_end | user | Empty in M1. M2 populates when Stripe lands. FK exists from day one so we don't migrate users later. |
| mastery_record [v2 · M2 stub] | last_seen_at · score · attempts · correct | user, topic | Empty in M1. M2 spaced-repetition reads/writes here. Per (user_id, topic_id). |
Modifications to v1 entities: users.org_id nullable · users.deleted_at nullable · lessons.subject_id + lessons.topic_id FK + lessons.deleted_at · beats.deleted_at · interactions.deleted_at · assets.content_hash + assets.deleted_at.
-- omnitutor schema · v0 · M1 minimum CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE users ( id TEXT PRIMARY KEY, -- ULID anon_id TEXT UNIQUE NOT NULL, email TEXT UNIQUE, settings_json JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ended_at TIMESTAMPTZ, client_meta_json JSONB NOT NULL DEFAULT '{}' ); CREATE TABLE lessons ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id), user_id TEXT NOT NULL REFERENCES users(id), subject TEXT NOT NULL, -- physics, math, history, english, ... topic TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', -- draft|active|complete|abandoned level_at_start TEXT NOT NULL, -- pop|hs|ug|grad intent_json JSONB NOT NULL, -- output of setup modal plan_json JSONB, -- output of plan modal lock created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ ); CREATE INDEX idx_lessons_session ON lessons(session_id); CREATE TABLE beats ( id TEXT PRIMARY KEY, lesson_id TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, ord INT NOT NULL, kind TEXT NOT NULL, -- concept|derivation|problem|test|free content_json JSONB NOT NULL, narration_text TEXT, audio_url TEXT, viz_url TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending|streaming|ready|failed created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (lesson_id, ord) ); CREATE TABLE interactions ( id TEXT PRIMARY KEY, lesson_id TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, beat_id TEXT REFERENCES beats(id) ON DELETE SET NULL, type TEXT NOT NULL, -- ask, hint, attempt, adjust, visualize, animate, advance, ... payload_json JSONB NOT NULL, response_json JSONB, ts TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE cache ( key TEXT PRIMARY KEY, -- sha256(model + intent_signature + ...) model TEXT NOT NULL, payload_json JSONB NOT NULL, hits INT NOT NULL DEFAULT 0, last_hit_at TIMESTAMPTZ, ttl_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_cache_model_ttl ON cache(model, ttl_at); CREATE TABLE assets ( id TEXT PRIMARY KEY, lesson_id TEXT REFERENCES lessons(id) ON DELETE CASCADE, beat_id TEXT REFERENCES beats(id) ON DELETE CASCADE, type TEXT NOT NULL, -- audio|diagram|flashcards|notes|slides|video url TEXT NOT NULL, mime TEXT NOT NULL, size_bytes BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE events ( id TEXT PRIMARY KEY, session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, lesson_id TEXT REFERENCES lessons(id) ON DELETE SET NULL, type TEXT NOT NULL, payload_json JSONB NOT NULL DEFAULT '{}', ts TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_events_session_ts ON events(session_id, ts); CREATE TABLE model_runs ( id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, lesson_id TEXT REFERENCES lessons(id) ON DELETE SET NULL, beat_id TEXT REFERENCES beats(id) ON DELETE SET NULL, model TEXT NOT NULL, -- haiku-4-5 | sonnet-4-6 | opus-4-7 | elevenlabs purpose TEXT NOT NULL, -- plan | beat | ask | adjust | tts | viz | makeme prompt_tokens INT, completion_tokens INT, cost_usd NUMERIC(10,5), latency_ms INT, status TEXT NOT NULL, -- ok | rate_limited | refused | error | timeout error_code TEXT, ts TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_model_runs_ts ON model_runs(ts); CREATE INDEX idx_model_runs_trace ON model_runs(trace_id); CREATE TABLE magic_links ( token TEXT PRIMARY KEY, -- random 32-byte hex user_id TEXT NOT NULL REFERENCES users(id), email TEXT NOT NULL, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, -- 15 min from issue created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE rate_buckets ( scope TEXT NOT NULL, -- ip | user | api_key key TEXT NOT NULL, bucket TEXT NOT NULL, -- plan | ask | viz | global count INT NOT NULL DEFAULT 0, window_start TIMESTAMPTZ NOT NULL, PRIMARY KEY (scope, key, bucket, window_start) );
-- v2 · ALTER existing tables for soft-delete + multi-tenancy + asset dedup ALTER TABLE users ADD COLUMN org_id TEXT, -- nullable · M3 classroom seam ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE sessions ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE lessons ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE beats ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE interactions ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE assets ADD COLUMN content_hash TEXT, ADD COLUMN deleted_at TIMESTAMPTZ; -- TTS dedup · same narration_text + voice_id never stored twice CREATE UNIQUE INDEX idx_assets_content_hash ON assets(content_hash) WHERE content_hash IS NOT NULL; -- Subjects · canonical (physics, math, history, english, ...) CREATE TABLE subjects ( id TEXT PRIMARY KEY, code TEXT UNIQUE NOT NULL, -- physics | math | history | english name TEXT NOT NULL, archetype TEXT NOT NULL, -- science | humanities | language | skill | curiosity created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Topics · canonical lessons land here, free-form get canonical=false CREATE TABLE topics ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL REFERENCES subjects(id), name TEXT NOT NULL, canonical BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_topics_subject ON topics(subject_id); -- Lessons now FK to subjects + topics (keep free-form fallback) ALTER TABLE lessons ADD COLUMN subject_id TEXT REFERENCES subjects(id), ADD COLUMN topic_id TEXT REFERENCES topics(id); -- existing 'subject' / 'topic' text columns retained as fallback display -- Plan revisions · history of every plan version (initial, iterate, adjust) CREATE TABLE plan_revisions ( id TEXT PRIMARY KEY, lesson_id TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, revision_num INT NOT NULL, plan_json JSONB NOT NULL, triggered_by TEXT NOT NULL, -- initial | iterate | adjust feedback_text TEXT, -- chip clicked or free-text created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (lesson_id, revision_num) ); -- Idempotency · prevents double-bill on retry / SSE reconnect CREATE TABLE idempotency_keys ( key TEXT PRIMARY KEY, -- client-supplied via Idempotency-Key header request_hash TEXT NOT NULL, -- sha256 of request body · catches mismatch user_id TEXT REFERENCES users(id), status_code INT NOT NULL, response_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL -- 24h from creation · purged by cron ); CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at); -- M2 STUBS · created empty · populated in M2 ───────────────────────────── CREATE TABLE subscriptions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), plan_code TEXT NOT NULL, -- free | starter | pro | classroom status TEXT NOT NULL, -- trialing | active | past_due | canceled stripe_customer_id TEXT, stripe_subscription_id TEXT, current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); CREATE TABLE mastery_records ( user_id TEXT NOT NULL REFERENCES users(id), topic_id TEXT NOT NULL REFERENCES topics(id), last_seen_at TIMESTAMPTZ, score NUMERIC(4,3), -- 0.000–1.000 attempts INT NOT NULL DEFAULT 0, correct INT NOT NULL DEFAULT 0, next_review_at TIMESTAMPTZ, -- spaced-rep schedule updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, topic_id) ); CREATE INDEX idx_mastery_next_review ON mastery_records(user_id, next_review_at); -- Convention: every read query filters WHERE deleted_at IS NULL. -- Soft-delete cascade is enforced in app code, not DB triggers.
The endpoints every later phase depends on. JSON in, JSON out (or SSE). Everything goes through /v1/. Auth via session cookie.
▸ idempotency [v2]: any state-changing POST may include Idempotency-Key: <client-uuid>. Server stores response in idempotency_keys for 24h. Retries with same key + matching body return the cached response. Mismatched body returns 409 conflict. Mandatory on /v1/plan · /v1/lesson/:id/adjust · /v1/lesson/:id/makeme · /v1/lesson/:id/visualize · /v1/lesson/:id/animate.
| Endpoint | Method | Purpose | Notes |
|---|---|---|---|
| /v1/healthz | GET | liveness probe · returns {ok:true, ts} | P0 |
| /v1/session | POST | create or refresh anonymous session · sets cookie | P0 |
| /v1/plan | POST | setup intent → plan + Beat 1 stub (Haiku) | P3/P4 |
| /v1/lesson/:id/stream | GET (SSE) | stream Beats 2–7 (Sonnet) · see §5 events | P5 |
| /v1/lesson/:id | GET | read full lesson (cached or live) | P5 |
| /v1/lesson/:id/ask | POST | student question mid-lesson · returns answer + audio URL | P6 |
| /v1/lesson/:id/adjust | POST | adjust-level rework · returns new plan, re-streams beats | P9 |
| /v1/lesson/:id/attempt | POST | Coach answer submission · returns verdict + feedback | P7 |
| /v1/lesson/:id/visualize | POST | generate SVG diagram for current beat | P8 |
| /v1/lesson/:id/animate | POST | generate mini-simulation | P8 |
| /v1/lesson/:id/makeme | POST | generate output (flashcards, notes, slides, video, etc.) | P9 |
| /v1/lesson/:id/whiteboard | POST | submit student drawing · returns Tutor comment | P8 |
| /v1/event | POST | analytics ingestion · client batches | P1 |
| /v1/auth/magic-link | POST | issue magic link to email · used for "save my work" | P1/P9 |
| /v1/auth/consume | GET | consume magic link · upgrade anonymous user · sets cookie | P1/P9 |
| /v1/lessons | GET | "my library" · paginated list of user's lessons | P9 |
| /v1/lesson/:id/abandon | POST | explicit abandon · marks lesson, frees beat-stream resources | P6 |
// Request { "subject": "physics", "topic": "Hooke's law & SHM", "intent": { "why": "homework", "level": "first time", "time": "30 min", "style": "problem-driven", "free_text": "Skip Lagrangian, focus on the physical intuition.", "advanced": { "pace": "balanced", "tone": "friendly", "outputs": ["flashcards"], "anchor": "none" } } } // Response · 200 { "ok": true, "lesson_id": "01HZX...", "plan": { "summary": "30 min · undergrad · problem-driven · friendly tone", "beats": [ { "ord": 1, "kind": "concept", "title": "The spring at rest", "est_min": 3 }, { "ord": 2, "kind": "concept", "title": "What restoring force means", "est_min": 4 }, /* … */ ], "after": "damped oscillators" }, "beat1": { "id": "01HZX...", "content_json": { /* … */ }, "narration_text": "…", "audio_url": "https://cdn.omnitutor.ai/audio/…" }, "trace_id": "req_…" }
SSE on GET /v1/lesson/:id/stream. The client consumes events in order, hydrates the right rail plan tree, and renders beats as they arrive.
| Event | Fires when | Payload |
|---|---|---|
| plan_ready | Plan finalized · sent immediately on connect | { plan, total_beats } |
| beat_partial | Partial beat content (every ~500ms while a beat generates) | { ord, content_delta, status:"streaming" } |
| beat_complete | Beat finished · narration + audio URL ready | { ord, content_json, narration_text, audio_url } |
| audio_ready | TTS clip ready for an existing beat | { beat_ord, audio_url } |
| lesson_complete | All beats ready · stream closes | { duration_ms, model_costs } |
| error | Recoverable or fatal error | { code, message, recoverable, retry_after_ms? } |
| heartbeat | Every 15s · keeps connection alive | { ts } |
// example wire transcript on /v1/lesson/01HZX/stream event: plan_ready data: {"plan":{"beats":[…]},"total_beats":7} event: beat_partial data: {"ord":2,"content_delta":"In a system where…","status":"streaming"} event: beat_complete data: {"ord":2,"content_json":{…},"narration_text":"…","audio_url":"…"} event: heartbeat data: {"ts":1715389200000} // … repeats for beats 3–7 … event: lesson_complete data: {"duration_ms":12450,"model_costs":{"haiku":0.0021,"sonnet":0.0387}}
If the client's connection drops mid-stream (mobile network · tab backgrounded · proxy timeout), the client reconnects with the standard SSE Last-Event-ID header. Server replays from the next event after that ID. Each event carries a monotonic id: field. No generated beat is ever lost — the server retains the stream buffer for 5 minutes after lesson_complete · cached beats are durable in DB.
// reconnect example GET /v1/lesson/01HZX/stream HTTP/1.1 Last-Event-ID: 01HZX-evt-00007 // last event the client saw // server response: stream resumes from event 8 id: 01HZX-evt-00008 event: beat_complete data: {"ord":3,…}
Same content, faster delivery. Cache key is deterministic from intent, so two students asking "Hooke's law for first-timer · 30 min · problem-driven" get the same cached lesson.
| Layer | Key | TTL | Notes |
|---|---|---|---|
| plan | sha256(subject + topic + intent_signature) | 7 days | Setup-modal output. Re-uses across students with same intent. |
| beat content | sha256(plan_id + ord + level) | 30 days | Each beat cached individually. Student adjustments invalidate downstream beats. |
| TTS audio | sha256(narration_text + voice_id) | forever | Voice clips never change. Stored on S3, URL-keyed. |
| visualization | sha256(beat_id + viz_request) | forever | Generated SVGs / sims. Reusable across students. |
| Make-me asset | sha256(lesson_id + output_type) | forever | Per-lesson, per-output flashcards/slides/notes/video. |
Pre-warming: a nightly job picks the top 100 most-asked plan keys and generates them at low priority. Cold-start for trending topics becomes hot-start.
| Trigger | What invalidates | What survives |
|---|---|---|
| adjust-level applied | all beats ord > current · plan | beats ord ≤ current · TTS audio · viz |
| student edits intent (re-runs setup) | entire lesson plan + beats | TTS audio (key by narration_text · still reusable) |
| beat regeneration explicitly requested | that one beat only | all other beats · audio · viz |
| cache TTL hit | that one row | everything else |
| global flush (admin only) | everything in cache table | DB rows · S3 objects · audio |
| Stage | What student does | What server does |
|---|---|---|
| M1 · default | opens omnitutor.ai · no signup | Server creates anonymous user with random ULID + sets ot_session HttpOnly cookie. All work tied to anon_id. |
| M1 · save my work | enters email in "save my work" prompt | Magic-link email · clicking links email to anon_id. Library now persists across devices. |
| M2 · full | Google sign-in | OAuth · existing anon work transferred to authenticated user · Stripe customer ID linked. |
No PII required for M1. Cookies are first-party, HttpOnly, Secure, SameSite=Lax. Session inactivity expires at 30 days.
▸ soft delete [v2]: account deletion sets users.deleted_at + cascades app-side to sessions / lessons / beats / interactions / assets. Read queries default WHERE deleted_at IS NULL. Hard delete after 30 days via cron. users.org_id nullable today; M3 classroom flips it to required for org-bound users.
| Surface | Convention | Example |
|---|---|---|
| DB columns + JSON keys | snake_case | created_at · narration_text · plan_json |
| URL paths | kebab-case | /v1/lesson/:id/visualize |
| Python vars + functions | snake_case | def build_plan(intent: Intent) -> Plan: |
| Frontend JS vars + functions | camelCase | function loadBeat(beatId) |
| Constants | SCREAMING_SNAKE | HAIKU_TIMEOUT_MS = 5000 |
| Subject codes | lower-snake | physics · math · history · english |
| Beat kinds | lower-snake | concept · derivation · problem · test · free |
| IDs | ULID (TEXT) | 01HZX5T6Y2C8P1Q9S3F4WJK7M · sortable, time-encoded |
| Trace IDs | req_ + ULID | propagated through every log line for a single request |
Sliding-window counters per scope × bucket. Anonymous IPs throttle harder than authenticated users. Excess returns code:"over_quota" with drama-modal copy.
| Scope | Bucket | M1 limit | Notes |
|---|---|---|---|
| IP (anonymous) | plan | 5 / hour | protect from drive-by abuse |
| IP (anonymous) | ask | 60 / hour | in-lesson questions |
| User (authenticated) | plan | 30 / hour | generous for real students |
| User (authenticated) | ask | 300 / hour | no realistic ceiling |
| Global | all | $50 / day | spend cap · see §10 cost guardrail |
Every log line carries the same dictionary. structlog emits JSON. CloudWatch indexes by trace_id for request-level replay.
{
"ts": "2026-05-10T08:14:23.421Z",
"level": "info", // debug | info | warn | error
"msg": "haiku call ok",
"trace_id": "req_01HZX...", // every line of one request shares this
"session_id": "01HZX...",
"user_id": "01HZX...",
"lesson_id": "01HZX...", // optional · null until lesson exists
"beat_id": null,
"path": "POST /v1/plan",
"status": 200,
"latency_ms": 1483,
"model": "haiku-4-5", // optional · only for LLM-touching calls
"cost_usd": 0.0019,
"cache": "miss" // hit | miss | bypass
}
Two rails the system never crosses, both checked before any LLM call.
Daily spend tracked in model_runs. When daily total ≥ $50 (M1 cap), all new lesson generations return code:"over_quota" with drama-modal copy: "Tutor is full for the day · come back in <hours>." Already-active lessons finish streaming. Cap raises to $200/day in M2 with billing.
Every student-supplied input (topic, free-text in setup, ask, adjust, whiteboard text) passes through a 200-token Haiku safety classifier before the main model call. If refused: code:"refused", reason:"safety", message:"…". Categories blocked: explicit harm, CSAM, self-harm encouragement, doxxing. Output also scanned for the same. Refusal is logged as a safety.refused event for review.
Every non-2xx response carries the same shape. Frontend can render it uniformly. Drama modal explains, never spinner.
{
"ok": false,
"code": "rate_limited", // stable enum
"message": "Tutor is thinking too hard. Try again in a sec.",
"recoverable": true,
"retry_after_ms": 1500,
"trace_id": "req_01HZX..."
}
| Code | Meaning | UX |
|---|---|---|
| rate_limited | model API throttle | Owl: "thinking hard, one more sec…" · auto-retry after retry_after_ms |
| model_unavailable | Anthropic outage | Fall back to cached or to Haiku · Owl explains |
| invalid_input | setup intent malformed | Field-specific error · re-open setup modal |
| not_found | lesson_id doesn't exist | Redirect to discovery · friendly toast |
| unauthorized | session expired | Refresh anon session · retry once silently |
| over_quota | rate-limit or daily cap hit (§8 §10) | Drama modal: "Tutor is full · try in <X>." · no spinner |
| refused | content moderation blocked input or output (§10) | Owl: "I can't teach that one. Want to try something else?" |
| internal | unexpected | Owl: "I lost my place. Mind starting over?" · log trace_id |
Single stream. Every event has type, session_id, lesson_id?, ts, and payload_json. Used for analytics, replay, and quality monitoring.