← Mesh Desk / API
Tokens

Drive Mesh Desk from your own code

The page reads your Istio configuration in the browser for free — VirtualService, DestinationRule, Gateway, ServiceEntry, PeerAuthentication, AuthorizationPolicy and RequestAuthentication, plus the Kubernetes Services and Deployments behind a host — parses every document with its line numbers, and runs deterministic checks over it: routes to subsets no DestinationRule defines, weights that do not total 100, catch-all routes that shadow the ones below, retries that cannot fit their timeout, fault injection and mirroring left on, and, for mutual TLS, the PeerAuthentication posture per scope, DestinationRules that switch TLS off and AuthorizationPolicies plaintext walks past. Its two paid steps are available over HTTP. You send one JSON object with task set to "traffic" or "mtls", the pasted YAML as config and the page’s check as a facts string, and the model replies with one JSON object: a routing audit with a YAML fix per finding and an answer for every flag, or an mTLS posture per scope with the fixes and a rollout to STRICT.

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 (“The run failed: ...”).

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

Failures that do not arrive as an HTTP status:

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

The task field: two lanes

task routes the run, and it takes exactly two values. Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the traffic lane when task is missing or anything else, so a typo silently gets you a traffic audit: always send one of the two values, and send the facts built for that same lane.

taskWhat it doesverdictUse it when
"traffic"Audits how requests are routed: confirms or dismisses each page flag (TR-01..TR-22), adds what text rules cannot see, gives the verbatim evidence and a YAML fix for each finding, says in plain words what each VirtualService host’s requests get today, and orders the fixes into a plan.unsafe | needs_work | readyBefore applying routing changes: canary splits, retries and timeouts, circuit breakers, mirrors, fault injection, gateways.
"mtls"Checks the mutual-TLS posture of the same config: answers every posture row the page computed, confirms or dismisses each page flag (MT-01..MT-13), fixes each finding and writes an ordered rollout to STRICT that does not break callers.exposed | permissive | strictBefore a security review, or to plan a PERMISSIVE-to-STRICT migration.

The page chains the two: after a traffic audit, “Check mTLS for these hosts” switches to the mtls lane on the same paste and adds a traffic_handoff string (below) with the traffic reply’s verdict, routed hosts and findings. Over the API that is a second run with task: "mtls", the mtls facts of the same paste, and that string.

Input fields

