Cyclone
A self-hosted EDI claims management suite for a single billing office. Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 and 005010X221A1), with a local-only FastAPI backend and a React UI for browsing, filtering, and inspecting the parsed data.
Local-only on purpose: binds to 127.0.0.1, no auth, no internet exposure.
Built for one operator, one machine, one trading partner (Colorado
Medicaid, currently).
Install
# Backend (Python 3.11+)
cd backend
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
# Frontend (Node 20+)
cd ..
npm install
Dev
Two terminals:
# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve
# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)
# Terminal 2 — frontend
npm run dev
# (Vite on http://localhost:5173)
Then open http://localhost:5173. Drop an .txt 837P or 835 file on the
Upload page; navigate to Claims, Remittances, Providers, or Activity to
see the parsed data.
The frontend reads its backend URL from VITE_API_BASE_URL (default
empty). Create a .env.local at the repo root with:
VITE_API_BASE_URL=http://127.0.0.1:8000
Without that, the UI falls back to its in-memory sample store via the
existing data adapter (parses are disabled).
Test
# Backend
cd backend && .venv/bin/pytest
# Frontend type-check + build
npm run typecheck
npm run build
# Frontend unit tests
npm test
Live updates
The Claims, Remittances, and Activity pages stay current without
manual refresh. The backend publishes an internal event on every
store write, the page opens a streaming HTTP connection to the
matching /api/<resource>/stream endpoint, and new rows are
appended to the table the moment they hit the database.
Wire format
Each stream endpoint emits newline-delimited JSON. The first batch
is the snapshot of currently-known rows; after that comes
snapshot_end with the count, then the live events.
{"type":"item","data":{"id":"CLM-1", "...":"..."}}
{"type":"item","data":{"id":"CLM-2", "...":"..."}}
{"type":"snapshot_end","data":{"count":2}}
{"type":"item","data":{"id":"CLM-3", "...":"..."}} ← live
{"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} ← idle keep-alive
Lines are {"type": ..., "data": ...}; known types are item,
snapshot_end, heartbeat, and (rare) item_dropped /
error. Heartbeats keep the connection alive when nothing is
happening — clients flip to stalled after 30s of total silence
(heartbeat or otherwise) and surface a ↻ Reconnect button.
Endpoints
| Method | Path | Subscribes to | Default sort |
|---|---|---|---|
| GET | /api/claims/stream |
claim_written |
-submission_date |
| GET | /api/remittances/stream |
remittance_written |
-received_date |
| GET | /api/activity/stream |
activity_recorded |
-timestamp (limit 50) |
All three accept the same query params as their non-streaming
counterparts (status, payer, date_from, …) so a frontend can
swap a one-shot fetch for a tail with no URL surgery. Responses
are Content-Type: application/x-ndjson.
Status pill
The pill in each toolbar reflects the current connection state:
| Status | Badge variant | What it means |
|---|---|---|
live |
success | snapshot received, listening for new events |
connecting |
warning | opening the stream (initial mount) |
reconnecting |
warning | previous attempt failed, backing off before retry |
stalled |
destructive | no event (including heartbeat) for 30s — click ↻ Reconnect |
error |
destructive | stream errored — click ↻ Reconnect |
closed |
destructive | page unmounted |
The reconnect button is only shown on stalled and error. The
backoff schedule on error is 1s → 2s → 4s → 8s → 16s → 30s capped.
Knobs
| Env var | Default | Effect |
|---|---|---|
CYCLONE_TAIL_HEARTBEAT_S |
15 |
Idle heartbeat interval (seconds). Lower it for tests. |
Persistence
Parsed batches, claims, remittances, matches, and activity events are
stored in a SQLite file at ~/.local/share/cyclone/cyclone.db by
default. The directory is auto-created on first run.
To use a different location, set CYCLONE_DB_URL:
export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db
# or
export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone
Backup
sqlite3 ~/.local/share/cyclone/cyclone.db ".backup /path/to/backup.db"
This is safe to run while the backend is running (uses SQLite's online backup API).
Project layout
.
├── backend/
│ ├── src/cyclone/
│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
│ │ ├── store.py # InMemoryStore, mappers, publish-on-write
│ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
│ └── tests/
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
│ ├── test_api.py # parse-837/835 round-trip
│ ├── test_api_gets.py # 6 GET endpoints
│ ├── test_store.py # store + mappers + iterators
│ ├── test_api_streaming.py
│ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
│ ├── test_pubsub.py # EventBus + subscribe/unsubscribe
│ └── test_api_parse_persists.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ │ # + useTailStream, useMergedTail (live tail)
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│ │ # + tail-stream.ts (NDJSON parser)
│ ├── store/ # zustand sample-data + parsed-batches store
│ │ # + tail-store.ts (FIFO-capped live tail slices)
│ └── types/ # shared TS types
├── docs/
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
│ └── superpowers/plans/ # implementation plan
├── tailwind.config.js # shimmer, scan, row-flash keyframes
└── package.json
Roadmap
Sub-projects 2, 3, 4, and 5 are shipped. Next up:
- Sub-project 3 (shipped) — More 837P/835 features.
- 837P validation rules: R034 enforces
REF*G1on frequency-code 7/8 claims; R035 enforcesBHT06transaction-type-code is in the allowed set per payer config. - 835 CAS deep-parsing: every CAS adjustment now surfaces with its
CARC reason code + label (e.g.
CO-29: The time limit for filing has expired), surfaced viaGET /api/remittances/{id}and rendered as an expansion row on the Remittances page. - 999 ACK transaction set: full inbound parser + outbound
serializer. Auto-generated on 837 ingest when
?ack=trueis passed toPOST /api/parse-837. Persisted to theackstable, browsable on a new/ackspage, and downloadable as the regenerated raw 999 text. - 270/271 eligibility (API-only):
POST /api/eligibility/requestbuilds a 270 from a JSON payload (subscriber, provider, payer, service type);POST /api/eligibility/parse-271ingests the response and returns structuredcoverage_benefits.
- 837P validation rules: R034 enforces
- Sub-project 4 (partially shipped) — Frontend features.
- Per-claim detail drawer (shipped): click any row on the Claims
page to open a side-panel drawer with the full claim context —
state + amount header, validation panel, service lines, diagnoses,
parties (billing provider, subscriber, payer), raw X12 segments,
matched remittance summary, and a vertical state-history timeline.
URL is synced (
?claim=...) so links and back-button restore the drawer. Keyboard nav:j/kmove between claims,esccloses,?opens the cheatsheet overlay. Skeleton + error + not-found (404) states are all distinct. Powered by a new backend endpointGET /api/claims/{claim_id}that returns the full drawer payload in a single round-trip. - Remaining SP4: batch diff view, advanced filters (date range, multi-status, saved filter sets).
- Per-claim detail drawer (shipped): click any row on the Claims
page to open a side-panel drawer with the full claim context —
state + amount header, validation panel, service lines, diagnoses,
parties (billing provider, subscriber, payer), raw X12 segments,
matched remittance summary, and a vertical state-history timeline.
URL is synced (
- Sub-project 5 (shipped) — Live updates.
- The Claims, Remittances, and Activity pages stream new rows in
real time over Server-Sent Events encoded as newline-delimited
JSON. The backend publishes a
claim_written/remittance_written/activity_recordedevent on every store write; the frontend opens an HTTP stream and dispatches each event into a per-resource Zustand store that the page reads on top of its base query results. - A small status pill in each toolbar surfaces the connection
state —
live/connecting/reconnecting/stalled/error/closed— and offers a manual ↻ Reconnect button onstalledorerror. Stale connections (no event for 30s, heartbeat included) flip tostalledautomatically; the back-off ladder on errors is 1s → 2s → 4s → 8s → 16s → 30s capped. See the "Live updates" section below for details.
- The Claims, Remittances, and Activity pages stream new rows in
real time over Server-Sent Events encoded as newline-delimited
JSON. The backend publishes a
SP3 endpoints
POST /api/parse-837?ack=true— existing 837 parse, plus optional auto-generated 999 ACK persisted alongside the batch.POST /api/parse-999— parse an inbound 999 ACK and persist it.GET /api/acks— list ACKs.GET /api/acks/{id}— ACK detail, including the regeneratedraw_999_text.POST /api/eligibility/request— build a 270 from JSON.POST /api/eligibility/parse-271— ingest a 271 and return parsed coverage benefits.
The UI gains a new Acks page (sidebar entry) that lists persisted ACKs and lets you download the regenerated 999 text.
SP4 endpoints (claim drawer)
GET /api/claims/{claim_id}— full claim context for the drawer payload: header fields, diagnoses, service lines, parties (billing provider / subscriber / payer), validation result, raw X12 segments, matched remittance summary (ornull), and the claim's state history (ordered newest-first). 404 with a structured{ "error": "Not found", "detail": "Claim {id} not found" }body when the claim doesn't exist — distinct from a transient fetch failure so the UI can render a dedicated not-found state.
SP5 endpoints (live updates)
GET /api/claims/stream— NDJSON stream of claim events. Emits a snapshot (filtered by the same query params asGET /api/claims), thensnapshot_end, then liveclaim_writtenevents as they arrive, with a 15s idle heartbeat.GET /api/remittances/stream— same shape for remittances (subscribes toremittance_written, default sort-received_date).GET /api/activity/stream— same shape for activity events (subscribes toactivity_recorded, defaultlimit=50).
License
No license file yet; this is internal-use software. Add a LICENSE file
when one is decided.