A triage, dispatch, and transparency layer over Chicago 311 service requests
(dataset v6vf-nfxy). DispatchLoop ranks open requests by real urgency,
assigns them to field crews, and keeps residents honestly informed —
especially when plans change. Built against the Feb 2026 OIG audit of the 311
process; the full design is in LOOP-311-DRD.md.
Important
The first visit or sign-in can take around a minute. The demo API may need to wake from its hosted idle state, and DispatchLoop continuously processes a high-volume inflow of public Chicago 311 tickets. Once the API is awake and the latest data is synchronized, navigation is substantially faster.
Use the shared password demo1234 with any account below.
| Experience | Department | |
|---|---|---|
| Office staff console | distr1@dispatchloop.demo |
311 City Services |
| Mobile crew view | crewmar1@dispatchloop.demo |
Streets & Sanitation |
| Mobile crew view | crew.cdot@dispatchloop.demo |
Transportation |
| Mobile crew view | crew.water@dispatchloop.demo |
Water Management |
| Mobile crew view | crew.buildings@dispatchloop.demo |
Buildings |
| Mobile crew view | crew.animals@dispatchloop.demo |
Animal Care |
| Mobile crew view | crew.aviation@dispatchloop.demo |
Aviation |
| Mobile crew view | crew.bacp@dispatchloop.demo |
Business Affairs |
| Mobile crew view | crew.health@dispatchloop.demo |
Public Health |
| Mobile crew view | crew.finance@dispatchloop.demo |
Finance |
| Mobile crew view | crew.external@dispatchloop.demo |
Outside Agencies |
Demo data is shared. Another visitor may update the same assignments while you are exploring the application.
- Priority-based staff console: ranks open requests with a visible severity rationale, manual override, filters, task history, and full request detail.
- Department workload view: expandable department-wise task lists sourced
directly from Chicago's
owner_departmentdata—no duplicate department assignment inside DispatchLoop. - Crew dispatch: manual assignment, reassignment, emergency preemption, and department-aware auto-assignment that fills crews toward five active tasks.
- Mobile crew workflow: expandable task cards, map links, location sharing, compact alerts, completed-work history, and English/Spanish controls.
- Voice status updates: tap-to-start/tap-to-stop speech interpretation bound to the expanded task, with safe transitions from assigned through completion.
- Resident tracking: public request lookup, operational progress, honest ETA, approved updates, and opt-in notification records without exposing internal notes.
- AI-assisted communication: Claude can interpret natural crew language and draft status-aware resident replies. Deterministic keyword/template fallbacks keep core workflows available when AI is not configured.
- Live Chicago 311 synchronization: incremental SODA ingestion with overlap, cursor tracking, Firestore coordination locks, pruning, and no writes back to City of Chicago systems.
Prototype stance: a read-only analytical layer over real city data with a simulated operational layer on top. Nothing is ever written back to Chicago's systems — "synchronizing" always means refreshing our own copy of the public feed.
Chicago 311 SODA (read-only)
|
v
FastAPI backend
+---------------------+---------------------+
| | |
Deterministic Claude AI Firebase Admin
priority / clustering - resident text |
ETA / state machine - Ask Loop v
- crew commands Firestore
├── serviceRequests
│ └── events
├── users
│ └── notifications
├── syncRuns
└── typeBaselines
Firebase Auth
|
v
Next.js
├── STAFF_OFFICE → /console, /ask
├── CREW → /field
└── public → /track/[sr]
backend/— Python (FastAPI + Firebase Admin + Cloud Firestore). All data access, domain logic (severity, clustering, honest ETA, ingest, categorization, notifications, Claude integration) and all authorization. Every protected route verifies the caller's Firebase ID token with the Admin SDK and reads their role fromusers/{uid}.src/— Next.js 16 App Router. A thin view layer. Firebase Auth signs users in the browser; the ID token is mirrored into an httpOnly cookie so server components can forward it to FastAPI. The frontend's role check is for routing and UI only — it is never the security boundary.
The SVG logo lives at public/screen.svg and renders via
src/components/loop-mark.tsx — in the nav on every page, on the login
screen, and on the landing page.
| Route | Who | What |
|---|---|---|
/console |
Office staff | Severity-ranked queue with category tags, duplicate coupling, manual/automatic dispatch, emergency preemption, reassignment, priority overrides, deferred work, department workloads, and resident-message approval |
/field |
On-site crews | Ordered queue with category/state filters, in-app alerts, live location + nearby requests, natural-language commands; Accept → En route → On site → Work performed / Blocked / Deferred → Complete |
/track/[sr] |
Residents (public) | Plain-language status, honest ETA with its basis, approved updates only, deferral and referral explanations, opt-in notifications |
/ask |
Staff | Natural-language questions → SoQL → live portal, with the query and row count always shown |
Enable Authentication → Email/Password and Cloud Firestore, then create your staff and crew accounts in the Auth console. This project stores no passwords — Firebase owns credentials.
cd backend
py -3.12 -m pip install -r requirements.txt
cp .env.example .env # fill in FIREBASE_PROJECT_ID / CLIENT_EMAIL / PRIVATE_KEYAttach a Firestore profile to each Auth user (this is what grants a role):
py -3.12 -m scripts.seed_demo_users \
--email distr@dispatchloop.demo --name "Trent Dispatch Unit" \
--role STAFF_OFFICE --department "311 City Services"
py -3.12 -m scripts.seed_demo_users \
--email crewmar1@dispatchloop.demo --name "Marcus Crew Unit" \
--role CREW --department "Streets and Sanitation"
py -3.12 -m scripts.seed_demo_users --list # verify
# or seed the bundled department-spanning demo set (Auth accounts must exist first)
py -3.12 -m scripts.seed_demo_users --from-json scripts/demo_users_all_departments.jsonLoad the 311 data and start the API:
py -3.12 -m scripts.import_json_to_firestore # bundled 500-row snapshot
# or, live: py -3.12 -m scripts.import_soda_to_firestore
py -3.12 -m uvicorn app.main:app --reload --port 8000npm install
cp .env.example .env.local # fill in the NEXT_PUBLIC_FIREBASE_* values
npm run dev # http://localhost:3000npm run backend, npm run backend:import, npm run backend:users and
npm run reset:ops are shortcuts for the commands above.
Sign in at /login; you'll land on /console (STAFF_OFFICE) or /field
(CREW) based on your Firestore profile. /track needs no account.
While a staff session is open the app reconciles with the city portal every
60 s (the exact $query is in backend/app/domain/soda.py), re-scores the
queue, refreshes duplicate proposals and categorizes new requests. If the
portal is unreachable it falls back to the bundled snapshot in data/.
Frontend (.env.local) — BACKEND_URL, NEXT_PUBLIC_BACKEND_URL, and the six
NEXT_PUBLIC_FIREBASE_* values.
Backend (backend/.env) — FIREBASE_PROJECT_ID / FIREBASE_CLIENT_EMAIL /
FIREBASE_PRIVATE_KEY (or FIREBASE_SERVICE_ACCOUNT_JSON, or
GOOGLE_APPLICATION_CREDENTIALS), optional ANTHROPIC_API_KEY,
CLAUDE_MODEL, SODA_APP_TOKEN, FRONTEND_ORIGIN, and the SODA_SYNC_*
scheduler settings. CRON_SECRET is only needed if an external scheduler is
used instead of the backend scheduler.
Never put FIREBASE_PRIVATE_KEY or FIREBASE_CLIENT_EMAIL in a
NEXT_PUBLIC_* variable. Service-account JSON files are gitignored.
| Collection | Document | Holds |
|---|---|---|
users/{uid} |
Firebase Auth UID | role (STAFF_OFFICE/CREW), name, email, department, active. Subcollection notifications is the crew inbox. |
serviceRequests/{srNumber} |
Chicago sr_number |
chicago (City mirror, incl. location GeoPoint and raw), dispatch (our operational state), resident (message + contact), cluster (coupling proposal). Subcollection events is the audit trail. |
syncRuns/{autoId} |
auto | Reconciliation history: status, counts, timestamps, error. |
typeBaselines/{id} |
srType__ward |
Empirical close-time distributions behind the honest ETA. |
Using the sr_number as the document ID is what makes every import
idempotent. docs/firestore-migration-map.md records how each former SQLite
table maps here.
Chicago 311 status: Open (the City's, read-only)
DispatchLoop status: EN_ROUTE (ours, operational)
A crew moving a request to EN_ROUTE does not change anything in the
City's system, and the UI shows both.
State machine (validated server-side on every transition):
UNASSIGNED → ASSIGNED → ACCEPTED → EN_ROUTE → ON_SITE → WORK_PERFORMED → COMPLETED
├→ BLOCKED → ON_SITE
└→ DEFERRED → ASSIGNED (office)
cd backend && py -3.12 -m pytest tests/ -qThe Admin SDK is mocked, so the suite needs no credentials or emulator. It covers token verification, role enforcement, inactive-user rejection, transition legality, cross-crew denial, import idempotency, the "sync never clobbers dispatch state" guarantee, GeoPoint handling, and the resident-privacy strip.
npm run reset:ops # clear operational state, keep city data + users
# or: cd backend && py -3.12 -m scripts.reset_ops_firestore --dry-runVercel hosts the Next.js layer; the FastAPI backend needs a persistent Python host (Fly.io, Railway, Render, a VM). Firestore removes the old requirement for a persistent SQLite filesystem.
| Vercel | FastAPI host |
|---|---|
NEXT_PUBLIC_BACKEND_URL, BACKEND_URL |
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY |
NEXT_PUBLIC_FIREBASE_* (6 values) |
ANTHROPIC_API_KEY, CLAUDE_MODEL, SODA_APP_TOKEN |
FRONTEND_ORIGIN |
|
SODA_SYNC_ENABLED, SODA_SYNC_INTERVAL_SECONDS, SODA_SYNC_PAGE_SIZE, SODA_SYNC_OVERLAP_SECONDS, SODA_SYNC_LOCK_TTL_SECONDS |
The FastAPI process owns the incremental SODA scheduler. Keep
SODA_SYNC_ENABLED=true on an always-on backend to run the default 120-second
loop. The Firestore coordination lock prevents overlapping sync runs across
backend instances. Free hosts that sleep cannot run an in-process scheduler
while suspended; the next backend start performs a fresh incremental sync.
Deploy firestore.rules with the Firebase CLI. FastAPI is the mutation
boundary — the rules deny all client writes and scope reads to what each role
is entitled to.
- Authorization is server-side, always. The browser's role cookie only drives routing; FastAPI re-derives the role from Firestore on every call, so a tampered cookie buys nothing.
- AI proposes, code decides. Claude parses crew commands into structured intent and drafts resident text, but never writes to Firestore. Every transition is validated against the state machine and the caller's assignment before anything is persisted.
priority = w1·hazard + w2·overdue + w3·cluster + w4·context— seebackend/app/domain/severity.py. The overdue signal compares days-open to the empirical median close time for that type, which no city tool computes today. A manual override is shown as an override, never as derived.- One event, two audiences. Events carry
internalNoteandpublicText. Free-text (AI-drafted) resident messages require office approval; structured deferral and referral text is deterministic and auto-approved./trackstrips internal notes server-side. - Every Claude-derived value is labeled with its source (
claudevskeyword/templatefallback) — no AI output is presented as ground truth.