This is exactly what the page sends (meshkit.js buildInput, passed through mustBeObject; app.js run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string, in this order:

FieldTypeRequiredMeaning
taskstringyes"traffic" or "mtls".
configstringyesThe pasted YAML (or kubectl JSON), verbatim; line 1 is its first line. Every line in the reply is counted in this text. A large paste is cut on whole-document boundaries (below).
factsstringyesThe page’s free check for this lane, Meshkit.facts(A, lane), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
notestringnoWhat the engineer says: the symptom, the goal, a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against config, says so when they disagree, and answers it in summary.
traffic_handoffstringnomtls lane only: a JSON string {from, verdict, headline, hosts, findings} built from a traffic reply on the same paste (app.js trafficHandoff). The model uses it to know which hosts carry the traffic that matters, and checks it against config. buildInput drops it for the traffic lane.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject turns the input into a flat object of strings and drops empty values, which is why facts and traffic_handoff travel as strings and why an empty note is simply absent.

The config string

config is the paste itself: several YAML documents separated by ---, a kind: List, or kubectl -o json output. Dropped files are joined into one paste, each introduced by a # file: name comment, and the page normalises line endings to \n. Send the text you want the reply to quote: every resource in the reply must be a ref from facts.source.resources, every line must fall inside that resource’s lines of config, and every evidence must be copied from it. The page reads up to 400 000 characters and 400 documents, and sends at most 60 000 characters: past that it keeps whole documents from the start and the end of the paste and replaces the middle with one comment,

# [mesh-desk: lines X-Y (N documents) not sent]

(the model is told never to quote or rewrite what was not sent), with a sentence in facts.notes. facts is still computed from the whole paste. Both worked examples below are small, so their config is the paste unchanged.

The facts string

facts is produced by the page’s free check: Meshkit.analyze parses the paste and runs the rules, and Meshkit.facts(A, lane) turns that analysis into this object for one lane — routes appears only in the traffic facts, posture only in the mtls facts, and flags holds only that lane’s flags. It is how the model knows which flags to answer and which posture rows to reproduce. The simplest source is meshkit.js itself, which runs in Node: Meshkit.mustBeObject(Meshkit.buildInput(lane, Meshkit.analyze(yamlText), note, handoff)) returns the whole body (the two worked examples below also show the facts in full). Its top-level keys, in the order built:

KeyContents
sourcelines, format (yaml | json), namespaces, resources[] (per resource its ref such as VirtualService/reviews — with a namespace segment when two resources share a name — kind, name, namespace ("(not set)" appended when the manifest omits it), api_version, line and end_line), and up to ten parse_errors.
hostsPer fully-qualified host: host, the virtual_services and destination_rules that name it, subsets_defined, subsets_routed, gateways, mtls_server (the PeerAuthentication mode computed for the workloads behind it; "(default)" means Istio’s PERMISSIVE default), mtls_client, and service_entry / service when pasted.
routesTraffic lane. Every http route: vs, index, name, line, match, destinations[] (host, subset, weight), timeout, retries, fault, mirror.
posturemTLS lane. One row per PeerAuthentication (scope mesh, namespace/<ns> or workload/<ns>/<labels>) and one effective/<ns> row per namespace in the paste, each with mode, source and port-level ports[] (port, mode).
flagsEvery flag the page raised for this lane: uid (F1, F2, ...), id (the catalogue id: TR-01..TR-22 or MT-01..MT-13), sev (high | medium | low | info), resource, line, text and detail. The reply must answer every non-info flag by its uid.
verdict_candidateThe verdict this lane’s flags point at (the page’s own reading).
notesPlain sentences about what was not read or not sent.

Without the page’s check. facts is required, but the platform only checks that it is a string. With a hand-made minimal facts the model still reads the config, but you lose what the check adds: with no flags there is nothing to confirm or dismiss, with no source.resources there are no refs to name resources by, and with no posture the mtls reply has no rows to reproduce. Build it with meshkit.js as above.

The traffic_handoff string

Only for task: "mtls", and only for the paste the traffic reply was about (the page drops it as soon as the paste is edited). The page builds it from the normalised traffic reply (recon.js normalize, below):

JSON.stringify({ from: "traffic", verdict: r.verdict, headline: r.headline,
  hosts: r.routes.map(x => x.host),
  findings: r.findings.map(f => ({ id: f.id, resource: f.resource, severity: f.severity, title: f.title })) })

The mtls reply is then expected to cover every handed-off host with a posture row or a finding, and to say in summary how the traffic findings interact with mTLS. It is a previous reply, not evidence: the model checks it against config.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. Both lanes share the finding shape {id, flags[], resource, line, severity, title, evidence, why, fix}: id is a catalogue id of the lane or other; flags lists the page uids it confirms ([] for something the page did not flag); resource is a ref from facts.source.resources, copied exactly; line is a line of config inside that resource; severity is high | medium | low; evidence is one to three lines copied verbatim from config; fix is a YAML fragment, or a whole corrected resource (with kind, metadata.name and metadata.namespace) when the fix belongs in another or a missing resource. And both share flag_answers[]: {flag, status, reason}, exactly one for every page flag whose sev is not info, with status confirmed (a finding lists that uid) | dismissed (with the reason it does not apply).

task: "traffic"

task: "mtls"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (routes for traffic, posture for mtls). An unknown verdict shows as needs_work or permissive and counts as a disagreement; an unknown severity becomes medium and counts as one too; ids like TR3 or MT_4 become TR-03 and MT-04; flags keeps only F<n> values; findings with no resource, evidence and title are dropped; questions is capped at 5, plan and rollout at 12. A reply carries 8 sections in either lane (verdict, headline and six more), which is what the page counts when it recovers a partial reply.

Worked examples

Both are built with the app’s own modules from the examples the page ships (the example buttons on the app page), exactly as the page builds them: Meshkit.mustBeObject(Meshkit.buildInput(e.lane, Meshkit.analyze(e.raw), e.note)). Each shows the config text and the decoded facts object (save them under the file names given to run the samples in steps 3–5), the request body, and a saved model reply (reply set r2).

Traffic: A reviews canary that shadows its testers route, plus a ratings fault left on

4 resources: VirtualService/reviews, DestinationRule/reviews, VirtualService/ratings, DestinationRule/ratings. The page’s free check reads 3 http routes and raises 8 flags (3 high, 3 medium, 2 low) for the traffic lane; its own reading of the verdict is unsafe. The engineer’s note: “We shipped reviews v3 as a 5% canary an hour ago. The testers (end-user: jason) say they never see v2, and ratings errors jumped. What is wrong before I apply the next change?”

The config string, 1,587 characters ending in a newline — save it as traffic-config.yaml:

# file: bookinfo/reviews-routing.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: reviews
  namespace: bookinfo
spec:
  hosts:
    - reviews
  http:
    - name: canary-split
      route:
        - destination:
            host: reviews
            subset: v1
          weight: 90
        - destination:
            host: reviews
            subset: v3
          weight: 5
    - name: testers
      match:
        - headers:
            end-user:
              exact: jason
      route:
        - destination:
            host: reviews
            subset: v2
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: reviews
  namespace: bookinfo
spec:
  host: reviews
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2
---
# file: bookinfo/ratings-routing.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: ratings
  namespace: bookinfo
spec:
  hosts:
    - ratings
  http:
    - fault:
        abort:
          percentage:
            value: 20
          httpStatus: 503
      route:
        - destination:
            host: ratings
            subset: v1
      timeout: 2s
      retries:
        attempts: 3
        perTryTimeout: 1s
        retryOn: 5xx,connect-failure
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: ratings
  namespace: bookinfo
spec:
  host: ratings
  trafficPolicy:
    connectionPool:
      http:
        http1MaxPendingRequests: 100
  subsets:
    - name: v1
      labels:
        version: v1

The facts object, decoded and complete — save it as traffic-facts.json:

{
  "source": {
    "lines": 86,
    "format": "yaml",
    "namespaces": [
      "bookinfo"
    ],
    "resources": [
      {
        "ref": "VirtualService/reviews",
        "kind": "VirtualService",
        "name": "reviews",
        "namespace": "bookinfo",
        "api_version": "networking.istio.io/v1",
        "line": 2,
        "end_line": 29
      },
      {
        "ref": "DestinationRule/reviews",
        "kind": "DestinationRule",
        "name": "reviews",
        "namespace": "bookinfo",
        "api_version": "networking.istio.io/v1",
        "line": 31,
        "end_line": 44
      },
      {
        "ref": "VirtualService/ratings",
        "kind": "VirtualService",
        "name": "ratings",
        "namespace": "bookinfo",
        "api_version": "networking.istio.io/v1",
        "line": 47,
        "end_line": 69
      },
      {
        "ref": "DestinationRule/ratings",
        "kind": "DestinationRule",
        "name": "ratings",
        "namespace": "bookinfo",
        "api_version": "networking.istio.io/v1",
        "line": 71,
        "end_line": 85
      }
    ],
    "parse_errors": []
  },
  "hosts": [
    {
      "host": "ratings.bookinfo.svc.cluster.local",
      "virtual_services": [
        "VirtualService/ratings"
      ],
      "destination_rules": [
        "DestinationRule/ratings"
      ],
      "subsets_defined": [
        "v1"
      ],
      "subsets_routed": [
        "v1"
      ],
      "gateways": [],
      "mtls_server": "PERMISSIVE (default)",
      "mtls_client": "ISTIO_MUTUAL (auto)"
    },
    {
      "host": "reviews.bookinfo.svc.cluster.local",
      "virtual_services": [
        "VirtualService/reviews"
      ],
      "destination_rules": [
        "DestinationRule/reviews"
      ],
      "subsets_defined": [
        "v1",
        "v2"
      ],
      "subsets_routed": [
        "v1",
        "v3",
        "v2"
      ],
      "gateways": [],
      "mtls_server": "PERMISSIVE (default)",
      "mtls_client": "ISTIO_MUTUAL (auto)"
    }
  ],
  "routes": [
    {
      "vs": "VirtualService/reviews",
      "index": 0,
      "name": "canary-split",
      "line": 11,
      "match": "every request",
      "destinations": [
        {
          "host": "reviews",
          "subset": "v1",
          "weight": 90
        },
        {
          "host": "reviews",
          "subset": "v3",
          "weight": 5
        }
      ],
      "timeout": null,
      "retries": null,
      "fault": false,
      "mirror": false
    },
    {
      "vs": "VirtualService/reviews",
      "index": 1,
      "name": "testers",
      "line": 21,
      "match": "header end-user exact jason",
      "destinations": [
        {
          "host": "reviews",
          "subset": "v2",
          "weight": null
        }
      ],
      "timeout": null,
      "retries": null,
      "fault": false,
      "mirror": false
    },
    {
      "vs": "VirtualService/ratings",
      "index": 0,
      "name": "",
      "line": 56,
      "match": "every request",
      "destinations": [
        {
          "host": "ratings",
          "subset": "v1",
          "weight": null
        }
      ],
      "timeout": "2s",
      "retries": {
        "attempts": 3,
        "perTryTimeout": "1s",
        "retryOn": "5xx,connect-failure"
      },
      "fault": true,
      "mirror": false
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "TR-03",
      "sev": "high",
      "resource": "VirtualService/reviews",
      "line": 11,
      "text": "http[0] (canary-split) matches every request, so the route below it never runs",
      "detail": "Istio evaluates http routes top to bottom and stops at the first match; put the route with no match last."
    },
    {
      "uid": "F2",
      "id": "TR-01",
      "sev": "high",
      "resource": "VirtualService/reviews",
      "line": 11,
      "text": "The route to reviews subset v3 names a subset DestinationRule/reviews does not define (v1, v2)",
      "detail": "Envoy has no cluster for an undefined subset: those requests fail with 503 (NR / no healthy upstream)."
    },
    {
      "uid": "F3",
      "id": "TR-10",
      "sev": "high",
      "resource": "VirtualService/ratings",
      "line": 56,
      "text": "http[0] injects aborts into every request it routes",
      "detail": "Fault injection is a chaos-test tool; left in a production VirtualService it fails real requests."
    },
    {
      "uid": "F4",
      "id": "TR-02",
      "sev": "medium",
      "resource": "VirtualService/reviews",
      "line": 12,
      "text": "http[0] splits traffic 90/5 = 95, not 100",
      "detail": "Istio's validation expects the weights of one route to total 100. Older control planes reject the resource; newer ones scale the numbers, so the split is not the one written."
    },
    {
      "uid": "F5",
      "id": "TR-04",
      "sev": "medium",
      "resource": "VirtualService/ratings",
      "line": 66,
      "text": "http[0] allows 3 retries of 1s but the 2s timeout leaves room for 1",
      "detail": "(1 + attempts) x perTryTimeout = 4s exceeds the route timeout; the last retries are cut off."
    },
    {
      "uid": "F6",
      "id": "TR-07",
      "sev": "medium",
      "resource": "DestinationRule/ratings",
      "line": 76,
      "text": "DestinationRule/ratings sets no outlierDetection for ratings, while routes to it retry",
      "detail": "Without outlier detection a failing pod keeps its share of traffic; retries then land on it again."
    },
    {
      "uid": "F7",
      "id": "TR-06",
      "sev": "low",
      "resource": "VirtualService/reviews",
      "line": 10,
      "text": "2 of 2 http routes in VirtualService/reviews set no timeout",
      "detail": "Istio sets no HTTP route timeout by default, so a hung backend holds the caller's connection until the caller gives up."
    },
    {
      "uid": "F8",
      "id": "TR-07",
      "sev": "low",
      "resource": "DestinationRule/reviews",
      "line": 36,
      "text": "DestinationRule/reviews sets no outlierDetection for reviews",
      "detail": "Without outlier detection a failing pod keeps its share of traffic; retries then land on it again."
    }
  ],
  "verdict_candidate": "unsafe",
  "notes": []
}

The request body the page sends, trimmed for display: the full body is 6,851 characters of compact JSON, and the two long strings are cut here after their first characters, with the cut marked inside the string. Untrimmed, config is the first block exactly and facts is the compact JSON.stringify of the second; the JavaScript makeInput in step 3 rebuilds the body byte for byte from the two saved files (check.js asserts it).

{
  "task": "traffic",
  "config": "# file: bookinfo/reviews-routing.yaml\napiVersion: networking.istio.io/v1\nkind: VirtualService\nmetadata:\n  name: reviews\n  namespace: bookinfo\nspec:\n  hosts:\n    ... [trimmed for display: 160 of 1,587 characters shown]",
  "facts": "{\"source\":{\"lines\":86,\"format\":\"yaml\",\"namespaces\":[\"bookinfo\"],\"resources\":[{\"ref\":\"VirtualService/reviews\",\"kind\":\"VirtualService\",\"name\":\"reviews\",\"namespace ... [trimmed for display: 160 of 4,425 characters shown]",
  "note": "We shipped reviews v3 as a 5% canary an hour ago. The testers (end-user: jason) say they never see v2, and ratings errors jumped. What is wrong before I apply the next change?"
}

The page would send this with Idempotency-Key: mesh-desk:traffic:d50ba79b-1ac3:a1 (see step 4). The saved reply, in full:

{
  "task": "traffic",
  "verdict": "unsafe",
  "headline": "The reviews route's catch-all shadows the testers rule and sends 5% of traffic to an undefined v3 subset, while ratings runs a 20% fault-abort on every request and a retry budget that outruns its timeout.",
  "findings": [
    {
      "id": "TR-03",
      "flags": [
        "F1"
      ],
      "resource": "VirtualService/reviews",
      "line": 11,
      "severity": "high",
      "title": "canary-split has no match and sits above testers, so testers never runs",
      "evidence": "- name: canary-split\n      route:\n    - name: testers",
      "why": "http[0] (canary-split) has no match condition, so Istio's top-to-bottom evaluation matches every request there, including jason's; http[1] (testers) with the end-user: jason header match is never reached. That is exactly why the testers say they never see v2.",
      "fix": "apiVersion: networking.istio.io/v1\nkind: VirtualService\nmetadata:\n  name: reviews\n  namespace: bookinfo\nspec:\n  hosts:\n    - reviews\n  http:\n    - name: testers\n      match:\n        - headers:\n            end-user:\n              exact: jason\n      route:\n        - destination:\n            host: reviews\n            subset: v2\n    - name: canary-split\n      route:\n        - destination:\n            host: reviews\n            subset: v1\n          weight: 90\n        - destination:\n            host: reviews\n            subset: v3\n          weight: 5\n"
    },
    {
      "id": "TR-01",
      "flags": [
        "F2"
      ],
      "resource": "VirtualService/reviews",
      "line": 19,
      "severity": "high",
      "title": "canary-split routes 5% of reviews traffic to subset v3, which is not defined",
      "evidence": "            host: reviews\n            subset: v3\n          weight: 5",
      "why": "DestinationRule/reviews only defines subsets v1 and v2, so Envoy has no cluster for v3; the 5% of requests sent there fail with 503 (no healthy upstream). This is the v3 canary's own 5% failing outright, separate from the testers being shadowed.",
      "fix": "apiVersion: networking.istio.io/v1\nkind: DestinationRule\nmetadata:\n  name: reviews\n  namespace: bookinfo\nspec:\n  host: reviews\n  subsets:\n    - name: v1\n      labels:\n        version: v1\n    - name: v2\n      labels:\n        version: v2\n    - name: v3\n      labels:\n        version: v3\n"
    },
    {
      "id": "TR-10",
      "flags": [
        "F3"
      ],
      "resource": "VirtualService/ratings",
      "line": 56,
      "severity": "high",
      "title": "ratings' only route aborts 20% of every request with fault injection",
      "evidence": "    - fault:\n            value: 20\n          httpStatus: 503",
      "why": "The route has no match condition, so it matches every request to ratings, and the fault.abort injects a 503 20% of the time before the request ever reaches a ratings pod. Fault injection is a chaos-test tool; left in this production VirtualService it directly explains the errors jumping after the canary shipped.",
      "fix": "apiVersion: networking.istio.io/v1\nkind: VirtualService\nmetadata:\n  name: ratings\n  namespace: bookinfo\nspec:\n  hosts:\n    - ratings\n  http:\n    - route:\n        - destination:\n            host: ratings\n            subset: v1\n      timeout: 2s\n      retries:\n        attempts: 3\n        perTryTimeout: 1s\n        retryOn: 5xx,connect-failure\n"
    },
    {
      "id": "TR-02",
      "flags": [
        "F4"
      ],
      "resource": "VirtualService/reviews",
      "line": 16,
      "severity": "medium",
      "title": "canary-split weights total 95, not 100",
      "evidence": "          weight: 90\n          weight: 5",
      "why": "90 + 5 = 95. Depending on control-plane version this is either rejected at apply time or silently renormalized, so the live split is not guaranteed to be the 90/5 written here.",
      "fix": "route:\n  - destination:\n      host: reviews\n      subset: v1\n    weight: 95\n  - destination:\n      host: reviews\n      subset: v3\n    weight: 5\n"
    },
    {
      "id": "TR-04",
      "flags": [
        "F5"
      ],
      "resource": "VirtualService/ratings",
      "line": 65,
      "severity": "medium",
      "title": "ratings retry budget (4s) exceeds its 2s route timeout",
      "evidence": "      timeout: 2s\n        attempts: 3\n        perTryTimeout: 1s",
      "why": "(1 + attempts) x perTryTimeout = (1 + 3) x 1s = 4s, which exceeds the 2s route timeout, so the overall timeout cuts retries off before all 3 can run - callers get fewer retries than configured, which compounds the errors from the fault injection above.",
      "fix": "timeout: 4s\nretries:\n  attempts: 3\n  perTryTimeout: 1s\n  retryOn: 5xx,connect-failure\n"
    },
    {
      "id": "TR-07",
      "flags": [
        "F6"
      ],
      "resource": "DestinationRule/ratings",
      "line": 78,
      "severity": "medium",
      "title": "DestinationRule/ratings has no outlierDetection while its route retries",
      "evidence": "  trafficPolicy:\n    connectionPool:\n        http1MaxPendingRequests: 100",
      "why": "VirtualService/ratings retries failed calls, but without outlierDetection a ratings pod that is failing keeps its full share of traffic, so retries can keep landing back on the same broken pod instead of being routed away from it.",
      "fix": "trafficPolicy:\n  connectionPool:\n    http:\n      http1MaxPendingRequests: 100\n  outlierDetection:\n    consecutive5xxErrors: 5\n    interval: 30s\n    baseEjectionTime: 30s\n    maxEjectionPercent: 50\n"
    },
    {
      "id": "TR-06",
      "flags": [
        "F7"
      ],
      "resource": "VirtualService/reviews",
      "line": 10,
      "severity": "low",
      "title": "both reviews http routes set no timeout",
      "evidence": "  http:\n    - name: canary-split\n    - name: testers",
      "why": "Neither http[0] nor http[1] sets a timeout, and Istio applies none by default, so a hung reviews pod holds the caller's connection open indefinitely instead of failing fast.",
      "fix": "http:\n  - name: canary-split\n    timeout: 5s\n    route:\n      - destination:\n          host: reviews\n          subset: v1\n        weight: 90\n      - destination:\n          host: reviews\n          subset: v3\n        weight: 5\n  - name: testers\n    timeout: 5s\n    match:\n      - headers:\n          end-user:\n            exact: jason\n    route:\n      - destination:\n          host: reviews\n          subset: v2\n"
    },
    {
      "id": "TR-07",
      "flags": [
        "F8"
      ],
      "resource": "DestinationRule/reviews",
      "line": 36,
      "severity": "low",
      "title": "DestinationRule/reviews has no outlierDetection",
      "evidence": "spec:\n  host: reviews\n  subsets:",
      "why": "No trafficPolicy is set at all for reviews, so a failing v1 or v2 pod is never ejected from the load balancing set. Reviews routes do not currently retry, so this is hygiene rather than an active failure mode today.",
      "fix": "apiVersion: networking.istio.io/v1\nkind: DestinationRule\nmetadata:\n  name: reviews\n  namespace: bookinfo\nspec:\n  host: reviews\n  trafficPolicy:\n    outlierDetection:\n      consecutive5xxErrors: 5\n      interval: 30s\n      baseEjectionTime: 30s\n      maxEjectionPercent: 50\n  subsets:\n    - name: v1\n      labels:\n        version: v1\n    - name: v2\n      labels:\n        version: v2\n"
    }
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F4",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F5",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F6",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F7",
      "status": "confirmed",
      "reason": ""
    },
    {
      "flag": "F8",
      "status": "confirmed",
      "reason": ""
    }
  ],
  "routes": [
    {
      "host": "reviews",
      "behaviour": "Every request lands in http[0] (canary-split) because it has no match condition and is evaluated first: 90% goes to subset v1, and 5% is sent to subset v3, which DestinationRule/reviews does not define, so that 5% fails with 503. The weights only total 95, so the real split the control plane applies may not even be 90/5. http[1] (testers, matching header end-user: jason) never runs because the catch-all above it already matched - so jason's requests also fall into the 90/5 v1/v3 split and never reach v2. Neither route sets a timeout, so a stalled reviews pod would hold connections open indefinitely."
    },
    {
      "host": "ratings",
      "behaviour": "The single route has no match condition, so it applies to every request, and a fault.abort injects an HTTP 503 on 20% of them before ratings is even called. Surviving requests go to subset v1 with a 2s timeout and up to 3 retries at 1s each - but (1+3) x 1s = 4s is more than the 2s timeout, so the timeout cuts retries short before all 3 can run. DestinationRule/ratings has no outlierDetection, so none of this backs off a struggling v1 pod."
    }
  ],
  "plan": [
    "Add subset v3 (labels version: v3) to DestinationRule/reviews so the 5% canary slice has a real destination instead of 503ing.",
    "Remove the fault.abort from VirtualService/ratings's route - it is aborting 20% of all ratings traffic in production.",
    "Reorder VirtualService/reviews so the testers match-based route runs before the canary-split catch-all.",
    "Fix the canary-split weights to total 100 (e.g. 95/5).",
    "Raise the ratings route timeout (or reduce its retry attempts) so the retry budget fits inside the timeout.",
    "Add outlierDetection to DestinationRule/ratings and DestinationRule/reviews so a failing pod is ejected instead of keeping its share of traffic.",
    "Add explicit timeouts to both reviews http routes."
  ],
  "questions": [
    "No Service or Deployment for reviews is in this paste - do pods labeled version: v3 already exist and are healthy? That confirms the subset I'm proposing to add to DestinationRule/reviews actually has somewhere to route to.",
    "Was the intended canary split 90/10, 95/5, or something else? The pasted weights total 95; I assumed v3 keeps 5% and raised v1 to 95, but confirm if the real target differs.",
    "Is the ratings fault injection left over from a chaos-test run that should already have been reverted, or is it meant to stay (behind a header match, say) for ongoing resilience testing?"
  ],
  "summary": "Two separate bugs explain what you're seeing: VirtualService/reviews's canary-split route has no match condition and sits above testers, so every request including jason's is caught there first and never reaches the v2 rule - and that same route sends 5% of traffic to subset v3, which DestinationRule/reviews never defines, so that slice 503s outright. Ratings' errors are a different, second problem: its only route runs a 20% fault-abort on every request, and its 3x1s retry budget doesn't fit inside its 2s timeout, so retries get cut short too, and there's no outlier detection to route around a struggling pod. Fix the undefined v3 subset and pull the fault injection first since those are outright failures, then reorder the reviews routes and correct the weight math before you ship anything else on top of this canary."
}

