← Wire Desk / API
Tokens

Drive Wire Desk from your own code

Everything the web page does is available over HTTP: post two or more captured frames as hex, get back either a named field map with a Kaitai spec, a Wireshark dissector and a Python parser, or a read on which regions of the frame are compressed, encoded, obfuscated or genuinely encrypted. The natural use is a CI job that re-runs the map whenever a firmware build changes the wire format and fails the build when a field moves, or a script that walks a folder of saved captures overnight and writes one protocol.ksy per device instead of one per afternoon of staring at hex.

The browser and the API send the same input object. The one thing the browser does that you do not have to reproduce is the free local prescan — see prescan, honestly below.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your token as Authorization: Bearer aut_… and nothing else. There is no app-slug header: an app-user token already binds the pair {app, subject}, so the token alone says which app you are calling and who you are. The only call that names the app in its body is the guest mint, and the only call that names the app in a header is none of them.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate for the lane you are about to run, then top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token, or ask the owner to sponsor usage.
not_found404Unknown job id, or the app slug in the guest mint does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — frames is the usual one — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop a poll.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

Start with task: it picks the lane

task is the first field to decide and the only one that changes the shape of the answer. It is the lane router. Two lanes read the same frames and return the same envelope, then diverge completely in the body — a caller that reads fields[] out of an armor reply finds an empty array, and a caller that reads regions[] out of a map reply finds the same. Send it explicitly on every call; the reply echoes it back in task, and that echo is what you branch on.

taskthe question it answersthe body it addsposture values
map What do these bytes mean? Framing, endianness, and every field at its offset with a width, a type, a role, the observed values and the evidence that decided it — plus the checksum algorithm, the byte ranges that could not be named, and the next capture that would settle them. framing, endianness, fields[], checksum, unresolved[], next_captures[] decoded, partial, opaque
armor Which parts are deliberately hard, and what kind of hard? Per-region entropy readings, container signatures and block alignment decide compressed versus encoded versus cheaply obfuscated versus genuinely encrypted — with a confirmation test for each technique, the blockers, and the pivot when frame-staring cannot win. regions[], techniques[], blockers[], pivots[] transparent, packed, obfuscated, encrypted, mixed

The lanes are complementary, not alternatives, and the usual order is map first: it produces unresolved[], and unresolved[] is exactly what the armour lane is for. Carry it across in handoff and the second run starts where the first stopped rather than re-deriving the header. Two lanes over one capture are two metered runs.

Worked example: task: "map"

Two frames off an RS-485 sensor bus. The request, trimmed to the fields that matter:

{
  "task": "map",
  "frames": "# frame 1 [tx] poll reply, bay A idle (19 bytes)\n0000: aa 55 11 21 00 0b 42 41 59 41 01 41 ac 00 00 12\n0010: d4 19 98\n# frame 2 [tx] poll reply, bay A heating (19 bytes)\n0000: aa 55 12 21 00 0b 42 41 59 41 05 41 b6 00 00 12\n0010: 6b 07 2b",
  "frame_count": 2,
  "transport": "serial",
  "goal": "parse",
  "context": "Two frames off an RS-485 bus between an HVAC controller and a bay sensor, 19200 8N1, the same bay polled a minute apart. The vendor documents nothing."
}

The reply, with the long strings cut for reading. Nothing is optional; every key is present:

