fixRAgent triage API

One photo in, one triage out: what it is, whether it is broken, how urgent, and a link to tell us whether the answer was right.

Get a key →

The triage API

Most of what comes in can wait. The job is knowing which.

POST one photograph. One fixed JSON shape comes back: what the thing is, whether a fault is visible, one of four urgency words, which trade it needs, and a link to tell us what it turned out to be. Below are reads the engine actually returned, both recorded on the same day. Both found a real fault. Only one of them has to be dealt with tonight — and that difference is the product.

Move now
How urgent
EMERGENCY
What it is
Copper water supply pipe
Who to call
Plumber
What is wrong
An active continuous plumbing leak is spraying from a corroded copper pipe elbow joint in the basement.
How sure this is
three of three reads agreed
Can wait
How urgent
THIS WEEK
What it is
Central AC Condenser
Who to call
HVAC Technician
What is wrong
The outdoor condenser unit exhibits significant surface rusting on the top fan guard and cabinet exterior, requiring rust removal and protective coating.
How sure this is
three of three reads agreed

Both recorded 13 September 2026 on fast-a1v11-gemini-3.5-flash-lite-2026-09-12 — 1abde77c and 882c093e — one call each, three reads each, three agreed. These are reads the engine returned, not illustrations. What is printed here is the published projection of each reply, the same one the front page serves; the fields the reference below documents are the fields you get.

What your system gets — the first of the two, in full
{
  "diagnosis_id": "1abde77c-4b71-42fc-9dc1-1596b0d9901e",
  "engine_version": "engine-v2-vote-1.0.0",
  "rubric_version": "v1.1-2026-09-11",
  "config_id": "fast-a1v11-gemini-3.5-flash-lite-2026-09-12",
  "config_source": {
    "fallback_used": false
  },
  "agreement": {
    "mode": "fast",
    "n": 3,
    "k": 3,
    "decided": true
  },
  "core": {
    "is_building_asset": true,
    "asset_type": "Copper water supply pipe",
    "photo_subject": "Copper plumbing pipe with an active pinhole leak and severe corrosion at the elbow joint",
    "fault_detected": true,
    "fault_visible_in_photo": true,
    "fault_summary": "An active continuous plumbing leak is spraying from a corroded copper pipe elbow joint in the basement.",
    "tier": "EMERGENCY",
    "criterion_1_fired": false,
    "safety_hazard": true,
    "hazard_detail": "active-loss emergency: Continuous active spraying and dripping of water from the pressurized copper pipe joint.",
    "trade_required": "Plumber"
  },
  "callback_url": "/api/outcome?diagnosis_id=1abde77c-4b71-42fc-9dc1-1596b0d9901e",
  "extended": {
    "observations": []
  }
}

Where this goes in your system

There is one fixed payload and your developers map it to your own work-order fields. No integration to install and nothing to keep in sync: you read the fields you want. The field notes further down this page are written for that mapping. A worked example:

The field we returnThe work-order field it fills
core.tieryour priority field — four words, mapped to your own four
core.asset_typethe asset or equipment the order is against
core.trade_requiredthe vendor category, or the queue it routes to
core.fault_summarythe order's description, written for a person to read
core.safety_hazardthe flag that takes it out of the normal queue
core.resident_explanationthe line you send the resident, as written
diagnosis_idyour external reference, so the outcome can come back
callback_urlwhere you POST what it turned out to be, once you know

Get a key and try it on your own photo

Leave an email and one line on what you are building. Your own key is made the moment you send, and a person at AR Logic sends it to that address, with a curl line that uses it. It is yours: sixty requests a UTC day on that key.

The form POSTs { kind: "api_key", email, use_case, source, role, variant, consent_version } to /api/leads. The reply names the key's id, never the key: the key reaches you by email. consent_version is the version of the consent line above, recorded with the request. Before you type, the page checks whether that endpoint is live on the deployment you are reading; if it is not, the form is disabled and says so rather than pretending, and the same two lines by email to support@fixragent.com do the same job.

If you are not the one writing the code

Most people who open this page were sent it by someone who manages buildings. The same engine, with no integration to build, is at /pm-onboard — a portfolio, the photographs your residents already send, and the same four urgency words. /connect is the step that joins it to Buildium. The one-page summary of the whole thing, including what a pilot involves, is at /one-pager, and the terms are at /api-terms.

OpenAPI 3.1.0 engine engine-v2-vote-1.0.0 rubric v1.1-2026-09-11 contract v1-DRAFT-2026-09-10 machine copy openapi.json Postman collection file mock b1442b0b…mock.pstmn.io terms /api-terms one page /one-pager

Who this is for. A developer with a photo and nowhere to send it — a tenant-reporting app, a maintenance inbox, a handyman's own tool. You POST the photo; one fixed JSON shape comes back: what it is, whether a fault is visible, one of four urgency words, which trade, a line you can send to the tenant as written, and a callback_url to tell us what it turned out to be.

What you do not get today. Keys come from the form below — a person reads each request and sends the key by email; no pricing; no dashboard — the Triage Profile and the payload are the product. A direct Buildium connection is built and proven only in our own Buildium test account; it needs Buildium Premium and is not open to any manager account yet (after you sign in, /connect shows what it would read and write); for any other PMS or CMMS, your developers map our JSON to your work-order fields, and the field notes on this page are written for that.

The one rule to remember. Visible wiring a person could touch, or water near anything electrical, forces the urgency to EMERGENCY in code (the urgency section). The one exception: when the photo is not of a building at all (is_building_asset is false, for example a person, a pet or a vehicle), the answer is P4, not urgent, and that rule is checked first.

Make a request

POST /api/triage — one photo in, one triage out. Three steps:

  1. Base64 the photo (JPEG, PNG or WebP; 1 KB to 3 MB decoded) and put it in the image field of a JSON body.
  2. Send it with your demo key in the x-triage-key header. ?config=fast reads the photo three times — that is what production served on the one call measured on 13 Sep 2026 at 13:36:18Z (Parameters has the reading), and agreement.n on your reply says how many reads answered. ?config=deep reads it five times. Both report how many of those reads agreed. &extended=1 adds the rest of the contract.
  3. Read core (the twenty fields below), keep diagnosis_id, and when you find out what it turned out to be, POST that to callback_url.

curl

curl -X POST "https://fixragent.com/api/triage?config=fast&extended=1" \
  -H "Content-Type: application/json" \
  -H "x-triage-key: $FIXRAGENT_KEY" \
  -d "{\"image\":\"$(base64 -w0 photo.jpg)\",\"mimeType\":\"image/jpeg\"}"

JavaScript — one file, no dependencies (Node 18 or newer)

curl -fsSL https://fixragent.com/sdk/js/index.mjs -o fixragent-triage.mjs

import { triage } from './fixragent-triage.mjs';
const reply = await triage(await readFile('photo.jpg'), { key: process.env.FIXRAGENT_KEY, config: 'fast' });
console.log(reply.core.tier, reply.core.trade_required, reply.core.resident_explanation);

Python — one file, standard library only (3.9 or newer)

curl -fsSL https://fixragent.com/sdk/python/fixragent_triage/__init__.py -o fixragent_triage.py

