Adds three live-tail streaming endpoints that emit an NDJSON snapshot
then forward new event-bus events as they arrive, with a 15s idle
heartbeat (overridable via CYCLONE_TAIL_HEARTBEAT_S for tests).
Each endpoint:
1. yields a snapshot of existing rows as {"type":"item","data":<row>}
2. terminates the snapshot with {"type":"snapshot_end","data":{"count":N}}
3. subscribes to its event kind and forwards each new event as an
{"type":"item","data":<event>} line
4. emits a {"type":"heartbeat","data":{"ts":<iso>}} line every
CYCLONE_TAIL_HEARTBEAT_S seconds when idle
5. checks request.is_disconnected() before each yield and unsubscribes
from the bus on cleanup so a closed stream releases its queue
The shared tail loop lives in api._tail_events, which polls
bus.subscribe_raw()'s queue directly instead of using the bus's
async-iterator wrapper — wait_for on an async generator cancels the
inner future on timeout, which poisons subsequent __anext__ calls with
StopAsyncIteration. Queue.get() is idempotent under cancellation, so
heartbeats don't break the subscription.
EventBus gains an unsubscribe(queue, kinds) method (idempotent) so
the tail loop can release its queue in a try/finally. The disconnect
test asserts the subscriber list is empty after the body iterator is
closed, validating no queue leak per open stream.
Tests in test_api_stream_live.py: 8 tests covering snapshot shape,
post-snapshot publish, heartbeat timing, multi-item snapshots, and
client disconnect cleanup. Plus 2 tests in test_pubsub.py for the
new unsubscribe method.
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
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
│ │ ├── store.py # InMemoryStore, mappers
│ │ ├── __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_parse_persists.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ └── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│ ├── store/ # zustand sample-data + parsed-batches store
│ └── 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, and the first half of 4 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, real-time streaming of NDJSON GET responses, 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 (
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.
License
No license file yet; this is internal-use software. Add a LICENSE file
when one is decided.