{
  "task": "map",
  "title": "AA-55 framed sensor telemetry, 2 frames",
  "posture": "decoded",
  "confidence": "medium",
  "verdict": "A length-prefixed AA-55 frame whose CRC-16/MODBUS trailer verifies in both frames, so every byte of the 19-byte frame is accounted for.",
  "exec_summary": "...",
  "framing": {
    "style": "length-prefixed",
    "evidence": "u16 big-endian at offset 4 equals 11 in both frames, which is the byte count between offset 6 and the CRC field at offset 17"
  },
  "endianness": "big",
  "fields": [
    {"id": "F-001", "offset": 0,  "width": 2, "name": "sync_word",  "type": "bytes",    "endian": "n/a",    "role": "magic",    "observed": "aa 55 in both frames", "evidence": "prescan MAGIC-PREFIX", "confidence": "high"},
    {"id": "F-002", "offset": 2,  "width": 1, "name": "sequence",   "type": "u8",       "endian": "n/a",    "role": "sequence", "observed": "0x11 then 0x12", "evidence": "prescan SEQUENCE-FIELD: steps by 1", "confidence": "high"},
    {"id": "F-003", "offset": 3,  "width": 1, "name": "msg_type",   "type": "u8",       "endian": "n/a",    "role": "type",     "observed": "0x21 in both frames", "evidence": "constant across frames of equal length", "confidence": "medium"},
    {"id": "F-004", "offset": 4,  "width": 2, "name": "payload_len","type": "u16",      "endian": "big",    "role": "length",   "observed": "0x000b = 11", "evidence": "prescan LENGTH-FIELD", "confidence": "high"},
    {"id": "F-005", "offset": 6,  "width": 4, "name": "bay_id",     "type": "ascii",    "endian": "n/a",    "role": "address",  "observed": "\"BAYA\"", "evidence": "prescan ASCII-RUNS at offset 6", "confidence": "high"},
    {"id": "F-006", "offset": 10, "width": 1, "name": "status",     "type": "bitfield", "endian": "n/a",    "role": "flags",    "observed": "0x01 idle, 0x05 heating", "evidence": "prescan BITFIELD: only bits 0 and 2 ever move", "confidence": "medium"},
    {"id": "F-007", "offset": 11, "width": 4, "name": "temp_c",     "type": "f32",      "endian": "big",    "role": "payload",  "observed": "21.5 then 22.75", "evidence": "prescan float32 candidate; the u32 reading is 1101529088 and means nothing", "confidence": "medium"},
    {"id": "F-008", "offset": 15, "width": 2, "name": "humidity_pct_x100", "type": "u16", "endian": "big", "role": "payload",  "observed": "0x12d4 = 4820", "evidence": "48.20% read as hundredths is the only plausible physical scale", "confidence": "low"},
    {"id": "F-009", "offset": 17, "width": 2, "name": "crc",        "type": "u16",      "endian": "little", "role": "checksum", "observed": "19 98 then 07 2b", "evidence": "prescan CHECKSUM-VERIFIED", "confidence": "high"}
  ],
  "checksum": {
    "algorithm": "crc16-modbus",
    "offset": 17, "width": 2, "endian": "little",
    "range": "bytes 0 through 16 inclusive",
    "verified": "prescan-confirmed",
    "note": ""
  },
  "unresolved": [],
  "next_captures": [
    {"action": "Poll the same bay twice with only the setpoint changed", "resolves": "whether offset 15 is humidity or the setpoint echo"}
  ],
  "assumptions": ["..."], "open_questions": ["..."],
  "coverage_check": [{"id": "MAGIC-PREFIX", "addressed": true, "note": "..."}],
  "artifacts": [
    {"name": "protocol.ksy",  "language": "yaml",   "content": "meta:\n  id: aa55_sensor\n  endian: be\n..."},
    {"name": "dissector.lua", "language": "lua",    "content": "local p = Proto(\"aa55\", \"AA-55 sensor bus\")\n..."},
    {"name": "parse.py",      "language": "python", "content": "import struct\n..."}
  ],
  "next_steps": ["..."],
  "summary": "..."
}

Worked example: task: "armor"

The same request object with "task": "armor" — nothing else changes — over a capture whose body is block-aligned and high-entropy. The envelope keys are identical; the body is not:

{
  "task": "armor",
  "title": "WP-framed application protocol, 64-byte aligned body",
  "posture": "encrypted",
  "confidence": "medium",
  "verdict": "The body from offset 8 onward is high-entropy with no container signature and every frame length is a multiple of 16, so it is a block cipher and no field mapping inside it is possible without key material.",
  "exec_summary": "...",
  "regions": [
    {
      "id": "R-001", "offset": 8, "width": 64,
      "character": "high-entropy",
      "technique": "AES-CBC or another 16-byte block cipher",
      "assessment": "Two to four sentences on what this region is and what it would take to read it.",
      "evidence": "prescan HIGH-ENTROPY-REGION: 7.81 bits/byte over offsets 8-71; prescan BLOCK-ALIGNED: every frame length is a multiple of 16",
      "confidence": "medium"
    }
  ],
  "techniques": [
    {
      "name": "Fixed-key XOR over the body",
      "indicators": ["byte-wise difference between the two frames repeats with period 4"],
      "how_to_confirm": "XOR the two frames together from offset 8 and look for a repeating 4-byte pattern",
      "effort": "low"
    }
  ],
  "blockers": [
    {"blocker": "Per-frame IV with no reuse", "why": "Nothing can be recovered from ciphertext alone.", "workaround": "Instrument the client before encryption."}
  ],
  "pivots": [
    {"pivot": "The client binary", "rationale": "The key has to exist in the process that built these frames.", "first_move": "Search the binary for the constant 4-byte prefix seen at offset 8."}
  ],
  "assumptions": ["..."], "open_questions": ["..."],
  "coverage_check": [{"id": "BLOCK-ALIGNED", "addressed": true, "note": "..."}],
  "artifacts": [
    {"name": "confirm.md", "language": "markdown", "content": "# Confirmation plan\n1. ..."},
    {"name": "probe.py",   "language": "python",   "content": "import sys\n..."}
  ],
  "next_steps": ["..."],
  "summary": "..."
}