from fixragent_triage import triage
reply = triage(open('photo.jpg', 'rb').read(), key=os.environ['FIXRAGENT_KEY'], config='fast')
print(reply['core']['tier'], reply['core']['trade_required'], reply['core']['resident_explanation'])

The three ways make the same request and return the same JSON. The two clients take the photo as bytes, a base64 string or a data URL, send your key as x-triage-key, and turn a refused reply into an error that carries the status, the API's own error code, its sentence and the Retry-After seconds. The clients section below has the options, the outcome call and the Spanish switch; the MCP tool is the fourth way, for an agent. To use fixRAgent inside Claude, ChatGPT, Cursor or Gemini, follow the setup for your AI assistant.

Sample response (captured 13 Sep 2026, abridged)

{
  "diagnosis_id": "cce64cbc-97b2-410d-a761-2a8d65d570ad",
  "engine_version": "engine-v2-vote-1.0.0",
  "rubric_version": "v1.1-2026-09-11",
  "config_id": "fast-a1v11-gemini-3.5-flash-lite-2026-09-12",
  "config_source": {
    "candidates_file": "api/_engine-v2-candidates.json",
    "candidates_file_present": true,
    "requested": "fast",
    "fallback_used": false,
    "fallback_reason": null,
    "warning": null,
    "vote": true
  },
  "agreement": {
    "mode": "fast",
    "n": 3,
    "k": 3,
    "decided": true,
    "votes": {
      "fault": 0,
      "no_fault": 3,
      "no_verdict": 0
    },
    "vote_rule": "decided when the majority verdict has k >= ceil(n/2) parsed votes; n = reads attempted, failed reads count in n and never in k; a tie is never decided; undecided re-votes once",
    "re_voted": false,
    "reported_round": 1,
    "rounds": [
      {
        "round": 1,
        "n": 3,
        "reads_parsed": 3,
        "read_failures": [],
        "k": 3,
        "decided": true,
        "votes": {
          "fault": 0,
          "no_fault": 3,
          "no_verdict": 0
        },
        "reason": "k >= ceil(n/2)",
        "reported": true
      }
    ],
    "reads_attempted_total": 3,
    "majority_verdict": false,
    "need": 2,
    "verdict_field": "grounded fault_detected (contract R1 visible-in-photo + R3 taxonomy class and summary), majority per a2_selfcons_v11 over parsed reads",
    "delivered_matches_majority": true,
    "applicable": true
  },
  "callback_url": "/api/outcome?diagnosis_id=cce64cbc-97b2-410d-a761-2a8d65d570ad",
  "callback": {
    "method": "POST",
    "url": "/api/outcome",
    "note": "api/outcome.js reads diagnosis_id from the JSON BODY on POST; the query string on callback_url is there so the link identifies the diagnosis to a human and in logs, not because the endpoint reads it.",
    "required_body": {
      "diagnosis_id": "cce64cbc-97b2-410d-a761-2a8d65d570ad",
      "outcome": [
        "fixed",
        "not_fixed",
        "partial"
      ],
      "reported_via": [
        "results_screen",
        "scan_log",
        "triage_link"
      ]
    }
  },
  "core": {
    "is_building_asset": true,
    "asset_type": "HVAC Air Handler and Furnace",
    "asset_category": "HVAC",
    "photo_subject": "HVAC air handler and furnace unit with connected ductwork and piping",
    "report_photo_agreement": null,
    "fault_detected": false,
    "fault_visible_in_photo": false,
    "fault_summary": "",
    "fault_classes": [],
    "severity": "P4",
    "tier": "WHENEVER",
    "criterion_1_fired": false,
    "tier_forced_by": null,
    "visible_wiring": false,
    "water_near_electrical": false,
    "safety_hazard": false,
    "hazard_detail": null,
    "trade_required": null,
    "confidence": 1,
    "resident_explanation": "Your HVAC system is currently in a healthy state with no visible faults or active damage."
  },
  "provenance": {
    "contract_version": "v1-DRAFT-2026-09-10",
    "models": {
      "identification": "gemini-3.5-flash-lite",
      "reasoning": "gemini-3.5-flash-lite"
    },
    "temperature": 0.2,
    "prompt_hash": "b52b0e83c8d01ccb",
    "prompt_base": "api/_engine-v2-fast.json system_prompt (sha256 93f5cb191f1c14d46ab33a003622fe94e32feb069e29c61fb69c3c0be70d3f56) + user_text — the run-2 a1_rubric_v11 export, held to the harness by qa/engine-v2-fast/check-export-drift.py; the resident report, when sent, rides in the user text (api/_engine-v2-fast.js USER_TEXT_WITH_REPORT, unmeasured variant)",
    "agreement": {
      "mode": "fast",
      "n": 3,
      "k": 3,
      "decided": true,
      "votes": {
        "fault": 0,
        "no_fault": 3,
        "no_verdict": 0
      },
      "vote_rule": "decided when the majority verdict has k >= ceil(n/2) parsed votes; n = reads attempted, failed reads count in n and never in k; a tie is never decided; undecided re-votes once",
      "re_voted": false,
      "reported_round": 1,
      "rounds": [
        {
          "round": 1,
          "n": 3,
          "reads_parsed": 3,
          "read_failures": [],
          "k": 3,
          "decided": true,
          "votes": {
            "fault": 0,
            "no_fault": 3,
            "no_verdict": 0
          },
          "reason": "k >= ceil(n/2)",
          "reported": true
        }
      ],
      "reads_attempted_total": 3,
      "majority_verdict": false,
      "need": 2,
      "verdict_field": "grounded fault_detected (contract R1 visible-in-photo + R3 taxonomy class and summary), majority per a2_selfcons_v11 over parsed reads",
      "delivered_matches_majority": true,
      "applicable": true
    },
    "prompt_addendum": "none — the RUBRIC v1.1 axes and criterion-1 facts are asked for by the exported system prompt and enforced by responseSchema (sha256 559265bb8d66ae4fbe98d756b0f574fb51a042997388c4b58ff630071a04991b); the model is never asked for severity or tier",
    "latency_ms": 4174,
    "photo_sha256": "a4232950f74c029d219eacff3d0f84ad86744c3c584b3c2fdafb49afbfeed913",
    "exif_strip_version": "exif-strip-v3-2026-09-11",
    "model_proposed_severity": null,
    "severity_assigned_by": "server:RUBRIC-v1.1-2026-09-11 via a2_selfcons_v11 vote (assignSeverityV11 on the voted facts)",
    "severity_overwritten": false,
    "vote": {
      "candidate": "a1_rubric_v11",
      "n": 3,
      "rounds": [
        {
          "round": 1,
          "n": 3,
          "reads_parsed": 3,
          "read_failures": [],
          "k": 3,
          "decided": true,
          "votes": {
            "fault": 0,
            "no_fault": 3,
            "no_verdict": 0
          },
          "reason": "k >= ceil(n/2)",
          "reported": true
        }
      ],
      "tally": {
        "n_votes_parsed": 3,
        "n_asked": 3,
        "fault_detected": {
          "true": 0,
          "n": 3,
          "voted": false
        },
        "is_building_asset": {
          "true": 3,
          "n": 3,
          "voted": true
        },
        "n_agreeing": 3,
        "base_pool": "raw-matching agreeing reads",
        "rubric_axes_pool": {
          "indices": [
            0,
            1,
            2
          ],
          "rule": "all reads"
        },
        "fault_classes": {
          "counts": {},
          "voted": []
        },
        "safety_hazard": {
          "true": 0,
          "n": 3,
          "voted": false
        },
        "fault_visible_in_photo": {
          "true": 0,
          "n": 3,
          "voted": false
        },
        "rubric_axes": {
          "hazard_class": {
            "counts": {
              "H0": 3
            },
            "voted": "H0",
            "tie_rule": "outright"
          },
          "active_loss": {
            "counts": {
              "L0": 3
            },
            "voted": "L0",
            "tie_rule": "outright"
          },
          "habitability": {
            "counts": {
              "C0": 3
            },
            "voted": "C0",
            "tie_rule": "outright"
          },
          "deferral_cost": {
            "counts": {
              "D0": 3
            },
            "voted": "D0",
            "tie_rule": "outright"
          }
        },
        "criterion_1": {
          "visible_wiring": {
            "true": 0,
            "n": 3,
            "voted": false,
            "tie_rule": "tie -> FALSE (v1.1 §2.1 uncertain does not fire)"
          },
          "water_near_electrical": {
            "true": 0,
            "n": 3,
            "voted": false,
            "tie_rule": "tie -> FALSE (v1.1 §2.1 uncertain does not fire)"
          }
        },
        "base_vote_index": 0,
        "fault_prose_cleared": [],
        "confidence_agreement_fraction": 1,
        "decision": {
          "n": 3,
          "k": 3,
          "need": 2,
          "decided": true,
          "votes": {
            "fault": 0,
            "no_fault": 3,
            "no_verdict": 0
          },
          "majority_verdict": false,
          "reason": "k >= ceil(n/2)"
        }
      },
      "rubric": {
        "rubric_version": "v1.1-2026-09-11",
        "rubric_v11_sha256": "f5968d73f21b1488da9fb25b1568ed7efdfa7cc888b4cec56b20c2b72a4ee951",
        "rubric_axes": {
          "hazard_class": "H0",
          "active_loss": "L0",
          "habitability": "C0",
          "deferral_cost": "D0",
          "hazard_reason": "none visible",
          "active_loss_reason": "no active leaks or losses visible",
          "habitability_reason": "unit is intact and operational",
          "deferral_reason": "nothing changes in a week"
        },
        "rubric_axes_invalid": {},
        "criterion_1": {
          "visible_wiring": false,
          "water_near_electrical": false,
          "visible_wiring_reason": "none visible",
          "water_near_electrical_reason": "none visible"
        },
        "criterion_1_invalid": {},
        "criterion_1_fired": false,
        "criterion_1_facts_fired": [],
        "severity_reason_row": 7,
        "step": "2:v1-row-7",
        "rubric_p_uncapped": "P4",
        "severity_capped_by": null,
        "tier_forced_by": null,
        "illegal_p1_no_fault": false,
        "severity_source": "assignSeverityV11 on VOTED facts (never voted, never model-emitted)",
        "per_vote_severity": [
          "P4",
          "P4",
          "P4"
        ],
        "severity_final": "P4",
        "tier_final": "WHENEVER"
      },
      "per_read": [
        {
          "fault_detected": false,
          "fault_visible_in_photo": false,
          "fault_classes": [],
          "severity": "P4",
          "tier": "WHENEVER",
          "criterion_1_fired": false,
          "is_building_asset": true,
          "finish_reason": "STOP",
          "attempts": 1,
          "latency_ms": 3989,
          "usage": {
            "prompt_tokens": 9572,
            "output_tokens": 976
          }
        },
        {
          "fault_detected": false,
          "fault_visible_in_photo": false,
          "fault_classes": [],
          "severity": "P4",
          "tier": "WHENEVER",
          "criterion_1_fired": false,
          "is_building_asset": true,
          "finish_reason": "STOP",
          "attempts": 1,
          "latency_ms": 4154,
          "usage": {
            "prompt_tokens": 9572,
            "output_tokens": 1139
          }
        },
        {
          "fault_detected": false,
          "fault_visible_in_photo": false,
          "fault_classes": [],
          "severity": "P4",
          "tier": "WHENEVER",
          "criterion_1_fired": false,
          "is_building_asset": true,
          "finish_reason": "STOP",
          "attempts": 1,
          "latency_ms": 4170,
          "usage": {
            "prompt_tokens": 9572,
            "output_tokens": 1066
          }
        }
      ],
      "read_failures": [],
      "confidence_definition": "agreement fraction on the fault verdict over the reads that parsed (a2_selfcons_v11) — a vote count, not a model number"
    }
  },
  "stored": {
    "degraded": false,
    "table": "diy_guest_scans"
  },
  "warnings": []
}

