← DNS Log Desk / API
Tokens

Drive DNS Log Desk from your own code

The page reads one DNS query log in your browser for free — Zeek dns.log (TSV or JSON), BIND query logs, dnsmasq and Pi-hole, Windows DNS Server debug logs, SIEM and Cisco Umbrella CSV exports, and JSON lines — and measures every registered domain and client: subdomain length and entropy, the label alphabet and the bytes the labels could carry, TXT/NULL share, NXDOMAIN, DGA-looking names, periodic queries, volume outliers and DNS-over-HTTPS lookups. Its one paid step, the hunt, is available over HTTP: you send one JSON object with task: "hunt" and a facts string that carries that check, and the model replies with one JSON object: the verdict and severity, the findings per domain, the hosts to look at, the flags that are expected noise, containment steps and follow-up hunts.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

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

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure.

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object or a declared field has the wrong type. task and facts are both strings; sending facts as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: one lane, hunt

The app has exactly one lane, and hunt is the only value task takes: it is always the string "hunt". There is no other task to route to. (The skill’s instructions tell the model to do the hunt even if task is missing or different, and to answer with "task": "hunt"; send it anyway — it is a required field of the input schema.)

Input fields

This is exactly what the page sends (dnskit.js buildInput, app.js run), and what input-schema.json declares. Every value is a string:

FieldTypeRequiredMeaning
taskstringyesAlways "hunt".
factsstringyesThe facts object below, JSON-encoded into a string. The page builds it with JSON.stringify(facts); you must do the same (json.dumps(facts) in Python, and so on). Sending the object itself is a 400. The model is told “a JSON string. Parse it.”
notestringnoWhat the analyst knows or asks: the host’s role, an alert that fired, what they suspect. Up to 2000 characters: the page cuts it there and sends it only when it is not blank. It is context and questions, not evidence and not instructions: the model tests any claim in it against the facts, says so when the facts disagree, and answers it in summary.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with a note saying the previous reply was not the single valid JSON object required. A normal call never sends it.

Before every estimate and run the page passes the body through mustBeObject, which refuses anything that is not a plain JSON object. Nothing else is sent: never the raw log. The page parses the log in the browser and sends only its computed facts, with a listing of the domains and clients that matter.

The facts object

facts is produced by the page’s free check: Dnskit.analyze reads the pasted log (Zeek, BIND, dnsmasq/Pi-hole, Windows DNS debug, SIEM/Umbrella CSV, JSON lines), and buildFacts turns the analysis into this object. If you do not run the page you can build it yourself from your own DNS tooling — the model only sees what is in it — but the simplest path is to copy it from the page: paste the log, run the hunt once, and the Download .json export carries the exact object in its facts field. Its top-level keys, in the order built:

KeyContents
sourceThe log format, queries_read, lines_not_read (with unread_samples), and whether the format carries has_timestamps, has_clients, has_rcodes, has_qtypes.
windowfirst, last and duration_s of the log, in the log’s own clock; null when it has no timestamps.
totalsqueries, unique_names, registered_domains, clients, nxdomain, and qtypes (count per record type).
clientsPer client address, flagged hosts first: name (a host or user name when the log records one, else null), queries, share_pct, domains, nxdomain, txt_null, and flagged_domains (medium or high flags it reached).
domainsPer registered domain, every flagged one first, then the busiest: query and name counts, top clients, qtypes, txt_null, nxdomain, subdomain lengths, avg_entropy (bits per character), encoding (hex | base32 | base64 | text | none), est_bytes (an upper bound on what the unique labels could carry outward), first/last, real samples, flags, and the markers known_service, internal and dga_like.
dgaPer client with generated-looking names: dga_domains, nx_total, nx_domains, resolved (generated names that answered), samples, and when the generated names started and stopped.
beaconsClient–domain pairs queried on a timer: events, median_interval_s, jitter_pct (standard deviation over mean of the gaps) and how long the timer ran.
dohDNS-over-HTTPS endpoint names looked up, and by whom.
flagsEvery finding: id, sev (high | medium | low | info), text, detail, and the domain, client or clients it concerns. IDs by family: X data, T tunnelling, G DGA, B beacons, V volume, D DoH bypass (the full table is in the skill and on the app page).
verdict_candidatesThe verdicts the flags point at (the page’s own reading), from the same list as the reply’s verdict.
notesPlain sentences about what was left out of the listing. Every total above is still computed over the whole log.