Held to the page’s own reconciliation (recon.js, run by check.js on this exact body and reply), this reply has 0 disagreements in 26 statements checked.

What the step 4 samples print for this reply (after the truncated/charged line; the Python form):

unsafe - The reviews route's catch-all shadows the testers rule and sends 5% of traffic to an undefined v3 subset, while ratings runs a 20% fault-abort on every request and a retry budget that outruns its timeout.
TR-03 VirtualService/reviews 11 high
TR-01 VirtualService/reviews 19 high
TR-10 VirtualService/ratings 56 high
TR-02 VirtualService/reviews 16 medium
TR-04 VirtualService/ratings 65 medium
TR-07 DestinationRule/ratings 78 medium
TR-06 VirtualService/reviews 10 low
TR-07 DestinationRule/reviews 36 low
route reviews
route ratings

Had you pressed “Check mTLS for these hosts” after this reply, the page would send the mtls lane on the same paste (the mtls facts of this config, the same note) with this traffic_handoff, shown decoded; in the body it is the compact JSON.stringify of it. That body is 5,546 characters and its key would be mesh-desk:mtls:b7489ab-15aa:a1:

{
  "from": "traffic",
  "verdict": "unsafe",
  "headline": "The reviews route's catch-all shadows the testers rule and sends 5% of traffic to an undefined v3 subset, while ratings runs a 20% fault-abort on every request and a retry budget that outruns its timeout.",
  "hosts": [
    "reviews",
    "ratings"
  ],
  "findings": [
    {
      "id": "TR-03",
      "resource": "VirtualService/reviews",
      "severity": "high",
      "title": "canary-split has no match and sits above testers, so testers never runs"
    },
    {
      "id": "TR-01",
      "resource": "VirtualService/reviews",
      "severity": "high",
      "title": "canary-split routes 5% of reviews traffic to subset v3, which is not defined"
    },
    {
      "id": "TR-10",
      "resource": "VirtualService/ratings",
      "severity": "high",
      "title": "ratings' only route aborts 20% of every request with fault injection"
    },
    {
      "id": "TR-02",
      "resource": "VirtualService/reviews",
      "severity": "medium",
      "title": "canary-split weights total 95, not 100"
    },
    {
      "id": "TR-04",
      "resource": "VirtualService/ratings",
      "severity": "medium",
      "title": "ratings retry budget (4s) exceeds its 2s route timeout"
    },
    {
      "id": "TR-07",
      "resource": "DestinationRule/ratings",
      "severity": "medium",
      "title": "DestinationRule/ratings has no outlierDetection while its route retries"
    },
    {
      "id": "TR-06",
      "resource": "VirtualService/reviews",
      "severity": "low",
      "title": "both reviews http routes set no timeout"
    },
    {
      "id": "TR-07",
      "resource": "DestinationRule/reviews",
      "severity": "low",
      "title": "DestinationRule/reviews has no outlierDetection"
    }
  ]
}