What you are looking at. A real reply: one photo of a gas furnace and air handler, read three times. Three things to notice. config_source says where the configuration came from: candidates_file names api/_engine-v2-candidates.json, the file shipped beside the function; fallback_used is false and vote is true, so the reply was produced by a measured configuration, and config_id names it (see Parameters). agreement is the tally: mode fast, n 3, k 3, decided true, and vote_rule is the decided rule in words, stamped on every reply. And stored says what happened to the database row, so a degraded write never reads like a clean one.

The tier on this reply is WHENEVER: three of three reads found no fault. The same photo read five times, next, found no fault as well. So on this photo the served engine agrees with itself — three of three, five of five, no fault. That is why config_id is stamped on every reply, and why agreement is repeatability, not accuracy, inside one configuration only.

What the deep configuration adds

?config=deep reads the same photo five times, independently, and tallies the reads fact by fact. The delivered answer is not a single read picked out: the fault verdict is the answer most reads gave (a tie reads no fault); each photo-borne fact — visible_wiring, water_near_electrical — is the answer most reads gave, with a tie reading false, so the EMERGENCY floor never fires on a coin toss; each of the four graded readings is the most common value, a tie taking the lower one except the fuel-gas / carbon-monoxide / exposed-conductor tie, which rounds up; and the urgency is then derived once, in code, from those tallied facts. The prose comes from the read whose own urgency matches the tallied one. A read that failed or returned no verdict counts against agreement — it is in n, never in k — so the engine that fails most cannot read as the most consistent. The reply is decided when the answer most reads gave has at least half the reads asked for (k >= ceil(n/2), stamped on every reply as agreement.vote_rule); a tie is never decided. When the first round is undecided the door takes a second round of reads — the same number again — and reports what it has, with both rounds' counts in agreement.rounds and a sentence in warnings; so a deep call can cost two rounds, and says when it did. ?config=fast is the same tally over three reads.

Sample deep response for the same photo, reduced to what changed (captured 13 Sep 2026, abridged)

