23 KiB
Sub-project 22 — Cyclone Pipeline Agent: Design Spec
Date: 2026-06-21
Status: Draft (awaiting user review)
Branch: sp22-pipeline-agent (not yet created; will branch from main)
Aesthetic direction: n/a (backend automation; no UI surface)
1. Scope
A production-grade agent (sibling Python package cyclone-pipeline) that
automates the full submission lifecycle of an EDI 837P file through Cyclone
to the Gainwell MFT, and watches for the inbound TA1 + 999 ACKs that
confirm acceptance. The agent exists so a single human operator can drop a
file in and walk away; the agent handles the browser upload, SFTP
submission, ACK polling, and report generation end to end, and writes a
self-contained run directory that OpenClaw / Nora / any other automation
can read.
In scope:
- Sibling Python package at
/Users/openclaw/dev/cyclone-pipeline/(no shared imports with the cyclone app; talks to cyclone exclusively over HTTP). - CLI surface:
run <file>,run-batch <dir>,status <run-id>,resume <run-id>,check-835 <run-id>. - Library surface:
CyclonePipelineclass withrun(),run_batch(),resume(),check_835()for embedding in OpenClaw / Nora. - 7-phase state machine: pre-flight → upload (browser) → verify parse → SFTP submit → wait TA1 → wait 999 → scan HTML + report. The "scan HTML" and "final report" steps are combined as phase 7 (the HTML scan populates the report; the report write is phase 7's postcondition).
- Per-run output folder with
report.{md,json},run.log,run.state.json, andscreenshots/with stable filenames. - Crash-safe resume:
run.state.jsonis written after each phase's postcondition;resumeskips completed phases. - Headless + visible browser modes; default visible so screenshots are useful for debugging.
- Defensive reliability: typed exceptions, 3× retry with backoff on idempotent operations, timeouts per phase, screenshots on failure, Keychain-failure guidance.
- Test suite:
pytest+pytest-asyncio+respx(httpx mock) +pytest-playwrightfor the small browser surface. Non-browser tests run on every PR; Playwright tests gated tomain.
Out of scope (deferred):
- 835 waiting within a single run. CO Medicaid 835 remittances land
the following Monday, on the payment cycle. A run that blocks for up
to 7 days is impractical. The agent defers 835 detection to a separate
check-835 <run-id>subcommand that the operator runs on/after Monday. The report always carries thecheck-835recipe. - 277CA polling. CO Medicaid does not use 277CA claim-level ACKs;
the agent never polls for them. (The
GET /api/277ca-acksendpoint exists in the API surface but is not exercised by this agent.) - Watch-folder / daemon mode. The agent is on-demand. A cron job that
invokes
cyclone-pipeline run-batch /inbox/*.txt --stricton a 5-min schedule achieves the same effect with no daemon in the loop. - Multi-payer support. The agent targets CO Medicaid (the only
configured payer in
config/payers.yaml). A new payer means new payer-specific regex / file-naming / SFTP block — out of scope for v1. - Patient peek or other drill-down features. This is a backend automation; no UI changes.
- Editing the audit log or any existing cyclone data. The agent only reads + triggers; it never writes to cyclone's DB except through the published APIs.
2. Locked decisions
2.1 Architecture
API-first + minimal Playwright shell. The browser is used only for the
upload page (drop zone, Parse button, progress bar). Everything else
(verification, SFTP submit, scheduler control, ACK polling, claim-state
checks) hits the FastAPI directly via httpx.AsyncClient.
Rationale: the upload page is the only surface in cyclone with a real interactive flow. Clearinghouse submission is API-only; the MFT scheduler is API-only; ACK retrieval is API-only. A pure-Playwright approach would be 3–5x more code and considerably flakier on the API-driven phases, with no benefit.
2.2 Sibling package, not an extra
The agent lives at /Users/openclaw/dev/cyclone-pipeline/ with its own
pyproject.toml, not as a new optional extra on the existing cyclone
package. Rationale: the agent has Playwright as a hard dependency; we
do not want Playwright in cyclone's dev env for developers who never
invoke the agent. Separate package = clean separation of concerns.
2.3 Real round-trip, but only the parts that are time-bounded
The agent waits for TA1 (envelope ACK) and 999 (file-level ACK). It does not wait for 277CA (CO Medicaid doesn't use it) or 835 (payment cycle is next Monday). 835 detection is a separate, fast follow-up command that the operator runs on Monday.
Rationale: blocking a run for up to 7 days is impractical and ties up operator attention. The TA1 + 999 receipt is the unambiguous "your file was accepted by the payer at the file level" signal; the 835 is eventually-consistent and doesn't need a tight loop.
2.4 CO Medicaid / Gainwell specifics
- SFTP server:
mft.gainwelltechnologies.com:22 - Inbound path:
/CO XIX/PROD/coxix_prod_11525703/FromHPE - Outbound file naming:
11525703-837P-{yyyymmddhhmmssSSS}-1of1.txt(17-digit Mountain Time timestamp; matches theclearhouseconfig'soutboundtemplate inconfig/payers.yaml). - Inbound ACKs (in observed order):
- TA1 (seconds to minutes) — envelope-level
- 999 (minutes) — file-level
- HTML (sometimes) — non-X12 artifact, lands in inbound, not parsed
- 835 (next Monday) — remit
- 277CA — never used by CO Medicaid
2.5 Failure semantics
- Upload retries: 3× attempts; each attempt is a fresh page load + file re-attach + click. Screenshots on every attempt.
- SFTP submit retries: 3× attempts with
1s → 2s → 4sbackoff. The submit endpoint is POST and is not auto-retried after a network timeout (would double-submit); the retry covers409 Conflict/503 Service Unavailableonly. - TA1 timeout (5 min): if no TA1 arrives, the run is soft-fail. The submission is "in flight" — the report says so and recommends checking Gainwell directly.
- 999 timeout (15 min): same — soft-fail. Most production runs complete this phase in < 2 min.
- 999 received with status
R(rejected): hard-fail. The 999's rejection segments surface in the report with full context. - HTML detected in inbound: warn in the report; do not fail.
- 835 not present at end of run: expected; reported as
"deferred — expected next Monday" with the
check-835recipe. - Process crash (SIGKILL, power loss, etc.): the next
resumeskips completed phases by readingrun.state.json. No work is lost except the in-flight phase.
2.6 Idempotency
- The MFT outbound filename embeds a millisecond Mountain Time
timestamp, so back-to-back submissions to the same input file are
unique on the SFTP server. No
Idempotency-Keyheader is needed. - The agent captures the new
claim_id[]set from phase 3 (verify parse) and refuses to SFTP-submit if any of those claim IDs already appear in a priorprocessed_inbound_filesrow within a configurable dedup window (default 24h).--forceoverrides the dedup. - The
check-835command is fully idempotent: queryingGET /api/remittances?since=<date>is read-only.
2.7 Output shape
Every run writes a dated folder under ./runs/. The folder is
self-contained — anyone reading it has the full picture without needing
the live cyclone instance.
runs/2026-06-21-1430-001/
├── run.log # structured JSON, structlog
├── run.state.json # for resume
├── report.md # human-readable summary
├── report.json # machine-readable, OpenClaw/Nora-friendly
└── screenshots/
├── 01-preflight.png
├── 02-upload-1.png # attempts are numbered
├── 02-upload-2.png
├── 03-parse-complete.png
├── 04-submit.png
├── 05-ta1.png
├── 06-999.png
├── 07-inbound-scan.png # HTML detection screenshot
└── 99-final-inbox.png # final state of the Inbox page
On failure, the finally block adds screenshots/FAIL-{phase}.png and
a failure.md with the traceback + phase + remediation hint.
3. Module map
| File | Role |
|---|---|
pyproject.toml |
Project metadata; declares httpx, playwright, click, pydantic, structlog; dev deps: pytest, pytest-asyncio, respx, pytest-playwright |
README.md |
Install, run, embed-in-agent examples |
src/cyclone_pipeline/__init__.py |
Public API re-exports |
src/cyclone_pipeline/__main__.py |
python -m cyclone_pipeline run … |
src/cyclone_pipeline/cli.py |
Click CLI: run, run-batch, status, resume, check-835 |
src/cyclone_pipeline/pipeline.py |
CyclonePipeline orchestrator; 7-phase state machine |
src/cyclone_pipeline/api_client.py |
httpx.AsyncClient wrapper with typed methods |
src/cyclone_pipeline/browser.py |
Playwright UploadPage class for the upload UI |
src/cyclone_pipeline/waiters.py |
Reusable wait primitives (claim state, ACK arrival) |
src/cyclone_pipeline/state.py |
RunState dataclass + run.state.json writer/reader |
src/cyclone_pipeline/screenshots.py |
Dated folder + stable filename helper |
src/cyclone_pipeline/report.py |
report.md + report.json writer |
src/cyclone_pipeline/selectors.py |
Centralized Playwright selectors with fallbacks |
src/cyclone_pipeline/exceptions.py |
Typed errors: UploadError, ParseError, SubmitError, AckTimeoutError, SchedulerNotRunningError, IdempotencyError |
src/cyclone_pipeline/logging_setup.py |
structlog config matching cyclone's SP18 JSON style |
src/cyclone_pipeline/check_835.py |
The deferred 835 detector (pure API, no browser) |
tests/conftest.py |
Shared fixtures: fake API server, mock browser, fake clock |
tests/test_api_client.py |
Typed responses, retry behavior, timeouts (respx) |
tests/test_waiters.py |
Backoff, timeout, predicate resolution (fake clock) |
tests/test_browser.py |
safe_click, attach_file, progress wait (pytest-playwright) |
tests/test_pipeline.py |
End-to-end orchestration, state machine, resume (mocks) |
tests/test_report.py |
Markdown + JSON shape, screenshot paths (golden files) |
tests/test_cli.py |
run, run-batch, resume, check-835 (Click's CliRunner) |
tests/test_check_835.py |
check-835 happy path + "835 not yet present" path |
tests/fixtures/sample_837p.edi |
A small valid 837P file for tests (copied from cyclone's tests/fixtures/minimal_837p.txt) |
4. Pipeline phases
The state machine in pipeline.py advances through 7 phases. Each phase
has: precondition, execute, postcondition, retry_policy, and a
side-effect of writing run.state.json + a screenshot at the end of a
successful attempt.
Phase 1 — Pre-flight (API)
GET /api/health→ expectstatus == "ok"anddb.ok == true.GET /api/admin/scheduler/status→ record scheduler state (running/stopped). Ifstopped, the agent will callPOST /api/admin/scheduler/tickper-iteration in phase 5/6 instead of relying on the polling loop.- Verify the browser-side URL is reachable:
HEAD {browser_base}/viahttpx(cheap, no rendering). If the frontend dev server isn't up, fail-fast withBrowserNotReachableErrorrather than discovering this in phase 2. - Capture the API base URL + browser base URL for the report.
- Screenshot the Upload page in its empty state →
screenshots/01-preflight.png.
Phase 2 — Upload (Playwright)
chromium.launch(headless=VISIBLE)(configurable).page.goto(f"{browser_base}/upload").expect(drop_zone).to_be_visible(timeout=10s).set_input_files("input[type=file]", file_path)(the hidden input behind the drop zone;set_input_filesworks on hidden inputs).expect(file_name_display).to_have_text(file.name).safe_click("button:has-text('Parse')").wait_for_progress_complete()— polls the progress bar (text_contentcontaining"100%") or the success toast (text_contentcontaining"Parsed N claims"), whichever comes first, with a 5 min ceiling.- Screenshots:
screenshots/02-upload-{attempt}.pngper attempt.
Phase 3 — Verify parse (API)
- The browser-side toast gives the parsed claim count; capture it.
GET /api/claims?since=<phase1_start_ts>→ filter to claims whosesubmission_date >= phase1_start_ts; these are the new ones.- Collect the set of new
claim_idvalues. - If 0 new claims after a 30s poll, fail with
ParseError.
Phase 4 — SFTP submit (API)
- Dedup check: query
GET /api/admin/scheduler/processed-filesfor the past 24h; if any outbound filename pattern matches a recent submission, fail withIdempotencyErrorunless--force. POST /api/clearhouse/submitwith{payer_id: "CO_TXIX", transaction_type: "837P"}. The body is small; the server picks the batch from the newly-parsed claims.- Expect 200; if 5xx with a "keychain" hint, raise
SubmitErrorwith the macOS Keychain setup recipe inline. - Screenshot:
screenshots/04-submit.png(the Inbox page right after submit, showing the batch in the lane).
Phase 5 — Wait for TA1 (API poll)
GET /api/ta1-acks?since=<phase4_ts>in a loop, every 5s, with a 5 min ceiling.- Match by
isa13(the control number in the submitted interchange envelope; surfaced in the TA1 record). - If scheduler is
stopped, callPOST /api/admin/scheduler/tickbetween polls to manually drive the MFT fetch. - On hit: capture
ta1_id,accepted(vsrejected), screenshot the TA1 detail in the Acks page →screenshots/05-ta1.png. - On timeout: soft-fail; record
ta1_status: "timeout".
Phase 6 — Wait for 999 (API poll)
GET /api/acks?since=<phase4_ts>in a loop, every 10s, with a 15 min ceiling.- Match by
original_batch_id(or, if not available, byfunctional_group_control_numberfrom the submitted file's GS06). - On hit: capture
ack_id,status(A= accepted,R= rejected,E= rejected with errors), the rejection segment details if rejected. - On
A: screenshot the Acks page →screenshots/06-999.png. - On
R: hard-fail; recordack.status == "rejected", the rejection segments, and the recommended remediation in the report. - On timeout: soft-fail; record
ack_999_status: "timeout".
Phase 7 — Scan for HTML + final report (API + local)
GET /api/admin/scheduler/processed-files?since=<phase4_ts>— list inbound files the MFT scheduler saw during the run. If any hasfile_type == "html"or an unrecognized extension, log a WARN, capture the filename, and add it to the report's "Detected artifacts" section.- Generate
report.mdandreport.json(see §5). - Screenshot the Inbox page →
screenshots/99-final-inbox.png. - Emit a one-line summary on stdout for shell pipelines:
RESULT: PASS run=2026-06-21-1430-001 ta1=TA1-42 999=999-43 835=deferred.
5. Output format
5.1 report.md (human)
# Cyclone pipeline run — 2026-06-21 14:30 PDT
| Field | Value |
|---------------|------------------------------------|
| Run id | 2026-06-21-1430-001 |
| Input | /path/to/axiscare-837p.txt (12.4 KB) |
| Result | PASS — TA1 + 999 received, 835 deferred |
| Duration | 2m 14s |
## Phase summary
| Phase | Status | Duration | Detail |
|------------------------|--------|----------|-----------------------------------------|
| 1. Pre-flight | OK | 0.4s | health.ok, scheduler running |
| 2. Upload | OK | 2.1s | 1 attempt, 17 claims parsed |
| 3. Verify parse | OK | 0.8s | claim_ids: CLM-001 … CLM-017 |
| 4. SFTP submit | OK | 4.3s | filename 11525703-837P-20260621143012345-1of1.txt |
| 5. Wait TA1 | OK | 12s | ack id: TA1-42, accepted |
| 6. Wait 999 | OK | 1m 14s | ack id: 999-43, status Accepted |
| 7. Scan HTML + report | WARN | 0.2s | 1 HTML file in inbound, skipped; report.md + report.json written |
## Inbound artifacts detected
- 1 HTML file (`gainwell_status_20260621.htm`) — not parsed, not a failure.
## Expected next steps
- **835 expected Monday** (2026-06-28; computed as next Monday in
Mountain Time at the moment of report generation; if today is
Monday before noon, expected same day, otherwise next Monday).
Re-run: `cyclone-pipeline check-835 2026-06-21-1430-001`
## Artifacts
- screenshots/01-preflight.png
- screenshots/02-upload-1.png
- screenshots/03-parse-complete.png
- screenshots/04-submit.png
- screenshots/05-ta1.png
- screenshots/06-999.png
- screenshots/99-final-inbox.png
5.2 report.json (machine)
{
"run_id": "2026-06-21-1430-001",
"input": {
"path": "/path/to/axiscare-837p.txt",
"size_bytes": 12700,
"sha256": "…"
},
"result": "pass",
"result_detail": "TA1 + 999 received, 835 deferred",
"started_at": "2026-06-21T14:30:00-06:00",
"finished_at": "2026-06-21T14:32:14-06:00",
"duration_s": 134,
"phases": [
{"n": 1, "name": "preflight", "status": "ok", "duration_s": 0.4},
{"n": 2, "name": "upload", "status": "ok", "duration_s": 2.1, "attempts": 1},
{"n": 3, "name": "verify_parse", "status": "ok", "duration_s": 0.8, "claim_ids": ["CLM-001", "…"]},
{"n": 4, "name": "submit", "status": "ok", "duration_s": 4.3, "filename": "11525703-837P-…"},
{"n": 5, "name": "wait_ta1", "status": "ok", "duration_s": 12, "ta1_id": "TA1-42", "accepted": true},
{"n": 6, "name": "wait_999", "status": "ok", "duration_s": 74, "ack_999_id": "999-43", "status": "A"},
{"n": 7, "name": "scan_and_report", "status": "warn", "duration_s": 0.2, "html_files": ["gainwell_status_20260621.htm"], "report_paths": {"md": "report.md", "json": "report.json"}}
],
"artifacts": {
"screenshots": ["01-preflight.png", "…", "99-final-inbox.png"],
"report_md": "report.md",
"report_json": "report.json",
"run_log": "run.log"
},
"next_steps": {
"check_835": "cyclone-pipeline check-835 2026-06-21-1430-001",
"expected_835_by": "2026-06-28"
}
}
On soft-fail (TA1 or 999 timeout) or hard-fail (999 rejected), the
result is "soft_fail" or "hard_fail", the offending phase gets
status: "timeout" / "rejected", and a failure block is added
with the traceback, recommended remediation, and the failure.md path.
5.3 Exit codes
| Code | Meaning |
|---|---|
| 0 | PASS — TA1 + 999 both received, both accepted |
| 2 | SOFT_FAIL — TA1 or 999 timed out; submission in flight, manual check needed |
| 3 | HARD_FAIL — 999 rejected, upload failed irrecoverably, or submit refused |
| 4 | USAGE — bad CLI args, missing file, backend unreachable after retries |
This mirrors common CI conventions so OpenClaw / Nora / shell scripts
can branch on $? without parsing JSON.
6. Library API (for OpenClaw / Nora)
from cyclone_pipeline import CyclonePipeline, RunResult
async def main():
pipeline = CyclonePipeline(
api_base="http://127.0.0.1:8000",
browser_base="http://127.0.0.1:5173",
run_dir="./runs",
headless=False,
timeouts={"ta1_s": 300, "ack_999_s": 900},
)
result: RunResult = await pipeline.run(file_path="axiscare-837p.txt")
# result.outcome: "pass" | "soft_fail" | "hard_fail" | "usage"
# result.exit_code: int (0/2/3/4; mirrors CLI exit codes in §5.3)
# result.report: RunReport (Pydantic model; serializes to report.json)
# result.run_id: str
# Or batch:
results = await pipeline.run_batch(file_paths=[...], strict=False)
# Or resume:
result = await pipeline.resume(run_id="2026-06-21-1430-001")
# Or check 835 later:
ack = await pipeline.check_835(run_id="2026-06-21-1430-001")
# ack.found, ack.remittance_id, ack.total_paid, ...
RunResult is a Pydantic model so OpenClaw / Nora can serialize it
straight to JSON without any extra glue.
7. Tests
| Test file | Strategy |
|---|---|
tests/test_api_client.py |
respx mock; verify typed responses, retry-on-idempotent, no-retry-on-POST, timeout behavior |
tests/test_waiters.py |
Inject fake clock + fake state; verify backoff math, timeout, predicate resolution |
tests/test_browser.py |
pytest-playwright against a Vite dev server spun up in CI; covers safe_click, set_input_files, progress wait |
tests/test_pipeline.py |
Mock both api_client and browser; drive fake "happy path" through every phase + "phase 5 timeout" + "phase 3 parse error" + "phase 6 reject" |
tests/test_report.py |
Golden-file comparison for report.md and report.json |
tests/test_cli.py |
Click's CliRunner; covers all 5 subcommands + error paths |
tests/test_check_835.py |
Mock GET /api/remittances?since=…; happy path + "835 not yet present" path |
CI: GitHub Actions matrix on Python 3.11 + 3.12. Non-Playwright tests
run on every PR; Playwright tests gated to main push (the Vite
dev-server + chromium setup is too slow for every PR).
Coverage target: 90% line coverage on pipeline.py, 85% on the rest.
browser.py is exempt from the strict target (Playwright's own
assertion failures are the real test).
8. Files to add
pyproject.toml— package metadata, deps, dev deps, scripts entryREADME.md— install, run, embed-in-agent examples- 14 source files under
src/cyclone_pipeline/listed in §3 - 8 test files under
tests/listed in §3 tests/fixtures/sample_837p.edi— small valid 837P for tests (copied from cyclone'stests/fixtures/minimal_837p.txt)
Total: 24 files (1 pyproject.toml, 1 README.md, 14 src .py, 8
test .py, 1 fixture).
9. Files NOT touched
- No changes to the cyclone repo. The agent is a strict HTTP consumer of cyclone's published API.
- No changes to cyclone's DB schema, API, CLI, or UI.
- No new dependencies added to cyclone's
pyproject.toml.
10. Future work (explicit non-goals for v1)
- Watch-folder daemon —
cyclone-pipeline watch /inbox/*.txtthat runs continuously and submits files as they appear. Achievable with the currentrun-batch+ a 1-line shell loop; the daemon is just UX. - Multi-payer support — adding a new payer (e.g., a second Medicaid MCO) means new payer-specific SFTP block, file naming template, and ID-matching strategy. Worth doing once a second payer is on the books.
- Slack / email notification on completion —
report.jsonis the integration point; a 50-LOC notifier could post a summary. v1 just prints the one-line summary and writes the file. - Pushing reports to S3 / a share — same as above;
report.jsonis the integration point. - Web UI for run history — out of scope; OpenClaw / Nora can read
the
runs/directory and surface the JSON. - 277CA polling for payers that use it — straightforward to add
behind a
--payer-configflag once a second payer is configured. 277CA support in cyclone is already shipped; only the agent's wait list changes.
11. Open questions (none for v1)
None. The design is locked pending user review of this spec.