The input object

The input object is exactly what the app's own form submits, field for field. It is the body of /estimate, /run and /run-stream — the object itself, not wrapped in anything.

fieldtypemeaning
taskstring, required map or armor. The lane router, described above. An unrecognised value is not rejected — the model answers the closest lane and names the lane it chose in title — so send one of the two literals and branch on the task the reply echoes.
framesstring, required The capture itself: one labelled block per frame, each block a header line followed by offset: hh hh hh … lines. The browser normalises whatever was pasted — xxd, hexdump -C, od, a Wireshark hex stream, a C byte array, a backslash-x escaped string — into that one shape, and the shape is what the run receives. Two frames is the working minimum; one frame has nothing to diff against. Lowercase hex pairs, decimal offsets in the citations, hex offsets in the gutter. The browser clips the capture to about 6,000 characters of hex on frame boundaries, and an over-long capture is better cut by you than by the budget.
frame_countnumber How many frames the capture really has, which may be more than the number of blocks in frames when the middle of a long capture was dropped to fit. It changes how much the answer is allowed to claim: three frames cannot establish a 16-bit enum, and the reply is expected to say low rather than guess. Send the honest count.
transportstring Where it was captured: serial, tcp, udp, usb, can, bluetooth, file or unknown. This is not decoration — it changes what framing is even possible. A UDP datagram is self-delimiting and needs no length field; a serial stream cannot be parsed without one, so on serial the absence of a length field is a finding rather than a shrug. It also decides which DissectorTable the generated Lua registers on: tcp.port, udp.port or wtap_encap.
goalstring parse (build a working parser), document (write the protocol down), interop (talk to the device) or debug (find out why a frame is being rejected). Emphasis, not exclusivity — the artifacts are produced either way, but debug pushes the answer toward the validation fields and document toward the prose.
contextstring, optional What the device is, what action produced each frame, what is already known and what has already been ruled out. This is the highest-value optional field in the object: "frame 1 is idle, frame 2 is after the heater started" is what turns an offset that varies into a temperature. An explicit statement — that it is a Modbus variant, that the device is a known model, that the capture is one side of a TLS session — is planned around rather than argued with. Clipped at about 4,000 characters.
prescanobject, optional for an API caller The browser's deterministic measurements over the same bytes. Shape and honest advice below.
handoffobject, optional Present when this run continues the other lane's result: {from, posture, unresolved: [{offset, width, why}], fields_named: [{offset, width, name, role}]}. from is map or armor. When it is present the run starts at those regions instead of re-deriving the header, and it will say which lane got it wrong if it disagrees. This is how you chain the two lanes in a script: run map, take its unresolved[] and fields[], post them back as handoff on the armor run.
retry_notestring, optional Send only on a retry, when a previous reply failed to parse. The instruction is obeyed exactly — "the previous reply was not valid JSON; return the same field map with shorter evidence strings and only the Kaitai artifact" is the sort of thing that works. Bump the attempt suffix on the Idempotency-Key when you add it, because the body has changed.

prescan, honestly

prescan is optional for an API caller. In the browser it is computed for free before the run by a local frame scanner: it diffs every frame offset by offset, tries thirteen checksum algorithms at every plausible field position and range, tests every offset, width and endianness against two length-field conventions, walks six TLV shapes from eight start offsets, measures Shannon entropy in a sliding window and scans for container signatures. None of that is something you have to reproduce. Omit the key entirely, or send {"facts": {"resources": [], "flags": []}}, and the run still works — the model reads frames either way. What you lose is the reconciliation, not the answer: a caller who omits prescan simply gets a reply with an empty coverage_check and nothing to hold the answer against.

What makes it worth sending is that contract. Every id you send in facts.flags must come back exactly once in coverage_check, which turns a fact your own tooling already established into something the reply is measured by. An entry with addressed: false and a real reason in note is a correct answer — the prescan is deliberately eager and some of its flags do not matter for a given protocol — and it is a different thing from silence. A flag that never appears at all is a failed run. Ids you did not send should not appear either.

This is the whole sub-shape, exactly as the browser sends it:

