Driving Linux Triage from your own code
Linux Triage is a metered SkillSafe app. Everything the page does over the network, you can do from a script: mint a token, price a lane for free, submit a paste, and read back the same JSON envelope the page renders.
Base URL https://api.skillsafe.ai/v1/app-api · slug linux-triage · model gpt-terra (resolves to gpt-5.6-terra)
The task field comes first
Every run input carries task, and it is the field that decides everything else. It selects one of four lanes over the same work object — one console paste — and each lane has its own allowed verdicts and its own emphasis inside an otherwise identical envelope. If task is missing or unrecognised the model picks the closest lane and names its choice in summary rather than blending two contracts.
| task | Lane | Allowed verdicts | What differs |
|---|---|---|---|
triage | The fault chain | root-cause-identified | narrowed | insufficient-evidence | chain[] is populated and is the spine of the answer: one entry per link, earliest cause first, each quoting the line that proves it. |
denial | What was denied | policy-change | relabel | not-the-cause | no-denials-present | chain[] is [] by contract. One findings[] entry per denial, each quoting its audit record. permissive=1 denials are reported as logged-and-allowed, which is not-the-cause. |
package | The package state | recoverable-in-place | needs-intervention | no-package-failure | chain[] is [] by contract. The package manager is taken from the error grammar (dpkg:, nothing provides, failed to commit transaction), never from distro. |
runbook | The runbook | ready-to-run | needs-approval | blocked-on-evidence | chain[] here is the preconditions list, not a causal chain. Every step carries verify, and every risk: "high" step carries rollback. At least one findings entry with severity: "crit" is the stop condition. |
The endpoints
- POST https://api.skillsafe.ai/v1/app-api/guest — mint a guest token
- GET https://api.skillsafe.ai/v1/app-api/me — subject type and credit balance
- POST https://api.skillsafe.ai/v1/app-api/estimate — price a run — free, creates no job
- POST https://api.skillsafe.ai/v1/app-api/run — submit; returns a job_id
- GET https://api.skillsafe.ai/v1/app-api/jobs/{job_id} — poll a job to a terminal state
- POST https://api.skillsafe.ai/v1/app-api/run-stream — submit and stream the reply as SSE
The envelope, and what can go wrong
Success is {"ok":true,"data":{...}}. Failure is {"ok":false,"error":{"code":"...","message":"...","details":{...}}} with a matching HTTP status.
| code | HTTP | What it means, and what to do |
|---|---|---|
UNAUTHORIZED | 401 | No token, or a token that has expired or been revoked. Mint a new guest token or sign in again. |
FORBIDDEN | 403 | The token is valid but not for this app, or the call needs a personal token and you sent a guest one. |
NOT_FOUND | 404 | Wrong slug, or a job id that does not belong to this subject. |
VALIDATION_ERROR | 400 | The body is malformed. error.details names the offending field. |
PAYMENT_REQUIRED | 402 | The balance is below min_credits. Compare them in step 3 and this never happens. |
RATE_LIMITED | 429 | Back off and retry with the SAME Idempotency-Key — a retry on the same key cannot double-bill. |
INTERNAL | 500 | Transient. Retry once on the same key before treating it as a failure. |
1. A tiny client helper
Every endpoint returns the same envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code":"...","message":"..."}} on failure. Write the unwrapping once.
# Everything below is a POST or GET against this base, with a bearer token. BASE="https://api.skillsafe.ai/v1/app-api" SLUG="linux-triage" TOKEN="YOUR_TOKEN" # see step 2, or copy one from /tokens.html
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "linux-triage"
TOKEN = "YOUR_TOKEN" # see step 2, or copy one from /tokens.html
def call(path, body=None, token=TOKEN, method=None):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read())
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "linux-triage";
let TOKEN = "YOUR_TOKEN"; // see step 2, or copy one from /tokens.html
async function call(path, body, token = TOKEN) {
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: "Bearer " + token } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const payload = await res.json();
if (!payload.ok) throw new Error(JSON.stringify(payload.error));
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "linux-triage"
var token = "YOUR_TOKEN" // see step 2, or copy one from /tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error json.RawMessage `json:"error"`
}
func call(path string, body interface{}) (json.RawMessage, error) {
var rdr io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(string(env.Error))
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class HostTriage {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "linux-triage";
static String token = "YOUR_TOKEN"; // see step 2, or copy one from /tokens.html
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null && !token.isEmpty()) {
b.header("Authorization", "Bearer " + token);
}
b = (jsonBody == null)
? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // an {"ok":true,"data":...} envelope
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "linux-triage"
TOKEN = "YOUR_TOKEN" # see step 2, or copy one from /tokens.html
def call(path, body = nil, token = TOKEN)
uri = URI(BASE + path)
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token && !token.empty?
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "linux-triage";
$TOKEN = "YOUR_TOKEN"; // see step 2, or copy one from /tokens.html
function call(string $path, ?array $body = null, ?string $token = null) {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) { throw new RuntimeException(json_encode($payload["error"] ?? null)); }
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public static class HostTriage
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "linux-triage";
static string Token = "YOUR_TOKEN"; // see step 2, or copy one from /tokens.html
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(string path, object body = null)
{
var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (!string.IsNullOrEmpty(Token))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean())
throw new Exception(doc.RootElement.GetProperty("error").ToString());
return doc.RootElement.GetProperty("data");
}
}
2. Get a token
A guest token is minted with one call and is enough for /me and /estimate. Running a lane is metered, so it needs a personal token — sign in on the app page or copy one from the token panel.
curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" \
-d "{\"slug\":\"$SLUG\"}"
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
# A guest token can call /me and /estimate. Running a lane needs a personal
# token, which comes from signing in on the app page or on /tokens.html.
guest = call("/guest", {"slug": SLUG}, token=None)
TOKEN = guest["token"]
print(guest["subject_type"]) # "guest"
const guest = await call("/guest", { slug: SLUG }, null);
TOKEN = guest.token;
console.log(guest.subject_type); // "guest"
raw, err := call("/guest", map[string]string{"slug": slug})
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
SubjectType string `json:"subject_type"`
}
json.Unmarshal(raw, &guest)
token = guest.Token
token = ""; // no token for the guest call itself
String guest = call("/guest", "{\"slug\":\"" + SLUG + "\"}");
// pull data.token out of the envelope with your JSON library, then:
// token = thatValue;
guest = call("/guest", { "slug" => SLUG }, nil)
token = guest["token"]
puts guest["subject_type"] # "guest"
$guest = call("/guest", ["slug" => SLUG]);
$TOKEN = $guest["token"];
echo $guest["subject_type"]; // "guest"
Token = ""; // no token for the guest call itself
var guest = await Call("/guest", new { slug = Slug });
Token = guest.GetProperty("token").GetString();
3. Check the session and the balance
Do this before every run. Comparing credits here against min_credits from step 4 is what stops a submit turning into a 402.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":124500,...}}
# `credits` is the balance the run will be charged against. Compare it with
# min_credits from /estimate BEFORE you submit, so a run never 402s.
me = call("/me")
print(me["subject_type"], me.get("credits"))
const me = await call("/me");
console.log(me.subject_type, me.credits);
raw, _ = call("/me", nil)
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
json.Unmarshal(raw, &me)
String me = call("/me", null);
System.out.println(me);
me = call("/me")
puts me["subject_type"], me["credits"]
$me = call("/me", null, $TOKEN);
echo $me["subject_type"], " ", $me["credits"];
var me = await Call("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run — free, and per lane
/estimate creates no job and costs nothing. It is also the authoritative check that the app is bound to the model you expect. Re-estimate whenever you change task: the four lanes have different prompts and different output caps, so their reservations differ.
cat > input.json <<'JSON'
{
"task": "triage",
"console_output": "x nginx.service - A high performance web server\n Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)\n Active: failed (Result: exit-code) since Fri 2026-08-14 22:31:22 UTC; 14s ago\n Process: 8127 ExecStart=/usr/sbin/nginx -g daemon on; (code=exited, status=1/FAILURE)\nAug 14 22:31:06 HOST-1 nginx[8127]: nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)\nAug 14 22:31:22 HOST-1 systemd[1]: nginx.service: Start request repeated too quickly, start-limit-hit",
"distro": "debian",
"distro_source": "read-from-paste",
"posture": "balanced",
"recent_changes": "Rebooted at 22:29 after an unattended kernel upgrade.",
"definition_of_fixed": "Production web node behind a load balancer; one node may leave rotation.",
"identities_masked": true,
"scan": {
"distro": "debian",
"units": [{"unit": "nginx.service", "lines": 12, "errors": 6, "fails": 4,
"starts": 3, "start_limit": true,
"restart_loop": {"starts": 3, "span_s": 12, "period_s": 6}}],
"status": [{"unit": "nginx.service", "loaded": "loaded", "enabled": "enabled",
"active": "failed", "exit_code": 1, "exit_table": "process",
"exit_meaning": "generic failure - the service decided to exit"}],
"denials": [],
"packages": [],
"oom": [],
"disks": [],
"flags": [
{"id": "unit-failed", "title": "nginx.service is in the failed state",
"severity": "crit", "state": "definite"},
{"id": "restart-loop", "title": "nginx.service is restarting in a loop",
"severity": "crit", "state": "definite"},
{"id": "start-limit-hit", "title": "nginx.service hit the systemd start limit",
"severity": "crit", "state": "definite"}
]
},
"clip": {"characters_cut": 0, "head_lines": 0, "tail_lines": 0}
}
JSON
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"slug\":\"$SLUG\",\"input\":$(cat input.json)}"
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...}}
# estimate is FREE and creates no job. hold_credits is a RESERVATION against
# the full output cap, not the price. It differs per lane, so re-estimate
# whenever you change `task`.
INPUT = json.loads(r"""
{
"task": "triage",
"console_output": "x nginx.service - A high performance web server\n Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)\n Active: failed (Result: exit-code) since Fri 2026-08-14 22:31:22 UTC; 14s ago\n Process: 8127 ExecStart=/usr/sbin/nginx -g daemon on; (code=exited, status=1/FAILURE)\nAug 14 22:31:06 HOST-1 nginx[8127]: nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)\nAug 14 22:31:22 HOST-1 systemd[1]: nginx.service: Start request repeated too quickly, start-limit-hit",
"distro": "debian",
"distro_source": "read-from-paste",
"posture": "balanced",
"recent_changes": "Rebooted at 22:29 after an unattended kernel upgrade.",
"definition_of_fixed": "Production web node behind a load balancer; one node may leave rotation.",
"identities_masked": true,
"scan": {
"distro": "debian",
"units": [{"unit": "nginx.service", "lines": 12, "errors": 6, "fails": 4,
"starts": 3, "start_limit": true,
"restart_loop": {"starts": 3, "span_s": 12, "period_s": 6}}],
"status": [{"unit": "nginx.service", "loaded": "loaded", "enabled": "enabled",
"active": "failed", "exit_code": 1, "exit_table": "process",
"exit_meaning": "generic failure - the service decided to exit"}],
"denials": [],
"packages": [],
"oom": [],
"disks": [],
"flags": [
{"id": "unit-failed", "title": "nginx.service is in the failed state",
"severity": "crit", "state": "definite"},
{"id": "restart-loop", "title": "nginx.service is restarting in a loop",
"severity": "crit", "state": "definite"},
{"id": "start-limit-hit", "title": "nginx.service hit the systemd start limit",
"severity": "crit", "state": "definite"}
]
},
"clip": {"characters_cut": 0, "head_lines": 0, "tail_lines": 0}
}
""")
est = call("/estimate", {"slug": SLUG, "input": INPUT})
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
assert me["credits"] >= est["min_credits"], "top up before running"
const INPUT = {
"task": "triage",
"console_output": "x nginx.service - A high performance web server\n Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)\n Active: failed (Result: exit-code) since Fri 2026-08-14 22:31:22 UTC; 14s ago\n Process: 8127 ExecStart=/usr/sbin/nginx -g daemon on; (code=exited, status=1/FAILURE)\nAug 14 22:31:06 HOST-1 nginx[8127]: nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)\nAug 14 22:31:22 HOST-1 systemd[1]: nginx.service: Start request repeated too quickly, start-limit-hit",
"distro": "debian",
"distro_source": "read-from-paste",
"posture": "balanced",
"recent_changes": "Rebooted at 22:29 after an unattended kernel upgrade.",
"definition_of_fixed": "Production web node behind a load balancer; one node may leave rotation.",
"identities_masked": true,
"scan": {
"distro": "debian",
"units": [{"unit": "nginx.service", "lines": 12, "errors": 6, "fails": 4,
"starts": 3, "start_limit": true,
"restart_loop": {"starts": 3, "span_s": 12, "period_s": 6}}],
"status": [{"unit": "nginx.service", "loaded": "loaded", "enabled": "enabled",
"active": "failed", "exit_code": 1, "exit_table": "process",
"exit_meaning": "generic failure - the service decided to exit"}],
"denials": [],
"packages": [],
"oom": [],
"disks": [],
"flags": [
{"id": "unit-failed", "title": "nginx.service is in the failed state",
"severity": "crit", "state": "definite"},
{"id": "restart-loop", "title": "nginx.service is restarting in a loop",
"severity": "crit", "state": "definite"},
{"id": "start-limit-hit", "title": "nginx.service hit the systemd start limit",
"severity": "crit", "state": "definite"}
]
},
"clip": {"characters_cut": 0, "head_lines": 0, "tail_lines": 0}
};
const est = await call("/estimate", { slug: SLUG, input: INPUT });
console.log(est.model, est.model_alias, est.hold_credits, est.min_credits);
var input map[string]interface{}
json.Unmarshal([]byte(inputJSON), &input) // inputJSON is the object shown in the cURL tab
raw, _ = call("/estimate", map[string]interface{}{"slug": slug, "input": input})
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
json.Unmarshal(raw, &est)
String input = INPUT_JSON; // the object shown in the cURL tab
String est = call("/estimate", "{\"slug\":\"" + SLUG + "\",\"input\":" + input + "}");
System.out.println(est);
input = JSON.parse(INPUT_JSON) # the object shown in the cURL tab
est = call("/estimate", { "slug" => SLUG, "input" => input })
puts est["model"], est["hold_credits"], est["min_credits"]
$input = json_decode(INPUT_JSON, true); // the object shown in the cURL tab
$est = call("/estimate", ["slug" => SLUG, "input" => $input], $TOKEN);
echo $est["model"], " ", $est["hold_credits"];
var input = JsonSerializer.Deserialize<object>(InputJson); // shown in the cURL tab
var est = await Call("/estimate", new { slug = Slug, input });
Console.WriteLine(est.GetProperty("model").GetString());
5. Run it and poll
Use this when you do not need progress. Always send an Idempotency-Key: a retry with the same key returns the same job rather than billing a second run.
# Submit, then poll. Always send an Idempotency-Key: a retry with the same key
# returns the SAME job instead of billing a second run.
KEY="ht-triage-$(printf %s "$(cat input.json)" | shasum | cut -c1-16)-0"
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "{\"slug\":\"$SLUG\",\"input\":$(cat input.json)}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
until [ "$(curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')" != "running" ]; do sleep 2; done
curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"
import hashlib, time
key = "ht-triage-" + hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + "-0"
req = urllib.request.Request(BASE + "/run",
data=json.dumps({"slug": SLUG, "input": INPUT}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = call("/jobs/" + job_id)
if job["status"] != "running":
break
time.sleep(2)
reply = json.loads(job["text"]) # the envelope described below
print(reply["lane"], reply["verdict"], len(reply["steps"]))
const key = "ht-triage-" + INPUT.task + "-0";
const started = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key
},
body: JSON.stringify({ slug: SLUG, input: INPUT })
}).then(r => r.json());
let job = { status: "running" };
while (job.status === "running") {
await new Promise(r => setTimeout(r, 2000));
job = await call("/jobs/" + started.data.job_id);
}
const reply = JSON.parse(job.text);
console.log(reply.lane, reply.verdict);
b, _ := json.Marshal(map[string]interface{}{"slug": slug, "input": input})
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "ht-triage-0")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
// read data.job_id, then GET /jobs/{id} every two seconds until status != "running"
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "ht-triage-0")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"slug\":\"" + SLUG + "\",\"input\":" + input + "}"))
.build();
HttpResponse<String> started = CLIENT.send(run, HttpResponse.BodyHandlers.ofString());
// read data.job_id, then poll GET /jobs/{id} until status != "running"
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "ht-triage-0"
req.body = JSON.generate({ "slug" => SLUG, "input" => input })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
job = { "status" => "running" }
while job["status"] == "running"
sleep 2
job = call("/jobs/#{job_id}")
end
reply = JSON.parse(job["text"])
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: ht-triage-0",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG, "input" => $input]));
$started = json_decode(curl_exec($ch), true);
$jobId = $started["data"]["job_id"];
do { sleep(2); $job = call("/jobs/" . $jobId, null, $TOKEN); }
while ($job["status"] === "running");
$reply = json_decode($job["text"], true);
var run = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
run.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
run.Headers.Add("Idempotency-Key", "ht-triage-0");
run.Content = new StringContent(
JsonSerializer.Serialize(new { slug = Slug, input }), Encoding.UTF8, "application/json");
var started = await Http.SendAsync(run);
// read data.job_id, then GET /jobs/{id} every two seconds until status != "running"
6. Or stream it
The app itself uses this. Concatenate every delta payload and parse the concatenation once at the end — an individual delta is a fragment, not valid JSON.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" \
-d "{\"slug\":\"$SLUG\",\"input\":$(cat input.json)}"
# event: job -> {"job_id":"job_..."}
# event: delta -> {"text":"..."} many of these; concatenate them
# event: done -> {"status":"succeeded","charged_credits":...,"truncated":false}
#
# Concatenate every delta and parse the result ONCE at the end. The app itself
# also watches the accumulating text for section keys ("chain", "steps") to
# advance its progress card - the deltas are not individually valid JSON.
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps({"slug": SLUG, "input": INPUT}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
acc, event = "", None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
acc += json.loads(line[6:]).get("text", "")
reply = json.loads(acc)
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key
},
body: JSON.stringify({ slug: SLUG, input: INPUT })
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", acc = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
acc += (JSON.parse(line.slice(6)).text || "");
}
}
}
const reply = JSON.parse(acc);
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "ht-triage-0")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
// bufio.Scanner over res.Body: track "event: " lines, accumulate the "data: "
// payloads whose event is "delta", then json.Unmarshal the concatenation once.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "ht-triage-0")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"slug\":\"" + SLUG + "\",\"input\":" + input + "}"))
.build();
CLIENT.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(System.out::println); // track event:/data: pairs, join the deltas
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "ht-triage-0"
req.body = JSON.generate({ "slug" => SLUG, "input" => input })
acc, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
event = line[7..].strip if line.start_with?("event: ")
acc << (JSON.parse(line[6..])["text"] || "") if line.start_with?("data: ") && event == "delta"
end
end
end
end
reply = JSON.parse(acc)
$acc = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Accept: text/event-stream",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: ht-triage-0",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG, "input" => $input]));
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$acc, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) { $event = trim(substr($line, 7)); }
elseif (str_starts_with($line, "data: ") && $event === "delta") {
$acc .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
$reply = json_decode($acc, true);
var stream = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
stream.Headers.Add("Accept", "text/event-stream");
stream.Headers.Add("Idempotency-Key", "ht-triage-0");
stream.Content = new StringContent(
JsonSerializer.Serialize(new { slug = Slug, input }), Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
var acc = new StringBuilder();
string ev = null, line;
while ((line = await rdr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line.Substring(7);
else if (line.StartsWith("data: ") && ev == "delta")
acc.Append(JsonDocument.Parse(line.Substring(6)).RootElement.GetProperty("text").GetString());
}
var reply = JsonDocument.Parse(acc.ToString());
The run input, field by field
| Field | Type | Meaning |
|---|---|---|
task | string | triage, denial, package or runbook. Required in practice. |
console_output | string | The paste. Send it already masked if it carries identities — the page masks in the browser and so should you. |
distro | string | debian, rhel, fedora, arch or unknown. |
distro_source | string | read-from-paste or stated-by-operator. The latter overrules the paste. |
posture | string | balanced, cautious or fast. |
recent_changes | string | What changed and what was already tried. The highest-value optional field. |
definition_of_fixed | string | What a good outcome is, and what is off the table. Binds the runbook. |
identities_masked | boolean | Whether pseudonyms are in play, so the model knows not to guess at what HOST-1 stands for. |
scan | object | The structured read: units[], status[], denials[], packages[], oom[], disks[], flags[]. You may send null, but the reply loses its grounding and its coverage check. |
clip | object | characters_cut, head_lines, tail_lines — so the model knows it is reading an excerpt of an excerpt. |
The output envelope — identical on every lane
One JSON object, no fence, no prose around it. Every key is present on every reply; an empty array is a complete answer.
{
"lane": "triage",
"title": "nginx.service cannot bind 443 because caddy already holds it",
"headline": "One sentence an on-call engineer could act on.",
"verdict": "root-cause-identified",
"confidence": "high",
"summary": "Two to five sentences: what broke, what it means, what is least certain.",
"chain": [
{"order": 1, "what": "caddy.service started at 22:29 and took :443",
"evidence": "Aug 14 22:29:58 HOST-1 systemd[1]: Started caddy.service - Caddy web server.",
"line": 29, "certainty": "definite"}
],
"steps": [
{"order": 1, "action": "Identify the process holding 443", "command": "ss -ltnp | grep ':443'",
"why": "Names the conflicting listener before anything is stopped.",
"risk": "low", "reversible": "yes", "rollback": "", "verify": "one line naming a pid and a program"}
],
"findings": [
{"title": "The restart loop is a consequence, not the cause", "severity": "warn",
"why": "systemd restarted three times in twelve seconds because the bind failed each time.",
"evidence": "nginx.service: Start request repeated too quickly, start-limit-hit"}
],
"artifact": "# Triage note\n\n...markdown...",
"coverage_check": [
{"flag_id": "restart-loop", "answered": "yes", "note": "Explained as downstream of the bind failure."}
],
"questions": ["Was caddy installed deliberately, or pulled in as a dependency?"]
}
One worked example per lane
task: "triage" — The fault chain
chain[] is populated and is the spine of the answer: one entry per link, earliest cause first, each quoting the line that proves it. Verdicts: root-cause-identified | narrowed | insufficient-evidence.
{
"task": "triage",
"console_output": "<the systemctl status block and the journal tail>",
"distro": "debian",
"posture": "balanced",
"recent_changes": "Rebooted at 22:29 after an unattended kernel upgrade.",
"definition_of_fixed": "Production node behind an LB; one node may leave rotation.",
"scan": { "...": "the browser scan, see the shape above" }
}
task: "denial" — What was denied
chain[] is [] by contract. One findings[] entry per denial, each quoting its audit record. permissive=1 denials are reported as logged-and-allowed, which is not-the-cause. Verdicts: policy-change | relabel | not-the-cause | no-denials-present.
{
"task": "denial",
"console_output": "<the httpd status block plus the type=AVC records>",
"distro": "rhel",
"posture": "cautious",
"definition_of_fixed": "SELinux must stay enforcing.",
"scan": { "denials": [{"lsm": "selinux", "perms": ["name_bind"], "comm": "httpd",
"scontext_type": "httpd_t", "tcontext_type": "unreserved_port_t",
"tclass": "tcp_socket", "permissive": false}] }
}
task: "package" — The package state
chain[] is [] by contract. The package manager is taken from the error grammar (dpkg:, nothing provides, failed to commit transaction), never from distro. Verdicts: recoverable-in-place | needs-intervention | no-package-failure.
{
"task": "package",
"console_output": "<the dpkg --configure -a output plus df -h and df -i>",
"distro": "debian",
"posture": "balanced",
"scan": { "packages": [{"mgr": "apt", "kind": "interrupted", "pkg": "", "detail": ""}],
"disks": [{"fs": "/dev/vda2", "mount": "/", "size": "20G", "used": "20G",
"stated_pct": 100, "computed_pct": 100, "inode_table": false}] }
}
task: "runbook" — The runbook
chain[] here is the preconditions list, not a causal chain. Every step carries verify, and every risk: "high" step carries rollback. At least one findings entry with severity: "crit" is the stop condition. Verdicts: ready-to-run | needs-approval | blocked-on-evidence.
{
"task": "runbook",
"console_output": "<the same paste>",
"recent_changes": "Carried over from the fault chain lane: ...",
"definition_of_fixed": "No reboot before Sunday.",
"scan": { "...": "as above" }
}
Notes that will save you a round trip
- Re-estimate on every lane change.
hold_creditsis per lane. Showing one lane’s reservation for another lane’s run is wrong in both directions. - Hash the lane into the idempotency key. Two lanes over the same paste are two distinct runs; one key for both returns the first lane’s answer for the second.
hold_creditsis a reservation, not a price. It prices the full output cap.charged_creditson the terminal job is what you actually paid.- Handle
truncated: true. A balance betweenmin_creditsandhold_creditsstill runs, with a reduced cap. The reply is real but cut short — do not present it as complete. - Send
scanif you can. Without it the model has no facts to be held to andcoverage_checkcomes back empty, which is exactly the grounding the app exists for. - Mask before you send. The page replaces host names, addresses, accounts, MACs, UUIDs, mail addresses and container ids with stable pseudonyms in the browser. A script that skips that step sends the real ones.