mTLS: A STRICT namespace with a plaintext port, a TLS-off DestinationRule and a DENY policy plaintext walks past

4 resources: PeerAuthentication/default, PeerAuthentication/legacy-batch, DestinationRule/ledger, AuthorizationPolicy/deny-sandbox. The page computes 4 posture rows — namespace/payments STRICT, workload/payments/app=legacy-batch STRICT (port 9090 DISABLE), effective/billing PERMISSIVE, effective/payments STRICT — and raises 3 flags (3 high) for the mTLS lane, reading the verdict as exposed. The engineer’s note: “Security review next week. We believe payments is fully mTLS and billing blocks the sandbox namespace. Are we right?”

The config string, 953 characters ending in a newline — save it as mtls-config.yaml:

# file: payments/peer-authentication.yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: payments
spec:
  mtls:
    mode: STRICT
---
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: legacy-batch
  namespace: payments
spec:
  selector:
    matchLabels:
      app: legacy-batch
  mtls:
    mode: STRICT
  portLevelMtls:
    9090:
      mode: DISABLE
---
# file: payments/ledger-dr.yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: ledger
  namespace: payments
spec:
  host: ledger.payments.svc.cluster.local
  trafficPolicy:
    tls:
      mode: DISABLE
---
# file: billing/deny-sandbox.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: deny-sandbox
  namespace: billing