{
  "facts": {
    "resources": [{"id": "FRAMES", "label": "2 frames read, 38 bytes total, lengths 19"}],
    "flags":     [{"id": "MAGIC-PREFIX", "label": "every frame starts with the same 2 bytes: aa 55"}]
  },
  "checksums": [                      // up to 3, verified arithmetically over EVERY frame
    {"algorithm": "crc16-modbus", "width": 2, "endian": "little", "position": "last 2 bytes",
     "offset": 17, "covers": "bytes 0 .. 16 (from the start of the frame)",
     "rangeStart": 0, "rangeEndExclusive": 17, "frames": 2}
  ],
  "length_fields": [                  // up to 4, in confidence order; [0] is the one to trust
    {"offset": 4, "width": 2, "endian": "big", "basis": "bytes after this field",
     "adjust": 2, "formula": "value == bytes remaining after this field - 2"}
  ],
  "counters": [                       // up to 3
    {"offset": 2, "width": 1, "endian": "n/a", "step": 1, "wrapped": false, "values": [17, 18]}
  ],
  "variance": [                       // up to 64 head offsets, ascending
    {"offset": 0, "kind": "constant", "distinct": 1, "values": ["aa", "aa"]},
    {"offset": 2, "kind": "counter",  "distinct": 2, "values": ["11", "12"]}
  ],
  "tail_variance": [                  // up to 8 offsets counted from the END; offset -1 is the last byte
    {"offset": -1, "kind": "varying", "distinct": 2, "values": ["98", "2b"]}
  ],
  "entropy": {
    "whole_frame_bits": [3.62, 3.71], // one Shannon reading per frame, bits per byte
    "high_regions": [                 // up to 12 merged windows at or above the high-entropy threshold
      {"frame": 1, "offset": 8, "width": 64, "bits": 7.81}
    ]
  },
  "magics":  [{"frame": 1, "offset": 8, "id": "gzip", "label": "gzip stream"}],
  "ascii":   [{"offset": 6, "length": 4, "text": "BAYA", "frames": [1, 2]}],
  "tlv":     {"start": 4, "type_width": 1, "length_width": 2, "endian": "big", "elements": 3},
  "alignment": {"lengths": [19, 19], "fixedLength": true, "multiples": [], "largestMultiple": 1,
                "trailingZeroRun": 0},
  "bitfields": [{"offset": 10, "mask": 4, "bits": ["bit 2"], "values": [1, 5]}],
  "timestamps": [{"offset": 12, "endian": "big", "values": [1755820800], "spreadSeconds": 0,
                  "iso": "2025-08-22T00:00:00Z"}],
  "floats":  [{"offset": 11, "endian": "big", "values": [21.5, 22.75], "distinct": 2}],
  "claimed_offsets": {"0": "magic prefix", "1": "magic prefix", "4": "length field",
                      "5": "length field", "17": "checksum field", "18": "checksum field"}
}

tlv is null when no TLV walk consumed a frame exactly. variance[].kind is one of constant, low-variance, counter, varying or random. claimed_offsets maps a byte position to the stronger explanation it already has, and it is why a length field's low byte is not also reported as a two-bit flags field. checksums, length_fields and counters are arithmetic that held for every frame, not inference — the reply may add to them and may disagree in writing, but a reply that quietly contradicts them is contradicted back on the page.

These are the flag ids the scanner raises, and therefore the ids you can raise by hand:

idfires when
MAGIC-PREFIXEvery frame starts with the same one or more bytes — a sync word, a version, or a fixed header field. It anchors the whole map.
NO-MAGICNo two frames share even their first byte, so either these are not the same message type or the frame really does open with a varying field.
COMMON-SUFFIXEvery frame ends with the same bytes — a terminator, a constant footer, or padding.
FIXED-LENGTHEvery frame is exactly the same size, so framing is by size alone and no length field is needed.
LENGTH-FIELDAn offset, width and endianness reproduces the frame length in every frame. The stream is parseable from this field alone.
NO-LENGTH-FIELDVariable-length frames and no offset reproduces the length, so framing must be by delimiter or by the transport.
CHECKSUM-VERIFIEDOne of the thirteen algorithms reproduces the field in every frame. The strongest single fact in the scan.
NO-CHECKSUM-FOUNDNone of the thirteen reproduces any trailing or header field: either a MAC, a CRC variant not tried, or none at all.
SEQUENCE-FIELDAn offset steps monotonically across the frames in order, wrap-around included.
TLV-FRAMINGA type-length-value walk consumes every frame exactly.
ASCII-RUNSA run of printable bytes sits at the same offset in every frame — a device id, a command name, a version string.
BITFIELDAn offset where only a few bit positions ever change: flags, not a number.
TIMESTAMPFour bytes read as a Unix epoch land in a plausible window with a plausible spread.
HIGH-ENTROPY-REGIONA merged window at or near 8 bits per byte: encrypted, compressed, or already random. The armour lane's starting point.
CONTAINER-MAGICA recognised container signature inside a frame — gzip, zlib, zstd, LZ4, bzip2, xz, zip, PNG, JPEG, DER, CBOR, MessagePack, a TLS record. This makes the posture packed, not encrypted.
BLOCK-ALIGNEDEvery frame length is a multiple of 8 or 16, which with high entropy and no signature points at a block cipher.
TRAILING-ZEROSEvery frame ends in a run of zero bytes: padding, a reserved tail, or a fixed-size buffer sent short.
TOO-FEW-FRAMESFewer frames than the scan needs to distinguish a counter from a coincidence. Expect low confidence and a populated next_captures.
UNREAD-BLOCKSPart of the paste did not decode as bytes at all, so the capture the run saw is smaller than the capture you pasted.

