# Oxynet CPET API: complete integration guide Oxynet analyses cardiopulmonary exercise tests. It parses raw metabolimeter exports, detects ventilatory thresholds with deep-learning models trained on expert-labelled tests, analyses exercise oscillatory ventilation (EOV) and the ventilatory control loop behind it, reads fat and carbohydrate use from gas exchange, and computes derived CPET quantities. Base URL: https://app.oxynet.net Auth: `X-API-Key: ` header on every request. Status: research software. Output is not a clinical diagnosis. ================================================================================ 0. IF SOMEONE ASKS YOU TO "CONNECT OXYNET", DO THIS ================================================================================ **There is nothing to install and no server to register.** If you can make an HTTP request, you can already use Oxynet. Setting up an MCP connector is an optional convenience for repeat use, not a prerequisite. Do not make the user do it before their first analysis. Follow these four steps in order. STEP 1. Ask for the key, once. "Paste your Oxynet API key (it starts with sk_live_)." Treat it as a secret: keep it in a shell variable or in memory for the session. Do NOT write it into a file, a config, or a command line that gets logged, and do not repeat it back in your reply. STEP 2. Verify it works. export OXYNET_API_KEY= curl -s -H "X-API-Key: $OXYNET_API_KEY" https://app.oxynet.net/v1/capabilities A 200 with `key_name` means you are connected. Tell the user which analyses their key includes (the `analyses` field): thresholds, oscillatory breathing and substrate use are licensed separately, so this is worth stating up front. STEP 3. You are connected. Say so, and ask which file they want analysed. When they name a PATH, do not read the file into this conversation. Ask Oxynet for an upload ticket and let the shell do the transfer: curl -s -X POST -H "X-API-Key: $OXYNET_API_KEY" \ -H 'Content-Type: application/json' -d '{}' https://app.oxynet.net/v1/uploads then run the `curl` line it returns with the real path substituted. Only the cpet_id comes back to you. **Ask. Do not go looking for a file yourself.** Never search the user's disk or repository for something that looks like a CPET and upload it on your own initiative. A CPET is health data, exports routinely carry the patient's name in the filename, and sending one to an external service without being asked is not yours to decide. Agent runtimes block this, and they are right to. When the user names a file, that is the authorisation, upload that one, unchanged. Before you upload, say this once: "Oxynet needs no patient identifiers. If this export carries a name or date of birth, anonymise it first. A neutral filename is enough for most formats, since Oxynet ignores the name and never stores the bytes." If they want to see it work before handing over anything of theirs: curl -s -H "X-API-Key: $OXYNET_API_KEY" https://app.oxynet.net/v1/sample returns a synthetic recording as {"filename", "content"}. POST those two fields to https://app.oxynet.net/v1/cpet/content and analyse the cpet_id you get back. No patient data involved, and it exercises the identical path. STEP 4. OPTIONAL, only if they will use Oxynet repeatedly. Offer to set up a persistent connection so the tools are always available. Then, and only then, use section 6. Ask before changing any configuration on their machine, because writing an API key into an agent config file is a change the user should approve explicitly, and many agent runtimes will refuse it unless they have. WHAT IF YOU CANNOT MAKE HTTP REQUESTS? Some assistants cannot call arbitrary URLs without a configured connector. If that is you, skip to section 6 and set one up. For ChatGPT that is a Custom GPT Action, which takes about a minute and needs only the schema URL and the key. ================================================================================ 1. THE ONE RULE ================================================================================ Hand Oxynet the vendor file. Work with the handle it returns. Do NOT read the CPET file, map its columns, or convert its units yourself. Oxynet has 23 vendor parsers with automatic format detection (Cosmed, Cortex / MetaSoft, Vyntus, VO2Master, Parvo-style exports and more) and knows each one's unit conventions, per-phase clock resets and decimal-separator defects. A VO2 column in L/min transcribed as mL/min is wrong by 1000 and nothing downstream will notice. Uploading returns a `cpet_id`. Every later call refers to the recording by that id, so the breath-by-breath signal never travels through your context. A short test is ~12,000 tokens of JSON; the summary you get instead is a few hundred. ================================================================================ 2. ENDPOINTS ================================================================================ POST /v1/uploads **The right call when the file is on disk.** Returns a one-shot `upload_url` (and a ready-to-run `curl` line) valid for 5 minutes. POST the file to it as multipart field `file`, with NO API key. The ticket is the credential. The response is the same summary as POST /v1/cpet, including the cpet_id. This exists so the bytes never travel through an agent's context. A 360 KB export inlined is roughly 90,000 tokens; through a ticket it costs none, and the file goes straight from disk to Oxynet. Body: {"count": 12} mints twelve at once, returned as `upload_urls`. **For a folder, do this rather than looping.** One ticket per request cost three steps per file before a single analysis had run; a cohort of twelve went from 36 round trips to 14. Each URL still accepts exactly one file and they expire together, so mint them when you are ready to upload. POST /v1/cpet Multipart upload (field name `file`). Use from your own code. POST /v1/cpet/content JSON upload, for callers that cannot send multipart (ChatGPT Actions, Gemini function calling). Body: {"filename": "test.csv", <- MUST keep the real extension "content": "", <- CSV / TXT / XML / JSON "content_base64": "", <- use instead for XLS / XLSX "retain_hours": 24} <- optional Returns: cpet_id, detected format, channels present and their coverage, the sampling interval the file arrived at, duration, flags. GET /v1/cpet/{cpet_id} The same summary again. Also returns `embedded_labels`, thresholds the metabolimeter itself recorded, when the export carries them. GET /v1/cpet/{cpet_id}/series?channels=VE,PetCO2&max_points=400 Downsampled signals FOR PLOTTING ONLY. Never compute from these; every measurement runs server-side on the full-resolution record. DELETE /v1/cpet/{cpet_id} Delete now rather than waiting for expiry. POST /v1/cpet/{cpet_id}/analyze Body: {"analyses": ["vt", "eov", "substrate"], "medications": []} "substrate" returns fat and carbohydrate oxidation rates stage by stage, FATMAX, the crossover to carbohydrate and gross efficiency. It needs VO2 and VCO2; a load channel adds a work-rate axis and the efficiency, and without one it runs against %VO2peak alone rather than refusing. Three things in the result must be passed on rather than rounded off: a FATMAX reported as not found means it lies outside the range the protocol covered, a ramp protocol means nothing reached steady state and FATMAX reads high, and windows above RER 1.0 carry a bound rather than a measurement. For the headline numbers alone, without the stage means and fitted curves, use the `substrate_summary` metric instead. To pick a model, send `"model": "rosie"` (`"model_name"` also works). An UNRECOGNISED field is now a 422 naming it, not a silent drop, so a typo can no longer run on the default model behind a 200. Omit the model entirely unless the user asked for a specific one. The server default matches what the Oxynet web application uses. Returns one envelope per analysis: status, findings, quality, notes, provenance (model, version, analysis version, timestamp). An analysis that cannot run does not stop the others. POST /v1/cpet/{cpet_id}/compute Body: {"metrics": ["vo2max", "ve_vco2_slope", "gas_quality"]} See GET /v1/metrics for the full registry with each entry's requirements. GET /v1/sample A synthetic CPET as {"filename", "content"}. POST those two fields to /v1/cpet/content to demo the whole flow with no patient data. `?oscillating=true` for one that exercises the EOV detector. Synthetic: not a real person, and not validation data. GET /v1/capabilities What this key may do: products, models, metrics, limits. GET /v1/metrics The derived-quantity registry. GET /v1/formats Every supported format with a LABEL saying what it actually is. The ids are frozen and are not self-explanatory: they mix vendor software (`cosmed`), one site's export profile of it (`brescia` is Cosmed Omnia with the subject block stripped) and Oxynet's own files (`exercise_threshold_app` is our canonical JSON). Also returns `if_unsupported`: the shape to convert a file INTO when nothing can read it, which is the only case where converting a file yourself is correct. GET /v1/openapi.json OpenAPI 3.0 schema. Public; no key needed to fetch it. ================================================================================ 2b. A FOLDER OF RECORDINGS ================================================================================ The case this API is shaped for, and the one worth getting right. A cohort is not "the single-file flow, many times": done that way it is three steps per file before a single result comes back. 1. Mint every ticket in ONE call: POST /v1/uploads {"count": 12} 2. POST every file in ONE shell command, pairing each file with one URL: files=(/data/*.xls) urls=(URL1 URL2 ...) # from upload_urls, same order for i in "${!files[@]}"; do curl -sS -F "file=@${files[$i]}" "${urls[$i]}" done 3. Analyse each cpet_id and keep only the FIELDS YOU NEED for the question. Twelve files cost 14 steps this way against 36, and about 16,000 tokens of results. **Never accumulate raw signals.** `/series` is for drawing one picture, not for holding a cohort in context: twelve recordings of signals is hundreds of thousands of tokens and every measurement is available server-side anyway. **Reason over the structured results, not over the physiology.** Comparing two visits, ranking patients by change, building a table, spotting outliers: that is your work, and the numbers to do it with come back typed and versioned. What is NOT your work is deciding where a threshold is or whether an oscillation is real. **Different files are not automatically comparable.** Check `source.format` and `sampling`, and read the `notes` on each result. A ramp and a staged test place FATMAX differently; a recording with no PetCO2 cannot be scored for oscillation at all, and a refusal for one file is not a gap in the cohort but a fact about that file. ================================================================================ 3. DERIVED QUANTITIES ================================================================================ vo2max, vemax, rermax Peak values from a 20 s rolling average of the full pre-trim signal, so a peak reached after the ventilatory maximum is not lost. o2_pulse Peak VO2 per heartbeat. Needs HR. ve_vco2_slope Slope over the first 25/50/75/100 % of exercise, each with a bootstrap 95 % interval. Reported as a PROFILE, not one number: a slope that climbs across a test is a different finding from a flat one. Quote the profile. gas_quality Cross-channel checks on the gas analysis. `n_checks` says how many were runnable. An absent check is not a passed one. sampling_adequacy Which oscillation periods this file's sampling can resolve. Coarse sampling narrows the band; it does not invalidate the recording. clock_integrity Whether the clock is monotonic or had to be rebuilt from per-phase resets. ================================================================================ 4. HOW TO READ RESULTS ================================================================================ * THERE IS NO CONFIDENCE SCORE, and you must not invent one. Nothing in Oxynet is calibrated against clinical outcomes. `quality` (good / acceptable / poor / unusable) describes the RECORDING, not the certainty of the answer. * A REFUSAL IS INFORMATION. `status: not_analysable`, or a metric returned as `unavailable`, always states why. Report the reason. Do not work around a missing channel by computing something else and presenting it as equivalent. * NEVER INFER AN ABSENT FINDING FROM AN ABSENT NOTE. * For oscillatory ventilation, three results answer different questions and merging them is the commonest misreading: - GRADE and BURDEN: how MUCH oscillation there was. - MARGIN.LOOP_GAIN: how CLOSE the control loop is to oscillating on its own. 0 stable, 1 self-sustaining. Defined even when the grade is "none", which is its value: a grade saturates and this does not. - LOOP: the factorisation, when an oscillation is clear enough to invert. g = plant_gain x controller_gain. `delay_s` comes from the phase and is NOT a factor of g. * `margin.corroborated` false means end-tidal CO2 did not oppose ventilation at that period, so whatever is resonating is not the chemoreflex loop. Report it as ventilatory variability, not instability. * `delay_s` is the whole lag in the return path, not a circulation time. Calling it "circulation time" is an interpretation, not a measurement. * `co2_amplitude_suspect` means the end-tidal CO2 waveform was distorted while ventilation stayed clean, which means a clipped sample. The bias has a direction: plant gain becomes a LOWER bound and controller gain an UPPER one. Report bounds. * `clarity` is how many times the oscillation's wavelet power exceeds the fitted background. It is a multiple, not a probability. ================================================================================ 5. ERRORS ================================================================================ Every error returns {"error": CODE, "message": ..., "context": {...}}. UNSUPPORTED_FORMAT Not recognised as any known metabolimeter export. PARSER_ERROR Format recognised, contents unreadable. MISSING_CHANNEL A required signal is absent. `context` lists what was needed and what was present. Use it to decide whether a different analysis is possible. INSUFFICIENT_DURATION Too short for this analysis; `context` gives the minimum. SAMPLING_TOO_COARSE Averaging interval too long to resolve the target band. PRODUCT_NOT_ENABLED This key does not include that analysis. Thresholds and oscillatory breathing are licensed separately. TIER_INSUFFICIENT The requested model needs a higher tier. CPET_NOT_FOUND Unknown id, or the recording passed its expiry. RATE_LIMITED Back off; honour the Retry-After header. QUOTA_EXCEEDED Monthly analysis limit reached. FILE_TOO_LARGE Over 25 MB. Uploads and refused analyses are never billed. ================================================================================ 6. CONNECTING AN ASSISTANT ================================================================================ ChatGPT (Custom GPT Action) Explore GPTs -> Create -> Configure -> Create new action -> Import from URL: https://app.oxynet.net/v1/openapi.json Authentication: API Key; Auth Type: Custom; Custom Header Name: X-API-Key. The schema is served as OpenAPI 3.0 with all schemas inlined and no multipart operations, which is what the importer accepts. Claude Code claude mcp add --transport http oxynet https://app.oxynet.net/oxynet-mcp \ --header "X-API-Key: YOUR_KEY" Claude Desktop Settings -> Connectors -> Add custom connector -> https://app.oxynet.net/oxynet-mcp Leave the other fields blank. A browser opens and asks for the API key once; Desktop stores a token and refreshes it automatically. There is no header to configure and no client ID to obtain. Desktop has no shell, so upload by attaching the file to the chat. Claude already holds the contents, and passing them as `content` costs nothing extra. The upload-ticket route needs a shell and does not apply here. Gemini CLI (~/.gemini/settings.json) {"mcpServers": {"oxynet": { "httpUrl": "https://app.oxynet.net/oxynet-mcp", "headers": {"X-API-Key": "YOUR_KEY"}}}} Any other framework Generate a client from https://app.oxynet.net/v1/openapi.json. The MCP tools, for reference get_capabilities() what this key may do. Call it first. get_sample_cpet() a cpet_id for synthetic data, to try it out create_upload(count=N) one-shot upload URLs; N at once for a folder upload_cpet(content=...) when you already hold the contents get_cpet(cpet_id) format, channels, sampling, load, flags analyze_cpet(cpet_id, [..]) "vt", "eov", "substrate" list_metrics() every derived quantity, with what each needs compute_metrics(cpet_id,..) phases, gas_quality, substrate_summary, peaks get_cpet_series(cpet_id,..) downsampled, for drawing only delete_cpet(cpet_id) remove a recording now "signal" is NOT an analysis name: it is get_cpet plus compute_metrics. ================================================================================ 7. PRIVACY ================================================================================ Uploaded bytes are parsed and discarded, never written to storage. The parsed record is deleted after 24 hours by default, or immediately on DELETE. Records are partitioned per API key. CPET exports routinely carry patient names in the filename and the file header. Anonymise before uploading, and tell the user to do the same. ================================================================================ 8. GETTING A KEY ================================================================================ Keys are issued by Oxynet. If you do not have one, the user must obtain it from Oxynet directly, as you cannot self-provision. Without a valid key every endpoint except /health and /v1/openapi.json returns 401.