Worked example: an Umbrella export with one unclear domain

A Cisco Umbrella DNS report (CSV) of 254 queries from ten clients over about four hours. One host, 10.40.2.19 (dev-build-03 in the identity column), sent 18 queries to ledgerline-sync.example: 14 of them TXT, with base32-shaped subdomains averaging 3.87 bits per character. The check raises T02, T04 and T05 (all medium) and reads the log as suspicious_unconfirmed. This is the facts object the page builds for it, trimmed for display: every key is kept, but only the first 3 of the 26 listed domains and the first 3 of the 10 clients are shown (so its notes line still describes the full listing). It is still valid input; save it as facts.json to run the samples below:

{
  "source": {
    "format": "Cisco Umbrella DNS report (CSV)",
    "lines": 255,
    "queries_read": 254,
    "lines_not_read": 0,
    "unread_samples": [],
    "has_timestamps": true,
    "has_clients": true,
    "has_rcodes": true,
    "has_qtypes": true
  },
  "window": {
    "first": "2026-09-28 14:02:01",
    "last": "2026-09-28 17:58:15",
    "duration_s": 14174
  },
  "totals": {
    "queries": 254,
    "unique_names": 71,
    "registered_domains": 34,
    "clients": 10,
    "nxdomain": 0,
    "qtypes": {
      "A": 147,
      "AAAA": 90,
      "TXT": 14,
      "SRV": 3
    }
  },
  "clients": [
    {
      "client": "10.40.2.19",
      "name": "dev-build-03",
      "queries": 46,
      "share_pct": 18.1,
      "domains": 18,
      "nxdomain": 0,
      "txt_null": 14,
      "flagged_domains": [
        "ledgerline-sync.example"
      ]
    },
    {
      "client": "10.40.2.11",
      "name": "ana.silva",
      "queries": 28,
      "share_pct": 11,
      "domains": 18,
      "nxdomain": 0,
      "txt_null": 0,
      "flagged_domains": []
    },
    {
      "client": "10.40.2.34",
      "name": "hugo.brandt",
      "queries": 27,
      "share_pct": 10.6,
      "domains": 13,
      "nxdomain": 0,
      "txt_null": 0,
      "flagged_domains": []
    }
  ],
  "domains": [
    {
      "domain": "ledgerline-sync.example",
      "queries": 18,
      "unique_names": 16,
      "unique_subdomains": 15,
      "clients": [
        {
          "client": "10.40.2.19",
          "queries": 18
        }
      ],
      "clients_total": 1,
      "qtypes": {
        "A": 4,
        "TXT": 14
      },
      "txt_null": 14,
      "nxdomain": 0,
      "avg_sub_len": 21.7,
      "max_sub_len": 23,
      "max_label_len": 22,
      "avg_entropy": 3.87,
      "encoding": "base32",
      "randomish_pct": 93.3,
      "est_bytes": 203,
      "first": "2026-09-28 15:34:00",
      "last": "2026-09-28 15:53:49",
      "samples": [
        "pv4gdhpprv7pzhlnohxmuv.k.ledgerline-sync.example",
        "4mkltjbkcjsuyb7cie5ybn.k.ledgerline-sync.example",
        "5jikfga4c3nduv47g2chat.k.ledgerline-sync.example",
        "ledgerline-sync.example"
      ],
      "flags": [
        "T04",
        "T02",
        "T05"
      ]
    },
    {
      "domain": "corp.local",
      "queries": 30,
      "unique_names": 6,
      "unique_subdomains": 5,
      "clients": [
        {
          "client": "10.40.2.15",
          "queries": 5
        },
        {
          "client": "10.40.2.27",
          "queries": 4
        },
        {
          "client": "10.40.2.38",
          "queries": 4
        },
        {
          "client": "10.40.2.22",
          "queries": 4
        },
        {
          "client": "10.40.2.34",
          "queries": 3
        },
        {
          "client": "10.40.2.41",
          "queries": 3
        }
      ],
      "clients_total": 9,
      "qtypes": {
        "A": 16,
        "AAAA": 11,
        "SRV": 3
      },
      "txt_null": 0,
      "nxdomain": 0,
      "avg_sub_len": 5.4,
      "max_sub_len": 8,
      "max_label_len": 8,
      "avg_entropy": 2.5,
      "encoding": "none",
      "randomish_pct": 0,
      "est_bytes": 16,
      "first": "2026-09-28 14:05:16",
      "last": "2026-09-28 17:58:15",
      "samples": [
        "_ldap._tcp.dc._msdcs.corp.local",
        "intranet.corp.local",
        "print01.corp.local",
        "fs01.corp.local"
      ],
      "flags": [],
      "internal": true
    },
    {
      "domain": "microsoft.com",
      "queries": 25,
      "unique_names": 6,
      "unique_subdomains": 6,
      "clients": [
        {
          "client": "10.40.2.31",
          "queries": 4
        },
        {
          "client": "10.40.2.11",
          "queries": 4
        },
        {
          "client": "10.40.2.22",
          "queries": 3
        },
        {
          "client": "10.40.2.38",
          "queries": 3
        },
        {
          "client": "10.40.2.19",
          "queries": 3
        },
        {
          "client": "10.40.2.41",
          "queries": 3
        }
      ],
      "clients_total": 9,
      "qtypes": {
        "A": 14,
        "AAAA": 11
      },
      "txt_null": 0,
      "nxdomain": 0,
      "avg_sub_len": 10.3,
      "max_sub_len": 16,
      "max_label_len": 12,
      "avg_entropy": 3.01,
      "encoding": "none",
      "randomish_pct": 0,
      "est_bytes": 37,
      "first": "2026-09-28 14:02:01",
      "last": "2026-09-28 17:54:09",
      "samples": [
        "settings-win.data.microsoft.com",
        "self.events.data.microsoft.com",
        "v10.events.data.microsoft.com",
        "graph.microsoft.com"
      ],
      "flags": []
    }
  ],
  "dga": [],
  "beacons": [],
  "doh": [],
  "flags": [
    {
      "id": "T02",
      "sev": "medium",
      "text": "High-entropy subdomains to ledgerline-sync.example",
      "detail": "14 unique subdomains of 8+ characters average 3.87 bits per character (the source skill's threshold is 3.5).",
      "domain": "ledgerline-sync.example",
      "clients": [
        "10.40.2.19"
      ]
    },
    {
      "id": "T04",
      "sev": "medium",
      "text": "TXT/NULL-heavy traffic to ledgerline-sync.example",
      "detail": "14 of 18 queries (77.8%) ask for TXT, NULL or ANY records.",
      "domain": "ledgerline-sync.example",
      "clients": [
        "10.40.2.19"
      ]
    },
    {
      "id": "T05",
      "sev": "medium",
      "text": "Encoded labels to ledgerline-sync.example",
      "detail": "14 of 14 subdomains of 16+ characters are base32-shaped; at that alphabet the unique labels could carry about 203 B.",
      "domain": "ledgerline-sync.example",
      "clients": [
        "10.40.2.19"
      ]
    }
  ],
  "verdict_candidates": [
    "suspicious_unconfirmed"
  ],
  "notes": [
    "26 of 34 registered domains are listed: every flagged one first, then the busiest; every total covers all of them."
  ]
}