Resource ids — the measurements rather than the conclusions — are FRAMES, FORMATS, DIRECTIONS, VARIANCE, LENGTH-ALTS, FLOAT-CANDIDATES and ASCII-NOISE. Resources are context and are not reconciled; only flags are.

The output contract

The answer arrives as one JSON object, as a string, at output.output on the finished job. Both lanes return the same twelve envelope keys; the body underneath differs. Every key listed here is always present in a well-formed reply — "" or [] rather than omitted — and the normaliser fills in the defaults noted below when the model drifts.

The shared envelope — identical in both lanes

keytypemeaning
taskenummap or armor. The lane that was answered. Branch on this, not on what you asked for.
titlestringA short name for the capture, e.g. "AA-55 framed sensor telemetry, 2 frames". Empty becomes Untitled capture.
postureenumThe one-word state. The value set differs per lane — see the enum table. An unrecognised value normalizes to partial on map and to mixed on armor. This is the value a CI gate should branch on.
confidenceenumhigh, medium or low, for the answer as a whole. Unrecognised normalizes to low, which is the safe direction. Two frames rarely justify high.
verdictstringONE sentence naming the single thing that decides the posture. Required. An empty verdict fails the parse outright in both lanes.
exec_summarystringTwo to five sentences someone can act on without reading the tables.
assumptionsstring[]What had to be assumed because the capture is silent on it — the bus speed, whether a frame is a request or a reply, whether the two frames are the same message type. Blank entries are dropped.
open_questionsstring[]What would be asked before anyone writes code against this. Also where a paste that reads as an attack on a third party's service gets said out loud.
coverage_checkobject[]{id, addressed, note}. One entry per prescan.facts.flags id, exactly once, and no ids you did not send. addressed is coerced to a boolean; addressed: false means deliberately set aside, with the reason in note. An entry with no id is dropped. Empty when you sent no prescan.
artifactsobject[]{name, language, content} — the take-away files, complete and pasteable, never a sketch with ... in it. A missing name becomes artifact-1, artifact-2, …; an unrecognised language becomes text; an entry with empty content is dropped entirely, so count what you got rather than assuming three files came back.
next_stepsstring[]Ordered, concrete, one line each.
summarystringOne closing paragraph.

The map body

keytypemeaning
framingobject{style, evidence}. style is the framing convention; evidence is the sentence that settles it, and it is expected to quote an offset and a width — "u16 big-endian at offset 4 equals the byte count from offset 6 to the CRC in both frames". An unrecognised style becomes unknown.
endiannessenumThe protocol's overall byte order, or mixed when a header field and a payload field disagree. Unrecognised becomes unknown.
fieldsobject[]{id, offset, width, name, type, endian, role, observed, evidence, confidence}. offset is decimal from the start of the frame and width is in bytes. Entries are sorted by offset ascending, then by width. An entry whose offset is not a non-negative integer, or whose width is not a positive integer, is dropped. A missing id becomes F-001, F-002, …; a missing name becomes unnamed. At least one surviving entry is required — a map reply with none fails the parse.
checksumobject or null{algorithm, offset, width, endian, range, verified, note}. Present only when algorithm is a non-empty string, or when verified is exactly none; otherwise the key is null and there is no integrity field to speak of. range is prose — "bytes 0 through 16 inclusive". verified defaults to asserted, which is the honest default: it means nobody has proved it.
unresolvedobject[]{offset, width, why, next_step} — every byte range that could not be assigned, with the reason and the single next move that would resolve it. An entry with an empty why is dropped. This array is the input to the armour lane: it is what handoff.unresolved carries.
next_capturesobject[]{action, resolves}. The most valuable part of a partial map — "poll the same bay twice with only the setpoint changed" resolves more than any amount of staring at two frames. An entry with an empty action is dropped.

The armor body

