Crash Inbox

Drive Crash Inbox from your pipeline

Everything the web page does is available over HTTP. Base URL: https://api.skillsafe.ai/v1/app-api. Every response is the same envelope — {"ok": true, "data": {...}} on success, {"ok": false, "error": {"code": "...", "message": "...", "details": {...}}} on failure. Check ok before you touch data.

The task field comes first

Crash Inbox is one app with four lanes over one work object — an error-telemetry export. Every request must carry a task field naming the lane. The lane decides which fourth section comes back; the other six sections are identical across all four.

taskFourth sectionWhat the lane answersScope
triageFIX ORDERWhich of these is worth fixing first? Rows merged into bugs, ranked by users affected, with the unactionable ones separated out before anything is ranked.whole export
dataflowDATA FLOWWho produced the invalid value, and who merely crashed on it? Producer, carriers, consumer, and the one boundary a check belongs at.one error
enrichREWRITTEN MESSAGEHow do we make the next occurrence readable from telemetry alone? A rewritten throw, the context worth attaching, and the context that must never be.one error
handlingHANDLING CHANGESWhere is this being swallowed, retried or rethrown blind? Tagged swallow / rethrow / context / retry / filter / boundary.one error

If task is absent or is not one of those four, the reviewer picks the closest lane and names its choice in ## SUMMARY rather than blending two contracts. Do not rely on that — send the field. The three single-error lanes also need focus_error_id; without it they will answer about the highest-ranked error.

The input object

These are the exact fields app.js submits. Anything else is ignored.

FieldTypeMeaning
taskstringOne of the four lane ids above. Required.
export_textstringThe pasted export. Clipped at 60,000 characters on a whole-row boundary with the header row kept, and an inline marker saying how many rows were dropped.
export_was_clippedbooleanTrue when the app clipped it.
export_clip_notestring|nullHuman-readable note about what was cut.
environmentstringunstated, production, beta, staging or local.
contextstring|nullThe user's free-text note.
maskedbooleanTrue when identifiers were replaced with placeholders. Masking runs BEFORE the scan, so every other field here is derived from masked text.
prescanobjectThe deterministic in-browser scan. See below.
focus_error_idstring|nulldataflow / enrich / handling only: the G-id of the single error the lane is about.
focus_errorobject|nulldataflow / enrich / handling only: { id, message, class, stack, hits, users } for that error.
codestring|nulldataflow / enrich / handling only: the user's source around the error. Clipped at 12,000 characters.
code_was_clippedbooleanTrue when the code paste was clipped.
{
  "task": "triage",
  "export_text": "message,stack,hits,users,version\n\"Cannot read properties of undefined (reading 'workspaceId')\",\"TypeError: ...\n    at resolveWorkspace (src/session/resolve.ts:88:31)\",4120,388,1.84.2",
  "export_was_clipped": false,
  "export_clip_note": null,
  "environment": "production",
  "context": "the week after the 1.84 rollout",
  "masked": false,
  "prescan": { "rows": 8, "distinct_errors": 7, "checks": [ ... ], "errors": [ ... ] }
}

The prescan object, and why unknown matters

prescan is what the browser computed from the same text before any model saw it. Passing it is optional over the API but strongly recommended: the reviewer is instructed not to re-derive counts, and an absent prescan makes every volume claim a guess.

KeyMeaning
rows / distinct_errors / collapsed_rowsHow many rows were read, how many distinct bugs they collapse to, and the difference.
total_hits / total_users / top_share_pctVolume. Null when the export carried no such column — null is not zero.
versions / platforms / frame_kindsDistinct builds and platforms seen, and a count of frames by kind (app, vendor, extension, runtime, unknown).
checks[]Thirteen rules, each { id, verdict, detail, evidence }. verdict is pass, fail or unknown.
blind_spots[]Plain sentences naming what the scan could not see.
errors[]Up to 25 distinct bugs, each with id (G1, G2, …), rank, message, class, class_label, rows_merged, hits, users, hits_per_user, versions, platforms, has_stack, minified, first_party_frame, extension_only, opaque_message, carries_value, culprit and top_frames[].

Each check's verdict is tri-state. unknown means the export never carried the column the rule needs — it is not a soft fail, and the reviewer is instructed never to restate one as the other. A rule that has local evidence stays definite even when a capability is missing: an export that declares a version column and returns it empty on every row is a definite fail, while an export with no version column at all is unknown.

The output contract

The reply is Markdown with exactly seven ## headings, in this order, and nothing outside them:

## VERDICT
## SUMMARY
## FINDINGS
## <the lane's own section>
## UNKNOWNS
## NEXT STEPS
## GROUNDING

The parser is deliberately tolerant of a stream that stopped early: it renders the sections that arrived and reports “N of 7 recovered”. Recovery is counted against this lane's seven sections, so a complete reply always reports 7 of 7.

