How TeachBack is built
TeachBack is a Hebrew-first learning tool where the student explains a topic out loud and the AI only asks questions. This is the engineering side: the decisions, the architecture that enforces them, how the work is checked, and what is deliberately not finished.
The shape of it
- Client
- Vite and React, a single-page app. Hebrew-first and right to left, with an English mode.
- Server
- Express, in the same container as the built client. One deployable unit, so there is no version skew between the two.
- Voice
- A real-time model session over a WebSocket the server owns. The browser never holds a provider credential.
- Data
- Postgres through Supabase, with authentication and row-level rules in the same place as the data.
- Public site
- Static HTML on a CDN with no build step. It has no dependency on the app, so it stays readable when the app is down.
Six decisions worth explaining
Two of these are running in production and can be checked from outside today. The other four are implemented and in review, not deployed. Each one says which it is, because a decision described in the past tense reads as shipped, and four of these are not.
1. The deployment declares what it is allowed to do, and refuses to start otherwise
Implemented, in review. Not deployed.
A single variable states the mode: serve the static sample, or make real model calls. In production the server refuses to boot when it is unset.
Because the health check is memory-only and always answers 200. An unset mode would deploy green while voice, evaluation and upload were all quietly dead, and neither the uptime monitor nor the platform health check could see it. Failing to start is loud. Starting wrong is not.
2. Exactly one place constructs the model client
Implemented, in review. Not deployed: the deployed server still constructs four.
Four independent SDK clients became one gateway. Every call goes through it, with its timeout, its cancellation signal and its output ceiling.
Because a limit that lives in four places is four limits, and the weakest one is the real one. Consolidating it made a single grep the proof: one construction site, or the change did not land.
3. Routes that are out of scope are removed, not defended
Implemented, in review. Not deployed.
Seven route prefixes answer 410 Gone, from a declaration of
what the deployment no longer offers. The check runs before any body
parser or file handler.
Because a route you do not serve cannot be exploited, and the cheapest
place to stop a request is before anything reads its body. 410
rather than 404 is deliberate: it says removed on purpose,
not missing by accident.
4. Every route states its own request ceiling
Implemented, in review. Not deployed: the deployed server still mounts one global 50MB parser.
One global 50MB JSON parser became a 64kb default, with a larger allowance only on the one route that needs a transcript, plus explicit caps on transcript length.
Because a single global ceiling is set by whichever route needs the most, and every other route silently inherits it. The route that accepts a paragraph should not accept fifty megabytes.
5. The public site publishes by allowlist, not by blocklist
In production since 17 September 2026.
The static site serves its repository directly, so its ignore file names what may be published and denies the rest.
Because a blocklist silently re-exposes every new file, and a repository that gains internal notes and planning documents over time will eventually publish one. An allowlist fails the safe way: something new is invisible until someone says otherwise.
6. The client build and the server build do not share a directory
In production since 20 September 2026.
The client compiled into dist/ and the server into
dist/server/, and dist/ is the directory the
app serves statically. Every compiled backend file was therefore
downloadable from the public origin. The two outputs are now siblings
and the static root points at the client one explicitly.
Because this one was not theoretical. The route handlers, the
environment module and the server entry point each returned real
JavaScript with a 200. It was found by asking what the
running container actually serves rather than what the build script
intends, and the fix is confirmed the same way: those paths now return
the client shell, byte for byte identical to a path that never existed.
A status code alone could not have shown this, because the app answers
unknown paths with its own page.
How the work is checked
Three habits do most of the work here, and all three come from getting it wrong first.
- A test that cannot fail proves nothing. For the protections above, the fix is removed and the suite is re-run. If it stays green, the test was decorative. It should fail loudly, and only the relevant cases should fail.
- Measure what the browser paints, not what the stylesheet says. Colour and layout are verified from computed styles in a real browser at phone and desktop widths, because a variable can be correct in the file and overridden in the page.
- A check whose empty result means "fine" needs a control. An empty result is only evidence once the same check has produced a known-true non-empty one. Otherwise a broken command and a clean system look identical.
What this does not claim
- The conversation and feedback in the worked example were written to show the shape of the loop. They are not a recording of a real learner, and the page says so in its own first paragraph.
- Four of the six decisions above are in review, not deployed. Each one is marked. Only the two marked as running in production can be checked from outside today, and they are the two worth checking.
- The product is Hebrew-first. English support in the interface is partial and still landing.
- This page covers decisions and architecture. It uses no private source history and no customer data.
See it rather than read about it
The worked example needs no account and no microphone. The app needs both, and is Hebrew-first. There is a language switch inside the app, but not on the sign-in screen you land on first.