{
  "diagnosis_id": "2a0a447e-9ef1-4ec2-bde0-46ef5e7088bb",
  "config_id": "engine-v2.deep.gemini-3-5-flash-lite.a1_rubric_v11.n5",
  "agreement": {
    "mode": "deep",
    "n": 5,
    "k": 5,
    "decided": true,
    "votes": {
      "fault": 0,
      "no_fault": 5,
      "no_verdict": 0
    },
    "vote_rule": "decided when the majority verdict has k >= ceil(n/2) parsed votes; n = reads attempted, failed reads count in n and never in k; a tie is never decided; undecided re-votes once",
    "re_voted": false,
    "reported_round": 1,
    "rounds": [
      {
        "round": 1,
        "n": 5,
        "reads_parsed": 5,
        "read_failures": [],
        "k": 5,
        "decided": true,
        "votes": {
          "fault": 0,
          "no_fault": 5,
          "no_verdict": 0
        },
        "reason": "k >= ceil(n/2)",
        "reported": true
      }
    ],
    "reads_attempted_total": 5,
    "majority_verdict": false,
    "need": 3,
    "verdict_field": "grounded fault_detected (contract R1 visible-in-photo + R3 taxonomy class and summary), majority per a2_selfcons_v11 over parsed reads",
    "delivered_matches_majority": true,
    "applicable": true
  },
  "core": {
    "severity": "P4",
    "tier": "WHENEVER",
    "criterion_1_fired": false,
    "tier_forced_by": null,
    "water_near_electrical": false,
    "visible_wiring": false
  }
}

Five of five reads found no fault, so nothing is named as broken, no trade is owed, and the tier is WHENEVER; both photo-borne facts, water_near_electrical and visible_wiring, came back false. Agreement is repeatability, not accuracy: reads can agree and still be wrong, and it says nothing about the urgency, the trade, or the parts. A Triage Profile prints agreement as a count of reads, never as a percentage: the one-pager writes it as a sentence, and the Triage Profile that ships today prints the two numbers as figures.

The two calls behind these samples took 4.2 s (three reads) and 4.7 s (five reads), from one machine, one call each. That is what was measured; it is not a service level.

Try it without a key

A mock server replays the captured replies above so you can wire up a client before a key arrives. It does not run the engine and it ignores the photo you send; it matches on the path and the query string only. It also ignores every header, including x-triage-key — a wrong key still gets a 200 — and a POST with no query string returns the five-read reply, so do not test your key handling or your query handling against it (measured 13 Sep 2026).

mock

curl -X POST "https://b1442b0b-4a7a-4135-ab4d-9c4b9c1ab0d2.mock.pstmn.io/api/triage?config=fast&extended=1" \
  -H "Content-Type: application/json" -d '{"image":"AAAA"}'

