⚠️ SIMULATION ONLY. This project simulates the dispatcher's side of an emergency call for testing, research, and practice. It is not certified for live emergency call-taking, gives no medical advice, and must never be connected to a real emergency line. In a real emergency, call your local emergency number (911 in the US, Canada, and Mexico).
A protocol-grounded, multilingual synthetic 911 dispatcher, built by accesSOS — the nonprofit making emergency services accessible.
Real dispatchers follow strict, trained protocol decision trees. Everyone building toward 911 — accessibility apps, crash detection, alarm monitoring, AI callers — needs to test against a realistic dispatcher, and there has never been an open, faithful stand-in for one. This engine plays that role by strictly executing a loaded protocol pack, never improvising: every word it says is rendered from the pack's string catalog, so it structurally cannot say anything the playbook does not say.
- Jurisdiction is a pack: a country/region's call-intake playbook (case-entry questions,
protocol decision trees, dispatch determinants, post-dispatch instructions) with provenance and
licensing declared per pack. See
schema/pack.schema.json. - Language is a catalog: protocol logic references string ids, and each pack carries per-locale catalogs. Any playbook can speak any language it ships catalogs for. The reference pack speaks English and Spanish; Canadian packs will ship English/French.
A Mexico pack is not a translated US pack — different protocols, different institutions. The schema keeps those concerns apart so both can grow independently.
npm install
npm test # engine + simulator suites (keyless)
npm run sim # branch-sweep every pack in every locale, enforce invariants
npm run coverage # score every pack against the published requirements rubrics
npm run score # score every pack's calls against the QA variablesnpm run call -- us-openises-emd --locale es --scoreAn interactive call in the terminal — the practice use case in its smallest honest form, and the fastest way to feel whether a pack you are writing works, since a branch sweep tells you every path terminates but not whether the questions land in an order a frightened person could follow. It reads stdin, so it scripts as well as it converses:
printf '12 Pine St\n555-0100\nchest pain\n58\nyes\nyes\nno\n' | npm run call -- us-nhtsa-emdnpm run demo # then open http://localhost:4179/demo/Runs a call in the browser and animates the dispatcher's "brain" in real time over the pack's
decision tree: the path taken, the active node, what is being assessed next, and milestones —
protocol selected, and what resources were deployed at what response level. Built on the engine's
onEvent stream and packGraph() export, which any richer UI (e.g. React Flow) can consume the
same way.
import { DispatchSession, loadPackFromFile } from 'open-dispatch-simulator';
const pack = loadPackFromFile('packs/us-nhtsa-emd/pack.json');
const call = new DispatchSession(pack, { locale: 'es' });
let utterances = call.start(); // "Nueve-uno-uno." / "¿Cuál es la dirección…?"
utterances = call.answer('Calle Reforma 10');
// … keep answering until call.isDone()
console.log(call.result()); // protocol, determinant, response level, transcript| Piece | What it is |
|---|---|
caseEntry |
Universal intake questions asked on every call, in order |
protocols[].keywords |
Per-locale complaint keywords that select the protocol |
protocols[].keyQuestions |
The decision tree: choice questions with conditional edges |
protocols[].determinants |
Ordered rules mapping answers to a neutral response level |
protocols[].postDispatch |
Instructions read after responders are dispatched |
scripts |
v0.3: interactive instruction scripts (CPR, choking, childbirth) a card hands off to |
protocols[].dispatcherNotes |
v0.3: content for the dispatcher, structurally never spoken |
lexicon |
v0.4: per-locale vocabulary the value extractors read |
strings.<locale> |
Every utterance template, per locale — validated complete at load time |
provenance |
Where the playbook came from and its redistribution license |
Schema v0.2 adds three features (v0.1 packs remain valid): extract: "number" on a question
captures the first number in the answer; determinant and edge conditions may then be numeric
({ "slot": "age", "gt": 35 } — never matching when no number was captured, so age-unknown falls
to whichever tier the pack lists as default); edges gain when (compound conditions on prior
slots) and gotoProtocol — faithful card jumps, e.g. both bundled real packs now route an
unconscious, non-breathing patient to their Cardiac Arrest card mid-case-entry, exactly as the
printed flow charts direct.
Schema v0.3 adds interactive instruction scripts — the part of a card the dispatcher
performs rather than reads. postDispatch is a list of lines; a script is a small graph of
say / ask / stay steps that branches on what the caller answers, hands off to other scripts
("jump to I1: AED Instructions"), and ends by holding the line. A card selects one with
postDispatchScripts, ordered and conditional, so the same card routes an infant, a child, and an
adult to different CPR scripts exactly as the printed decks do. The call no longer ends when the
ambulance is rolling: the response level is fixed at dispatch, and everything after it is the
caller being talked through what to do.
Scripts must form a DAG — the loader rejects a pack whose scripts can reach themselves, so a pack that loads cannot trap a caller in a loop. Termination is a property of the content, not a runtime step budget.
v0.3 also adds dispatcherNotes: the cards' "Call Taker Prompts", "Dispatcher Short Report" and
"Useful Information" — content for the call-taker that is never spoken. Those string ids are
kept disjoint from every spoken id, so "never said to the caller" is enforced by the loader rather
than trusted.
Schema v0.4 adds value extraction. A caller does not answer in fields — asked for an
address they say "uh, we're at 12 Pine Street, the blue house on the corner" — so a read-back
that repeats all of that is not a read-back, it is an echo. A question may declare
extract: "address" | "phone" | "age" | "count" | "number", and {slot} then interpolates the
value instead of the sentence:
I have 12 Pine Street — is that correct?
Tengo Calle Reforma 10, ¿es correcto?
age is unit-aware, which is a routing question and not only a cosmetic one: "he's six months
old" is 0.5 years, not 6, so the cardiac-arrest card reaches the infant CPR script rather
than the child one. It also reads the locale's words for a newborn. Extraction that recognises
nothing returns nothing and the caller's own words stand — losing information is never the failure
mode, and result().answers always keeps what was actually said.
The words these extractors match are language, not logic, so they live beside the pack's keywords:
the engine ships tables for the locales the corpus speaks (en/es/fr) and a pack may override them
or add a locale with its own lexicon. A pack that asks for word-aware extraction in a locale
nothing covers is rejected at load, the same as a missing string.
The loader (loadPack) enforces the grounding contract up front: every referenced string must
exist in every declared locale, templates may only interpolate collected slots, every edge and
determinant must reference real questions and options. A pack that loads is safe to execute.
runCall / runBatch / sweepScripts (see src/sim.ts) drive scripted callers
through a pack and score the outcomes: turns to dispatch, clarify rate, and the distribution of
protocols, determinants, and response levels. sweepScripts enumerates every combination of
choice options per protocol, and npm run sim (also a CI gate) enforces the simulator's core
invariant: every call reaches dispatch with a response level, in every locale. Dispatchers also
clarify-and-re-ask when a choice answer doesn't parse (clarifyAttempts, default 1), and keyword
matching is Unicode word-boundary aware, so "know" never matches the keyword "no".
"I don't know" is its own answer. It contains the word not; "no sé" contains no; "je ne
sais pas" contains pas — and all three are negative keywords in the flagship pack, so a caller
who couldn't answer was being recorded as one who answered in the negative. Those phrases now
resolve to an unknowns outcome distinct from both a yes/no and an answer that didn't parse, and
the dispatcher moves on rather than asking again: re-asking someone who just said they don't
know is the antipattern every call-taking guideline warns about.
A pack can still mean it — the OpenISES M10 card offers "not sure" as a real answer to how the caller knows the person is dead — so the longer match wins and a tie goes to the pack, whose vocabulary is specific to the question being asked.
The loader also refuses a pack whose option keywords shadow each other. Options are matched in order, so an earlier option's keyword sitting inside a later one's makes the later unreachable: "no shock indicated" contains the whole word shock. That bug was found once by a coverage sweep; now it cannot load.
Locale completeness runs in both directions. The loader already refuses a pack missing a string in any declared locale; the suite additionally refuses one where a locale is a byte-for-byte copy of the default, or where an answer option is recognisable by only one word — a Spanish caller answering "para nada" should not be asked the question again.
Real PSAPs don't all sound the same, so sessions accept a persona — deterministic given its
seed, so eval runs reproduce exactly:
new DispatchSession(pack, { persona: { seed: 7, confirmRate: 1, clarifyAttempts: 2 } });- Phrasing variants: a catalog entry may be an array of equivalent wordings; the persona picks one (still only ever the pack's own strings — grounding is preserved).
- Read-backs: questions may declare a
confirmStringId("Okay, {address}.") that the persona speaks after the answer with probabilityconfirmRate. - Patience:
clarifyAttemptscontrols how often an unparsed answer is met with clarify-and-re-ask.
Personas change phrasing and pacing, never the clinical outcome — a pinned test asserts the same answers produce the same protocol, determinant, and response level under every seed.
There has never been an open way to answer "is this protocol set complete?" without comparing it
against someone's proprietary cardset. So instead we measure the corpus against published
requirements — a state's administrative rule, a national curriculum — encoded as machine-readable
rubrics in rubrics/, citation by citation:
npm run coverage # every pack against every rubric
npm run coverage -- --pack us-openises-emd # one pack, with its evidence
npm run coverage -- --json # machine-readableus-openises-emd [en/es/fr] vs Maine EMDPRS §II.2 — Protocol Requirements
31 met · 2 partial · 4 unmet of 37 scored (84%); 3 program-scope requirements not scored
✓ ME-II-2-A-3 Verification of the call-back number.
§II.2.A.3
· caseEntry:q_callback (slot callback)
~ ME-II-2-A-23a Medical management: cardio-pulmonary resuscitation (CPR) and AED.
§II.2.A.23.a
· /push (hard|down)|compress|…/ → pd_cpr_push (en) +2 more
! no match for /\baed\b|defibrillat|…/
Two rubrics ship today: Maine's EMDPRS §II.2 protocol-requirements list (public law — Maine adopts MPDS® statewide, but the requirements list is Maine's own, and no proprietary content is encoded) and the NHTSA EMD National Standard Curriculum's EMDPRS structural elements plus its 32 chief complaint types (US federal, public domain).
Every finding prints the question, string, or protocol it came from, so a reader with the pack open can check the tool's work. Requirements that belong to the agency rather than the protocol — QA/QI case review, dispatcher training, record-keeping — are marked program-scope and never scored against a pack. This is deliberately the only comparison we publish; see docs/PRIVATE-PACKS.md for why we never publish similarity claims against proprietary systems.
The library is only reachable from JavaScript, and an AI caller, a crash-detection client or a
practice UI is usually neither in this process nor in this language. npm run serve puts the
dispatcher behind three endpoints — no new dependencies, just node:http:
npm run serve # http://127.0.0.1:4180
curl -s localhost:4180/calls -H 'content-type: application/json' \
-d '{"pack":"us-openises-emd","locale":"es"}'
curl -s localhost:4180/calls/$ID/answer -H 'content-type: application/json' \
-d '{"text":"Calle Reforma 10"}'Each response carries what the dispatcher said, the question now pending, and — once the call is
over — the same result() the library returns: protocol, determinant, response level, transcript.
GET /packs lists the corpus with its provenance, and GET /packs/:id/graph serves the decision
tree a visualizer needs.
It binds to loopback and has no authentication, because it is a test fixture. Every response says so in a
noticefield. Don't put it on a public interface, and don't wire it to anything that answers real calls.
npm run sim answers did the call reach dispatch. That is the floor, not the bar.
npm run score asks whether it was handled to protocol, on the six variables Maine's EMDPRS
§III.4.C requires a licensed EMD centre to track for every reviewed call:
npm run score # every pack's branch sweep, scored
npm run score -- --pack us-openises-emd # one pack
npm run score -- --json # machine-readableus-openises-emd — 57525 calls scored
all-caller-questions 57525 pass 100% pass
protocol-selection 57477 pass · 48 partial 100% pass
complaint-questions 57525 pass 100% pass
priority 57525 pass 100% pass
pre-arrival-instructions 19398 pass · 38127 n/a 100% pass
post-dispatch-instructions 57525 pass 100% pass
information captured: 604189/935028 answers parsed (65%) — a property of the callers, not of compliance
Two things it deliberately separates. Compliance is a property of the dispatcher and the pack:
a fail means a card asked nothing, dispatched nothing, or promised instructions it never gave.
Information capture is a property of the caller — someone who answers "I don't know" four
times has not made the dispatcher non-compliant — and it is the number an AI-caller harness
actually wants. A card that routes straight to dispatch, as both bundled EMD packs do for a
patient who isn't breathing, scores n/a rather than "skipped the interrogation": that is the
protocol working, not failing.
Two things it does not claim: it cannot judge whether a determinant was clinically right, which needs a ground truth no synthetic call has, and it scores a session this engine ran rather than an arbitrary transcript.
npm run diff -- packs/us-openises-emd/pack.json packs/us-nj-emd/pack.json
npm run diff -- a/pack.json b/pack.json --jsonStructural, never textual: what each pack asks, what its determinants can branch on, which response levels each card can reach, where it jumps — not how closely two packs' wordings resemble each other. (A similarity metric that exists gets quoted, and the one comparison this project refuses to publish is similarity against a proprietary system.)
Two packs from different jurisdictions share no ids, so cards are lined up first by id and then
through the shared complaint taxonomy the coverage rubrics already use — which is why New
Jersey's chest_pain_heart_problems and Mexico's inc10314_infarto align despite being in
different languages. Response level names are never mapped: CODE_RED and
SIMULTANEOUS_ALS_BLS are two jurisdictions' words, and asserting they mean the same thing
would be a clinical claim, not a diff.
The hunt for sources — what shipped, what's pending, verified dead ends, and open leads — is recorded in docs/PROTOCOL-SOURCES.md.
- Only openly licensed playbooks ship here — public-domain sources (e.g. the NHTSA EMD National Standard Curriculum), state-published protocols, and public-records SOPs, each with provenance declared in the pack. Proprietary systems (e.g. MPDS®/ProQA®) are not included and must not be contributed; agencies that license them may encode them as private packs and load them locally — see docs/PRIVATE-PACKS.md for the mechanics and the permissions you need first.
- Real calls never shape this project. No real emergency calls, transcripts, or personal data in the repository, the public corpus, or the engine's behaviour — the engine is grounded in protocol documents, not in recordings, and nothing is ever trained or tuned on a call. The one use of real calls is replay validation (docs/REPLAY.md): a call's facts and the dispatcher's observable behaviour are coded into a private, placeholdered case file and replayed through the deterministic engine to measure agreement. Publicly released recordings (public-records releases, investigation reports, court exhibits) may be coded by people or by tools, including AI assistants, for the question/instruction study. A partner agency's own calls are processed only on that agency's machines under a written data plan. Case files live outside the repository, and only aggregate results are ever published.
- The bundled
packs/us-nhtsa-emdis a heavily simplified reference subset that exists to exercise the engine, not a usable medical protocol. packs/us-openises-emdis the flagship source-faithful pack: the Open ISES Project's freely licensed (GFDL-1.2+) EMD guide cards — the All Callers Interrogation plus all 34 complaint cards and the H1/H3/H4 procedure cards, trilingual, with the source's own Code RED/YELLOW response taxonomy, and the I1 (AED), I2/I3/I4 (adult, child and infant CPR), I5/I6/I7 (adult, child and infant choking) and I8 (childbirth) instruction cards as interactive scripts, routed by age. See its SOURCE.md for attribution, digitization decisions, and which cards are still to come.packs/us-nj-emddigitizes the official State of New Jersey EMD Guidecards (nj.gov/911, updated May 2026): All Caller Interrogation + Chest Pain, Cardiac Arrest/DOA, Unconscious/Fainting, and Unknown/Person Down, with New Jersey's own dispatch tiers (SIMULTANEOUS_ALS_BLS/BLS_DISPATCH/FOLLOW_LOCAL_PROTOCOL). License status: written permission pending — see SOURCE.md.packs/us-alameda-policeis the corpus's first police call-taking pack, grounded on the City of Alameda PD's published Dispatcher Training Manual: the What/When/Where/Who/Weapons interrogation, top-down suspect descriptions, and the manual's ownPRIORITY_1/PRIORITY_2/PRIORITY_3classifications plus 30-second fire/medical screen-and-transfer (TRANSFER_FIRE_EMS). License: written permission pending — see SOURCE.md.packs/mx-cnie-911grounds a Spanish-language Mexico pack on the Catálogo Nacional de Incidentes de Emergencia v3.0 (SESNSP, official June 2024 — the normative classification for every Mexican 9-1-1 center): verbatim incident codes, definitions, andALTA/MEDIApriorities, with card jumps implementing the catalog's own reclassification-by-definition. Open-use license (Libre Uso MX) — see SOURCE.md.
- Protocol corpus: US state-published protocols; Canada (bilingual EN/FR); Mexico (starting from the national 911 incident catalog, CNIE).
- Richer caller-answer matching (the v0 keyword matcher is deliberately simple), dispatcher persona traits (patience, interruption handling, interpreter-relay simulation), and call-scoring for automated evaluation.
- A practice-call web UI so anyone can safely rehearse calling 911 in their own language.
CI runs typecheck, tests, pack validation, and a provenance-license allowlist on every push and
PR (.github/workflows/ci.yml). Contributions require a DCO sign-off — see
CONTRIBUTING.md, CODE_OF_CONDUCT.md, and
SECURITY.md.
AGPL-3.0-only. Protocol packs carry their own content licenses in provenance.
"accesSOS" and the accesSOS logo are trademarks of accesSOS — see TRADEMARKS.md.