Step 1 — get a token

Tokens are per-app. The easiest way to get one for Crash Inbox is the token page: it reads the token this browser already holds, shows it masked, and copies it as a shell export. Sign in there first if you want the run billed to your own credits rather than to a guest wallet.

Programmatically, mint a guest token — note the body key is slug:

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug": "crash-inbox"}'

Then send it as Authorization: Bearer YOUR_TOKEN on every call below.

Step 2 — confirm the session

GET /me returns subject_type (user or guest) and credits. Call it once to prove the token works before spending anything. The helper defined here is reused by every later step.

Step 3 — build the input and price it

POST /estimate is free and creates no job. It returns model, model_alias, markup_bps, hold_credits and min_credits. hold_credits is a reservation priced against the full output cap, not the price — the actual charge is usually far lower. Estimate per lane: the four lanes do not cost the same.

Step 4 — run it, and poll

POST /run returns {job_id}; poll GET /jobs/{id} until status is terminal. Always send an Idempotency-Key derived from the lane plus a hash of the input plus an attempt counter — a retried request that reuses the key is not billed twice. The web app uses crash-inbox:<lane>:<hash>:<attempt>.

A terminal job carries charged_credits and, when the balance only covered a reduced output cap, truncated: true. Treat a truncated reply as partial and say so — do not present it as complete.

Step 5 — stream it instead

POST /run-stream returns server-sent events. delta frames carry text as it is produced; the terminal frame carries the job record. The web app advances its progress card by watching for the ## headings arriving in the stream, which is a real signal rather than a timer.

Errors

codeHTTPWhat to do
UNAUTHORIZED401No token, or a token for a different app. Mint a new one (step 1).
INSUFFICIENT_CREDITS402Balance below min_credits. estimate is free — check it first.
VALIDATION_ERROR400The input object is malformed. details.violations names the field.
RATE_LIMITED429Back off. Do not tight-loop a poll.
NOT_FOUND404Wrong job id, or a job that belongs to another subject.

One worked example per lane

Each block is the shape of a real reply, trimmed to its fourth section. The other six headings are identical in every lane.

task: "triage"

Input: the whole export. No focus_error_id.

## VERDICT

act-now — one first-party regression is reaching 510 users across two builds, and the loudest row in the export is a cancellation that should never have been reported.

## FIX ORDER

| rank | error | why now | who | first move |
| --- | --- | --- | --- | --- |
| 1 | G1 undefined workspaceId | 510 users across 1.84.1 and 1.84.2; 2 dashboard rows, one bug | src/session — editor team | Guard the evicted-id path in hydrate() before resolveWorkspace reads it |
| 2 | G2 Failed to fetch | 119.6 hits per user: a retry loop, and every frame is an unmapped bundle offset | unclear from the export | Upload source maps for 1.84.2, then re-triage |
| 3 | G4 AbortError | 22,400 hits from 5,410 users is the largest number here and none of it is a defect | src/preview | Filter cancellations at the reporter |

## UNKNOWNS

- Whether G2 is one bug or several cannot be decided while its frames are minified; source maps would settle it.

task: "dataflow"

Input adds focus_error_id: "G1" and optionally code.

## DATA FLOW

- [producer] hydrate (src/session/open.ts:142) — returns undefined for an evicted id and does not signal it; this is where the invalid value enters.
- [carrier] openSession (src/session/open.ts:142) — passes ctx straight to resolveWorkspace without checking it.
- [consumer] resolveWorkspace (src/session/resolve.ts:88) — reads ctx.id, then ws.workspaceId, and throws. This is the symptom, not the bug.
- [boundary] openSession, immediately after hydrate — a check belongs where the absence is still meaningful, not at the throw site where the caller's intent has been lost.

task: "enrich"

Input adds focus_error_id for an error whose message names nothing.

## REWRITTEN MESSAGE

```ts
throw new Error(`state.restore failed: profile=${profileId} step=${step} reason=${reason}`);
```

- Attach the operation name and the step — the tracker groups on the message prefix, so a stable `state.restore failed:` keeps occurrences in one bucket.
- Attach the profile id, not the profile: an id is enough to find the record and carries nothing about the person.
- [never] Never attach the request body, the auth header or anything the user typed. The tracker is not a place where that data has a retention policy.

task: "handling"

Input adds focus_error_id and, ideally, the surrounding code.

## HANDLING CHANGES

- [filter] src/preview/controller.ts:210 — an aborted preview is the app working. Drop it at the reporter rather than handling it; do not add error-handling code for a non-error.
- [retry] src/net/client.ts:44 — five attempts each reporting separately turns one network fault into five telemetry events. Report once, on the final failure, with the attempt count attached.
- [rethrow] src/net/client.ts — the final `throw new Error("request failed")` drops the cause, so the stack points at the retry loop rather than at the fault. Attach the original error.

What the API will not do