spec:
  selector:
    matchLabels:
      app: invoices
  action: DENY
  rules:
    - from:
        - source:
            namespaces: ["sandbox"]

The facts object, decoded and complete — save it as mtls-facts.json:

{
  "source": {
    "lines": 53,
    "format": "yaml",
    "namespaces": [
      "billing",
      "payments"
    ],
    "resources": [
      {
        "ref": "PeerAuthentication/default",
        "kind": "PeerAuthentication",
        "name": "default",
        "namespace": "payments",
        "api_version": "security.istio.io/v1",
        "line": 2,
        "end_line": 9
      },
      {
        "ref": "PeerAuthentication/legacy-batch",
        "kind": "PeerAuthentication",
        "name": "legacy-batch",
        "namespace": "payments",
        "api_version": "security.istio.io/v1",
        "line": 11,
        "end_line": 24
      },
      {
        "ref": "DestinationRule/ledger",
        "kind": "DestinationRule",
        "name": "ledger",
        "namespace": "payments",
        "api_version": "networking.istio.io/v1",
        "line": 27,
        "end_line": 36
      },
      {
        "ref": "AuthorizationPolicy/deny-sandbox",
        "kind": "AuthorizationPolicy",
        "name": "deny-sandbox",
        "namespace": "billing",
        "api_version": "security.istio.io/v1",
        "line": 39,
        "end_line": 52
      }
    ],
    "parse_errors": []
  },
  "hosts": [
    {
      "host": "ledger.payments.svc.cluster.local",
      "virtual_services": [],
      "destination_rules": [
        "DestinationRule/ledger"
      ],
      "subsets_defined": [],
      "subsets_routed": [],
      "gateways": [],
      "mtls_server": "STRICT",
      "mtls_client": "DISABLE (DestinationRule/ledger, trafficPolicy)"
    }
  ],
  "posture": [
    {
      "scope": "namespace/payments",
      "mode": "STRICT",
      "source": "PeerAuthentication/default",
      "ports": []
    },
    {
      "scope": "workload/payments/app=legacy-batch",
      "mode": "STRICT",
      "source": "PeerAuthentication/legacy-batch",
      "ports": [
        {
          "port": "9090",
          "mode": "DISABLE"
        }
      ]
    },
    {
      "scope": "effective/billing",
      "mode": "PERMISSIVE",
      "source": "the Istio default - no PeerAuthentication pasted for namespace billing",
      "ports": []
    },
    {
      "scope": "effective/payments",
      "mode": "STRICT",
      "source": "PeerAuthentication/default",
      "ports": []
    }
  ],
  "flags": [
    {
      "uid": "F2",
      "id": "MT-04",
      "sev": "high",
      "resource": "PeerAuthentication/legacy-batch",
      "line": 23,
      "text": "Port 9090 of workload/payments/app=legacy-batch is DISABLE inside a STRICT policy",
      "detail": "This port accepts only plaintext: a gap in an otherwise mutual-TLS workload."
    },
    {
      "uid": "F3",
      "id": "MT-06",
      "sev": "high",
      "resource": "DestinationRule/ledger",
      "line": 35,
      "text": "DestinationRule/ledger (trafficPolicy) sends plaintext to ledger.payments.svc.cluster.local, which requires mTLS (PeerAuthentication/default)",
      "detail": "The server rejects plaintext, so every call from a client of this rule fails (connection reset, 503 UC)."
    },
    {
      "uid": "F4",
      "id": "MT-10",
      "sev": "high",
      "resource": "AuthorizationPolicy/deny-sandbox",
      "line": 49,
      "text": "AuthorizationPolicy/deny-sandbox DENYs by namespaces while the workloads accept plaintext (PERMISSIVE from the Istio default - no PeerAuthentication pasted for namespace billing)",
      "detail": "A plaintext request has no peer identity, so a DENY on principals or namespaces never matches it: exactly the callers you meant to block can connect without a sidecar. Make the scope STRICT, or deny on notPrincipals."
    }
  ],
  "verdict_candidate": "exposed",
  "notes": []
}