config=fast returns the three-read reply, config=deep the five-read reply, in full. The same requests and examples are in the Postman collection file (Collection v2.1 — import it, then point baseUrl at the mock or at https://fixragent.com). The file is generated from the captured replies by qa/api-launch-kit/build-postman.js, so it cannot say something the handler did not. The Postman environment file points the same requests at production: import it beside the collection, put your key in the environment's key variable (the one marked secret), switch the environment on, and the fast request runs for real.

The clients: JavaScript and Python

Two thin clients, each one file with no dependencies, each generated against openapi.json so the types cannot drift from the handler: sdk/js/index.mjs for JavaScript (Node 18 or newer, or a browser) and sdk/python/fixragent_triage/__init__.py for Python (3.9 or newer, standard library only). Download the file and import it; that is the install today. The same files are the @fixragent/triage npm package and the fixragent-triage PyPI package, and those install lines land here with the first release.

JavaScriptPython
Installcurl -fsSL https://fixragent.com/sdk/js/index.mjs -o fixragent-triage.mjscurl -fsSL https://fixragent.com/sdk/python/fixragent_triage/__init__.py -o fixragent_triage.py
Triageawait triage(photo, { key, config, extended, problemText, reporter, lang })triage(photo, key=, config=, extended=, problem_text=, reporter=, lang=)
Outcomeawait reportOutcome({ callbackUrl, outcome: 'fixed' })report_outcome('fixed', callback_url=…)
ErrorsFixragentError with status, code, message, retryAfterFixragentError with status, code, message, retry_after
Typestypes.d.ts, generated from openapi.jsontypes.py (TypedDict), generated from openapi.json
Channelsends variant: source:sdk-js, counted as channel apisends variant: source:sdk-python, counted as channel api
MockbaseUrl: 'https://b1442b0b-4a7a-4135-ab4d-9c4b9c1ab0d2.mock.pstmn.io'base_url='https://b1442b0b-4a7a-4135-ab4d-9c4b9c1ab0d2.mock.pstmn.io'
Read mesdk/js/README.mdsdk/python/README.md

Spanish. lang: 'es' in JavaScript, lang='es' in Python, or ?lang=es on the URL: the prose fields come back in Spanish and hazard_detail stays in English, on purpose — the hazard line is never machine-translated. The reply's language object says what was served.

Both clients replay the captured replies on this page through an injected transport in their tests, and the JavaScript client has been read against the mock byte for byte. Source: sdk/ in the repository, MIT.

The one-page sheet

A printable one-page summary — the Triage Profile the deep reply above makes, the payload behind it, and the pilot offer — is at /one-pager, and as a PDF in Letter and A4. It shows no photo: the photo behind that Triage Profile is a user upload held under a licence that covers processing only, so the sheet shows the Triage Profile's structure with the engine's own words and coordinates.

On your own site: the photo drop

No key and no code to write: paste one tag where the box should sit on your page — <script src="https://fixragent.com/embed.js" data-source="your-site" async></script>. Change your-site to a short name for your site; every Triage Profile made from your page carries that name, so the Triage Profiles from your site count as yours. The box takes a photo and shows the same Triage Profile as fixragent.com, it grows to fit the Triage Profile, and it sets no cookies on your site. Wix, Squarespace and WordPress steps, and a live box to try first, are on the embed page.

The three guards

This is a demo door, so three things stand in front of it. They run in this order, and the cheapest one runs first.

#GuardLimitWhat you see when it stops you
1IP rate limit20 requests per IP per 1 hour — callers without a key of their own only. An issued key is judged by the key (a per-minute burst and its daily limit), never by the address it calls from429 rate_limited
2Demo key60 requests per UTC day, per key401 demo_key_required or 429 demo_key_quota
3Daily spending brakeA daily spending brake protects the service. A key with its own cap is bounded by that cap instead503 budget_exhausted, with Retry-After set to the real number of seconds until 00:00 UTC

In plain words: twenty requests an hour from one address when you have no key; sixty a day on each key; and a daily spending brake that protects the service — when it trips you get 503 budget_exhausted until 00:00 UTC, and a key with its own cap is not stopped by it. When any of the three stops you, the reply says which one, in a sentence, with a Retry-After that is computed from the clock rather than a round number.

The key goes in the x-triage-key header. The brake is checked before the request body is read, so hitting it costs nothing, and the 503 carries Retry-After, retry_after_seconds and resets_at, all three computed from the current time to the next 00:00 UTC.

Keys with a usage plan: bursts, a hard cap, your running total

A pilot or paid key can carry a usage plan. A key with a plan is treated differently from the demo key in four ways, and every one of them is there so a campaign can send hundreds of photos in minutes without failing and without a surprise bill.

WhatHow it worksWhat you see
Burst allowanceThe per-address hour limit does not apply; your key has its own per-minute allowance instead (all your photos can come from one server).429 key_burst_limited with Retry-After in seconds
Hard capA fixed number of assessments per window (your pilot period, or a calendar month). Past it, nothing is run and nothing is billed.429 key_cap_reached, a usage block, and Retry-After to the window reset
Running totalEvery answer to your key carries your usage. GET /api/usage with the same x-triage-key header returns it without sending a photo.Headers X-Key-Usage-Used, X-Key-Usage-Cap, X-Key-Usage-Remaining, X-Key-Usage-Window, X-Key-Usage-Resets, and a usage block in the body
Retries are freeSend an Idempotency-Key header (8–128 characters) and the same key within 24 hours returns the stored answer. Without one, the same photo with the same text from the same key within 10 minutes is treated the same way. A retry sent while the first request is still running can run twice; it is billed once.200 with a replayed block and X-Triage-Replayed; nothing new is billed

The billing rule. You are billed for distinct photos (or distinct Idempotency-Keys) in the window, and never for more than your cap. The door checks the cap before it runs the engine. Requests already in flight on other servers at the moment the cap is crossed can still be answered; those are never billed. The cap on your key is set with us in writing and can be raised the same day.

When the engine is busy. If our model provider is rate-limiting or down, you get 503 engine_busy with Retry-After instead of a 502. Nothing is billed. Retry with the same photo or the same Idempotency-Key and it is never charged twice.

curl -s https://fixragent.com/api/usage -H "x-triage-key: $FIXRAGENT_KEY"

/api/usage answers for pilot keys with a usage cap; a demo key has a fixed 60 a day and gets 401 demo_key_required here.

severity and tier

The server assigns both, in code. The model is asked for observations — four graded readings and two photo-grounded booleans — and the server derives severity from the rubric's decision table and tier from severity. Whatever severity the model proposed is kept in provenance.model_proposed_severity and overwritten, and the overwrite itself is recorded in severity_overwritten.

severitytier
P1EMERGENCY
P2TODAY
P3THIS WEEK
P4WHENEVER

Criterion 1 — the EMERGENCY floor. If visible wiring is present in the photo, or water is present near anything electrical, the answer is EMERGENCY. By definition. The one exception: when the photo is not of a building at all (is_building_asset is false, for example a person, a pet or a vehicle), the answer is P4, not urgent, and that rule is checked first. When criterion 1 does force EMERGENCY, criterion_1_fired tells you that is what happened.

Where either fact is uncertain, criterion 1 deliberately does not fire and the decision table decides instead — otherwise every dim photo of a basement would be an emergency. Low-voltage wiring (doorbell, thermostat, ethernet) never fires it either; it is called out in fault_summary instead.

One thing this endpoint will not do is drop the tier when it cannot see a fault: a photo can show exposed conductor with no nameable fault, and that is still an emergency.

Parameters

NameInValuesNotes
configqueryfast | deepWhich configuration answers. fast reads the photo three times (three reads is what production served as of one measured call — 13 Sep 2026 13:36:18Z, POST https://fixragent.com/api/triage?config=fast answered 200 with config_id fast-a1v11-gemini-3.5-flash-lite-2026-09-12, engine_version engine-v2-vote-1.0.0, agreement n 3, k 3, decided; the reading is filed at qa/api-launch-kit/captured/prod-triage-fast-read-2026-09-13T1336Z.json and on the block record — agreement.n on your own reply says what your deployment gave you); deep reads it five times. Both come from api/_engine-v2-candidates.json, shipped beside the function and pinned by sha256 to the prompt, schema and model the code would serve; the reply's config_source names the file and config_id names the configuration. When that file cannot be served — absent, unparseable, missing the configuration you asked for, or mismatched against the code — a HARDCODED FALLBACK answers instead and says so loudly: config_source.fallback_used is true, config_source.fallback_reason says why, config_source.warning and the top-level warnings array carry a sentence saying the response was not produced by a measured candidate, the config_id begins with "fallback-", and the response carries an X-Triage-Config-Fallback header (the box below lists every reason).
extendedquery1 | trueSet to 1 to also receive the extended block. Omitted, extended comes back null.
kquerystringThe demo key, as an alternative to the x-triage-key header. Prefer the header: a key in a query string ends up in logs.
langqueryen | esThe language the free-text fields come back in. A lang field in the JSON body wins over the query; with neither, the first tag of the Accept-Language header decides (es-* gives Spanish), and the default is English. Under es the engine writes fault_summary, photo_subject, asset_type, trade_required, resident_explanation and the other prose fields in Spanish. hazard_detail, resident_do_not and every *_reason field stay in English on every path, and the reply's language object says what was served (Spanish).
x-triage-keyheaderstringThe demo key. Required on production; 60 requests per UTC day. Exception: a browser request whose Origin is precisely https://fixragent.com or https://www.fixragent.com (fixRAgent's own Triage Profile) needs no key; it is still rate-limited per IP and bounded by the daily spending brake.

Where the configuration comes from, and when we shout. Every reply carries config_source: candidates_file (api/_engine-v2-candidates.json, shipped beside the function), candidates_file_present, requested (fast or deep), fallback_used, fallback_reason, warning, and vote (true when the multi-read path answered). The loader refuses the file — and the hardcoded two-pass fallback answers — when the file is absent (candidates_file_absent), unparseable (candidates_file_unparseable), has no entry under the name you asked for (config_not_in_candidates_file), disagrees with the code that would serve it on the model, the prompt or schema hash, the rubric version, the engine version, the read count or the temperature (candidate_pin_mismatch, with mismatch listing which), or when the multi-read module itself cannot load (vote_module_unavailable). Then four separate things say so: config_source.fallback_used, a sentence in config_source.warning, the top-level warnings array, and a config_id that begins with fallback-. There is also an X-Triage-Config-Fallback header. A response produced this way was not produced by a measured candidate and should not be reported as one. Neither captured sample on this page carries it: both were answered by the served configurations, fallback_used: false. The record copy of the measurements, qa/engine-v2/ENGINE-V2-CANDIDATES.json, is on no deployment and is never read at request time. Whatever the deployment serves when you call it is stamped on your reply — read config_source there, and treat the config_id stamped there as the authority.

Spanish

Send lang=es on the query string, or "lang": "es" in the body, and the prose fields come back in Spanish: fault_summary, photo_subject, asset_type, trade_required, resident_explanation, resident_why_it_matters, resident_what_happens_next, root_cause, failure_mechanism and the rest of the free text. A browser that prefers Spanish gets the same by its Accept-Language header, and the Triage Profile on the home page does the same when the browser asks for Spanish or the page is opened with ?lang=es.

Three things are the same in both languages, on purpose. Every JSON key, enum value and taxonomy id is the one this page documents. The four urgency words on the wire are EMERGENCY, TODAY, THIS WEEK and WHENEVER, assigned in code; the Spanish Triage Profile maps them to its own four words. And hazard_detail, resident_do_not and every *_reason field are written in English on every path, so a safety line is always the engine's own English sentence; the Spanish Triage Profile prints it after a Spanish lead-in, marked as English.

The reply carries a language object: requested (what you sent, or null), served (en or es), source (body, query, header or default) and hazard_text, a reading of the language hazard_detail actually came back in: en, or not-en when the engine wrote it in another language despite the instruction (measured on 14 Sep 2026: nine of twelve hazard reads under the first wording came back Spanish, so the server reads the line instead of trusting the request). The Triage Profile on the home page withholds a not-en hazard line and prints a fixed sentence in its place; a client of the API decides the same way, from this field. Read served before you assume the language: an unknown value is answered in English and requested keeps what you sent.

Request body

FieldTypeNotes
image requiredstringThe photo, base64. A `data:image/...;base64,` prefix is accepted and stripped. Decoded size must be at least 1KB and at most 3MB (3,145,728 bytes) — see the note below.
langstringen or es. Same as the lang query parameter; the body value wins (Spanish).
mimeTypestringUsed by the metadata stripper. A format it cannot clean is refused with 415.
problem_textstringThe reporter's own words. Treated as reported symptoms, never as ground truth — where the photo and the report disagree the photo wins on the fault, and the report still wins on the hazard.
reporterstringWho wrote problem_text. It changes how the engine refers to them, nothing else.
configstringSame as the query parameter; the query parameter wins.
extendedboolean | stringSame as the query parameter.
rolestringFree-form funnel context: who is on the other end. Stored as-is. No vocabulary is ruled yet.
variantstringFree-form funnel context: which surface rendered the Triage Profile.

Why the photo ceiling is 3MB and not something rounder. The photo arrives base64-encoded inside a JSON body, and the platform caps the whole request body before this function is entered at all. Base64 adds a third, so 3MB of photo is about 4MB on the wire, which fits under that cap with room for the rest of the body.

This page used to say 20MB. A 20MB photo is about 27MB as base64 JSON, so no such request could ever have reached the handler — the limit was unreachable and everybody planning around it was planning around nothing.

The platform number is cited, not measured. vercel.com/docs/functions/limitations was fetched on 2026-09-11 and reads: "The maximum payload size for the request body or the response body of a Vercel Function is 4.5 MB" (page last updated August 24 2026). A competing figure of 100 MB appears in a local tooling knowledge file; the changelog URL it cites returned HTTP 404 on the same day, and no figure superseding 4.5 MB appears on the live limits page. No request of any size has been put through a deployed copy of this function to find the ceiling — that is why this says cited and not measured.

The response contract, versioned

The 200 body is published as a JSON Schema: https://fixragent.com/schemas/triage-response-v1.json. Every response, errors included, names the schema it answers under in the X-Triage-Schema header (readable from a browser), and every 200 repeats it in schema_version. Today that is triage-response-v1@1.1.0.

Additive only within v1. A required field is never removed or renamed, its type never changes, and an enum never loses or changes a value. New optional fields can appear — ignore fields you do not know. A change that would break any of that ships as triage-response-v2 at a new URL, and v1 keeps answering. Run npm run check:response-contract to refuse any change that breaks the v1 lock; CI wiring ships separately. core, customer, data_use and the top-level ids are contract-stable; extended, provenance, agreement and contract_validation are diagnostic and may grow.

Response: customer — the part you show your customer

Six fields you can put in front of your own customer, under your own brand, without rewriting them. Every value is derived in code from core and the rubric's hazard axis; none is copied from model text, so none carries our name, a hedge, or an empty sentence.

FieldWhat it is
customer.actioncall_now (severity P1, a gas or carbon-monoxide fault, visible wiring or water near electrics, or a hazard the rubric scores life-safety-now — a reported gas smell is one) · book (P2, or any other named hazard) · monitor (P3, or a P4 with a named fault) · no_fault
customer.emergencytrue exactly when action is call_now — the one bit to split an emergency line on
customer.tradeplumbing · electrical · hvac · appliance · roofing · structural · general, or null when the photo is not a building asset
customer.sentenceone plain line for the end customer, in customer.language (en or es). Safety comes first: a gas or carbon-monoxide fault says leave the building and call the gas company from outside; visible wiring or water near electrics says keep away, switch the power off if it is safe, and call an electrician; any other named hazard says there is a safety concern and, if you smell gas, to leave the building. A named hazard never reads “not urgent”.
customer.find_a_pro_urloptional: a plain Google Maps "plumber near me" search on call_now and book, null otherwise. No partner id, no affiliate tag. Ignore it if you route to your own team.

Response: data_use — training

Training is off by default for every call on this endpoint. data_use.training_allowed is true only for a key whose holder has authorised it in writing, and no key is authorised today. Any request can make itself stricter with "do_not_train": true in the body or the header x-triage-do-not-train: 1; nothing in a request can turn training on. data_use.photo_bytes_stored is always false. Nothing trains on these calls today, so no pipeline reads this flag yet; it is recorded on every call so that any future one must honour it. If you show your own users a photo-consent choice, you collect it; our API terms say what you tell them.

Response: contract_validation

Every core that leaves this endpoint is checked against the whole of CONTRACT-v1.json — both halves of it.

The second half is newer than the first, and the gap it closed was real: before it existed, an engine reply carrying asset_category: "BANANA" came back 200 with that value intact — a value not in the contract's own list for that field. Eight sentences were enforced; fifty-one field definitions were never read.

The validator publishes which JSON-Schema keywords it can execute. If the contract ever uses one it cannot, that appears in contract_validation.rules_source.shape_drift and in warnings as UNENFORCED — a constraint that stopped being checked must never read like one that still is.

The policy is repair and record:

One rule has a standing exemption, and it is recorded rather than assumed: RUBRIC v1.1 §2.2's criterion-1 carve-out is the single legal way to reach P1 with fault_detected: false. When it applies it appears in contract_validation.exemptions, so an exemption never reads like conformance.

And one place where the contract and the code genuinely disagree, named rather than papered over: CONTRACT v1 types confidence as a number between 0 and 1 with no null, while the engine pipeline emits null whenever the model gave no usable number. There is no repair — a calibrated probability is the sort of thing a repair may not invent, and 0 is not "unknown", it is a confident no. So that one case is carved out by name: it still appears in shape_violations_found, it is described in contract_validation.contract_conflicts, and it is pushed onto warnings on every response it occurs in. It is an open item, not a fix. Remove the carve-out and the same reply is refused with 502 — which is how we know it is a decision on the record and not a blind spot.

Response: core

CONTRACT v1 CORE — 20 fields, in this order. 15 are CONTRACT-v1.json fields under their contract names; 5 (tier, criterion_1_fired, tier_forced_by, visible_wiring, water_near_electrical) are RUBRIC v1.1-2026-09-11 additions that CONTRACT-v1 predates. Everything the engine produced beyond these rides in extended; nothing is dropped.

FieldTypeMeaning
is_building_assetbooleanFalse when the photo is a person, pet, vehicle, food or document. False forces severity P4 before anything else is evaluated.
asset_typestring | nullThe specific asset, named precisely ("Gas water heater", not "appliance"). Null only when is_building_asset is false.
asset_categorystringHVAC | PLUMBING | ELECTRICAL | APPLIANCE | STRUCTURAL | OTHER | NOT_AN_ASSET.
photo_subjectstringWhat is actually in frame, named from the pixels alone and independent of anything the reporter said. This is the field that catches a fabricated answer about an asset that is not in the photo.
report_photo_agreementstring | null"true" | "false" | "uncertain" — does the reported problem describe what the photo shows. Null ONLY when no problem_text accompanied the photo.
true false uncertain null
fault_detectedbooleanThe scored verdict. Rides the photo: a report-only symptom is a hazard input, never a fault.
fault_visible_in_photoboolean | nullWhether the named fault is actually visible. Null when the engine did not answer.
fault_summarystringOne paragraph for the person dispatching. Empty string when no fault.
fault_classesarrayTaxonomy-v0 class ids, primary first. Empty array whenever fault_detected is false. Ids not in the taxonomy are dropped by the server rather than passed through.
54 taxonomy ids — the full list is the fault_classes enum in openapi.json
severitystringP1 | P2 | P3 | P4. ASSIGNED BY THE SERVER from the rubric's four graded readings. Never taken from the model — whatever the model proposed is kept in provenance.model_proposed_severity instead.
P1 P2 P3 P4
tierstringEMERGENCY | TODAY | THIS WEEK | WHENEVER. A pure function of severity, assigned in code. The only urgency wording a Triage Profile is allowed to use.
EMERGENCY TODAY THIS WEEK WHENEVER
criterion_1_firedbooleanTrue when the RUBRIC v1.1 EMERGENCY floor forced P1 — visible wiring, or water near anything electrical. This is the one legal way a triage reaches P1 with fault_detected false.
tier_forced_bystring | nullWhich rule overrode the decision table, when one did: "criterion-1:visible_wiring", "criterion-1:water_near_electrical", "not-a-building-asset", or "no-fault-detected-clamp".
visible_wiringbooleanPhoto-borne fact: a current-carrying conductor, splice or terminal a person could touch without a tool. Low-voltage wiring is excluded by definition. Uncertain reads as false, on purpose.
water_near_electricalbooleanPhoto-borne fact: water, wetting or wetting-corrosion in contact with, above, or beneath an electrical device. Uncertain reads as false, on purpose.
safety_hazardbooleanA hazard EXISTS. Additive — it never replaces the explanation, and it is true whenever the hazard reading is at least H1. Every P1 carries a hazard; not every hazard is a P1.
hazard_detailstring | nullThe named hazard. Non-empty when safety_hazard is true, null when it is false.
trade_requiredstring | nullThe trade to dispatch. Null when there is no fault.
confidencenumber | nullCalibrated 0-1 probability that the fault_detected verdict is correct. Out-of-range values are REJECTED to null, never rescaled on a guess. The Triage Profile does not print this number; it prints agreement instead.
resident_explanationstringThe engine's own plain-language line about the photo, delivered raw. It is model prose, not Triage Profile copy: on a no-fault reply it can assert absence (the sample above reads "no visible faults"), which a single read of a single photo cannot license. The shipping Triage Profile does not print this field; its no-fault paragraph is the fixed sentence "No fault was found, and that is an answer, not a blank." (triage-card.js, TEMPLATES.plain_states.NO_FAULT). Filled on every call, never "contact maintenance"; treat it as a draft to vet, not a line to forward.

Response: extended

Present only when extended=1 is requested; null otherwise. The rest of the contract output.

FieldMeaning
isHighRiskboolean — a projection of the readings (safety_hazard OR severity P1), not a fifth opinion.
hazard_classH0 | H1 | H2 — the hazard reading as the rubric recorded it (criterion 1 records H2).
active_lossL0 | L1 | L2 — the active-loss reading.
habitabilityC0 | C1 | C2 — the habitability reading.
deferral_costD0 | D1 | D2 — what one week of doing nothing does.
severity_reason_rowinteger|null — which row of the RUBRIC v1 decision table decided it (1..7). Null when an override decided instead; tier_forced_by then says which.
make_modelstring|null — only when legible on a label.
identification_reasoningstring|null — the distinguishing features behind asset_type.
observationsarray — up to 4 {label, box, confidence}. box is [ymin, xmin, ymax, xmax] as INTEGERS 0-1000 (the Clarity convention, not the 0-1 floats the fixragent.com web app uses for its own boxes). Divide by 10 for percentages of the photo's height and width.
loto_requiredboolean — lock-out/tag-out before work.
skill_levelinteger|null — 1 (handy resident) .. 5 (licensed specialist).
diy_appropriateboolean
estimated_duration_minutesinteger — technician time on site; 0 when no fault.
parts_manifestarray of {description, part_number, quantity}. part_number is null on every row and the server forces it so: procurement assigns SKUs, this engine does not.
resident_why_it_mattersstring
resident_do_notstring|null — the one thing the resident must not do.
resident_what_happens_nextstring
root_causestring|null — 2-3 sentences on the physical mechanism, for a supervisor.
failure_mechanismstring|null — one sentence, the failure mode in trade language.
differentialarray of {cause, tell} — up to 4 candidates, most likely first; tell is what a technician observes on site to confirm or rule it out.
wiring_water_reasonstring|null — one sentence on what in the photo decided visible_wiring and water_near_electrical.

Response: provenance

What produced this answer. Recorded by the wrapper — a model is never asked to report its own configuration.

FieldMeaning
contract_versionstring
modelsobject
temperaturenumber
prompt_hashFirst 16 hex of SHA-256 over both prompts as actually sent, base plus addendum.
prompt_baseThe prompts are IMPORTED from api/clarity-diagnose.js by way of api/_triage-pipeline.js, not copied: one engine, two doors.
prompt_addendumThe rubric addendum this endpoint appends, asking for the four graded readings and the two criterion-1 booleans.
agreementThe same object as the top-level agreement, kept with the provenance so a stored row carries it.
latency_msinteger
photo_sha256SHA-256 of the photo AFTER metadata stripping. The only thing kept about the image.
exif_strip_versionstring
model_proposed_severityWhat the model said. Kept for scoring, never published as the answer.
severity_assigned_byThe server plus the rubric version, on every call.
severity_overwrittenTrue when the rubric disagreed with the model.
spend_todayToday's spend against the daily brake. An estimate, not a bill.

Response: stored

What happened to the database row in public.diy_guest_scans. A degraded write never reads the same as a clean one.

Rows land in public.diy_guest_scans. The provenance columns (diagnosis_id, engine_version, rubric_version, config_id, role, variant) come from a migration that was applied to production on 12 Sep 2026; on a database where it has not been applied the insert is retried with the pre-existing columns and the response comes back with degraded: true and the migration's filename. The values are still inside ai_result; they are simply not queryable by column until the migration runs. A degraded write never reads the same as a clean one.

Errors

Every error is { "error": "<code>", "message": "<plain English>" }. The message is written for a person; it is never a stack trace and never an internal code.

Every one of these leaves through the same exit. The status you see is read from one table in api/triage.js; there is no second path that writes a status of its own. budget_exhausted used to be that second path — it hand-wrote 503 beside the table instead of reading it — and because the two numbers agreed, nothing ever showed. qa/triage-loop/error-status-redproof.js now drives all 14 codes to the wire and compares each response's ACTUAL status to the table.

Retry-After. The three responses that ask you to come back later — rate_limited, demo_key_quota and budget_exhausted — carry a Retry-After header, the same number in the body as retry_after_seconds, and a retry_after_basis naming which clock it came from (ip_rate_window for your address's own one-hour window, utc_day for the next 00:00 UTC). It is computed, never a round number: ask again 30 minutes into a closed rate-limit window and the header says 1800, not 3600. Everything else on this page sends no Retry-After, and says nothing in its message about trying again — a sentence that promises a retry with no header behind it is a promise nothing can keep.

StatusCodeMessage
405method_not_allowedMethod not allowed. POST a photo to this endpoint.
429rate_limitedToo many requests from this address. The Retry-After header on this response says how many seconds until this address is allowed again.
401demo_key_requiredA demo key is required. Send it as the x-triage-key header.
429demo_key_quotaThis key has used its 60 requests for today. The count resets at 00:00 UTC; the Retry-After header on this response says how many seconds that is.
503budget_exhaustedThe triage demo has used up its compute budget for today. This is a spending limit on our side, not a problem with your photo or your request. The budget resets at 00:00 UTC — please try again then.
400image_requiredA photo is required. Send it as base64 in the "image" field.
400image_invalidThat photo could not be read as base64 image data.
400image_too_smallThat photo is too small to diagnose.
413image_too_largeThat photo is larger than 3MB. Please send a smaller one.
415image_unsupportedThat image format cannot have its metadata stripped, so it is refused. Send a JPEG, PNG or WebP.
500metadata_strip_failedThat photo could not be cleaned of location metadata, so it was neither diagnosed nor stored.
502engine_errorThe diagnostic engine did not return a usable answer.
502contract_unrepairableThe diagnostic engine returned an answer that breaks the output contract in a way that could not be repaired safely, so nothing is returned.
500internal_errorSomething went wrong on our side.
429key_cap_reachedThis key has reached its assessment cap for the current window. Nothing past the cap is run or billed. The usage block says the cap and when the window resets; the Retry-After header says how many seconds that is.
429key_burst_limitedThis key sent more requests in the last minute than its burst allowance. Nothing was run or billed. The Retry-After header says how many seconds until the next request is accepted.
503engine_busyThe diagnostic engine is busy or could not be reached just now. Nothing was billed. Retry after the Retry-After header; the same photo or the same Idempotency-Key is never charged twice.

Five of them on the wire (captured 13 Sep 2026, abridged; the clock was fixed at 08:00 UTC, so the 503's Retry-After reads 57600)

HTTP 405
{
  "error": "method_not_allowed",
  "message": "Method not allowed. POST a photo to this endpoint."
}

HTTP 401
{
  "error": "demo_key_required",
  "message": "A demo key is required. Send it as the x-triage-key header."
}

HTTP 400
{
  "error": "image_required",
  "message": "A photo is required. Send it as base64 in the \"image\" field."
}

HTTP 413
{
  "error": "image_too_large",
  "message": "That photo is larger than 3MB. Please send a smaller one.",
  "max_photo_bytes": 3145728,
  "platform_request_body_limit": {
    "bytes": 4500000,
    "stated_as": "4.5 MB",
    "source": "https://vercel.com/docs/functions/limitations",
    "source_last_updated": "August 24, 2026",
    "fetched": "2026-09-11",
    "evidence": "cited from vendor documentation fetched on this date — NOT measured against a deployed function (nothing deploys from this branch)"
  }
}

HTTP 503   Retry-After: 57600
{
  "error": "budget_exhausted",
  "message": "The triage demo has used up its compute budget for today. This is a spending limit on our side, not a problem with your photo or your request. The budget resets at 00:00 UTC — please try again then.",
  "resets_at": "2026-09-14T00:00:00.000Z",
  "budget": {
    "day": "2026-09-13",
    "limit_usd": 250,
    "limit_source": "default"
  },
  "retry_after_seconds": 57600,
  "retry_after_basis": "utc_day"
}

What happens to your photo

Every uploaded photo has its metadata stripped — EXIF, XMP, IPTC, and the other carriers that hold GPS — before it is hashed, before it is sent to the model, and before anything is stored.

If the metadata cannot be stripped, the photo is refused (415 image_unsupported or 500 metadata_strip_failed) rather than processed. There is no pass-through fallback, because a photo that still carries GPS looks the same as one that was cleaned.

This endpoint stores no photo bytes anywhere. The only thing kept about the image is the SHA-256 of the stripped version, in provenance.photo_sha256. The model provider, Google, keeps what it is sent — prompt, response and photo — for 55 days. What we keep ourselves, and for how long, is in the privacy policy.

The outcome loop: callback_url

Each triage comes back with a diagnosis_id and a callback_url. The URL is not stored anywhere — it is derived on every reply as /api/outcome?diagnosis_id=<id> — and the id it carries is what joins the two records: the triage row (diy_guest_scans.diagnosis_id) and the outcome row (outcomes, one row per triage per lane). When you find out what the thing actually was, POST it back:

outcome

Send it with the diagnosis_id from a triage you made. This endpoint takes no key, and an id it has never seen is recorded as a new outcome row against nothing, so an id copied from an example writes noise into the corpus.

curl -X POST "https://fixragent.com/api/outcome" -H "Content-Type: application/json" \
  -d '{"diagnosis_id":"<the diagnosis_id from your triage reply>","outcome":"fixed","reported_via":"triage_link","fixed_by":"pro","trade":"hvac"}'
FieldValuesNotes
diagnosis_id requireduuidRead from the JSON body. The query string on callback_url is there so the link identifies the triage to a human and in logs; the endpoint does not read it. An id that is not a uuid is refused with 400 outcome_orphan_refused — an outcome that cannot join its triage is noise.
outcome requiredfixed | not_fixed | partialTap one is a complete answer on its own.
reported_via requiredresults_screen | scan_log | triage_linkWhich surface the answer came from. An API client reporting on its own behalf uses triage_link. Any other value is refused with 400 reported_via_invalid; nothing is downgraded to a permitted value, because a row that claims the wrong surface would be indistinguishable from a real one forever after.
fixed_bysteps | other_diy | pro | replacedOptional.
tradeplumber | electrician | hvac | appliance-repair | roofer | handyman | pest-control | landlord-maintenance | myself | otherOptional — who actually went.
fix_description, parts_textstringOptional; 1000 and 500 characters.
emailstringOptional. Used only to attach the outcome to an existing account server-side; the client never asserts an identity column.

Merge, not replace. The row is updated with only the keys you sent. A key you did not send leaves the stored value alone — an unanswered question and an answer of "none" must not write the same thing — so a second, fuller report never wipes a first one. A concurrent first report is merged onto rather than dropped.

Replies. 200 { ok: true, outcome: { id, diagnosis_id, outcome, fixed_by, reported_via } } on a write that landed. 400 with one of outcome_orphan_refused, outcome_label_invalid, reported_via_invalid, fixed_by_invalid, trade_invalid (each carries allowed). 409 triage_lane_migration_pending names the migration file if the database does not yet accept the triage-link lane; the write is refused, never downgraded. 500 is a plain { error: "Internal error" }.

The same endpoint answers GET /api/outcome?email=&diagnosis_ids= with the outcomes for triages that email's account owns — that is the app's own scan log, not an API surface, and it returns nothing for an id the account does not own.

The terms

Use of the API is governed by the API terms at /api-terms, alongside the terms of service and the privacy policy. In one line: this is an educational triage, not professional advice; gas, water near electrics and exposed wiring are licensed-trade jobs.

Help

This reference is the help centre; /help puts the common questions in plain words and sends each to its anchor here. Stuck, or something on the Triage Profile looks wrong? Write to support@fixragent.com. A founder reads every message and replies within one business day. Legal questions and deletion requests go to legal@fixragent.com.