← Sol Desk / API
Get a token

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.

Base URL

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.

taskWhat it doesExtra inputartifact.kind
auditThe security review: fourteen named checks, severity-ranked findings and the exploit paths behind the serious ones.none
defiThe economic attack surface: ten named DeFi attack vectors, the economic invariants, and where value actually moves.none
patternStructural gap review against the canonical shape of the detected protocol kind.none
standardsToken-standard conformance (ERC-20/721/1155/4626), member by member.none
testsA Foundry test plan plus the complete .t.sol file that implements it.prior_findingssolidity

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": []
}
FieldTypeRequiredNotes
taskstringyesExactly one of audit, defi, pattern, standards, tests.
contractstringyesThe 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.
notesstringnoFree-text context — what the contract is for, what is already known, what to look at first. May be empty.
prescanobjectno, 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_findingsarraynoFindings 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

CodeHTTPWhat it meansWhat to do
unauthorized401Missing, malformed or expired token.Mint a new one. Guest tokens expire; personal tokens outlive them.
validation_error400The body did not match the app's shape — most often a missing task or contract.Read error.details; it names the offending field.
payment_required402Balance below the run's minimum.Call /estimate first and compare min_credits against /me.
rate_limited429Too many requests.Back off and retry; never tight-loop.
internal500The platform failed, not your request.Retry once with the same Idempotency-Key, then give up and report it.
truncated: true200Not 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": {}
}
KeyTypeWhat it carries
lanestringThe lane that actually answered. Compare it to the task you sent.
lane_inferredbooleantrue means your task did not arrive or was not recognised and the model chose the closest lane. Treat it as a client bug.
contract_namestringThe primary contract's name as written, or unknown.
titlestringSix to ten words naming the contract and the job. Use it as the result heading.
posturestringblocked = 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.
verdictstringOne sentence: the answer a reviewer would give out loud.
solidity_pragmastringThe pragma as written, or none.
protocol_kindstringOne of amm, lending, staking, vault, token, nft, governance, bridge, auction, vesting, utility, unknown. The pattern lane reviews against this classification.
summarystringThree to six sentences, written to be pasted into a pull request. It always states the limit of the claim at least once.
assumptionsstring[]What had to be assumed because the source did not say.
open_questionsstring[]What a reviewer would ask the author before signing off.
findingsobject[]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_checkobject[]Exactly one entry per prescan.flags[].id sent, in the order sent. [] when no prescan was sent.
artifactobject{kind, filename, content}. content is a whole file, never a diff and never a fragment. kind: "none" means there is nothing to download.
next_laneobjectThe job a real reviewer would do next, and never the current lane. The usual chain is patternauditdefistandardstests; tests proposes audit when it found something it could not write a test for.
bodyobjectThe 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.

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.

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.

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.

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.

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.

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