keytypemeaning
regionsobject[]{id, offset, width, character, technique, assessment, evidence, confidence}, sorted by offset ascending. character is the classification; technique names what is doing it; assessment is two to four sentences on what the region is and what it would take to read it; evidence is expected to quote the measured entropy figure. An entry is dropped unless offset is a non-negative integer, width is positive and assessment is non-empty — so a region with no assessment silently disappears. A missing id becomes R-001, R-002, …
techniquesobject[]{name, indicators, how_to_confirm, effort}. indicators is a string array of what points at it; how_to_confirm is a test a person can run against the bytes they already hold. An entry with an empty name is dropped.
blockersobject[]{blocker, why, workaround} — what genuinely cannot be got past from the frames alone, such as a per-frame nonce with no reuse. An entry with an empty blocker is dropped.
pivotsobject[]{pivot, rationale, first_move} — where the work moves to when the bytes are exhausted: the client binary, the key provisioning path, the firmware image. An entry with an empty pivot is dropped.

At least one regions[] entry or one techniques[] entry must survive — an armor reply with neither fails the parse. Both empty is the one shape that cannot be rendered: a lane that found nothing to say about concealment should return a transparent posture with a region classified structured, not an empty body.

The enums

fieldvaluesfallback
posture on mapdecoded, partial, opaquepartial. decoded means every byte of the shortest frame is assigned to a defensible field; opaque means the framing itself is unresolved or most of the frame is high-entropy.
posture on armortransparent, packed, obfuscated, encrypted, mixedmixed. packed is recoverable with a known decoder and is the right answer whenever a container signature was found, however random the bytes look. obfuscated is cheap and reversible — XOR, byte-swap, a substitution table. encrypted means field mapping inside the region is impossible without key material.
confidence, fields[].confidence, regions[].confidencehigh, medium, lowlow
framing.stylefixed, length-prefixed, delimited, tlv, unknownunknown
endianness, fields[].endian, checksum.endianbig, little, mixed, n/a, unknownunknown for endianness; n/a for the per-field and checksum values, which is what a single byte or a byte string should carry anyway.
fields[].typeu8, u16, u32, u64, i8, i16, i32, f32, f64, bytes, ascii, bitfield, bcdbytes. Note there is no i64 and no u128; anything wider than eight bytes is bytes.
fields[].rolemagic, version, type, length, sequence, address, flags, timestamp, payload, checksum, padding, reserved, unknownunknown
checksum.verifiedprescan-confirmed, asserted, noneasserted. prescan-confirmed is only legitimate for an algorithm your prescan actually verified; asserting it otherwise is a disagreement the page prints.
regions[].characterhigh-entropy, compressed, encoded, structured, constant-blob, paddingstructured
techniques[].effortlow, medium, highmedium
artifacts[].languageyaml, lua, python, markdown, texttext. The map lane ships protocol.ksy (yaml), dissector.lua (lua) and parse.py (python); the armor lane ships confirm.md (markdown) and probe.py (python).

Enum matching is case-insensitive and trims whitespace, so "Length-Prefixed" is accepted; anything else falls back rather than erroring. The four hard failures are the only ones that abort a parse: a reply that is not a JSON object at all, an empty verdict, a map reply with no usable fields[] entry, and an armor reply with neither a usable regions[] entry nor a techniques[] entry. Everything else degrades. Separately, the web page grounds the reply against the bytes — a field past the end of the shortest frame, two fields claiming the same byte, a checksum that contradicts the brute-force, high confidence on an opaque frame — and shows each disagreement rather than refusing the answer. An API caller who wants that check has to run it, and the field list is arrives sorted by offset precisely so that the overlap pass is a single loop.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

Failing that, mint a guest token: POST /v1/app-api/guest with the body {"slug": "wire-desk"} and no Authorization header at all. It comes back as {"token", "guest_id"}. A guest token can call /me and /estimate; mapping a capture is metered, so /run needs a personal token from signing in, and a guest gets a 403 forbidden there unless the owner has sponsored usage.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://wire-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="aut_..."
#
# To mint a guest token from the command line instead. The slug goes in the BODY;
# there is no app-slug header anywhere in this API.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "wire-desk"}'
# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_..."}}
#
# A guest token is enough for /me and /estimate. Mapping a capture is metered and
# needs a personal token from signing in.

2. A tiny client

One helper that adds the bearer token, unwraps data and raises on error. There is no slug to thread through and no second header to remember: the token is the whole of the request identity, so the helper is genuinely this short.

# Every call is the same three things: the base URL, your bearer token, and a JSON
# body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"   # from https://wire-desk.skillsafe.ai/tokens.html

