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.
task | Fourth section | What the lane answers | Scope |
|---|---|---|---|
triage | FIX ORDER | Which 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 |
dataflow | DATA FLOW | Who produced the invalid value, and who merely crashed on it? Producer, carriers, consumer, and the one boundary a check belongs at. | one error |
enrich | REWRITTEN MESSAGE | How 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 |
handling | HANDLING CHANGES | Where 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.
| Field | Type | Meaning |
|---|---|---|
task | string | One of the four lane ids above. Required. |
export_text | string | The 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_clipped | boolean | True when the app clipped it. |
export_clip_note | string|null | Human-readable note about what was cut. |
environment | string | unstated, production, beta, staging or local. |
context | string|null | The user's free-text note. |
masked | boolean | True when identifiers were replaced with placeholders. Masking runs BEFORE the scan, so every other field here is derived from masked text. |
prescan | object | The deterministic in-browser scan. See below. |
focus_error_id | string|null | dataflow / enrich / handling only: the G-id of the single error the lane is about. |
focus_error | object|null | dataflow / enrich / handling only: { id, message, class, stack, hits, users } for that error. |
code | string|null | dataflow / enrich / handling only: the user's source around the error. Clipped at 12,000 characters. |
code_was_clipped | boolean | True 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.
| Key | Meaning |
|---|---|
rows / distinct_errors / collapsed_rows | How many rows were read, how many distinct bugs they collapse to, and the difference. |
total_hits / total_users / top_share_pct | Volume. Null when the export carried no such column — null is not zero. |
versions / platforms / frame_kinds | Distinct 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
## GROUNDINGVERDICT— one line: one ofact-now,needs-instrumentation,mostly-noise,not-yours,insufficient-input, an em dash, then one sentence.FINDINGS— bullets of the form- [severity] Title — detail (G3), severity incritical|high|medium|low, or exactlyNone.GROUNDING— one- G3: claim (from the scan: fact)line per substantive per-error claim. The web UI reconciles these against the scan and reports anyGid that does not exist.
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.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer YOUR_TOKEN"import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method="POST" if data else "GET")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read())
if not payload.get("ok"):
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]
print(call("/me"))const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(path, body, extraHeaders) {
const res = await fetch(BASE + path, {
method: body ? "POST" : "GET",
headers: Object.assign({
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}, extraHeaders || {}),
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error.code + ": " + payload.error.message);
return payload.data;
}
console.log(await call("/me"));package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func call(path string, body any) (map[string]any, error) {
var r io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, r)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var p struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error struct{ Code, Message string } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&p)
if !p.OK {
return nil, fmt.Errorf("%s: %s", p.Error.Code, p.Error.Message)
}
return p.Data, nil
}
func main() {
me, err := call("/me", nil)
fmt.Println(me, err)
}import java.net.URI;
import java.net.http.*;
public class CrashInbox {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = (jsonBody == null) ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
public static void main(String[] args) throws Exception {
System.out.println(call("/me", null));
}
}require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"
def call(path, body = nil)
uri = URI(BASE.to_s + path)
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]
end
puts call("/me")<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $path, ?array $body = null): array {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json"],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
print_r(call("/me"));using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
async Task<JsonElement> Call(string path, object? body = null) {
HttpResponseMessage res = body is null
? await http.GetAsync(Base + path)
: await http.PostAsync(Base + path,
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean()) {
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
Console.WriteLine(await Call("/me"));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.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d @input.jsonpayload = {"task": "triage", "export_text": open("errors.csv").read(),
"environment": "production", "context": None, "masked": False,
"prescan": prescan} # see "Building prescan yourself" below
est = call("/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])const payload = {
task: "triage",
export_text: exportCsv,
environment: "production",
context: null,
masked: false,
prescan // see "Building prescan yourself" below
};
const est = await call("/estimate", payload);
console.log(est.model, est.model_alias, est.hold_credits, est.min_credits);payload := map[string]any{
"task": "triage",
"export_text": exportCSV,
"environment": "production",
"masked": false,
"prescan": prescan,
}
est, err := call("/estimate", payload)
fmt.Println(est["hold_credits"], est["model_alias"], err)String payload = """
{"task":"triage","export_text":%s,"environment":"production","masked":false,"prescan":%s}
""".formatted(jsonString(exportCsv), prescanJson);
System.out.println(call("/estimate", payload));payload = {
"task" => "triage",
"export_text" => File.read("errors.csv"),
"environment" => "production",
"masked" => false,
"prescan" => prescan
}
est = call("/estimate", payload)
puts est["hold_credits"], est["model_alias"]$payload = [
"task" => "triage",
"export_text" => file_get_contents("errors.csv"),
"environment" => "production",
"masked" => false,
"prescan" => $prescan,
];
$est = call("/estimate", $payload);
echo $est["hold_credits"], " ", $est["model_alias"], PHP_EOL;var payload = new {
task = "triage",
export_text = File.ReadAllText("errors.csv"),
environment = "production",
masked = false,
prescan
};
var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("hold_credits"));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.
# submit
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crash-inbox:triage:9f2a1c04:0" \
-d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# poll
until curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB \
-H "Authorization: Bearer YOUR_TOKEN" | grep -q '"status":"succeeded"'; do sleep 2; done
curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB -H "Authorization: Bearer YOUR_TOKEN"import time
job = call("/run", payload) # Idempotency-Key header recommended, see note
job_id = job["job_id"]
while True:
j = call("/jobs/" + job_id)
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
print(j["status"], j.get("charged_credits"), j.get("truncated"))
print(j["output"]["output"]) # the seven-section Markdown replyconst job = await call("/run", payload, {
"Idempotency-Key": "crash-inbox:triage:9f2a1c04:0"
});
let j;
for (;;) {
j = await call("/jobs/" + job.job_id);
if (["succeeded", "failed", "cancelled"].includes(j.status)) break;
await new Promise(r => setTimeout(r, 2000));
}
console.log(j.status, j.charged_credits, j.truncated);
console.log(j.output.output);job, _ := call("/run", payload)
id := job["job_id"].(string)
var j map[string]any
for {
j, _ = call("/jobs/"+id, nil)
s := j["status"].(string)
if s == "succeeded" || s == "failed" || s == "cancelled" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println(j["status"], j["charged_credits"])String job = call("/run", payload);
String jobId = extract(job, "job_id");
String j;
while (true) {
j = call("/jobs/" + jobId, null);
String s = extract(j, "status");
if (s.equals("succeeded") || s.equals("failed") || s.equals("cancelled")) break;
Thread.sleep(2000);
}
System.out.println(j);job = call("/run", payload)
job_id = job["job_id"]
loop do
j = call("/jobs/#{job_id}")
if %w[succeeded failed cancelled].include?(j["status"])
puts j["status"], j["charged_credits"]
puts j.dig("output", "output")
break
end
sleep 2
end$job = call("/run", $payload);
$id = $job["job_id"];
do {
sleep(2);
$j = call("/jobs/" . $id);
} while (!in_array($j["status"], ["succeeded", "failed", "cancelled"], true));
echo $j["status"], " ", $j["charged_credits"] ?? "", PHP_EOL;
echo $j["output"]["output"], PHP_EOL;var job = await Call("/run", payload);
var id = job.GetProperty("job_id").GetString();
JsonElement j;
while (true) {
j = await Call($"/jobs/{id}");
var s = j.GetProperty("status").GetString();
if (s is "succeeded" or "failed" or "cancelled") break;
await Task.Delay(2000);
}
Console.WriteLine(j.GetProperty("output").GetProperty("output").GetString());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.
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crash-inbox:triage:9f2a1c04:0" \
-d @input.json
# Server-sent events: `delta` frames carry text, `job` frames carry the
# terminal record. Watch for the "## " headings to advance a progress bar.import json, urllib.request
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "crash-inbox:triage:9f2a1c04:0")
text = ""
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().strip()
if not line.startswith("data:"):
continue
evt = json.loads(line[5:])
if evt.get("text"):
text += evt["text"]
print(text)const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": "crash-inbox:triage:9f2a1c04:0"
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let text = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split("\n")) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5));
if (evt.text) text += evt.text;
}
}
console.log(text);body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "crash-inbox:triage:9f2a1c04:0")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data:") {
fmt.Print(line[5:])
}
}HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "crash-inbox:triage:9f2a1c04:0")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> System.out.print(l.substring(5)));uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "crash-inbox:triage:9f2a1c04:0"
req.body = JSON.dump(payload)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body { |chunk| print chunk }
end
end$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: crash-inbox:triage:9f2a1c04:0",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
echo $chunk;
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
req.Headers.Add("Idempotency-Key", "crash-inbox:triage:9f2a1c04:0");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
string? line;
while ((line = await sr.ReadLineAsync()) is not null) {
if (line.StartsWith("data:")) Console.Write(line[5..]);
}Errors
| code | HTTP | What to do |
|---|---|---|
UNAUTHORIZED | 401 | No token, or a token for a different app. Mint a new one (step 1). |
INSUFFICIENT_CREDITS | 402 | Balance below min_credits. estimate is free — check it first. |
VALIDATION_ERROR | 400 | The input object is malformed. details.violations names the field. |
RATE_LIMITED | 429 | Back off. Do not tight-loop a poll. |
NOT_FOUND | 404 | Wrong 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
- It will not read your error tracker. There is no integration and no outbound request; the export you send is the whole of the evidence.
- It will not symbolicate. Minified frames are reported as unreadable, not resolved.
- It will not invent an error id. Ids come from
prescan.errors[].id; the web UI reports any that do not. - It will not mask anything for you. Masking happens in the browser before the request is built; over the API, what you send is what the model sees.