← FIX Log Desk / API
Tokens

Driving FIX Log Desk from code

Everything the page does through the AI lanes, you can do over HTTP. The browser-side reader is not an API — it runs in the page and needs no account — so what is documented here is the metered part: estimating a lane, running it, and reading back the JSON contract that the page renders.

Base URL and envelope

Every endpoint below hangs off https://api.skillsafe.ai/v1/app-api. The app is addressed by the token you present, which is scoped to this app — there is no X-App-Slug header, and adding one changes nothing.

Every response is one of two shapes:

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

Errors

codeHTTPwhat it means and what to do
UNAUTHORIZED401No token, a malformed token, or one that has expired. Mint a new one.
FORBIDDEN403A guest token on a metered call. Running a lane needs a signed-in user unless the publisher sponsors guests.
INSUFFICIENT_CREDITS402The balance is below min_credits. Check /me against /estimate before submitting.
VALIDATION_ERROR400The input object did not match what the app accepts. The offending field is in error.details.
NOT_FOUND404No job with that id for this subject.
RATE_LIMITED429Back off and retry with the SAME Idempotency-Key. Never tight-loop.
INTERNAL500Retry once with the same Idempotency-Key; the reservation is reused.

The input contract — task comes first

The run input is the object itself, not wrapped in an input key. Its first and most important field is task, which selects the lane. Everything else is the same for all four lanes.

fieldtypewhat it is
taskstringRequired. One of session, lifecycle, execution, surveillance. If it is missing or unrecognised the model picks the closest lane and names the lane it chose in headline rather than blending two contracts.
factsobjectRequired. The measurement block. The page builds it from its own reader; you can download exactly this object from the read-out panel with the Facts JSON button and post it back. It carries file, quarantined, fix_versions, message_types, symbols, time_span_utc, sessions, flags, flag_counts, redaction_note, context, plus the lane-specific expansion (session_detail and health; orders and cancel_rejects; execution; surveillance).
log_excerptstringThe raw messages, one per line, each prefixed with its M<n> index. Cut on whole-message boundaries when it is too large, never inside a message. Secrets are already redacted.
clipobjectmessages_total, messages_sent, chars_cut, clipped, note. Tells the model that the facts cover the whole file even when the excerpt does not.
contextobjectour_comp_id, counterparty, asset_class, note. All optional. note is what the user is trying to find out, and it changes what the review leads with.

The four lanes

taskquestionverdict wordswhat body carries
sessionDid the transport work?healthy | degraded | brokenbody.session_state[], body.logon_terms, body.timeline[], body.gap_analysis[], body.reject_analysis[], body.recovery_steps[]
lifecycleWhat happened to each order?reconciled | discrepancies | unreconstructablebody.orders[] (one per order in facts.orders, including the clean ones), body.races[], body.state_machine_notes[]
executionWhat can honestly be said about these fills?documented | gaps | not-assessablebody.benchmark_statement, body.per_order[], body.venue_notes[], body.best_execution_factors[] (all five FINRA 5310 factors), body.data_gaps[]
surveillanceWhat would a surveillance screen make of this tape?no-signatures | inputs-present | escalate-to-reviewbody.signature_readout[], body.candidates[], body.reporting_notes[], body.not_concluded

One worked input per lane

Identical apart from task. The facts object differs per lane only in which section is expanded; the page rebuilds it on every lane switch, and so should you.

task: "session" — Did the transport work?

{
  "task": "session",
  "facts": "{ the whole facts object - see the Input contract section }",
  "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...",
  "clip": {
    "messages_total": 73,
    "messages_sent": 73,
    "chars_cut": 0,
    "clipped": false,
    "note": "This excerpt holds every message in the file."
  },
  "context": {
    "our_comp_id": "BUYSIDE1",
    "counterparty": "BRKR",
    "asset_class": "equities",
    "note": "The desk says they cancelled and it still filled."
  }
}

task: "lifecycle" — What happened to each order?

{
  "task": "lifecycle",
  "facts": "{ the whole facts object - see the Input contract section }",
  "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...",
  "clip": {
    "messages_total": 73,
    "messages_sent": 73,
    "chars_cut": 0,
    "clipped": false,
    "note": "This excerpt holds every message in the file."
  },
  "context": {
    "our_comp_id": "BUYSIDE1",
    "counterparty": "BRKR",
    "asset_class": "equities",
    "note": "The desk says they cancelled and it still filled."
  }
}

task: "execution" — What can honestly be said about these fills?

{
  "task": "execution",
  "facts": "{ the whole facts object - see the Input contract section }",
  "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...",
  "clip": {
    "messages_total": 73,
    "messages_sent": 73,
    "chars_cut": 0,
    "clipped": false,
    "note": "This excerpt holds every message in the file."
  },
  "context": {
    "our_comp_id": "BUYSIDE1",
    "counterparty": "BRKR",
    "asset_class": "equities",
    "note": "The desk says they cancelled and it still filled."
  }
}

task: "surveillance" — What would a surveillance screen make of this tape?

{
  "task": "surveillance",
  "facts": "{ the whole facts object - see the Input contract section }",
  "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...",
  "clip": {
    "messages_total": 73,
    "messages_sent": 73,
    "chars_cut": 0,
    "clipped": false,
    "note": "This excerpt holds every message in the file."
  },
  "context": {
    "our_comp_id": "BUYSIDE1",
    "counterparty": "BRKR",
    "asset_class": "equities",
    "note": "The desk says they cancelled and it still filled."
  }
}

The output contract

The model returns one JSON object and nothing else. These keys are present on every lane; only body differs. This is taken from the parsing code the page actually runs, not from intent — a field listed here is a field the renderer reads.