call() {                   # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

# jq is optional; python3 -c is used below so the samples need nothing installed.

3. Check the session and the balance

GET /me returns {"subject_type", "subject_id", "credits"}. subject_type is user or guest — a guest can price a run but cannot start one — subject_id identifies the subject the token is bound to, and credits is the wallet balance. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than as a 402 halfway through a batch of captures.

call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
#
# subject_type "guest" means /run will come back 403 unless the app sponsors usage.

4. Price the run — free

POST /estimate takes the same JSON the run would submit and returns {"hold_credits", "min_credits", "model", "model_alias", "markup_bps", "sponsor_enabled"}. It creates no job and charges nothing, and a guest token may call it. hold_credits is what gets reserved, not what the run costs: it prices the full output cap, so the charged_credits on the settled job is usually a fraction of it. Budget against hold_credits, report against charged_credits. min_credits is the balance you must clear to start at all, and sponsor_enabled says whether the app is covering the run for you.

The hold differs per lane, because the two prompts and the two output caps are different sizes — a map run that has to emit a Kaitai spec, a Lua dissector and a Python parser reserves more than an armor run that emits a confirmation plan and a probe script. So estimate the lane you are about to run: pricing map and then running armor on the strength of that number is how a batch job walks into a 402 on its fourth capture. The web app keeps one estimate per lane for exactly this reason.

The worked capture below is two frames of an AA-55 length-prefixed sensor protocol, and it is the same capture in every language and every step that follows.

# The capture: two frames, 19 bytes each, one labelled block per frame.
FRAMES='# frame 1 [tx] poll reply, bay A idle (19 bytes)\n0000: aa 55 11 21 00 0b 42 41 59 41 01 41 ac 00 00 12\n0010: d4 19 98\n# frame 2 [tx] poll reply, bay A heating (19 bytes)\n0000: aa 55 12 21 00 0b 42 41 59 41 05 41 b6 00 00 12\n0010: 6b 07 2b'

# task comes first because it picks the lane. prescan is OPTIONAL - these four
# flags are sent only so the reply is held to reconciling them in coverage_check.
INPUT='{"task":"map","frames":"'"$FRAMES"'","frame_count":2,"transport":"serial","goal":"parse","context":"Two frames off an RS-485 bus between an HVAC controller and a bay sensor, 19200 8N1. Frame 1 is idle, frame 2 is the same bay a minute later with the heater running. The vendor documents nothing.","prescan":{"facts":{"resources":[{"id":"FRAMES","label":"2 frames read, 38 bytes total, lengths 19"}],"flags":[{"id":"MAGIC-PREFIX","label":"every frame starts with aa 55"},{"id":"LENGTH-FIELD","label":"u16 big-endian at offset 4 verifies in both frames"},{"id":"CHECKSUM-VERIFIED","label":"crc16-modbus over bytes 0..16, little-endian at offset 17"},{"id":"TOO-FEW-FRAMES","label":"only 2 frames"}]}}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":3120,"min_credits":410,"sponsor_enabled":false}}
#
# estimate is FREE: no job, no charge, guests allowed. hold_credits is what gets
# RESERVED; the charged_credits on the settled job is normally much lower. Re-run
# this with "task":"armor" before an armour run - the hold is not the same.

5. Run it, then poll

POST /run takes the input object itself as the body. Do not wrap it in an input key: a wrapped body still returns 200, and the model never sees task, so you get a plausible answer for the wrong lane and no error to tell you. The call returns 202 {"job_id", "model", "model_alias"}; poll GET /jobs/{job_id} until status is succeeded or failed. The answer JSON is the string at data.output.output, and the terminal job also carries price_credits, charged_credits — the real cost — created_at and completed_at.

Always send an Idempotency-Key, and put the lane in it. A replayed key returns {"job_id", "deduped": true} instead of billing a second run, which is what makes a CI retry safe after a network blip. The web app derives it as wire-desk:<lane>:<hash>:a<attempt>, and the lane segment is not decoration: map and armor over one capture are two distinct runs, and a key that omits the lane makes the second one dedupe into the first and hand back the first lane's answer. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed — which includes adding a retry_note.

# The key carries the LANE. Two lanes over one capture must not share a key.
LANE="map"
KEY="wire-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

# The body is $INPUT itself - not {"input": ...}.
JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# The terminal job looks like this:
# {"ok":true,"data":{"status":"succeeded","model":"gpt-5.6-terra",
#   "output":{"output":"{\"task\":\"map\",\"title\":\"AA-55 framed sensor telemetry, 2 frames\", ...}"},
#   "price_credits":3120,"charged_credits":742,
#   "created_at":"2026-08-23T09:14:02Z","completed_at":"2026-08-23T09:14:37Z"}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream takes the same body and the same optional Idempotency-Key, and replies with text/event-stream. The sequence is a single job event carrying the job id and the model, then a run of delta events each carrying {"text"}, then exactly one terminator: done with the finished job — job_id, status, charged_credits and output — or error with the usual {"code", "message"}. Concatenating every delta.text in order gives you the same string that output.output would have held.

Two things to expect. The stream is a JSON object being generated left to right, so a partial buffer is not parseable and there is no point trying until the terminator arrives — the app uses the growing buffer only to show progress. And an idempotent replay may not stream at all: when the key has been seen before there is nothing left to generate, so the response can come back as plain application/json with the finished job in data. Check the Content-Type before you start parsing events.

# -N disables buffering so the deltas arrive as they are produced.
curl -N -sS -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

# event: job
# data: {"job_id":"job_...","model":"gpt-5.6-terra","model_alias":"gpt-terra"}
#
# event: delta
# data: {"text":"{\"task\":\"map\",\"title\":\"AA-55 framed"}
#
# event: delta
# data: {"text":" sensor telemetry, 2 frames\",\"posture\":\"decoded\","}
#
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":742,
#        "output":{"output":"{\"task\":\"map\", ...}"}}
#
# An `error` event replaces `done` on failure and carries {"code","message"}.

7. Parse the reply and pull the artifacts out

output.output is a string, and the model is asked for bare JSON with no prose and no fences — but a caller that assumes that will fail on the run where it wrapped the object in ```json. The web app's reader is four steps and it is worth copying exactly: trim, strip a leading fence and a trailing one, slice from the first { to the last }, then parse. Only then branch on task and read the lane body.

Three checks are worth running before you trust the object. Every prescan.facts.flags id you sent must appear exactly once in coverage_check, and no id you did not send may appear at all. On map, the fields arrive sorted by offset, so one pass tells you whether any two overlap or any field runs past the shortest frame — both are answers the page would contradict out loud. And a posture of decoded with an unresolved[] that is not empty is a self-contradiction worth failing on rather than rendering.

Then write artifacts[] to disk. Remember that an artifact with empty content was dropped by the normaliser, so check what you actually got: the map lane should hand back three files and the armor lane two.

# $OUT holds the terminal job envelope from step 5.
printf '%s' "$OUT" | python3 -c '
import json, re, sys

job = json.load(sys.stdin)["data"]
text = job["output"]["output"].strip()
text = re.sub(r"^```[a-z]*\s*", "", text, flags=re.I)
text = re.sub(r"```\s*$", "", text)
answer = json.loads(text[text.index("{"):text.rindex("}") + 1])

print(answer["task"], answer["posture"], answer["confidence"])
print(answer["verdict"])

if answer["task"] == "map":
    print(answer["framing"]["style"], answer["endianness"])
    for f in answer["fields"]:
        print("  %3d +%-2d %-9s %-8s %s" %
              (f["offset"], f["width"], f["role"], f["type"], f["name"]))
    ck = answer.get("checksum")
    if ck:
        print("  checksum", ck["algorithm"], "at", ck["offset"], ck["verified"])
    for u in answer["unresolved"]:
        print("  unresolved", u["offset"], u["width"], u["why"])
else:
    for r in answer["regions"]:
        print("  %3d +%-3d %-14s %s" %
              (r["offset"], r["width"], r["character"], r["technique"]))
    for t in answer["techniques"]:
        print("  technique", t["effort"], t["name"], "->", t["how_to_confirm"])
    for b in answer["blockers"]:
        print("  blocker", b["blocker"])

# Every artifact to its own file: protocol.ksy, dissector.lua, parse.py on the map
# lane; confirm.md and probe.py on the armour lane.
for a in answer["artifacts"]:
    with open(a["name"], "w") as fh:
        fh.write(a["content"])
    print("wrote", a["name"], a["language"], len(a["content"]), "bytes")
'

Idempotency, and why estimate is free

Hash the triple (task, input, attempt) and nothing else. The lane has to be in the key because map and armor over one capture are two distinct runs — different prompt, different output cap, different price — and a key that omits the lane makes the second call dedupe into the first and hand back the wrong lane's answer with deduped: true and no charge, which looks like a success until someone reads it. The attempt counter has to be in the key because a retry with a retry_note is a different body, and replaying a key with a different body is a 409 conflict rather than a fresh run. wire-desk:map:9f21c4ab8e7d0512:a1 is the whole convention.

What you hash inside the input is a choice worth making deliberately. The web app hashes a shallow shape — the lane, the raw paste, transport, goal and context — and leaves prescan out of it, because the prescan is derived from the same paste and including it would make the key change every time the scanner is improved, turning a free replay into a fresh charge. If you send a prescan, do the same: key on the capture, not on your measurements of it. Keep the key stable across a re-run of the same commit and a CI retry after a network blip costs nothing.

And run /estimate generously, because it is free in the strong sense: no job is created, no credits are held, nothing is charged, and a guest token is allowed. It is the right call to make before a batch — once per lane, on one representative capture — so a folder of two hundred saved captures is priced before the first one runs rather than discovered at the fortieth. The one number it does not give you is the cost: hold_credits is the reservation against the full output cap, and the figure to report afterwards is charged_credits on the settled job.