The request body, trimmed for display the same way (the full body is 4,196 characters). It has no traffic_handoff: this paste had no traffic run.

{
  "task": "mtls",
  "config": "# file: payments/peer-authentication.yaml\napiVersion: security.istio.io/v1\nkind: PeerAuthentication\nmetadata:\n  name: default\n  namespace: payments\nspec:\n  mtls ... [trimmed for display: 160 of 953 characters shown]",
  "facts": "{\"source\":{\"lines\":53,\"format\":\"yaml\",\"namespaces\":[\"billing\",\"payments\"],\"resources\":[{\"ref\":\"PeerAuthentication/default\",\"kind\":\"PeerAuthentication\",\"name\":\"d ... [trimmed for display: 160 of 2,733 characters shown]",
  "note": "Security review next week. We believe payments is fully mTLS and billing blocks the sandbox namespace. Are we right?"
}

The page would send this with Idempotency-Key: mesh-desk:mtls:94b1ce2b-1064:a1. The saved reply, in full:

{
  "task": "mtls",
  "verdict": "exposed",
  "headline": "Payments' namespace-wide STRICT mTLS is undercut by a plaintext port override on legacy-batch and a DestinationRule that disables TLS toward the STRICT ledger service, while billing's sandbox-blocking DENY is silently bypassed because billing has no mTLS policy at all.",
  "posture": [
    {
      "scope": "namespace/payments",
      "mode": "STRICT",
      "source": "PeerAuthentication/default",
      "comment": "Namespace-wide STRICT is in force, but it is undermined in practice by the legacy-batch port override (MT-04) and the ledger DestinationRule's plaintext client TLS (MT-06)."
    },
    {
      "scope": "workload/payments/app=legacy-batch",
      "mode": "STRICT",
      "source": "PeerAuthentication/legacy-batch",
      "comment": "The workload-level mode is STRICT, but portLevelMtls overrides port 9090 to DISABLE, opening a plaintext hole in an otherwise mTLS workload (MT-04)."
    },
    {
      "scope": "effective/billing",
      "mode": "PERMISSIVE",
      "source": "the Istio default - no PeerAuthentication pasted for namespace billing",
      "comment": "No PeerAuthentication exists for billing, so plaintext is accepted mesh-wide by default; the deny-sandbox AuthorizationPolicy's namespaces match cannot see identity from a plaintext caller (MT-10)."
    },
    {
      "scope": "effective/payments",
      "mode": "STRICT",
      "source": "PeerAuthentication/default",
      "comment": "Same STRICT posture as namespace/payments; see the two findings above for where it is still bypassed today."
    }
  ],
  "findings": [
    {
      "id": "MT-04",
      "flags": [
        "F2"
      ],
      "resource": "PeerAuthentication/legacy-batch",
      "line": 24,
      "severity": "high",
      "title": "Port 9090 override leaves legacy-batch in plaintext under a STRICT policy",
      "evidence": "mode: STRICT\n9090:\n  mode: DISABLE",
      "why": "spec.mtls.mode is STRICT for workloads selected by app=legacy-batch, but spec.portLevelMtls overrides port 9090 specifically to DISABLE. Any client that skips its sidecar (or an attacker on the pod network) can reach port 9090 in plaintext with no certificate, even though the rest of this workload requires mTLS.",
      "fix": "apiVersion: security.istio.io/v1\nkind: PeerAuthentication\nmetadata:\n  name: legacy-batch\n  namespace: payments\nspec:\n  selector:\n    matchLabels:\n      app: legacy-batch\n  mtls:\n    mode: STRICT\n"
    },
    {
      "id": "MT-06",
      "flags": [
        "F3"
      ],
      "resource": "DestinationRule/ledger",
      "line": 36,
      "severity": "high",
      "title": "DestinationRule/ledger forces plaintext toward a STRICT server",
      "evidence": "host: ledger.payments.svc.cluster.local\nmode: DISABLE",
      "why": "trafficPolicy.tls.mode: DISABLE tells every client that uses this DestinationRule to speak plaintext to ledger.payments.svc.cluster.local, but PeerAuthentication/default makes the payments namespace (and this host's server side) STRICT. The server-side sidecar will refuse the plaintext handshake, so every call routed through this rule fails outright (connection reset / 503 UC) - it is not a silent plaintext success.",
      "fix": "apiVersion: networking.istio.io/v1\nkind: DestinationRule\nmetadata:\n  name: ledger\n  namespace: payments\nspec:\n  host: ledger.payments.svc.cluster.local\n  trafficPolicy:\n    tls:\n      mode: ISTIO_MUTUAL\n"
    },
    {
      "id": "MT-10",
      "flags": [
        "F4"
      ],
      "resource": "AuthorizationPolicy/deny-sandbox",
      "line": 52,
      "severity": "high",
      "title": "deny-sandbox's DENY can't see identity from billing's plaintext callers",
      "evidence": "action: DENY\nnamespaces: [\"sandbox\"]",
      "why": "This policy denies callers whose verified source namespace is sandbox, but billing has no PeerAuthentication, so Istio's PERMISSIVE default lets callers connect without a sidecar or any peer identity. A request from sandbox that skips mTLS carries no namespace identity for Istio to match, so it never matches this DENY and reaches the invoices workload anyway - the exact traffic this policy was meant to block.",
      "fix": "apiVersion: security.istio.io/v1\nkind: PeerAuthentication\nmetadata:\n  name: default\n  namespace: billing\nspec:\n  mtls:\n    mode: STRICT\n"
    }
  ],
  "flag_answers": [
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "Config shows mtls.mode: STRICT with portLevelMtls 9090 mode: DISABLE on PeerAuthentication/legacy-batch."
    },
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": "Config shows DestinationRule/ledger trafficPolicy.tls.mode: DISABLE for a host whose PeerAuthentication/default is STRICT."
    },
    {
      "flag": "F4",
      "status": "confirmed",
      "reason": "Config shows AuthorizationPolicy/deny-sandbox DENYing on source.namespaces while billing has no PeerAuthentication (PERMISSIVE default)."
    }
  ],
  "rollout": [
    "Confirm every legacy-batch client actually has an Istio sidecar (istioctl proxy-status / proxy-config), then remove the portLevelMtls override on PeerAuthentication/legacy-batch so port 9090 inherits the workload's STRICT mode.",
    "Change DestinationRule/ledger's trafficPolicy.tls.mode from DISABLE to ISTIO_MUTUAL so its clients present mTLS certs that match the already-STRICT ledger.payments.svc.cluster.local server; verify with a test call before wider rollout.",
    "Add a namespace-scoped PeerAuthentication for billing (e.g. billing/default). If any billing workload may still lack a sidecar, land it as PERMISSIVE first, confirm with istioctl that all workloads report mTLS traffic, then flip it to STRICT.",
    "Once billing is STRICT, re-verify AuthorizationPolicy/deny-sandbox actually blocks a call from the sandbox namespace (istioctl authz check or a live test call) - only now does its namespaces match see verified identity.",
    "Re-run the page's checks (or istioctl analyze) over the updated config to confirm no host regressed to plaintext and no new high finding was introduced."
  ],
  "questions": [
    "Is the port 9090 plaintext override on legacy-batch intentional (e.g. a legacy protocol that cannot speak TLS), or should it be brought under the namespace's STRICT policy?",
    "Is DestinationRule/ledger's tls.mode: DISABLE a deliberate in-progress migration step, or should it already be ISTIO_MUTUAL now that PeerAuthentication/default is STRICT?",
    "Are all workloads in the billing namespace confirmed to have Istio sidecars injected, so billing can go straight to STRICT rather than staging through PERMISSIVE first?"
  ],
  "summary": "No on both counts. Payments is STRICT at the namespace level, but that is punched through twice: PeerAuthentication/legacy-batch leaves port 9090 in plaintext (MT-04), and DestinationRule/ledger forces its clients to speak plaintext to a host whose server side is STRICT, so those calls fail rather than succeed (MT-06). Billing does not block the sandbox namespace either: no PeerAuthentication is pasted for billing, so Istio's PERMISSIVE default applies, and the deny-sandbox DENY on source.namespaces never sees a peer identity from a plaintext caller - sandbox traffic bypasses it entirely (MT-10). Overall posture is exposed; see rollout for the order to close all three gaps without an outage."
}