{
  "lane": "session | lifecycle | execution | surveillance",
  "title": "string, under 80 characters",
  "verdict": "one of the lane's verdict words",
  "confidence": "high | medium | low",
  "headline": "one sentence",
  "summary": "two to five sentences",
  "findings": [
    { "id": "F1", "severity": "critical | high | medium | low | info",
      "title": "string", "what": "string", "evidence": ["M17", "O:ORD-2001"],
      "why": "string", "remedy": "string" }
  ],
  "reconciliation": [
    { "flag_id": "X1", "status": "confirmed | revised | cleared", "note": "string" }
  ],
  "checklist": [ { "item": "string", "owner": "string" } ],
  "open_questions": ["string"],
  "citations": ["M17", "O:ORD-2001", "X4"],
  "next_lane": { "lane": "session | lifecycle | execution | surveillance", "why": "string" },
  "question_answered": "string, or null - answers context.question directly",
  "body": { }
}

Nullable fields are real. Any quantity, price or rate the file does not establish comes back null, never 0. fill_rate: null (nothing came back at all) and fill_rate: 0 (acknowledged, then cancelled unfilled) are different facts and the renderer keeps them apart.

Two contracts the page enforces after the fact

Reconciliation. reconciliation must carry one entry for every flag in facts.flags whose severity is critical or high. The page renders a table of those flags against what the review said, and any it walked past is displayed as UNACCOUNTED FOR rather than quietly dropped. If you are consuming this API yourself, do the same check — it is the difference between a review and a summary.

The citation grammar is closed. Every reference must be one of M<n> (a message index, and every integer from 1 to facts.file.message_count is valid), O:<ClOrdID> (an order, by any id in its chain), X<n> (a flag), S:<sender> -> <target> (one direction of a session), SEQ:<session>#<n> (one sequence number, present or missing), SYM:<symbol>, V:<venue>, or T<tag>. facts.citable enumerates exactly what qualifies. The page checks every reference — including the ones inside body, not just the ones in citations — and shows an invented one struck through.

SEQ: exists because a message that never arrived has no M<n>, and in a gap analysis the hole is the subject. There is deliberately no form for a fill, a price or a time: cite the message that carries it and give the value in words.

Steps

1. Get a token

Every call needs Authorization: Bearer <token>. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token, which comes from signing in. The easiest way to get either without opening DevTools is the token panel: fixlog-desk.skillsafe.ai/tokens.html - it reads the token this browser already holds, shows the session state, and will mint a fresh guest token on request.

To mint a guest token from code, POST to /guest with no body and no authorization; the token comes back in data.token.

curl -s -X POST \
  "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Authorization: Bearer YOUR_TOKEN"

2. Check who you are and what you can spend

/me tells you the subject type (user or guest) and the credit balance. Compare the balance against the min_credits from the next step before you submit anything - a 402 after submit is a failure of your client, not of the user.

curl -s -X GET \
  "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer YOUR_TOKEN"

3. Estimate - free, and the authoritative model check

/estimate costs nothing 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: what is actually charged is nearly always lower. Estimate the lane you are about to run - the four lanes send different facts and reserve different amounts, so a hold from one lane is not the hold for another.

curl -s -X POST \
  "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "lifecycle", "facts": "{...}", "log_excerpt": "...", "context": {"our_comp_id": "BUYSIDE1"}}'

4. Run a lane and poll for the result

POST the run input to /run; you get back a job_id. Poll /jobs/{id} until status is terminal. Always send an Idempotency-Key derived from the task plus a hash of the input: a network blip that makes you retry must not bill the user twice. The app itself uses fix-<task>-<input hash>-<attempt>.

curl -s -X POST \
  "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Idempotency-Key: fix-lifecycle-3k9x2m1a-1" \
  -H "Content-Type: application/json" \
  -d '{"task": "lifecycle", "facts": "{ the whole facts object - see the Input contract section }", "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...", "clip": {"messages_total": 73, "messages_sent": 73, "chars_cut": 0, "clipped": false, "note": "This excerpt holds every message in the file."}, "context": {"our_comp_id": "BUYSIDE1", "counterparty": "BRKR", "asset_class": "equities", "note": "The desk says they cancelled and it still filled."}}'

5. Poll the job

status moves through queued and running to succeeded or failed. On success the model's text is in data.output. If data.truncated is true the reply was cut short by the output cap - surface that to the user rather than presenting a clipped answer as complete.

curl -s -X GET \
  "https://api.skillsafe.ai/v1/app-api/jobs/job_REPLACE_ME" \
  -H "Authorization: Bearer YOUR_TOKEN"

6. Stream it instead

/run-stream is the same call over Server-Sent Events. Events carry incremental text in delta and the terminal job in job. This is what the page itself uses, because a lane takes long enough to want a progress card - and because the named sections of the output contract arriving in the stream are what advance it.

curl -s -N -X POST \
  "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Idempotency-Key: fix-lifecycle-3k9x2m1a-1" \
  -H "Content-Type: application/json" \
  -d '{"task": "lifecycle", "facts": "{ the whole facts object - see the Input contract section }", "log_excerpt": "M1 20260223-13:29:55.010 OUT 8=FIX.4.4|9=101|35=A|...", "clip": {"messages_total": 73, "messages_sent": 73, "chars_cut": 0, "clipped": false, "note": "This excerpt holds every message in the file."}, "context": {"our_comp_id": "BUYSIDE1", "counterparty": "BRKR", "asset_class": "equities", "note": "The desk says they cancelled and it still filled."}}'

Notes that save a debugging session

What this app will not do, however you ask it