Driving Sol Desk over HTTP
Everything the page does, you can do from a script. One endpoint does the work; the rest is
authentication and polling. The app takes one Solidity source file you are about to deploy and a
task field naming which of five lanes to run over it, and returns a single JSON
envelope.
https://api.skillsafe.ai/v1/app-api
Every request carries Authorization: Bearer <token>. The token is scoped to this
app when it is minted, so no slug header is needed on later calls — there is no
X-App-Slug header anywhere in this API. POST /guest is the one call that
names the slug, in its JSON body. Get a token from
the token page without opening a developer console.
The task field comes first
Sol Desk is a five-lane app. Every run must name its lane in task,
because the lanes share one system prompt and one model and are routed by that field. If it is
missing or unrecognised the model picks the closest lane, answers that lane completely, and says so
by setting lane_inferred to true — which is a fallback, not a feature to
rely on.
task | What it does | Extra input | artifact.kind |
|---|---|---|---|
| audit | The security review: fourteen named checks, severity-ranked findings and the exploit paths behind the serious ones. | — | none |
| defi | The economic attack surface: ten named DeFi attack vectors, the economic invariants, and where value actually moves. | — | none |
| pattern | Structural gap review against the canonical shape of the detected protocol kind. | — | none |
| standards | Token-standard conformance (ERC-20/721/1155/4626), member by member. | — | none |
| tests | A Foundry test plan plus the complete .t.sol file that implements it. | prior_findings | solidity |
artifact.kind is what the lane normally emits. Only tests is required to
return a file; the other four may return markdown when there is something worth writing
down, and none otherwise. Always branch on artifact.kind rather than
assuming.
The run body is the input object
There is no input wrapper key. The JSON you post to
/estimate, /run and /run-stream is the input object.
Wrapping it in {"input": …} returns a cheerful 200 and a reply that never saw your
task field.
{
"task": "audit",
"contract": "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\ncontract C { ... }",
"notes": "optional free-text context from the user, may be empty",
"prescan": { "inventory": { }, "flags": [ ] },
"prior_findings": []
}
| Field | Type | Required | Notes |
|---|---|---|---|
| task | string | yes | Exactly one of audit, defi, pattern, standards, tests. |
| contract | string | yes | The Solidity source as a string. Several files may be concatenated with // file: path/Name.sol marker lines; the model treats them as one compilation unit and names the file in symbol when it matters. |
| notes | string | no | Free-text context — what the contract is for, what is already known, what to look at first. May be empty. |
| prescan | object | no, but send it | {inventory, flags} from the app's free in-browser Solidity reader. Grounds the model in real names and gives you something to reconcile. See below. |
| prior_findings | array | no | Findings from an earlier audit or defi run. Mainly for tests: every finding you pass gets at least one case that would have caught it, named in targets_finding. |
The prescan contract
The browser app runs a deterministic Solidity reader over the source before every run and passes its
findings in prescan.flags, each with a stable id like
SC-REENTRANCY-CEI-1 — the rule name with a per-rule sequence number appended.
The model must return exactly one coverage_check entry per flag id sent, in the
order they were sent, and none for ids that were not sent. That is what lets the free lane
hold the paid lane accountable: anything unaccounted for is a defect you can detect
programmatically.
sent = [f["id"] for f in payload["prescan"]["flags"]]
covered = [c["flag_id"] for c in result["coverage_check"]]
assert sent == covered, f"unreconciled: {set(sent) - set(covered)}"
Each flag is {id, rule, severity, area, title, symbol, line, evidence, detail}.
evidence is a verbatim slice of the source, so a flag is always quotable back to a line.
Each coverage_check entry answers with a status of
confirmed (the model agrees and there is a matching entry in findings),
set-aside (technically present, does not matter here — and note must say
concretely why), or superseded (a larger finding subsumes it, named by id). The reader
is regex-grade and is often wrong about intent; the model is expected to say so in the
note rather than agree politely.
prescan.inventory is the other half: the pragma, the SPDX line, the primary contract,
the contracts, state variables, functions, modifiers, events, custom errors, implicit getters,
imports, the detected standard and protocol kind, and a counts block. It is
context, not findings — nothing in it needs a coverage_check entry.
The lane trims it: the focused lanes (defi, standards) send only the flags
in their own areas and cap at thirty, the general lanes cap at forty.
You may send an empty prescan, or omit it entirely. The lane still works — it simply
has fewer facts to ground itself in and nothing to reconcile, and coverage_check comes
back as [].
The response envelope
Every endpoint returns the same wrapper. Success carries data; failure carries
error. Nothing returns a bare value, so a client can branch on the presence of
error alone.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "validation_error", "message": "…", "details": { ... }}}
Error codes
| Code | HTTP | What it means | What to do |
|---|---|---|---|
| unauthorized | 401 | Missing, malformed or expired token. | Mint a new one. Guest tokens expire; personal tokens outlive them. |
| validation_error | 400 | The body did not match the app's shape — most often a missing task or contract. | Read error.details; it names the offending field. |
| payment_required | 402 | Balance below the run's minimum. | Call /estimate first and compare min_credits against /me. |
| rate_limited | 429 | Too many requests. | Back off and retry; never tight-loop. |
| internal | 500 | The platform failed, not your request. | Retry once with the same Idempotency-Key, then give up and report it. |
| truncated: true | 200 | Not an error. The run hit the output cap, which is scaled down when the balance is low, so the JSON may be cut mid-object. | Found on the job payload, not in error. Top up and re-run; do not try to repair the JSON. |
The output contract
A successful job carries the model's reply as a string at
data.output.output. That string is a single JSON object and nothing else
— no code fence, no preamble, no trailing note — so JSON.parse it directly. Every key
below is present on every reply from every lane, and every array is present even when it is empty:
empty means [], never null and never the string "none".
{
"lane": "audit | defi | pattern | standards | tests",
"lane_inferred": false,
"contract_name": "the primary contract's name, or 'unknown'",
"title": "6-10 words naming the contract and the job",
"posture": "deployable | fix-first | blocked",
"verdict": "one sentence, the answer a reviewer would give out loud",
"solidity_pragma": "the pragma as written, e.g. '^0.8.20', or 'none'",
"protocol_kind": "amm | lending | staking | vault | token | nft | governance | bridge | auction | vesting | utility | unknown",
"summary": "3-6 sentences a reviewer could paste into a pull request",
"assumptions": ["what had to be assumed because the source did not say"],
"open_questions": ["what a reviewer would ask the author before signing off"],
"findings": [
{
"id": "SD-001",
"title": "short imperative statement of the problem",
"severity": "critical | high | medium | low | info",
"area": "access-control | reentrancy | arithmetic | external-calls | randomness | upgradeability | gas | dos | input-validation | events | oracle | accounting | standards | testing",
"symbol": "Contract.functionName or the state variable, as written",
"line": 0,
"why": "2-4 sentences: the mechanism, and what an attacker or an unlucky user gets",
"evidence": "verbatim slice of the pasted source, <= 240 chars",
"fix": "the corrected Solidity, or the precise change",
"confidence": "certain | likely | speculative"
}
],
"coverage_check": [
{"flag_id": "SC-REENTRANCY-CEI-1", "status": "confirmed | set-aside | superseded", "note": "why"}
],
"artifact": {"kind": "solidity | markdown | none", "filename": "", "content": ""},
"next_lane": {"lane": "", "reason": "one sentence for the button's label"},
"body": {}
}
| Key | Type | What it carries |
|---|---|---|
| lane | string | The lane that actually answered. Compare it to the task you sent. |
| lane_inferred | boolean | true means your task did not arrive or was not recognised and the model chose the closest lane. Treat it as a client bug. |
| contract_name | string | The primary contract's name as written, or unknown. |
| title | string | Six to ten words naming the contract and the job. Use it as the result heading. |
| posture | string | blocked = at least one critical, or a high that loses funds on the first transaction. fix-first = at least one high, or several mediums in one area. deployable = nothing above medium was visible in this source. |
| verdict | string | One sentence: the answer a reviewer would give out loud. |
| solidity_pragma | string | The pragma as written, or none. |
| protocol_kind | string | One of amm, lending, staking, vault, token, nft, governance, bridge, auction, vesting, utility, unknown. The pattern lane reviews against this classification. |
| summary | string | Three to six sentences, written to be pasted into a pull request. It always states the limit of the claim at least once. |
| assumptions | string[] | What had to be assumed because the source did not say. |
| open_questions | string[] | What a reviewer would ask the author before signing off. |
| findings | object[] | Ids are SD-001, SD-002, … sequential in descending severity order, ties in source order, never renumbered between sections. evidence is a verbatim slice of the source, at most 240 characters. line is 1-based, and 0 means "not certain" rather than a guess. Zero findings is a legitimate result. |
| coverage_check | object[] | Exactly one entry per prescan.flags[].id sent, in the order sent. [] when no prescan was sent. |
| artifact | object | {kind, filename, content}. content is a whole file, never a diff and never a fragment. kind: "none" means there is nothing to download. |
| next_lane | object | The job a real reviewer would do next, and never the current lane. The usual chain is pattern → audit → defi → standards → tests; tests proposes audit when it found something it could not write a test for. |
| body | object | The lane-specific payload. One shape per lane, below. |
Severity is calibrated, not vibes: critical means funds or control are lost with no
attacker precondition beyond calling a public function; high needs a realistic
precondition; medium is a real defect with a bounded blast radius; low is
correct but fragile; info is an observation worth writing down, and gas costs are
info unless they cause a denial of service. Nothing above medium is a
normal, good outcome.
body for task: "audit"
{
"checks": [{"id": "SEC-1", "name": "", "status": "pass | warn | fail | na", "note": "one or two sentences"}],
"severity_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0},
"exploit_paths": [
{"title": "", "preconditions": "", "steps": ["ordered, concrete, each one a transaction or a state"], "impact": ""}
]
}
checks contains all fourteen, in this order, every time —
SEC-1 access control on privileged functions, SEC-2 reentrancy and
checks-effects-interactions ordering, SEC-3 external call return values and failure
handling, SEC-4 arithmetic, casting and unchecked blocks,
SEC-5 input validation and zero-address handling, SEC-6 randomness and
block-value dependence, SEC-7 delegatecall, inline assembly and low-level
memory, SEC-8 denial of service and unbounded work, SEC-9 upgradeability
and initialization, SEC-10 event coverage for state changes, SEC-11 ether
handling and withdrawal paths, SEC-12 inheritance and dependency surface,
SEC-13 gas and storage layout, SEC-14 compiler version and pragma hygiene.
So body.checks.length === 14 is a cheap assertion worth making.
na is for a check the source genuinely cannot exercise — SEC-9 on a
non-upgradeable contract, SEC-11 on a contract that never touches ether — and the note
says which. exploit_paths is for critical and high findings
only; zero of them is a normal outcome and the array is then [].
body for task: "defi"
{
"vectors": [
{"id": "DFI-1", "name": "", "applicable": "yes | no | unclear",
"preconditions": "", "mechanism": "", "impact": "", "mitigation": "",
"severity": "critical | high | medium | low | info | none"}
],
"invariants": [{"statement": "", "holds": "yes | no | unproven", "why": ""}],
"value_flows": [{"asset": "", "from": "", "to": "", "trigger": "the function that moves it"}]
}
vectors contains all ten, in this order, every time —
DFI-1 spot-price and read-only oracle manipulation, DFI-2 slippage,
deadline and sandwich exposure, DFI-3 donation and share-inflation on first deposit,
DFI-4 fee-on-transfer and rebasing token assumptions, DFI-5 rounding
direction and dust accumulation, DFI-6 flash-loan amplification of single-block state,
DFI-7 reserve desync and liquidity withdrawal, DFI-8 admin-key economic
control, DFI-9 decimal and scaling mismatch across assets, DFI-10 ordering
dependence and extractable value.
applicable: "no" is a result, not a gap: the mechanism line says why the
shape of this contract rules the vector out, and severity is then none.
Every vector marked yes has a matching entry in findings, so you can join
the two arrays. invariants holds three to eight economic statements that must stay true;
holds: "unproven" is honest and common, and the why says what would prove
it. A contract that is plainly not DeFi still returns the full shape with every vector
no.
body for task: "pattern"
{
"protocol_evidence": "why it was classified as protocol_kind, quoting what gave it away",
"reference_outline": ["the ordered components a production contract of this kind has"],
"components": [
{"name": "", "expected_because": "", "status": "present | partial | absent",
"evidence": "verbatim if present, else what was looked for", "consequence": "", "remedy": ""}
],
"deviations": [{"deviation": "", "from_template": "", "verdict": "intentional | risky | unclear", "note": ""}]
}
reference_outline is 6–12 components and is written before looking at what is
missing — it is the shape of the thing, not a checklist of complaints. components covers
every entry in reference_outline, in the same order, plus anything else notable, so
components.length >= reference_outline.length. Most deviations are
intentional, and risky is reserved for one whose consequence can be named.
An absent component only becomes a finding when its absence has a consequence.
body for task: "standards"
{
"standard": "ERC-20 | ERC-721 | ERC-1155 | ERC-4626 | ERC-777 | none-detected | multiple",
"standard_evidence": "what made the call, quoted",
"interface_rows": [
{"member": "transferFrom(address,address,uint256)", "kind": "function | event | error",
"required_by": "ERC-20", "present": "yes | no | wrong-signature",
"signature_found": "as written in the source, or empty", "note": ""}
],
"behaviour_rows": [
{"rule": "", "required_by": "", "status": "pass | fail | unverifiable", "note": ""}
],
"extensions": [{"name": "", "present": "yes | no | partial", "note": ""}]
}
interface_rows covers every mandatory member of the detected standard,
present or not: for ERC-20 the six functions and two events, for ERC-721 the nine functions, three
events and ERC-165, for ERC-1155 the six functions, four events and ERC-165. Optional members go in
extensions, never here.
behaviour_rows is where conformance actually breaks and carries the real weight —
transfers to the zero address, safeTransferFrom calling the receiver hook and honouring
its return value, approve overwriting rather than incrementing, reverting versus
returning false on failure, event emission on mint and burn, balanceOf on
the zero address, supportsInterface answering for every implemented interface.
unverifiable means the behaviour lives in an imported base that was not pasted, and the
note says which import. extensions covers ERC-165, metadata, enumerable, ERC-2981
royalties, ERC-2612 permit, pausing and burn extensions where relevant. When no standard is detected,
standard is none-detected, interface_rows is empty, and
behaviour_rows carries the interface hygiene that still applies.
body for task: "tests"
{
"framework": "foundry",
"cases": [
{"id": "T-01", "name": "test_RevertWhen_…", "kind": "unit | negative | fuzz | invariant | fork",
"targets_finding": "SD-003 or empty", "given": "", "when": "", "then": ""}
],
"invariant_targets": [{"name": "", "statement": "", "handler_note": ""}],
"coverage_gaps": ["what this suite does not cover, and why"]
}
This is the one lane where artifact is required: kind: "solidity", a
filename ending .t.sol, and the complete test file — pragma, the
forge-std/Test.sol import, the contract, setUp(), and every case in
cases as a real function body with real assertions. Not a sketch, no // TODO.
Between 8 and 20 cases, named in Foundry's convention (test_, testFuzz_,
test_RevertWhen_, invariant_).
Every critical and high finding available to the lane gets a
negative case asserting the exploit is impossible — which is what
prior_findings is for. Fuzz cases take bounded inputs
(bound(x, 1, type(uint96).max)), never raw uint256. Note that
findings on this lane are about testability — state unreachable from outside, a
constructor that makes a case impossible to set up, a missing view accessor — not a second run of the
audit.
1. A tiny client
Everything below reuses the same four things: the base URL https://api.skillsafe.ai/v1/app-api, a bearer token, a JSON body, and a check on the error branch of the envelope. There is no X-App-Slug header — the token itself identifies the app. Keep the token out of the source file — read it from your shell environment.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
post() {
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body=None, method="POST"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"].get("code"), env["error"].get("message"))
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(path, body, method = "POST") {
const res = await fetch(`${BASE}/${path}`, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (env.error) throw new Error(`${env.error.code}: ${env.error.message}`);
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(path string, body any, method string) (map[string]any, error) {
token := os.Getenv("SKILLSAFE_TOKEN")
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, base+"/"+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data map[string]any `json:"data"`
Error *struct {
Code, Message string
} `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
class SolDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // parse the {data} / {error} envelope with your JSON library
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body = nil, method = :post)
uri = URI("#{BASE}/#{path}")
klass = method == :get ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env['error']['code']}: #{env['error']['message']}" if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function call(string $path, ?array $body = null, string $method = "POST") {
global $token;
$ch = curl_init(BASE . "/" . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$env = json_decode(curl_exec($ch), true);
if (isset($env["error"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
class SolDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SolDesk()
{
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
static async Task<JsonElement> Call(string path, object? body = null)
{
var content = new StringContent(JsonSerializer.Serialize(body ?? new {}),
Encoding.UTF8, "application/json");
var res = await Http.PostAsync($"{Base}/{path}", content);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (env.TryGetProperty("error", out var err) && err.ValueKind != JsonValueKind.Null)
throw new Exception(err.GetProperty("code").GetString());
return env.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page, which reads the token this browser already holds, shows it masked with a copy button, and can sign you in — no developer console. Programmatically, POST /guest mints an anonymous token good for the free calls (/me, /estimate). Running any of the five lanes is metered and needs a personal token, which comes from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "Content-Type: application/json" -d '{}'
guest = call("guest", {})
print(guest["token"]) # use this as TOKEN for the free calls
const guest = await call("guest", {});
console.log(guest.token);
guest, err := call("guest", map[string]any{}, "POST")
if err != nil {
panic(err)
}
fmt.Println(guest["token"])
String guest = SolDesk.call("guest", "{}");
System.out.println(guest);
guest = call("guest", {})
puts guest["token"]
$guest = call("guest", []);
echo $guest["token"], "\n";
var guest = await Call("guest", new {});
Console.WriteLine(guest.GetProperty("token").GetString());
3. Check who the token belongs to
GET /me returns exactly three fields — subject_type, subject_id and credits. There is no username and no email. Signed in means subject_type === "user"; a guest token reports "guest". Compare credits against the hold_credits from the next step before you submit a run, so a shortfall never turns into a 402.
curl -sS "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $TOKEN"
me = call("me", method="GET")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"
const me = await call("me", undefined, "GET");
const signedIn = me.subject_type === "user";
console.log(me.subject_type, me.credits);
me, err := call("me", nil, "GET")
if err != nil {
panic(err)
}
signedIn := me["subject_type"] == "user"
fmt.Println(me["subject_type"], me["credits"], signedIn)
// GET /me — build the request with .GET() instead of .POST()
String me = SolDesk.call("me", "{}");
System.out.println(me);
me = call("me", nil, :get)
puts me["subject_type"], me["credits"]
signed_in = me["subject_type"] == "user"
$me = call("me", null, "GET");
echo $me["subject_type"], " ", $me["credits"], "\n";
$signedIn = $me["subject_type"] === "user";
var me = await Call("me");
var signedIn = me.GetProperty("subject_type").GetString() == "user";
Console.WriteLine(me.GetProperty("credits").GetInt32());
4. Price the run with /estimate
Free, and it starts no job. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. hold_credits is a reservation priced at the full output cap, not a price — the settled charged_credits is usually far lower. Re-estimate whenever you change task, because each lane has its own prompt and its own output cap. One caveat worth knowing: /estimate performs no body validation at all — a malformed body, or even a bare string, returns a clean estimate. A successful estimate proves your token and the model binding, never your input shape.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"task": "audit",
"contract": "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.19;\ncontract C {}",
"notes": "",
"prescan": {"inventory": {}, "flags": []},
"prior_findings": []
}
JSON
body = {
"task": "audit",
"contract": open("SimpleSwapPair.sol").read(),
"notes": "",
"prescan": {"inventory": {}, "flags": []},
"prior_findings": []
}
est = call("estimate", body)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
assert me["credits"] >= est["min_credits"], "top up before running"
const body = {
task: "audit",
contract: solidopurce, // the .sol file as a string
notes: "",
prescan: { inventory: {}, flags: [] },
prior_findings: []
};
const est = await call("estimate", body);
console.log(est.model, est.model_alias, est.hold_credits);
body := map[string]any{
"task": "audit",
"contract": source,
"notes": "",
"prescan": map[string]any{"inventory": map[string]any{}, "flags": []any{}},
"prior_findings": []any{},
}
est, err := call("estimate", body, "POST")
if err != nil {
panic(err)
}
fmt.Println(est["model"], est["hold_credits"])
String body = """
{"task":"audit","contract":"pragma solidity ^0.8.19; contract C {}",
"notes":"","prescan":{"inventory":{},"flags":[]},"prior_findings":[]}
""";
String est = SolDesk.call("estimate", body);
System.out.println(est);
body = {
"task" => "audit",
"contract" => File.read("SimpleSwapPair.sol"),
"notes" => "",
"prescan" => { "inventory" => {}, "flags" => [] },
"prior_findings" => []
}
est = call("estimate", body)
puts est["model"], est["hold_credits"]
$body = [
"task" => "audit",
"contract" => file_get_contents("SimpleSwapPair.sol"),
"notes" => "",
"prescan" => ["inventory" => (object)[], "flags" => []],
"prior_findings" => [],
];
$est = call("estimate", $body);
echo $est["model"], " ", $est["hold_credits"], "\n";
var body = new {
task = "audit",
contract = File.ReadAllText("SimpleSwapPair.sol"),
notes = "",
prescan = new { inventory = new {}, flags = Array.Empty<object>() },
prior_findings = Array.Empty<object>()
};
var est = await Call("estimate", body);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
5. Run it and poll the job
POST /run returns a job_id immediately; poll GET /jobs/{job_id} until the status is terminal. The model's reply is a single JSON object in output.output — parse that, not the job wrapper. Pass an Idempotency-Key header so a retried request cannot double-bill; the app derives its own from (task, input, attempt), which is why two lanes over the same contract are two distinct runs. If truncated comes back true, the balance sat between min_credits and hold_credits and the answer was cut short — surface that rather than presenting a clipped result as complete.
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: sol-desk:audit:abc123:a1" \ -d @body.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["job_id"])') until curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \ | tee /dev/stderr | grep -q '"status":"succeeded"'; do sleep 2; done
import time
job = call("run", body) # add the Idempotency-Key header in call()
job_id = job["job_id"]
while True:
j = call(f"jobs/{job_id}", method="GET")
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
result = json.loads(j["output"]["output"])
print(result["posture"], result["verdict"])
for f in result["findings"]:
print(f["id"], f["severity"], f["title"])
const job = await call("run", body);
let j;
do {
await new Promise(r => setTimeout(r, 2000));
j = await call(`jobs/${job.job_id}`, undefined, "GET");
} while (!["succeeded", "failed", "cancelled"].includes(j.status));
const result = JSON.parse(j.output.output);
console.log(result.posture, result.findings.length);
job, err := call("run", body, "POST")
if err != nil {
panic(err)
}
id := job["job_id"].(string)
var j map[string]any
for {
j, _ = call("jobs/"+id, nil, "GET")
s := j["status"].(string)
if s == "succeeded" || s == "failed" || s == "cancelled" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println(j["output"])
String job = SolDesk.call("run", body);
// read job_id from the envelope, then poll GET /jobs/{id} until the status is
// succeeded, failed or cancelled, and JSON-parse output.output
job = call("run", body)
loop do
j = call("jobs/#{job['job_id']}", nil, :get)
break (@job = j) if %w[succeeded failed cancelled].include?(j["status"])
sleep 2
end
result = JSON.parse(@job["output"]["output"])
puts result["posture"], result["findings"].length
$job = call("run", $body);
do {
sleep(2);
$j = call("jobs/" . $job["job_id"], null, "GET");
} while (!in_array($j["status"], ["succeeded", "failed", "cancelled"], true));
$result = json_decode($j["output"]["output"], true);
echo $result["posture"], " ", count($result["findings"]), "\n";
var job = await Call("run", body);
var id = job.GetProperty("job_id").GetString();
JsonElement j;
do {
await Task.Delay(2000);
j = await Call($"jobs/{id}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed" or "cancelled"));
var result = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(result.RootElement.GetProperty("posture").GetString());
6. Stream it with /run-stream
Same body, same headers, but the reply is a Server-Sent Event stream of deltas. This is what the app itself uses: the deltas let the progress card advance on real signals — the moment "findings" or "checks" appears in the stream — instead of a bare character counter. Accumulate every delta and parse the whole thing once at the end; the JSON is only valid complete. If the stream dies early, keep what arrived: a partial object still carries the sections that finished.
curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d @body.json
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(body).encode())
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
raw = ""
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().strip()
if line.startswith("data:"):
payload = json.loads(line[5:].strip())
raw += payload.get("delta", "")
result = json.loads(raw)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Accept": "text/event-stream"
},
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let raw = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5).trim());
if (evt.delta) raw += evt.delta;
}
}
const result = JSON.parse(raw);
req, _ := http.NewRequest("POST", base+"/run-stream", &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var raw strings.Builder
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var evt struct{ Delta string }
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &evt)
raw.WriteString(evt.Delta)
}
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder raw = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> raw.append(extractDelta(l.substring(5))));
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req.body = JSON.generate(body)
raw = +""
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|
next unless line.start_with?("data:")
evt = JSON.parse(line[5..].strip)
raw << evt["delta"].to_s
end
end
end
end
result = JSON.parse(raw)
$raw = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$evt = json_decode(substr($line, 5), true);
$raw .= $evt["delta"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$result = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream") {
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")
};
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is { } line) {
if (!line.StartsWith("data:")) continue;
var evt = JsonDocument.Parse(line[5..]).RootElement;
if (evt.TryGetProperty("delta", out var d)) raw.Append(d.GetString());
}
7. One worked example per lane
Same endpoint, same body, one field different. Each of these assumes the post helper from step 1 and a body.json holding the Solidity source.
task: "pattern"
Classify the protocol and report what the canonical implementation carries that yours does not. Returns body.reference_outline, then one body.components entry per outlined component with present / partial / absent.
post run '{"task": "pattern", "contract": "<your .sol source>", "notes": "",
"prescan": {"inventory": {}, "flags": []}, "prior_findings": []}'
task: "audit"
The security review. This is the lane to start from if you only run one. Returns all fourteen body.checks (SEC-1..SEC-14), severity-ranked findings each with a verbatim evidence quote and a compilable fix, and an ordered body.exploit_paths entry per critical or high.
post run '{"task": "audit", "contract": "<your .sol source>", "notes": "",
"prescan": {"inventory": {}, "flags": []}, "prior_findings": []}'
task: "defi"
The economic attack surface — what an attacker gets, rather than what the code gets wrong. Returns all ten body.vectors (DFI-1..DFI-10) marked applicable yes/no/unclear, the body.invariants that must hold, and the body.value_flows.
post run '{"task": "defi", "contract": "<your .sol source>", "notes": "",
"prescan": {"inventory": {}, "flags": []}, "prior_findings": []}'
task: "standards"
Token-standard conformance, member by member. Returns body.standard with quoted evidence, a body.interface_rows entry for every mandatory member, and body.behaviour_rows for the rules where conformance actually breaks.
post run '{"task": "standards", "contract": "<your .sol source>", "notes": "",
"prescan": {"inventory": {}, "flags": []}, "prior_findings": []}'
task: "tests"
The Foundry suite. Pass the previous lane's findings in prior_findings and every one of them gets a case that would have caught it. Returns body.cases and, in artifact, the complete .t.sol file — pragma, imports, setUp() and every case as a real function with real assertions.
post run '{"task": "tests", "contract": "<your .sol source>", "notes": "",
"prescan": {"inventory": {}, "flags": []}, "prior_findings": []}'
Notes that will save you a support round trip
- The run body is the input object. Wrapping it in an
inputkey returns200while silently hidingtaskfrom the model, so every lane degrades to a guess. - There is no
X-App-Slugheader. The token identifies the app. coverage_checkis only meaningful if you send aprescan. Send the flags you want accounted for, and the model must return exactly one entry per flag id.- Findings quote your source verbatim in
evidence. If a quote does not appear in the file you sent, treat the finding as ungrounded and discard it — the prompt forbids it and it is worth asserting on your side too. - Sol Desk never compiles, deploys, forks or executes anything. It is a review desk, not an audit, and no response from it should be presented as one.