Inspiration The Department for Education's statutory attendance guidance advises schools to hold more than one emergency contact number for every pupil. So schools collect three or four at enrolment — and then the data just sits there. People change jobs. Numbers get reassigned. Grandparents move.
Nobody rings the list to check, because that is hours of office time spent on calls where nothing happens. So the school finds out which numbers are dead on the one morning it cannot afford to: when a child is missing and no one knows why.
That is the gap. Not "schools should call parents" — they already do. The gap is that the contact list quietly rots, and the only thing that tests it is the emergency.
Reachable is two phone workflows that fix each other.
What it does Workflow A — the contact check. Once a term, it rings each emergency contact. It says who it is and that it is not a person. It confirms it is speaking to the named adult before it says anything about any child, then asks two questions: are you still willing to be a contact, and is this the best number. If the contact wants their details changed, it does not take a new number — there is no field in the result schema that could hold one. The office updates records through a channel it already trusts.
Workflow B — the pattern follow-up. When a pupil has two consecutive register sessions coded N — "reason for absence not yet established" — it cascades through that pupil's contacts in the school's own listed order, stopping at the first confirmed conversation. It is supportive, not punitive: it asks what is going on, listens for barriers, and offers a call from the attendance officer.
The loop is the point. A number that turns out to belong to a stranger during a real absence call is flagged in contact health the moment that call ends — not next term — and the cascade moves on to contact 2. The termly check is what makes the cascade order worth following in the first place. The contact list is maintained by the calling rather than in spite of it.
What it never does: conclude a child is safe. There is no state, enum, or report cell that means "accounted for". It never writes to a register. It never calls a pupil flagged vulnerable. It gets a person in front of the right information faster, and then it stops.
How we built it Python 3.11, FastAPI, SQLite, and the calle-ai SDK (v0.7.0) — no other HTTP client exists anywhere in the app, which is what makes the credential story auditable.
The architecture is deliberately boring in the places that matter:
Both workflows are pure state machines. step(state, event) -> transition, no I/O, raising on impossible transitions rather than silently absorbing them. Every transition in the design doc has a test; 69 of them. One choke point for dialling. orchestrator.py is the only caller of client.create, and ten guards plus a ledger reservation run before it — a property asserted by tests, not by convention. Fail-closed dispositions. Every path that does not positively match a known good outcome falls through to NEEDS_HUMAN, never to CONFIRMED. Transcript-backed identity. A result claiming identity_confirmed: yes counts only if a turn the recipient actually spoke backs it up. speaker: bot is not evidence of anything. Everything runs with no network by default. A fake CALL-E server ships with the app; 423 tests pass without a credential or a socket. The idempotency key is derived from the authorised intent, not the attempt:
$$k = H\big(\text{workflow},\ \text{scope},\ \text{pupil},\ \text{contact},\ n\big)$$
where $n$ is the ordinal of the human decision that this specific call may happen. And the number of calls one absence trigger can ever produce is bounded:
$$C_{\max} = \lvert \text{contacts} \rvert \times a$$
with $a$ the per-household attempt budget. Both numbers are configuration, and both are enforced before the dial rather than counted afterwards.
Challenges we ran into The docs contradicted themselves, and the code proved it. The state machine returned a case to "ready" after voicemail so a second attempt could be authorised — but the safety doc derived the idempotency key from the intent alone, which made that second attempt structurally impossible. The provider would return the first call every time. Adding $n$, the authorisation ordinal, was the fix; it is the difference between a retry counter and a record of human consent.
A race nobody would have seen. A forced-concurrency probe put eight threads through the dial path at once. All eight passed the in-flight guard. Only a UNIQUE constraint stopped eight calls going out, and it did so by raising five unhandled IntegrityErrors. The guard and the reservation now happen under one lock; the dial itself stays outside it.
A number that was valid and still wrong. +4407899843283 — a UK number that kept its national trunk prefix — passes E.164 syntax perfectly. It would have dialled something. It is now refused, never repaired, because dropping the zero means guessing whose telephone rings.
We committed a real phone number. Our own repository-hygiene test caught it before the push. That test exists because we assumed we would make exactly this mistake.
Then we made two live calls, and they found two bugs 423 tests had not.
The first: CALL-E returned task_completed: false alongside a perfectly schema-valid outcome: "no_answer". Our classifier read task_completed first, so an ordinary unanswered call became NEEDS_HUMAN — meaning the cascade would never have advanced and the attempt budget would never have engaged. Every no-answer in a real school would have flooded the staff queue.
The second: the provider left a call queued for about a minute. Reading it back in that window retired the "never two calls for one case" guard while the phone was still ringing.
What we learned Our fake server was the most dangerous thing in the repository, because it agreed with us. Every one of its scripts asserted task_completed: true. That single wrong assumption sailed through 423 tests and was only caught by a real telephone. A fake written to match your fixtures validates your beliefs; a fake written to match the contract validates your code. We now have a test whose only job is to stop the fake drifting away from the API again.
We also learned how much of safety work is refusing to repair things. Not guessing a timezone from a country code. Not stripping a leading zero. Not coercing an unfamiliar status into a familiar one. Almost every safety guard in Reachable is a place where the obvious helpful thing is the wrong thing.
And the identity gate changed how we write prompts. "Do not mention the child until identity is confirmed" is not an instruction you can put anywhere in a task — it has to structurally precede the first place the child's name appears, and there is a test that asserts exactly that ordering in the rendered text.
Log in or sign up for Devpost to join the conversation.