Held to the page’s own reconciliation (recon.js, run by check.js on this exact body and reply), this reply has 0 disagreements in 15 statements checked.

What the step 5 samples print for this reply (the Python form):

exposed - Payments' namespace-wide STRICT mTLS is undercut by a plaintext port override on legacy-batch and a DestinationRule that disables TLS toward the STRICT ledger service, while billing's sandbox-blocking DENY is silently bypassed because billing has no mTLS policy at all.
posture namespace/payments STRICT
posture workload/payments/app=legacy-batch STRICT
posture effective/billing PERMISSIVE
posture effective/payments STRICT
MT-04 PeerAuthentication/legacy-batch 24 high
MT-06 DestinationRule/ledger 36 high
MT-10 AuthorizationPolicy/deny-sandbox 52 high

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

What comes back from the API is the model’s raw text in output.output. The page holds it to account before showing it (recon.js reconcile, with the facts, the config and the handoff it sent), and a direct API caller gets none of this:

If you call the API directly, apply your own checks — the evidence against your config, every flag answered, the posture modes, each fix re-checked — before you apply anything to a cluster.

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; Java’s makeInput from step 3 goes in the class).

Then a token. Two kinds work:

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

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

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

Step 2 — GET /me

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

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

Step 3 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then config as the YAML text, facts as a compact JSON string, the note when it is not blank, and — for the mtls lane only — the traffic_handoff string when you have one. The samples build the traffic input from the two blocks of the traffic worked example; step 5 reuses makeInput for the mtls one. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

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

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