The analyst’s note for this run: “Umbrella tagged ledgerline-sync.example as Newly Seen. dev-build-03 is a shared CI runner; people install all sorts of tools on it.”. The full request body, with facts stringified (this is what buildInput(facts, note) returns):

{"task":"hunt","facts":"{\"source\":{\"format\":\"Cisco Umbrella DNS report (CSV)\",\"lines\":255,\"queries_read\":254,\"lines_not_read\":0,\"unread_samples\":[],\"has_timestamps\":true,\"has_clients\":true,\"has_rcodes\":true,\"has_qtypes\":true},\"window\":{\"first\":\"2026-09-28 14:02:01\",\"last\":\"2026-09-28 17:58:15\",\"duration_s\":14174},\"totals\":{\"queries\":254,\"unique_names\":71,\"registered_domains\":34,\"clients\":10,\"nxdomain\":0,\"qtypes\":{\"A\":147,\"AAAA\":90,\"TXT\":14,\"SRV\":3}},\"clients\":[{\"client\":\"10.40.2.19\",\"name\":\"dev-build-03\",\"queries\":46,\"share_pct\":18.1,\"domains\":18,\"nxdomain\":0,\"txt_null\":14,\"flagged_domains\":[\"ledgerline-sync.example\"]},{\"client\":\"10.40.2.11\",\"name\":\"ana.silva\",\"queries\":28,\"share_pct\":11,\"domains\":18,\"nxdomain\":0,\"txt_null\":0,\"flagged_domains\":[]},{\"client\":\"10.40.2.34\",\"name\":\"hugo.brandt\",\"queries\":27,\"share_pct\":10.6,\"domains\":13,\"nxdomain\":0,\"txt_null\":0,\"flagged_domains\":[]}],\"domains\":[{\"domain\":\"ledgerline-sync.example\",\"queries\":18,\"unique_names\":16,\"unique_subdomains\":15,\"clients\":[{\"client\":\"10.40.2.19\",\"queries\":18}],\"clients_total\":1,\"qtypes\":{\"A\":4,\"TXT\":14},\"txt_null\":14,\"nxdomain\":0,\"avg_sub_len\":21.7,\"max_sub_len\":23,\"max_label_len\":22,\"avg_entropy\":3.87,\"encoding\":\"base32\",\"randomish_pct\":93.3,\"est_bytes\":203,\"first\":\"2026-09-28 15:34:00\",\"last\":\"2026-09-28 15:53:49\",\"samples\":[\"pv4gdhpprv7pzhlnohxmuv.k.ledgerline-sync.example\",\"4mkltjbkcjsuyb7cie5ybn.k.ledgerline-sync.example\",\"5jikfga4c3nduv47g2chat.k.ledgerline-sync.example\",\"ledgerline-sync.example\"],\"flags\":[\"T04\",\"T02\",\"T05\"]},{\"domain\":\"corp.local\",\"queries\":30,\"unique_names\":6,\"unique_subdomains\":5,\"clients\":[{\"client\":\"10.40.2.15\",\"queries\":5},{\"client\":\"10.40.2.27\",\"queries\":4},{\"client\":\"10.40.2.38\",\"queries\":4},{\"client\":\"10.40.2.22\",\"queries\":4},{\"client\":\"10.40.2.34\",\"queries\":3},{\"client\":\"10.40.2.41\",\"queries\":3}],\"clients_total\":9,\"qtypes\":{\"A\":16,\"AAAA\":11,\"SRV\":3},\"txt_null\":0,\"nxdomain\":0,\"avg_sub_len\":5.4,\"max_sub_len\":8,\"max_label_len\":8,\"avg_entropy\":2.5,\"encoding\":\"none\",\"randomish_pct\":0,\"est_bytes\":16,\"first\":\"2026-09-28 14:05:16\",\"last\":\"2026-09-28 17:58:15\",\"samples\":[\"_ldap._tcp.dc._msdcs.corp.local\",\"intranet.corp.local\",\"print01.corp.local\",\"fs01.corp.local\"],\"flags\":[],\"internal\":true},{\"domain\":\"microsoft.com\",\"queries\":25,\"unique_names\":6,\"unique_subdomains\":6,\"clients\":[{\"client\":\"10.40.2.31\",\"queries\":4},{\"client\":\"10.40.2.11\",\"queries\":4},{\"client\":\"10.40.2.22\",\"queries\":3},{\"client\":\"10.40.2.38\",\"queries\":3},{\"client\":\"10.40.2.19\",\"queries\":3},{\"client\":\"10.40.2.41\",\"queries\":3}],\"clients_total\":9,\"qtypes\":{\"A\":14,\"AAAA\":11},\"txt_null\":0,\"nxdomain\":0,\"avg_sub_len\":10.3,\"max_sub_len\":16,\"max_label_len\":12,\"avg_entropy\":3.01,\"encoding\":\"none\",\"randomish_pct\":0,\"est_bytes\":37,\"first\":\"2026-09-28 14:02:01\",\"last\":\"2026-09-28 17:54:09\",\"samples\":[\"settings-win.data.microsoft.com\",\"self.events.data.microsoft.com\",\"v10.events.data.microsoft.com\",\"graph.microsoft.com\"],\"flags\":[]}],\"dga\":[],\"beacons\":[],\"doh\":[],\"flags\":[{\"id\":\"T02\",\"sev\":\"medium\",\"text\":\"High-entropy subdomains to ledgerline-sync.example\",\"detail\":\"14 unique subdomains of 8+ characters average 3.87 bits per character (the source skill's threshold is 3.5).\",\"domain\":\"ledgerline-sync.example\",\"clients\":[\"10.40.2.19\"]},{\"id\":\"T04\",\"sev\":\"medium\",\"text\":\"TXT/NULL-heavy traffic to ledgerline-sync.example\",\"detail\":\"14 of 18 queries (77.8%) ask for TXT, NULL or ANY records.\",\"domain\":\"ledgerline-sync.example\",\"clients\":[\"10.40.2.19\"]},{\"id\":\"T05\",\"sev\":\"medium\",\"text\":\"Encoded labels to ledgerline-sync.example\",\"detail\":\"14 of 14 subdomains of 16+ characters are base32-shaped; at that alphabet the unique labels could carry about 203 B.\",\"domain\":\"ledgerline-sync.example\",\"clients\":[\"10.40.2.19\"]}],\"verdict_candidates\":[\"suspicious_unconfirmed\"],\"notes\":[\"26 of 34 registered domains are listed: every flagged one first, then the busiest; every total covers all of them.\"]}","note":"Umbrella tagged ledgerline-sync.example as Newly Seen. dev-build-03 is a shared CI runner; people install all sorts of tools on it."}

