How the service works
MathTrail stands between the chat a family already uses and the parent's Google Drive. The chat's model writes every task, and the service decides whether it reaches the child. It is one program: it calls no model of its own and keeps nothing between requests.
- One Go binary on Cloud Run
- Stateless
- No database
- No LLM calls of its own
- MCP 2026-07-28 · MCP Apps
Chat client1 · client
- Claude
- ChatGPT
- other MCP Apps hosts
The chat's model writes the task, and the card shows it to the child.
MCP · tool calls over HTTPS
MathTrail on Google Cloud2 · runtime
Cloud Run · one Go binary · stateless
The core — pure computation
- The rule
- Ratings
- Checks
- Solver
- Content
Around it: OAuth, the MCP server, the card, sealing, limits.
Drive API v3 · with the parent's own token
Google Drive3 · storage
MathTrail/mathtrail-profile.json
One file per child, in the parent's own Drive — the only thing kept for long.
01
The map
Who talks to whom. Neither the child nor the adult reaches MathTrail directly — they talk to the chat host. Everything in the middle block is a part of one process, not a separate service. Choose any part to see what it does and what it links to.
02
A lesson, call by call
The model and the card call the same tools and stand on one source of truth — the profile's file in the parent's Drive. For a lesson to go on, they have nothing to tell each other.
Task
- Child
- Card
- The host's model
- MathTrail
- Drive
1Child → The host's model: “a new task, please”
2The host's model → MathTrail: next_task
The host draws the card as soon as the call begins: “Preparing the next task…”
limits · the rule picks the topic, the level and the difficulty
3MathTrail → Drive: write the open request
4MathTrail → The host's model: the request is open — fetch the package
5The host's model → MathTrail: get_package
6MathTrail → The host's model: brief · 3 reference tasks · traps · templates · frames
The model writes the task, a Starlark solver and a self-check
7The host's model → MathTrail: submit_task — up to 3 attempts
checks · the solver must find exactly one right option
8MathTrail → Drive: the task becomes the current one, its answer sealed
9Card → MathTrail: read_task — the card asks every 4 s
10MathTrail → Card: text · drawing · five options · hint — no answer
Every call reads the profile from Drive as well — those reads are left out. The card that waits never sees the package: its reference tasks carry their answers. While the child solves, the model writes the next task ahead with prepare_task, and when the child asks for another, next_task puts it on a new card at once — without this wait.
Answer
- Child
- Card
- The host's model
- MathTrail
- Drive
1Child → Card: presses C
2Card → MathTrail: submit_answer — the chat spends no turn
the first answer alone · the seal is opened now · θ and δ move only on whether it is right
3MathTrail → Drive: write the ratings and the history
4MathTrail → Card: right or not · the trap behind C · the solution · the rating before → after
5Card → The host's model: ui/update-model-context — one line on what happened
6Child → The host's model: “why not 6?” — a question in the chat
7The host's model → Child: explains, starting from the trap
The press records the answer before anything is explained. If the host's message to the model is lost, the child still sees the result, and the model catches up at its next call.
Sign-in
- The parent's browser
- Host
- MathTrail
- Drive
1Host → MathTrail: POST /mcp, no token
2MathTrail → Host: 401 · where the metadata is
3Host → The parent's browser: opens /oauth/authorize · PKCE S256
4The parent's browser → MathTrail: authorize
A consent screen of its own: which client, and where it will send the parent back
5The parent's browser → Google: sign-in with Google · drive.file alone
6Google → The parent's browser: back, with Google's code
7The parent's browser → MathTrail: /oauth/callback
8MathTrail → Google: exchange the code
9MathTrail → Drive: /about — whose Drive is it?
user id = HMAC of the Drive account · Google's tokens sealed into our code
10MathTrail → The parent's browser: 302 · code = mt1.c.…
11The parent's browser → Host: hands the code back
12Host → MathTrail: /oauth/token · code + verifier
13MathTrail → Host: access 15 min · refresh 30 days
The parent signs in once. The service keeps nothing of this exchange: the request travels in a sealed state, and Google's tokens in a sealed code.
The current task as a state machine
Every “only once” rule is a field of the profile, checked within the same read, computation and write. Nothing else remembers anything.
nonenext_taskbrief readyrequestedsubmit_task ✓sealed, handed outissuedsubmit_answerrecorded onceanswered
requested ↺refused, attempts left — no more than three per requestrequested → nonethree refusals, or 15 minutes from the request's opening; the day's tasks are not spent, and three refusals count as a failed request of the dayissued → requested“Another task” before an answer — a skip is recorded, the rating untouchedanswered ↺any later answer — from a second tab or from the model — gets the first result back and writes nothinganswered → requestedthe next task, on a new card; the one answered is no longer currentanswered → issuedthe next task was written ahead — next_task puts it on a new card at once, skipping requestedissued → nonethe seal can no longer be opened — the key rotated twice; nothing is recorded
03
Where the state lives
Everything a server with state would keep in a database is kept by someone else: in a sealed token the chat holds, in a cookie in the parent's browser, or in the parent's own Drive. Restart the service or start a second copy — nothing is lost.
MathTrail itself
Nothing
No accounts, no sessions, no registrations, no Google tokens. Every request carries all it needs.
The chat's token store
Sealed tokens, opaque to the host
- Access token
- 15 min
- Refresh token
- 30 days, ≤ 90
Sealed inside are Google's tokens, an opaque user id for the limits, and the country the parent signed in from.
The parent's browser
Two cookies, no session
- Approved clients
- 180 days
- Sign-in guard (CSRF)
- 10 min
Losing them costs one more consent screen, or a new sign-in.
The parent's Drive
The only thing kept for long
mathtrail-profile.json- until deleted
The profile, the ratings and the current task, its answer sealed. The parent can open the file, and Drive keeps its history.
Secret Manager
Read once, when an instance starts
- Sealing keysafter a rotation, the previous key is still accepted
- 90 days
- Google's client secret
- pinned
The instance's memory
Caches alone: lost on a restart, and nothing breaks
- Request rate counters
- minutes
- Where the profile's file is
- 10 min
- Clients' documents
- 5 min – 24 h
Left out of v1 on purpose
- A database
- Queues
- A session store
- A shared cache
- A bank of ready tasks
- An LLM API client
- REST and OpenAPI
04
The profile's file
One child, one JSON file: not a cache of some truth on a server, but the truth itself. Every block has a cap, held on every write, so the file never grows without a bound: a usual one keeps within the target, and one near its caps goes past it. Past tasks are kept as fingerprints, not as text.
The file's size
≈ 61 KB / ≈ 79 KB
| Block | What it holds | Who writes it | Size | |
|---|---|---|---|---|
| Fingerprints | Up to 200 sketches of the tasks handed out, for the near-duplicate check — no text, no topic, no date | next_tasksubmit_task | 27 KB / 29 KB | |
| Rating history | Where θ and the topic's δ stood before each recent answer, and θ and every topic at the start of each of the last 7 days with answers | submit_answer | 14 KB / 15 KB | |
| Ratings and topics | θ and where the child started; for each topic: δ, the answers, the traps they fell into, mastery and its level | next_tasksubmit_tasksubmit_answer | 8 KB / 14 KB | |
| Recent answers | The last 20 answers and skips — and never fewer than the 5 answers the rule reads | next_tasksubmit_answer | 4 KB / 5 KB | |
| Current task, sealed | The answer, the trap behind each wrong option, the solution and the solver — one opaque string | next_tasksubmit_tasksubmit_answer | 4 KB / 8 KB | |
| Current task, open | What the card shows: the text, the drawing, five options, the hint — and, after the answer, the answer itself | next_tasksubmit_tasksubmit_answer | 2 KB / 4 KB | |
| Open request | While the task is being written: its id, the brief, the attempts used and who picked the topic | next_taskprepare_tasksubmit_task | 1.5 KB / 2 KB | |
| Service and child | The schema version, a random UUID, the revision; the pseudonym, the grade, the interests, the notes; the day's counters | save_profileedit_profileevery write | 0.6 KB / 1.5 KB |
A task written ahead lies beside the current one and weighs about as much. What the file does not hold: the texts of past tasks, the answer in the open until the child has answered, anything that names the parent, and a rank — ranks are computed when they are shown. Drive has no conditional write, so the file is read again before a save, and the write is refused if it has changed: two tabs never silently overwrite each other.
05
Sealed — and for how long
One mechanism does two jobs: it seals the sign-in into the tokens the chat holds, and a task's answer — the current task's and the one written ahead — into the profile. Each seal carries its purpose, so an access token cannot pass for a refresh token, and a sealed answer cannot pass for anything else.
A token, taken apart
mt1.a.kQ7fWx.5rJ0Yb2mQ8vK1nT4pXc9…
- mt1
- the format's version
- purpose
- c code · a access · r refresh · d client · s state · k consent · t a task's answer
- key id
- taken from the key itself, so a rotation goes unnoticed
- ciphertext
- XChaCha20-Poly1305, a random 192-bit nonce
Not a JWT: no one but the service reads it, and no claim can leak into a log from an opaque string.
Lifetimes, on a logarithmic scale
- Authorization code
60 s - Sign-in in progress
10 min - Access token
15 min - Refresh token
30 days, sliding, 90 at most - Sealing key
rotated every 90 days - Approved clients
180 days
A server without state cannot mark a code as used, so the code's window is kept small. After a rotation the previous key is still accepted, so neither a token nor a sealed answer is lost when the key changes.