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.
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The 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. |
401 | UNAUTHORIZED | No 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. |
402 | INSUFFICIENT_CREDITS | The 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. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An 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). |
429 | RATE_LIMITED | Too 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:
| What | What happens |
|---|---|
event: error on /run-stream | The 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 s | The 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:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | Always "hunt". |
facts | string | yes | The 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.” |
note | string | no | What 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_note | string | no | Sent 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:
| Key | Contents |
|---|---|
source | The log format, queries_read, lines_not_read (with unread_samples), and whether the format carries has_timestamps, has_clients, has_rcodes, has_qtypes. |
window | first, last and duration_s of the log, in the log’s own clock; null when it has no timestamps. |
totals | queries, unique_names, registered_domains, clients, nxdomain, and qtypes (count per record type). |
clients | Per 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). |
domains | Per 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. |
dga | Per 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. |
beacons | Client–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. |
doh | DNS-over-HTTPS endpoint names looked up, and by whom. |
flags | Every 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_candidates | The verdicts the flags point at (the page’s own reading), from the same list as the reply’s verdict. |
notes | Plain 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:
task:"hunt".verdict:tunnelling_exfil|dga_malware|c2_beacon|suspicious_unconfirmed|dns_bypass|benign|insufficient_data.secondary_verdicts[]: zero or more of the same values, not repeatingverdict.severity:critical|high|medium|low|none.confidence:high|medium|low.headline: one or two sentences: what is happening in this log, which host and which domain.findings[]: one to six, most serious first, each{domain, kind, assessment, clients[], queries, est_bytes, evidence, flags[]}withkindtunnelling|tool_signature|dga|beacon|volume|doh|lookup_service|otherandassessmentmalicious|suspicious|benign.queriesis that domain’s count in the facts;est_bytesis at most itsest_bytes, ornullwhen the finding is not about data leaving.hosts[]:{client, role, domains[], reading}withrolelikely_compromised|needs_review|expected_volume.false_positives[]:{domain, flags[], why}, flags that are expected on that domain (a reputation look-up, a blocklist, a CDN).containment[]:{priority, action, target}withprioritynow|soon|later.hunts[]:{where, look_for, why}, wherewhereisEDR,Sysmon,proxy,firewall,DNS server,passive DNSorSIEM.questions[]: things only the analyst can answer.summary: three to five sentences for the analyst, answeringnoteif one was sent.
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:
- Parsing and normalising. It strips a leading/trailing code fence and parses from the first
{to the last}(closing a cut-off object when needed). Domains are lower-cased and un-defanged. Unknown enum values fall back:severity→medium,confidence→low, findingkind→other,assessment→suspicious, hostrole→needs_review,priority→soon; a reply with no recognised verdict is a disagreement. - Domains and clients must exist. Every domain in a finding, a host, a false positive or a containment target must be in the log, every client address must be one of its clients, and a client said to have queried a domain must have.
- Counts and capacities must match. A finding’s
queriesmust match the log’s count for that domain (within 1%, at least one query), and noest_bytesmay exceed what its unique labels can carry. - High flags must be addressed, a tunnelling tool signature (T06) cannot be dismissed as a false positive, and
benigncannot stand over a high flag thatfalse_positivesdoes not explain. - The verdict needs backing.
tunnelling_exfil,dga_malwareandc2_beaconeach need a matching finding assessed malicious or suspicious and a severity abovelow; a beacon finding in a log without timestamps is a disagreement. A verdict outsideverdict_candidatesis noted.
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:
-
Personal — needed for the hunt. The hunt is metered in credits, so a run
needs a signed-in user (the page says “The hunt is metered, so it needs an
account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN. -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "dnslog-desk"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
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}..."
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see below
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
# ---- get a token
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "dnslog-desk"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// hunt.mjs -- Node 18+ (global fetch), run with: node hunt.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // see below
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
// ---- get a token
// personal token: paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "dnslog-desk" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see below
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// the token code below and steps 2-5 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// ---- inside main: get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "dnslog-desk"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// DnslogDesk.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class DnslogDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // see below
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// the token code below and steps 2-5 go here
}
}
// ---- inside main: get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "dnslog-desk"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see below
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
# ---- get a token
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "dnslog-desk" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // see below
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// ---- get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "dnslog-desk"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// Program.cs -- .NET 6+ console app with top-level statements; the token code and steps 2-5 go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // see below
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
// ---- top-level statements (above the Api class): get a token
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "dnslog-desk" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
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'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
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:
| Field | Meaning |
|---|---|
hold_credits | A 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_credits | The smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run the hunt; otherwise the page asks them to sign in. |
warnings | Input 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})'
with open("facts.json", encoding="utf-8") as f:
facts = json.load(f)
inp = {
"task": "hunt",
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
"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.", # optional; analyst context; the page caps it at 2000 chars
}
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
const facts = JSON.parse(readFileSync("facts.json", "utf8"));
const input = {
task: "hunt",
facts: JSON.stringify(facts), // a STRING, exactly as the page sends it
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.", // optional; analyst context; the page caps it at 2000 chars
};
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
raw, err := os.ReadFile("facts.json")
if err != nil {
panic(err)
}
var facts any
if err := json.Unmarshal(raw, &facts); err != nil {
panic(err)
}
factsJSON, _ := json.Marshal(facts) // compact; travels as a STRING
input := map[string]any{
"task": "hunt",
"facts": string(factsJSON),
"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.", // optional; analyst context; the page caps it at 2000 chars
}
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
JsonNode facts = JSON.readTree(Files.readString(Path.of("facts.json")));
Map<String, Object> input = new LinkedHashMap<>();
input.put("task", "hunt");
input.put("facts", JSON.writeValueAsString(facts)); // a STRING, compact
input.put("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."); // optional; analyst context; the page caps it at 2000 chars
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
facts = JSON.parse(File.read("facts.json", encoding: "UTF-8"))
input = {
task: "hunt",
facts: JSON.generate(facts), # a STRING, compact
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." # optional; analyst context; the page caps it at 2000 chars
}
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$facts = json_decode(file_get_contents("facts.json"));
$input = [
"task" => "hunt",
"facts" => json_encode($facts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
"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.", // optional; analyst context; the page caps it at 2000 chars
];
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
var facts = JsonNode.Parse(File.ReadAllText("facts.json"))!;
var input = new Dictionary<string, string>
{
["task"] = "hunt",
["facts"] = facts.ToJsonString(), // a STRING, compact
["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.", // optional; analyst context; the page caps it at 2000 chars
};
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
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:
output.output— the model’s reply as a string: the JSON object from the output contract. Parse it yourself (JSON.parse, from the first{to the last}). The samples print the verdict and headline, each finding’s domain, assessment andest_bytes, and each containment step’s priority and action.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 11 sections recovered”), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
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}]}'
body = json.dumps(inp)
key = "dnslog-desk:hunt:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for f in reply.get("findings", []):
print(f["domain"], f["assessment"], f.get("est_bytes"))
for c in reply.get("containment", []):
print(c["priority"], "-", c["action"])
const key = "dnslog-desk:hunt:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const f of reply.findings || []) console.log(f.domain, f.assessment, f.est_bytes);
for (const c of reply.containment || []) console.log(c.priority, "-", c.action);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "dnslog-desk:hunt:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
findings, _ := reply["findings"].([]any)
for _, x := range findings {
f, _ := x.(map[string]any)
fmt.Println(f["domain"], f["assessment"], f["est_bytes"])
}
containment, _ := reply["containment"].([]any)
for _, x := range containment {
c, _ := x.(map[string]any)
fmt.Println(c["priority"], "-", c["action"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "dnslog-desk:hunt:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("domain").asText() + " " + f.path("assessment").asText() + " " + f.path("est_bytes"));
}
for (JsonNode c : reply.path("containment")) {
System.out.println(c.path("priority").asText() + " - " + c.path("action").asText());
}
key = "dnslog-desk:hunt:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["findings"] || []).each { |f| puts "#{f["domain"]} #{f["assessment"]} #{f["est_bytes"].inspect}" }
(reply["containment"] || []).each { |c| puts "#{c["priority"]} - #{c["action"]}" }
$key = "dnslog-desk:hunt:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->domain, " ", $f->assessment, " ", json_encode($f->est_bytes ?? null), "\n";
foreach ($reply->containment ?? [] as $c) echo $c->priority, " - ", $c->action, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "dnslog-desk:hunt:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["domain"]} {f?["assessment"]} {f?["est_bytes"]?.ToJsonString() ?? "null"}");
foreach (var c in reply["containment"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{c?["priority"]} - {c?["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:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated 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}]}'
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for f in result.get("findings", []):
print(f["domain"], f["assessment"], f.get("est_bytes"))
for c in result.get("containment", []):
print(c["priority"], "-", c["action"])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": key },
body: JSON.stringify(input),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const f of result.findings || []) console.log(f.domain, f.assessment, f.est_bytes);
for (const c of result.containment || []) console.log(c.priority, "-", c.action);
sreq, err := newRequest("POST", "/run-stream", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
resultFindings, _ := result["findings"].([]any)
for _, x := range resultFindings {
f, _ := x.(map[string]any)
fmt.Println(f["domain"], f["assessment"], f["est_bytes"])
}
resultContainment, _ := result["containment"].([]any)
for _, x := range resultContainment {
c, _ := x.(map[string]any)
fmt.Println(c["priority"], "-", c["action"])
}
HttpRequest sreq = request("POST", "/run-stream", input, Map.of("Idempotency-Key", key)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode f : result.path("findings")) {
System.out.println(f.path("domain").asText() + " " + f.path("assessment").asText() + " " + f.path("est_bytes"));
}
for (JsonNode c : result.path("containment")) {
System.out.println(c.path("priority").asText() + " - " + c.path("action").asText());
}
uri, req = build_request("POST", "/run-stream", input, { "Idempotency-Key" => key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["findings"] || []).each { |f| puts "#{f["domain"]} #{f["assessment"]} #{f["est_bytes"].inspect}" }
(result["containment"] || []).each { |c| puts "#{c["priority"]} - #{c["action"]}" }
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $key]),
CURLOPT_POSTFIELDS => json_encode($input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->findings ?? [] as $f) echo $f->domain, " ", $f->assessment, " ", json_encode($f->est_bytes ?? null), "\n";
foreach ($result->containment ?? [] as $c) echo $c->priority, " - ", $c->action, "\n";
using var sreq = Api.Request("POST", "/run-stream", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var f in result["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["domain"]} {f?["assessment"]} {f?["est_bytes"]?.ToJsonString() ?? "null"}");
foreach (var c in result["containment"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{c?["priority"]} - {c?["action"]}");
What the page does that you now do
- Parses the log in the browser and builds
factsfrom it (dnskit.js); you build the same object and stringify it, or copy it from the page’s Download .json export. - Prices every input with
/estimateand refuses to run belowmin_credits. - Sends each attempt with an input-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Checks the reply against the log (
recon.js): domains, clients, query counts, byte estimates, the high flags and the verdict’s backing.
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.