# traffic-config.yaml = the config block of the traffic worked example, saved as is (with its final
# newline); traffic-facts.json = its facts block. --rawfile keeps the YAML byte for byte (jq 1.6+),
# jq -c turns the facts into ONE compact string, note is optional (the page trims it, max 2000 chars).
BODY=$(jq -n --rawfile config traffic-config.yaml --arg facts "$(jq -c . traffic-facts.json)" \
  --arg note "We shipped reviews v3 as a 5% canary an hour ago. The testers (end-user: jason) say they never see v2, and ratings errors jumped. What is wrong before I apply the next change?" \
  '{task: "traffic", config: $config, facts: $facts, note: $note}')
# mtls lane only, after a traffic run on the SAME paste: add --arg handoff "$(jq -c . handoff.json)"
# and traffic_handoff: $handoff to the object.

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

Step 4 — POST /run, then poll the job (traffic)

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

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so a traffic audit and an mTLS check of the same paste never share a key. The page uses mesh-desk:<lane>:<inputHash>:a<attempt>, where <lane> is traffic or mtls, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is mesh-desk:traffic:d50ba79b-1ac3:a1 and mesh-desk:mtls:94b1ce2b-1064:a1 (their reformat retries would use mesh-desk:traffic:d50ba79b-1ac3:a2 and mesh-desk:mtls:94b1ce2b-1064:a2). The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

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

KEY="mesh-desk:traffic:$(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 // [])[] | {id, resource, line, severity}], routes: [(.routes // [])[] | .host]}'

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

The alternative to step 4, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the mtls input from the mtls worked example (with its own key) and stream it; to stream the traffic input instead, pass the step 3 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

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

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

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each posture row’s scope and mode, then each finding’s id, resource, line and severity. To run the mtls lane after a traffic run on the same paste, pass the handoff string as makeInput’s last argument.

# the mtls worked example: mtls-config.yaml and mtls-facts.json
MBODY=$(jq -n --rawfile config mtls-config.yaml --arg facts "$(jq -c . mtls-facts.json)" \
  --arg note "Security review next week. We believe payments is fully mTLS and billing blocks the sandbox namespace. Are we right?" \
  '{task: "mtls", config: $config, facts: $facts, note: $note}')
MKEY="mesh-desk:mtls:$(printf '%s' "$MBODY" | shasum -a 256 | cut -c1-16):a1"

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

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

# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $MKEY" -d "$MBODY" \
  | awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
  | jq -r '.output.output' | jq '{verdict, headline, posture: [(.posture // [])[] | {scope, mode}], findings: [(.findings // [])[] | {id, resource, line, severity}]}'

What the page does that you now do

Or skip all of this and use Mesh Desk in the browser: the check, the flags, the routing table, the mTLS posture and the Markdown and CSV exports are free.