The output contract

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences. The run’s output text is that object:

Every array is present ([] when empty). Every domain must be one in the facts (a listed domain, one of its samples, or a domain named by a flag, dga, beacons or doh), every client must appear in the facts, and every high flag must be addressed somewhere by its id.

An excerpt of a reply for the worked example (the verdict fields, its one finding and the first two of its 3 containment steps; headline, hosts, false_positives, hunts, questions and summary are left out here). The full reply passes the page’s own checks with no disagreement:

{
  "task": "hunt",
  "verdict": "suspicious_unconfirmed",
  "secondary_verdicts": [],
  "severity": "medium",
  "confidence": "medium",
  "findings": [
    {
      "domain": "ledgerline-sync.example",
      "kind": "tunnelling",
      "assessment": "suspicious",
      "clients": [
        "10.40.2.19"
      ],
      "queries": 18,
      "est_bytes": 203,
      "evidence": "All 18 queries to this domain came from 10.40.2.19 between 2026-09-28 15:34:00 and 15:53:49 (about 20 minutes). 16 unique names / 15 unique subdomains, average subdomain length 21.7 characters (max 23, max label 22), average entropy 3.87 bits/character over subdomains of 8+ characters (above the source skill's 3.5 threshold), and 93.3% of subdomains look random. The labels are base32-shaped, which at that alphabet could carry at most about 203 bytes outward. 14 of the 18 queries (77.8%) are TXT rather than A. The domain carries no known_service or internal tag, so nothing marks this pattern as expected here.",
      "flags": [
        "T02",
        "T04",
        "T05"
      ]
    }
  ],
  "containment": [
    {
      "priority": "now",
      "action": "Preserve the Umbrella logs and dev-build-03's local process/execution history covering 15:34:00-15:53:49 before retention or reimaging rolls them off.",
      "target": "10.40.2.19"
    },
    {
      "priority": "soon",
      "action": "Identify the process, package or scheduled job on dev-build-03 that queried ledgerline-sync.example; the traffic already stopped over two hours before the log ends, so this is investigation rather than an active block.",
      "target": "10.40.2.19"
    }
  ]
}

The page checks the reply against the log; the API does not

What comes back from the API is the model’s raw text in output.output. The page does more with it before showing anything (recon.js), and a direct API caller gets none of this:

If you call the API directly, apply your own checks — domains, clients, query counts, byte estimates and the high flags against your log — before you block or isolate anything.

Step 1 — a tiny client helper and a token

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements).

Then a token. Two kinds work:

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # see below

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

# ---- get a token
# personal token: export SKILLSAFE_TOKEN=... (from /tokens.html) and skip this
# guest token: no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"dnslog-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 2 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 3 before it lets you run.

api GET /me | jq '.data // .error'

Step 3 — POST /estimate

Price the hunt before you run it. The body is the same input object you will send to /run (task, facts as a string, optional note); no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits”. What you actually pay is the job’s charged_credits after settlement (step 4), normally less; the rest of the hold is released. A balance below the hold is allowed but the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run the hunt; otherwise the page asks them to sign in.
warningsInput warnings (for example a missing required field). They do not refuse the call, so check them before you run.

The page re-prices whenever the log or the note changes. The amounts move with the model’s rates and grow with the size of facts, so read them from the call rather than hard-coding them.

# facts.json = the facts object from the worked example; jq -c turns it into ONE string
BODY=$(jq -n --arg facts "$(jq -c . facts.json)" --arg q "Umbrella tagged ledgerline-sync.example as Newly Seen. dev-build-03 is a shared CI runner; people install all sorts of tools on it." \
  '{task: "hunt", facts: $facts, note: $q}')

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings, model, model_alias, markup_bps})'

Step 4 — POST /run, then poll the job

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock. The page uses dnslog-desk:hunt:<hash>:a<attempt>, where <hash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex, and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the worked example that is dnslog-desk:hunt:3ffb17a7-11a8:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use a SHA-256 prefix.

The page itself uses /run-stream (step 5), not /run; both take the same body and header.

KEY="dnslog-desk:hunt:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' | jq '{verdict, headline, findings: [(.findings // [])[] | {domain, assessment, est_bytes}], containment: [(.containment // [])[] | {priority, action}]}'

Step 5 — POST /run-stream (Server-Sent Events)

The alternative to step 4, and what the page uses: same body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 4.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived.

# -N turns off buffering so frames print as they arrive
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY"

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"hunt\",...}"}}

# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY" \
  | awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
  | jq -r '.output.output' | jq '{verdict, headline, findings: [(.findings // [])[] | {domain, assessment, est_bytes}], containment: [(.containment // [])[] | {priority, action}]}'

What the page does that you now do

Or skip all of this and use DNS Log Desk in the browser: the per-domain and per-host check, the flags and the Markdown and CSV exports are free.