Drive Apex Desk from your own code
What this app does
Apex Desk reviews one Salesforce Apex deployment unit — a trigger, the handler and
service classes it calls, the SOQL those classes run, and the test class that is supposed to cover
them — across five lanes: bulkify projects the governor-limit budget over a trigger
batch and says what breaks first; security works through the sharing model, CRUD and
FLS enforcement, SOQL injection and hardcoded Ids; soql reviews every query for
selectivity and rewrites the ones that need it; refactor rewrites the unit into the
trigger-to-handler shape a Salesforce reviewer expects; and testgen writes the PNB
(positive, negative, bulk) test class the unit needs to be deployable. Everything the web page does
is one HTTP call away. Base URL: https://api.skillsafe.ai/v1/app-api, scoped to this
app by the token you send. The natural use is a CI job that runs bulkify and
security over every changed trigger in a pull request and fails the build on a
blocks-deploy verdict.
Derived from the agent skills @github/salesforce-apex-quality,
@forcedotcom/querying-soql, @forcedotcom/generating-apex,
@forcedotcom/generating-apex-test and
@forcedotcom/trigger-refactor-pipeline. Not affiliated with Salesforce, GitHub or the
authors of those skills.
Two things about the wire format are worth knowing before the first call, because between them they
account for nearly every wasted credit here. The request body for /run,
/run-stream and /estimate is the input object itself — there is
no {"input": {…}} wrapper, and no X-App-Slug header; the token carries the
slug. An input wrapper returns 200 and hides task from the
model, which is the worst of both worlds. And read the envelope, not the HTTP status,
for anything the app itself decided: a job that ran and then failed still arrives inside
{"ok":true,"data":{…}} with a terminal status.
The task field comes first
This app is five reviews over one work object, and task picks which one you get. It is
the field to get right before any other: it decides the shape of lane_detail, whether
you get a file back in artifact, what the metrics rows measure, and what
the run costs. Nothing else in the input changes the answer as much.
task | What it does | artifact.kind |
|---|---|---|
bulkify |
Governor-limit and bulk-safety review, projected over a trigger batch of batch_size
records: SOQL and DML in loops, callouts and async enqueues in loops, unbounded queries, heap
growth, missing recursion guards. |
none |
security |
Sharing model per class, CRUD and FLS enforcement on every user-reachable entry point, SOQL injection in dynamic queries, hardcoded record Ids, secrets in source. | none |
soql |
Per-query selectivity review — index usability, leading wildcards, negative operators, missing
LIMIT, over-wide field lists — plus the rewritten queries. |
soql |
refactor |
The unit rewritten into trigger-to-handler shape: one trigger per object that only delegates, a handler that owns context routing, bulk-safe and sharing-declared logic underneath. | apex |
testgen |
A PNB Apex test class — at least one positive, one negative and one bulk method per behaviour,
with @testSetup, Test.startTest() and real assertions. |
test |
An unrecognised task is not an error. If task is missing,
empty or not one of the five ids above, the model picks the closest lane, runs only
that one, and names the choice it made in the first sentence of exec_summary. The
lane field in the reply carries what it actually ran. It never blends two lanes'
contracts into one object — you always get exactly one lane_detail shape, and it is
the shape belonging to lane. So route on lane coming back, not on
the task you sent, and log the mismatch when they differ, because it means
something upstream of you is building a task id that this app does not have.
Two lanes deserve a word on cost before you meet /estimate. refactor and
testgen both return a complete file in artifact.body, so their output
ceiling — and therefore their hold_credits — is far above the three review-only lanes.
bulkify is the cheapest of the five. Re-estimate whenever the lane changes.
The envelope and the error codes
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same two-shape envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Authentication is one header on every call except the guest mint:
Authorization: Bearer <token>. There is no slug header — the token is minted for
apex-desk and carries the app with it.
| Code | HTTP | What causes it here | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | The input object is the wrong shape: no apex, an empty apex, a
batch_size outside 1–200, a prescan_facts sent as a JSON string
where an object was expected, or a body that is not JSON at all. |
Read error.details; it names the field. Nothing was charged — validation happens
before the job is created. |
UNAUTHORIZED | 401 | The Authorization header is missing, malformed or the token has expired. |
Mint a fresh token from the token page. Do not retry the same token. |
FORBIDDEN | 403 | The token is valid but not allowed here: most often a guest token calling
/run, which this app does not sponsor. Also a token minted for a different
app. |
Sign in and use a personal token. Guests can call /me and /estimate
and nothing else. |
NOT_FOUND | 404 | An unknown route, or a job_id that does not belong to this subject. A job created
by a guest token is invisible to your personal token and vice versa. |
Check the path, and poll with the same token that created the job. |
PAYMENT_REQUIRED | 402 | The balance is below the min_credits this run needs. The refactor and
testgen lanes hit this first because they hold the most. |
Call /estimate for the lane you are about to run and compare it against
/me before submitting. Top up, or run a cheaper lane. |
RATE_LIMITED | 429 | More than 30 requests in a minute from one IP, usually a poll loop with no sleep in it. | Back off — a run takes tens of seconds. Poll no faster than once a second and widen the gap on
each 429. |
INTERNAL | 500 | A failure on our side. It tells you nothing about whether the job was created. | Retry with the same Idempotency-Key. A replay returns the original
job instead of billing you for a second one. |
VALIDATION_ERROR is worth reading rather than retrying, and it is free. The three
fields that produce it in practice are a missing apex, a batch_size that
arrived as the string "200" instead of the number 200, and a
prescan_facts that was serialised twice — an object of JSON, not a JSON string of an
object. None of them cost a credit.
The input contract
This is the whole body for /estimate, /run and /run-stream.
Copy the shape, not just the fields:
{
"task": "bulkify",
"apex": "// file: AccountTrigger.trigger\ntrigger AccountTrigger on Account (before insert) { ... }",
"context": "free text: org size, what already failed, what cannot change",
"batch_size": 200,
"org_type": "production",
"api_version": "62.0",
"prescan_facts": { "...": "the browser prescan, see below" },
"handoff": {"from": "bulkify", "findings": []}
}
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | One of bulkify, security, soql, refactor,
testgen. An unrecognised value routes to the nearest lane rather than failing. |
apex | string | yes | The pasted Apex source, as one string with real newlines. Multiple files are separated by
// file: Name.cls marker lines. |
context | string | no | Free text. Org size, what already failed in production, what is frozen and cannot change. This is what turns a generic review into one about your org — see below. |
batch_size | integer | no | 1–200, default 200. The batch every projection in bulkify is computed
at. Send a number, not a string. |
org_type | string | no | production, sandbox, scratch or
managed-package. Changes what the review demands of you — see below. |
api_version | string | no | The API version of the metadata, e.g. "62.0". Omit it and the reviewer says so
rather than assuming a version. |
prescan_facts | object | no, but send it | The output of the app's in-browser Apex parser. Optional in the sense that the API accepts its absence; strongly recommended in the sense that the reply is measurably better with it. |
handoff | object | no | {"from": "<lane id>", "findings": [ … ]}. Set when the UI's "run the next lane
on this" button was used. |
The apex field and the file markers
apex is one string carrying the whole deployment unit. When it holds more than one
file, separate them with a marker line — // file: AccountTrigger.trigger,
// file: AccountTriggerHandler.cls, // file: AccountTriggerHandlerTest.cls
— at the start of each. Those markers do real work in both directions: the reviewer uses them to
make findings[].location a real file-and-line pair rather than a vague pointer, and on
the refactor and testgen lanes the same markers come back in
artifact.body so the browser can split the returned source into files. Without them, a
three-file unit reads as one anonymous blob and every location degrades to a line number in nothing.
Clipping. The browser clips an over-long paste from the middle of each
file — keeping the head and the tail, where the declarations and the closing logic live — and marks
the cut in-band with a line like
// [... 4180 characters cut from the middle of this file ...]. The reviewer is
instructed to notice that marker, say so in assumptions, and scope every claim to what
it can actually see. If you are clipping yourself, do the same: cut on whole-line boundaries, never
mid-statement, keep the class and trigger declarations, and leave a marker. A silent cut produces a
review of code you did not send.
What context and org_type actually change
context is the cheapest field on this page and the one most often left empty. Three
things belong in it: the size of the org (40,000 Accounts reads differently from 400), what already
failed and how (a data load of 5,000 records that died is a fact the review can aim at), and what
cannot change (a frozen global signature, a locked release, a field you are not allowed to add).
The last one matters most: a fix that a reviewer would otherwise recommend gets ruled out, and you
get the second-best fix that is actually available to you instead of a first-best you cannot use.
org_type is a small string with sharp edges. managed-package means every
global signature is frozen — no change may alter one — and without sharing
is scrutinised harder. production means a deployment needs 75% coverage overall with
every trigger covered, so testgen and the coverage metrics are held to that bar.
sandbox and scratch relax the deployment gate but not the correctness one.
prescan_facts: give the model ground truth
The app parses the paste in the browser before it sends anything. It masks comments and string
literals, brace-matches every loop so it knows what is inside one, parses every SOQL statement into
object, fields, WHERE and LIMIT, locates every DML statement, finds the
entry-point annotations and the sharing keywords, and projects the governor budget at your batch
size. The result is prescan_facts, and it is handed to the model as facts it
must reconcile rather than re-derive.
That distinction is the whole point. On the question of whether something is present — is this query inside a loop, does this class declare sharing, is there a test class — the prescan is authoritative and the model is instructed to defer to it. On the question of what it means the model may disagree, and when it does it must say which flag it is overruling and why. Sending the facts is what stops a reviewer from confidently describing a loop that is not there.
Top-level keys:
| Key | What it carries |
|---|---|
title | A human name for the unit, derived from the trigger or the primary class. |
files | The file paths found from the // file: markers. |
api_version | The version read from the paste, or "unknown". |
mode | "synchronous" or "asynchronous" — which limit table applies. |
batch_size | The batch every projection below was computed at. |
triggers[] | Per trigger: name, sobject, events, body_lines, delegates_only. |
classes[] | Per top-level class: name, sharing, is_test, implements. |
soql[] | Per query: id (Q1, Q2, …), object, in_loop, loop_depth, has_where, has_limit, where_fields, selectivity, reasons — plus line, fields, security_enforced and a clipped text. |
dml[] | Per statement: id (D1, …), op, line, in_loop, loop_depth, partial_allowed. |
callouts | How many HTTP callouts were found. |
async[] | The distinct async mechanisms present: future, queueable, batch, schedulable. |
recursion_guard | Boolean: whether a static re-entry guard exists. |
security{} | crud_checks, strip_inaccessible, user_mode_dml, enforced_queries, dynamic_queries, injection_risks, entry_points, classes_without_sharing_keyword, classes_without_sharing, hardcoded_ids. |
tests{} | has_test_class, classes, methods, asserts, see_all_data, test_setup, start_stop, run_as, mocks, negative, bulk, max_bulk_size. |
budget{} | The projection: batch_size, mode, rows[] (one per consumer, with base, in_loop, projected, limit, status, headroom, note), soql_projected, dml_projected, breaches, verdict. |
counts{} | Flags by severity: critical, high, medium, low. |
flags[] | The numbered findings, most severe first. Each is id (PS-001, PS-002, …), severity, rule, title, where, detail. |
An abridged one, from the bundled legacy example:
{
"title": "AccountTrigger + AccountService",
"files": ["AccountTrigger.trigger", "AccountService.cls"],
"api_version": "62.0",
"mode": "synchronous",
"batch_size": 200,
"triggers": [
{"name": "AccountTrigger", "sobject": "Account",
"events": ["before insert", "after update"], "body_lines": 11, "delegates_only": false}
],
"classes": [
{"name": "AccountService", "sharing": "none", "is_test": false, "implements": []}
],
"soql": [
{"id": "Q1", "object": "Contact", "line": 4, "in_loop": true, "loop_depth": 1,
"has_where": true, "has_limit": false, "where_fields": ["AccountId"],
"selectivity": "selective", "reasons": ["filters on an indexed lookup field"]},
{"id": "Q2", "object": "Account", "line": 19, "in_loop": false, "loop_depth": 0,
"has_where": true, "has_limit": false, "where_fields": ["Name"],
"selectivity": "risky", "reasons": ["LIKE with a leading wildcard cannot use the index",
"assembled by string concatenation"]},
{"id": "Q3", "object": "Opportunity", "line": 25, "in_loop": false, "loop_depth": 0,
"has_where": false, "has_limit": false, "where_fields": [],
"selectivity": "unbounded", "reasons": ["no WHERE clause", "no LIMIT"]}
],
"dml": [
{"id": "D1", "op": "update", "line": 7, "in_loop": true, "loop_depth": 2,
"partial_allowed": false}
],
"callouts": 1,
"async": [],
"recursion_guard": false,
"security": {
"crud_checks": 0, "strip_inaccessible": 0, "user_mode_dml": 0, "enforced_queries": 0,
"dynamic_queries": 1, "injection_risks": 1, "entry_points": ["AuraEnabled"],
"classes_without_sharing_keyword": ["AccountService"], "classes_without_sharing": [],
"hardcoded_ids": ["005xx0000012Q9zAAE"]
},
"tests": {"has_test_class": false, "classes": 0, "methods": 0, "asserts": 0,
"see_all_data": false, "test_setup": false, "start_stop": false,
"run_as": false, "mocks": false, "negative": false, "bulk": false,
"max_bulk_size": 0},
"budget": {
"batch_size": 200, "mode": "synchronous",
"rows": [
{"label": "SOQL queries", "base": 2, "in_loop": 1, "projected": 202, "limit": 100,
"unit": "queries per transaction", "status": "breach", "headroom": -102,
"note": "1 of them sits inside a loop"},
{"label": "DML statements", "base": 0, "in_loop": 1, "projected": 40000, "limit": 150,
"unit": "statements per transaction", "status": "breach", "headroom": -39850,
"note": "1 of them sits inside a loop; 1 of them sits in a nested loop"},
{"label": "Callouts", "base": 0, "in_loop": 1, "projected": 200, "limit": 100,
"unit": "callouts per transaction", "status": "breach", "headroom": -100,
"note": "a callout inside a loop also breaks the 120s callout budget"},
{"label": "Query rows", "base": 3, "in_loop": 1, "projected": null, "limit": 50000,
"unit": "rows per transaction", "status": "warn", "headroom": null, "unbounded": 1,
"note": "1 query has neither a WHERE nor a LIMIT"}
],
"soql_projected": 202, "dml_projected": 40000,
"soql_limit": 100, "dml_limit": 150, "breaches": 3, "verdict": "breach"
},
"counts": {"critical": 4, "high": 4, "medium": 1, "low": 0},
"flags": [
{"id": "PS-001", "severity": "critical", "rule": "soql-in-loop",
"title": "SOQL query inside a loop", "where": "AccountTrigger.trigger:4",
"detail": "Query Q1 runs once per iteration. Over a batch of 200 records that is 200 queries against a limit of 100."},
{"id": "PS-002", "severity": "critical", "rule": "dml-in-loop",
"title": "DML statement inside a loop", "where": "AccountTrigger.trigger:7",
"detail": "`update` runs once per iteration of a nested loop."},
{"id": "PS-003", "severity": "critical", "rule": "callout-in-loop",
"title": "HTTP callout inside a loop", "where": "AccountService.cls:38",
"detail": "A callout per iteration spends the 100-callout limit and the 120-second cumulative timeout."},
{"id": "PS-004", "severity": "critical", "rule": "soql-injection",
"title": "Dynamic SOQL assembled by concatenation", "where": "AccountService.cls:19",
"detail": "The term parameter is concatenated into the query string on an @AuraEnabled method."},
{"id": "PS-005", "severity": "high", "rule": "logic-in-trigger",
"title": "The trigger carries logic rather than delegating", "where": "AccountTrigger.trigger:1",
"detail": "11 body lines, no handler class."},
{"id": "PS-006", "severity": "high", "rule": "no-sharing-declaration",
"title": "Class declares no sharing keyword", "where": "AccountService.cls:1",
"detail": "AccountService runs in system context when entered directly."},
{"id": "PS-007", "severity": "high", "rule": "hardcoded-id",
"title": "Hardcoded record Id", "where": "AccountTrigger.trigger:10",
"detail": "005xx0000012Q9zAAE will not exist in another org."},
{"id": "PS-008", "severity": "high", "rule": "unbounded-query",
"title": "Query with neither WHERE nor LIMIT", "where": "AccountService.cls:25",
"detail": "Q3 is bounded only by the size of the Opportunity object."},
{"id": "PS-009", "severity": "medium", "rule": "swallowed-exception",
"title": "Empty catch block", "where": "AccountService.cls:41",
"detail": "The callout failure has nowhere to go."}
]
}
The rule ids are a closed set — soql-in-loop, dml-in-loop,
callout-in-loop, async-in-loop, soql-injection,
logic-in-trigger, multiple-triggers, unknown-trigger-event,
no-recursion-guard, no-sharing-declaration, without-sharing,
no-crud-fls, hardcoded-id, unbounded-query,
non-selective-query, wide-select, unchecked-partial-dml,
swallowed-exception, debug-noise, no-test-class,
see-all-data, no-bulk-test, no-start-stop-test,
no-negative-test, no-asserts, trivial-asserts,
no-api-version, old-api-version — so you can key CI policy off
rule rather than off prose.
Every flag must come back exactly once
This is the contract that makes the prescan worth sending. The model is instructed to
reconcile every prescan_facts.flags[].id exactly once in
coverage_check, with a status of confirmed,
cleared or out-of-scope and a one-line reason.
confirmed— the reviewer agrees the flag is a real problem, and normally there is a matching entry infindingsto point at.cleared— the reviewer read the flag and disagrees with what it means. The prescan is a parser, not a judge: a query in a loop over a two-element list is technically in a loop and practically fine, and saying so is the reviewer doing its job.out-of-scope— the flag belongs to another lane. Ano-bulk-testflag in thesoqllane is out of scope, and that is the honest answer. It is not a way to dodge an inconvenient finding, and a lane that marks its own flags out of scope is a reply to distrust.
So: after parsing, take the set of ids you sent and the set of flag_id values that came
back, and compare them. A missing id, a duplicated id, or an id you never sent are each grounds to
reject the reply rather than render it. This is the cheapest integrity check on the page and it
catches more than any amount of schema validation.
The handoff field
Set handoff when you are chaining lanes over the same paste — which is what the UI's
"run the next lane on this" button does. It names the lane the findings came from and carries them:
"handoff": {
"from": "bulkify",
"findings": [
{"id": "AD-001", "severity": "critical", "rule": "soql-in-loop",
"location": "AccountTrigger.trigger:4",
"problem": "Q1 runs once per record; 200 queries against a limit of 100."},
{"id": "AD-002", "severity": "critical", "rule": "dml-in-loop",
"location": "AccountTrigger.trigger:7",
"problem": "update inside a nested loop."}
]
}
The receiving lane treats those findings as already established: it does not
re-litigate them, and it references them by their original ids in assumptions. The
pairing that earns its keep is bulkify then refactor — the refactor gets
to start from a diagnosis instead of making its own, and the changes it emits line up with the
findings you already showed someone. security then refactor works the same
way. Note that handoff is not a substitute for prescan_facts: send both.
The output contract
The reply is one JSON object, no prose, no code fences, arriving as a string at
job.output.output. The envelope below is identical in every lane; only
lane_detail and artifact change shape with the lane. This is exactly what
the app's own renderer parses, so anything that renders in the browser is available to you.
{
"lane": "bulkify",
"unit_title": "AccountTrigger + AccountService",
"verdict": "blocks-deploy",
"headline": "one sentence a reviewer could paste into a pull request",
"exec_summary": "two to four sentences: what this unit does, what decides the verdict",
"facts": {
"triggers": 1, "classes": 1, "soql": 3, "dml": 1,
"api_version": "62.0", "sharing": "none", "mode": "synchronous"
},
"findings": [
{
"id": "AD-001",
"severity": "critical",
"rule": "soql-in-loop",
"location": "AccountTrigger.trigger:4",
"problem": "what is wrong, naming the construct",
"impact": "what happens in production because of it",
"fix": "what to change, concretely",
"snippet": "the corrected Apex, or \"\" when a snippet would not help"
}
],
"metrics": [
{"label": "SOQL queries per transaction", "value": "202", "limit": "100",
"status": "breach", "note": "Q1 sits inside the trigger loop"}
],
"lane_detail": { },
"artifact": {
"kind": "none",
"filename": "",
"language": "",
"body": "",
"note": "what the reader must check before using it"
},
"coverage_check": [
{"flag_id": "PS-001", "status": "confirmed", "note": "one line"}
],
"assumptions": ["..."],
"open_questions": ["..."],
"next_steps": ["..."],
"summary": "two or three sentences to close"
}
| Field | Type | What it holds |
|---|---|---|
lane | string | The lane that actually ran. Route on this, not on the task you sent. |
unit_title | string | A human name for the deployment unit, usually the trigger plus its handler. |
verdict | enum | blocks-deploy, needs-work or ready. Nothing else is legal. |
headline | string | One sentence, written to be pasted into a pull request comment. |
exec_summary | string | Two to four sentences. When the lane was chosen for you, the choice is named in the first one. |
facts | object | triggers, classes, soql, dml (integers), api_version, sharing, mode (strings). What the reviewer believes it read — compare it against your prescan. |
findings[] | array | Sequential AD-001, AD-002, … most severe first, each with severity, rule, location, problem, impact, fix, snippet. |
metrics[] | array | Three to eight rows: label, value, limit, status, note. |
lane_detail | object | The lane's own block. Shape is decided by lane — see below. |
artifact | object | kind, filename, language, body, note. A file you can write to disk, or kind: "none". |
coverage_check[] | array | One row per prescan flag: flag_id, status, note. |
assumptions[] | string[] | What the reviewer had to assume, including a clipped paste and any handoff findings taken as given. |
open_questions[] | string[] | Facts that mattered and were not in the paste. Read these before acting on the review. |
next_steps[] | string[] | What to do next, in order. |
summary | string | Two or three sentences to close. |
Arrays are always present, even when empty. No key is ever omitted. An empty
findings array with verdict: "ready" is a real answer, not a failure — it
is what a clean unit looks like, and exec_summary says what was checked and why it
passed.
The enums, and how verdict is decided
verdict—blocks-deploywhen anycriticalfinding exists,needs-workwhen the worst ishighormedium,readywhen the worst islowor there are none. That rule is deterministic: if you see acriticalfinding under areadyverdict, the reply is inconsistent and worth rejecting.findings[].severity—criticalmeans it fails or leaks data in production;highmeans it will fail under load, on a different org, or under a security review;mediummeans a real defect with a bounded blast radius;lowmeans style and maintainability. A style preference is never abovelow.metrics[].status—ok,warn,breach,unknown.unknownis a first-class answer: query row counts depend on org data and are not knowable from source, and sayingunknownis more useful than a guess.valueandlimitare strings for exactly this reason.artifact.kind—apex,soql,testornone. When it isnone,bodyis"".coverage_check[].status—confirmed,clearedorout-of-scope.
The artifact object
Three of the five lanes hand you a file. refactor returns kind: "apex"
with the complete rewritten source for every file that changed, each preceded by
its // file: Name.cls marker so you can split it the same way you joined it — no
"… rest unchanged" abbreviations, because the artifact is what gets pasted back into an editor.
testgen returns kind: "test" with a complete, compilable test class and a
filename of <ClassName>Test.cls. soql returns
kind: "soql" with every rewritten query, one per line, each preceded by a
-- Q3 AccountTriggerHandler.cls:22 comment so the whole set can be copied at once — and
kind: "none" when nothing needed rewriting.
bulkify and security always return kind: "none". Their
corrected fragments live in each finding's snippet instead, because a bulk fix or a
sharing fix is a few lines in context rather than a file. Never treat
artifact.note as decoration: it is where the reviewer says what it could not
verify — a type it could not see, an assumption baked into the rewrite, a test that will not pass
until a validation rule is disabled.
lane_detail, one shape per lane
Exactly one shape is present, and it is the shape belonging to lane. Read
result.lane first and then the block; do not probe for keys.
bulkify
"lane_detail": {
"budget": [
{"consumer": "SOQL queries", "outside_loops": 2, "inside_loops": 1,
"projected": 202, "limit": 100, "status": "breach",
"note": "Q1 in AccountTrigger.trigger:4 runs once per record"}
],
"hot_paths": [
{"path": "AccountTrigger -> for (Account a : Trigger.new) -> [SELECT ... Contact]",
"why": "one query per record", "cost_at_batch": "200 queries"}
],
"bulk_safe": false
}
budget[] must agree with prescan_facts.budget on the counts — a
disagreement there is a bug, not a judgement call. Rows the prescan could not compute, such as heap
and CPU, may be added with "projected": null and "status": "unknown".
bulk_safe is the boolean a CI job can gate on, and it is answered at exactly the
batch_size you sent, not "generally".
security
"lane_detail": {
"access_matrix": [
{"sobject": "Account", "operation": "read", "enforced": "no",
"evidence": "Q2 has no WITH SECURITY_ENFORCED and the class has no isAccessible check",
"required": "yes - reached from an @AuraEnabled method"}
],
"sharing": [
{"class_name": "AccountService", "declared": "none", "should_be": "with sharing",
"why": "reached directly from an @AuraEnabled method, so it runs in system context today"}
],
"injection": [
{"query_id": "Q2", "location": "AccountService.cls:19",
"vector": "the term parameter is concatenated into the query string",
"fix": "Database.queryWithBinds with a bind map, or String.escapeSingleQuotes"}
]
}
access_matrix is one row per (sObject, operation) pair the unit touches, so
Account read and Account update are two rows. injection[]
keys back to prescan_facts.soql[].id, which is how you line a finding up with the
query that caused it. The lane is precise about the three separate things people conflate:
object-level (CRUD), field-level (FLS) and record-level (sharing) security — a class marked
with sharing still needs FLS, and the matrix will say so.
soql
"lane_detail": {
"queries": [
{"query_id": "Q3", "location": "AccountService.cls:25", "object": "Opportunity",
"selectivity": "unbounded",
"problem": "no WHERE clause and no LIMIT",
"rewritten": "SELECT Id, Name, Amount, StageName FROM Opportunity WHERE AccountId IN :accIds AND StageName != 'Closed Won' WITH SECURITY_ENFORCED LIMIT 10000",
"expected_effect": "bounded by the batch instead of by the size of the object",
"keep_as_is": false}
]
}
One row per query in the paste — including the ones that are fine, which come back with
keep_as_is: true, an empty rewritten and a problem that says
why nothing needs doing. selectivity is unbounded, risky or
selective. A soql reply that flags every query is not a review, and the
lane is instructed accordingly.
refactor
"lane_detail": {
"changes": [
{"change_id": "C1", "area": "AccountTriggerHandler.run",
"before": "the SOQL that ran inside the loop, quoted from the paste",
"after": "the bulk-safe replacement",
"why": "one query for the whole batch instead of one per record",
"behaviour_preserved": true}
],
"structure": [
{"file": "AccountTrigger.trigger", "role": "delegates to the handler and does nothing else"},
{"file": "AccountTriggerHandler.cls", "role": "context routing and bulk-safe logic"}
]
}
behaviour_preserved: false is the field to read first. A refactor that changes what the
code does is sometimes the right answer — an empty catch that starts rethrowing changes
behaviour by design — but it is never done silently: the reasoning lands in
open_questions as well. structure[] is the file layout the rewrite assumes,
and it matches the // file: markers in artifact.body.
testgen
"lane_detail": {
"test_plan": [
{"scenario": "one Healthcare account creates one follow-up task",
"kind": "positive",
"method_name": "createsTaskForHealthcareAccount",
"setup": "one Account and one Case in @testSetup",
"asserts": ["exactly one Task exists", "its WhatId is the Account"]}
],
"coverage": {
"estimated_percent": "80-90",
"depends_on": "whether the else branch in handle() is reachable with the pasted validation rules",
"uncovered": ["the catch block in escalateNew(), which needs a forced DmlException"]
}
}
kind is positive, negative or bulk, and the plan
and the artifact are held to each other: every method in test_plan exists in
artifact.body, and every method in the body appears in the plan. That is a
cheap and effective assertion to run in CI. estimated_percent is deliberately a range,
not a number — coverage cannot be computed from source — and depends_on says what the
range hinges on.
Step by step
1. Get a token
Every call carries Authorization: Bearer <token>. There are two kinds, and the
difference is not subtle:
- A personal token — open the token page, sign in, and copy it. No DevTools and no digging through storage: the page reads the same storage the app itself uses, shows the token this browser holds, and gives you a Copy token and a Copy shell export button. This is the one that spends your credits, sees your run history, and is the only one that can run a lane.
- A guest token —
POST /v1/app-api/guestwith{"slug":"apex-desk"}and noAuthorizationheader mints an anonymous subject. A guest can call/meand/estimate— enough to price a lane and prove the contract — and nothing else./runand/run-streamare metered, this app does not sponsor guest usage, so a guest run comes back403 FORBIDDENhowever healthy the balance looks.
Worth saying once: the in-browser prescan — the parse, the loop matching, the SOQL analysis, the
governor projection and the PS-nnn flags — is free and needs no token at all. It is
only the model review that is metered. Keep the token out of your source and read it from the
environment at runtime.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://apex-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
TOKEN="$SKILLSAFE_TOKEN"
# Or mint a guest token — no Authorization header on this one call.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"apex-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","subject_type":"guest"}}
#
# A guest can /me and /estimate. Running a lane needs a personal token.
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
# Preferred: a personal token from https://apex-desk.skillsafe.ai/tokens.html,
# read from the environment rather than committed.
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def mint_guest():
"""A guest can /me and /estimate. It cannot /run — that is a 403."""
req = urllib.request.Request(
BASE + "/guest", data=json.dumps({"slug": "apex-desk"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Paste a personal token from https://apex-desk.skillsafe.ai/tokens.html,
// or inject it at runtime. Never commit it.
const TOKEN = "YOUR_TOKEN";
// A guest token: no Authorization header on this one call.
async function mintGuest() {
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "apex-desk" })
});
const json = await res.json();
return json.data.token; // guests can /me and /estimate, not /run
}
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// A personal token from https://apex-desk.skillsafe.ai/tokens.html.
func token() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}
func mintGuest() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "apex-desk"})
res, err := http.Post(base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out) // guests cannot /run
return out.Data.Token, nil
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// A personal token from https://apex-desk.skillsafe.ai/tokens.html, via the environment.
static final String TOKEN =
System.getenv("SKILLSAFE_TOKEN") != null
? System.getenv("SKILLSAFE_TOKEN") : "YOUR_TOKEN";
static String mintGuest() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"apex-desk\"}"))
.build();
// -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def mint_guest
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "apex-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]["token"] # guests cannot /run
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
// A personal token from https://apex-desk.skillsafe.ai/tokens.html.
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function mint_guest(): string {
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "apex-desk"]),
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body["data"]["token"]; // guests cannot /run
}
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
// A personal token from https://apex-desk.skillsafe.ai/tokens.html.
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static async Task<string> MintGuest() {
using var anon = new HttpClient();
var res = await anon.PostAsJsonAsync(Base + "/guest", new { slug = "apex-desk" });
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
return doc.GetProperty("data").GetProperty("token").GetString()!; // no /run for guests
}
2. A tiny client helper
Two things repeat on every call: the Authorization header, and unwrapping
data out of the envelope. Write them once. Everything after this step uses the
call helper below, and each one raises on ok: false rather than returning
a half-empty object for you to trip over three lines later.
# The shell equivalent of a helper: one function plus jq for the unwrap.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"
ad() { # ad GET /me | ad POST /estimate "$body"
curl -s -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+-d "$3"} \
| jq -e 'if .ok then .data else error("\(.error.code): \(.error.message)") end'
}
import json, urllib.error, urllib.request
class ApiError(Exception):
pass
def call(path, payload=None, method=None, headers=None):
"""POST when there is a payload, GET otherwise. Returns the unwrapped `data`."""
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
body = json.load(r)
except urllib.error.HTTPError as e:
body = json.load(e) # the error envelope arrives with a 4xx status
if not body.get("ok"):
err = body.get("error", {})
raise ApiError(f"{err.get('code')}: {err.get('message')}")
return body["data"]
async function call(path, payload, { method, headers } = {}) {
const res = await fetch(BASE + path, {
method: method || (payload ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...(headers || {})
},
body: payload ? JSON.stringify(payload) : undefined
});
const json = await res.json(); // the error envelope arrives with a 4xx status
if (!json.ok) throw new Error(`${json.error?.code}: ${json.error?.message}`);
return json.data;
}
type apiError struct{ Code, Message string }
func (e apiError) Error() string { return e.Code + ": " + e.Message }
func call(method, path string, payload any, headers map[string]string) (map[string]any, error) {
var body *bytes.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
} else {
body = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out struct {
Ok bool `json:"ok"`
Data map[string]any `json:"data"`
Error apiError `json:"error"`
}
json.NewDecoder(res.Body).Decode(&out)
if !out.Ok {
return nil, out.Error
}
return out.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the raw body; parse it with your JSON library of choice. */
static String call(String method, String path, String json, Map<String, String> headers)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (headers != null) headers.forEach(b::header);
b = (json == null) ? b.GET() : b.method(method, HttpRequest.BodyPublishers.ofString(json));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"ok\":false")) throw new RuntimeException(res.body());
return res.body(); // {"ok":true,"data":{...}}
}
class ApiError < StandardError; end
def call(path, payload = nil, method: nil, headers: {})
uri = URI(BASE + path)
verb = method || (payload ? "POST" : "GET")
req = (verb == "POST" ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise ApiError, "#{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless body["ok"]
body["data"]
end
<?php
class ApiError extends RuntimeException {}
function call(string $path, ?array $payload = null, array $headers = []) {
global $token;
$ch = curl_init(BASE . $path);
$hdr = array_merge(
["Authorization: Bearer $token", "Content-Type: application/json"], $headers);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $hdr,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new ApiError(($body["error"]["code"] ?? "ERROR") . ": "
. ($body["error"]["message"] ?? ""));
}
return $body["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
async Task<JsonElement> Call(string path, object? payload = null,
(string Name, string Value)? header = null) {
var msg = new HttpRequestMessage(
payload is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (payload is not null) msg.Content = JsonContent.Create(payload);
if (header is not null) msg.Headers.Add(header.Value.Name, header.Value.Value);
var res = await http.SendAsync(msg);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!doc.GetProperty("ok").GetBoolean()) {
var e = doc.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return doc.GetProperty("data");
}
3. Check the session and the balance
GET /me tells you which subject the token belongs to and what it can afford. Three
fields matter: subject_type (user or guest),
username, and credits. Call it before a run — a guest here
means the run will come back 403 no matter how healthy the balance looks, and comparing
credits against the estimate's min_credits is how you avoid a
402 after submitting.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","username":"ada","credits":41230}}
# A guest looks like this, and cannot /run:
# -> {"ok":true,"data":{"subject_type":"guest","username":"guest","credits":0}}
me = call("/me")
print(me["subject_type"], me["username"], me["credits"])
if me["subject_type"] != "user":
raise SystemExit("a guest cannot run a lane in this app")
const me = await call("/me");
console.log(me.subject_type, me.username, me.credits);
if (me.subject_type !== "user") throw new Error("a guest cannot run a lane in this app");
me, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["username"], me["credits"])
if me["subject_type"] != "user" {
panic("a guest cannot run a lane in this app")
}
String me = call("GET", "/me", null, null);
System.out.println(me); // {"ok":true,"data":{"subject_type":"user","username":"ada","credits":41230}}
if (!me.contains("\"subject_type\":\"user\""))
throw new RuntimeException("a guest cannot run a lane in this app");
me = call("/me")
puts "#{me['subject_type']} #{me['username']} #{me['credits']}"
abort "a guest cannot run a lane in this app" unless me["subject_type"] == "user"
$me = call("/me");
echo $me["subject_type"], " ", $me["username"], " ", $me["credits"], PHP_EOL;
if ($me["subject_type"] !== "user") {
exit("a guest cannot run a lane in this app\n");
}
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
if (me.GetProperty("subject_type").GetString() != "user")
throw new Exception("a guest cannot run a lane in this app");
4. Price the lane — free, no job
POST /estimate takes the same body as /run — the input object, unwrapped —
costs nothing and creates no job. Three fields on the way back are the contract this page is written
against, and are worth asserting so a model or pricing change is loud rather than silent:
modelisgpt-5.6-terramodel_aliasisgpt-terramarkup_bpsis1000
It also returns hold_credits, min_credits and
sponsor_enabled — false here, which is precisely why a guest
/run is a 403.
hold_credits is a reservation, not a price. It is the ceiling the
platform sets aside while the job runs, sized for the worst case of that lane's output cap. The
charged_credits on the settled job is usually far lower — a review that comes back
short is billed short. Budget against hold_credits so a run is never rejected
mid-flight; report against charged_credits.
Re-estimate on every lane change. The hold differs per lane, and here the spread is
wide rather than cosmetic. refactor carries the largest ceiling of the five: its
artifact.body is the complete rewritten source of every file that changed, so a
three-file unit prices like three files. testgen is next, because a complete PNB test
class with @testSetup and real assertions is itself a substantial file.
soql sits in the middle — it returns a row per query and a rewritten statement for each
one that needs it, so it scales with prescan_facts.soql.length rather than with the
paste. security scales with the number of sObject-and-operation pairs the unit touches.
bulkify is the tightest of the five: a budget table, a hot-path list and a boolean. An
estimate for bulkify tells you nothing useful about refactor. The web app
re-estimates on every lane switch for exactly this reason.
On the input side, apex and prescan_facts drive the hold together, and the
facts object is not small — a three-file unit with a dozen queries produces a soql
array, a budget projection and a flag list. Sending it typically moves the hold more
than the source did. It remains the right trade: it is what stops the reviewer describing loops that
do not exist.
# The body IS the input object. No {"input": ...} wrapper, no X-App-Slug header.
BODY='{"task":"bulkify",
"apex":"// file: AccountTrigger.trigger\ntrigger AccountTrigger on Account (before insert) {\n for (Account a : Trigger.new) {\n List<Contact> kids = [SELECT Id FROM Contact WHERE AccountId = :a.Id];\n }\n}",
"context":"Production org, about 40k Accounts. A data load of 5,000 failed last week.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":null}'
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY"
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":9400,"min_credits":9400,"sponsor_enabled":false}}
inp = {
"task": "bulkify",
"apex": apex_source, # one string, "// file:" markers between files
"context": "Production org, about 40k Accounts. A data load of 5,000 failed last week.",
"batch_size": 200, # a NUMBER, not "200"
"org_type": "production",
"api_version": "62.0",
"prescan_facts": facts, # an object, not a JSON string of an object
}
est = call("/estimate", inp) # the input object IS the body
assert est["model"] == "gpt-5.6-terra", est["model"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# The hold is per lane. Switching task means estimating again.
for lane in ("bulkify", "security", "soql", "refactor", "testgen"):
print(lane, call("/estimate", {**inp, "task": lane})["hold_credits"])
const input = {
task: "bulkify",
apex: apexSource, // one string, "// file:" markers between files
context: "Production org, about 40k Accounts. A data load of 5,000 failed last week.",
batch_size: 200, // a NUMBER, not "200"
org_type: "production",
api_version: "62.0",
prescan_facts: facts // an object, not a JSON string of an object
};
const est = await call("/estimate", input); // no { input: ... } wrapper
if (est.model !== "gpt-5.6-terra") throw new Error(`unexpected model ${est.model}`);
if (est.model_alias !== "gpt-terra") throw new Error(`unexpected alias ${est.model_alias}`);
if (est.markup_bps !== 1000) throw new Error(`unexpected markup ${est.markup_bps}`);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// The hold is per lane. refactor and testgen return a whole file and hold the most.
for (const task of ["bulkify", "security", "soql", "refactor", "testgen"]) {
const e = await call("/estimate", { ...input, task });
console.log(task, e.hold_credits);
}
input := map[string]any{
"task": "bulkify",
"apex": apexSource,
"context": "Production org, about 40k Accounts. A data load of 5,000 failed last week.",
"batch_size": 200,
"org_type": "production",
"api_version": "62.0",
"prescan_facts": facts,
}
est, err := call("POST", "/estimate", input, nil)
if err != nil {
panic(err)
}
if est["model"] != "gpt-5.6-terra" || est["model_alias"] != "gpt-terra" {
panic(fmt.Sprintf("unexpected model %v", est["model"]))
}
fmt.Println(est["markup_bps"], est["hold_credits"], est["min_credits"])
// Re-estimate whenever the lane changes; the holds are not close to each other.
for _, lane := range []string{"bulkify", "security", "soql", "refactor", "testgen"} {
input["task"] = lane
e, _ := call("POST", "/estimate", input, nil)
fmt.Println(lane, e["hold_credits"])
}
// jsonQuoted() is your JSON string escaper; apex is a STRING with real newlines.
// factsJson is the prescan serialised as a JSON OBJECT, or the literal null.
String body = "{\"task\":\"bulkify\""
+ ",\"apex\":" + jsonQuoted(apexSource)
+ ",\"context\":\"Production org, about 40k Accounts.\""
+ ",\"batch_size\":200"
+ ",\"org_type\":\"production\""
+ ",\"api_version\":\"62.0\""
+ ",\"prescan_facts\":" + factsJson + "}";
String est = call("POST", "/estimate", body, null);
if (!est.contains("\"model\":\"gpt-5.6-terra\"")) throw new RuntimeException(est);
if (!est.contains("\"model_alias\":\"gpt-terra\"")) throw new RuntimeException(est);
if (!est.contains("\"markup_bps\":1000")) throw new RuntimeException(est);
System.out.println(est); // hold_credits, min_credits, sponsor_enabled
input = { "task" => "bulkify",
"apex" => apex_source,
"context" => "Production org, about 40k Accounts.",
"batch_size" => 200,
"org_type" => "production",
"api_version" => "62.0",
"prescan_facts" => facts }
est = call("/estimate", input)
raise "unexpected model #{est['model']}" unless est["model"] == "gpt-5.6-terra"
raise "unexpected alias #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
raise "unexpected markup #{est['markup_bps']}" unless est["markup_bps"] == 1000
puts est["hold_credits"], est["min_credits"], est["sponsor_enabled"]
%w[bulkify security soql refactor testgen].each do |lane|
puts "#{lane} #{call('/estimate', input.merge('task' => lane))['hold_credits']}"
end
$input = [
"task" => "bulkify",
"apex" => $apexSource,
"context" => "Production org, about 40k Accounts.",
"batch_size" => 200,
"org_type" => "production",
"api_version" => "62.0",
"prescan_facts" => $facts,
];
$est = call("/estimate", $input);
if ($est["model"] !== "gpt-5.6-terra" || $est["model_alias"] !== "gpt-terra") {
throw new RuntimeException("unexpected model " . $est["model"]);
}
if ($est["markup_bps"] !== 1000) {
throw new RuntimeException("unexpected markup " . $est["markup_bps"]);
}
echo $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
foreach (["bulkify", "security", "soql", "refactor", "testgen"] as $lane) {
$e = call("/estimate", array_merge($input, ["task" => $lane]));
echo $lane, " ", $e["hold_credits"], PHP_EOL;
}
var input = new {
task = "bulkify",
apex = apexSource,
context = "Production org, about 40k Accounts.",
batch_size = 200,
org_type = "production",
api_version = "62.0",
prescan_facts = facts
};
var est = await Call("/estimate", input);
if (est.GetProperty("model").GetString() != "gpt-5.6-terra") throw new Exception("model");
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("alias");
if (est.GetProperty("markup_bps").GetInt32() != 1000) throw new Exception("markup");
Console.WriteLine(est.GetProperty("hold_credits"));
// The hold is per lane, so price the lane you are about to run.
foreach (var lane in new[] { "bulkify", "security", "soql", "refactor", "testgen" }) {
var e = await Call("/estimate", new {
task = lane, apex = apexSource, context = input.context,
batch_size = 200, org_type = "production", api_version = "62.0",
prescan_facts = facts
});
Console.WriteLine($"{lane} {e.GetProperty("hold_credits")}");
}
5. Run it, then poll the job
POST /run takes the input object as the body and returns
{"job_id": "job_…", "status": "queued"} immediately. Poll
GET /jobs/{job_id} until status is terminal — succeeded,
failed or cancelled — no faster than once a second, and back off on a
429. The review is the string at job.output.output; step 7 parses it.
Always send an Idempotency-Key, and build it from the lane id plus a hash of
the input. The lane belongs in the key because this app is five reviews over one paste and
they are five distinct runs: a key that hashes only the source will make your security
run return the bulkify job you already paid for, and it will look like it worked. The
hash belongs in it because the same lane over an edited paste is a new run — and editing between
lanes is exactly what this app encourages. An attempt counter belongs in it because a deliberate
re-run of an identical input is a second answer you are choosing to pay for.
Idempotency-Key: apex-desk:<lane>:<12 hex of sha256(apex + context + batch_size + org_type)>:a<attempt>
Idempotency-Key: apex-desk:bulkify:9f3c1a77b204:a1
Idempotency-Key: apex-desk:security:9f3c1a77b204:a1 same paste, different lane, different job
A retry must reuse the same key. A network timeout, a dropped connection, a
500 — none of those tell you whether the job was created. Replaying with the same key
returns the original job rather than starting a second one. Minting a fresh key on retry is how you
get billed twice for one review, and nothing downstream will tell you: you will simply have two jobs
and one answer you wanted.
Leave prescan_facts out of the hash. It is derived from apex, so hashing
it adds nothing — and if you improve your parser between attempts you want the retry to land on the
original job rather than quietly starting a second billable one. Do include
batch_size and org_type: both change the answer.
LANE="bulkify"
# The key: the app, the lane, a hash of the input, the attempt number.
DIGEST=$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-12)
KEY="apex-desk:$LANE:$DIGEST:a1"
JOB=$(curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY" | jq -r '.data.job_id')
# -> {"ok":true,"data":{"job_id":"job_7Kq2","status":"queued"}}
# Poll to a terminal state. Same key on any retry of the POST above, or you pay twice.
while :; do
OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
-H "Authorization: Bearer $TOKEN")
STATUS=$(printf '%s' "$OUT" | jq -r '.data.status')
case "$STATUS" in succeeded|failed|cancelled) break ;; esac
sleep 2
done
printf '%s' "$OUT" | jq -r '.data.output.output' | jq '.verdict, .headline'
import hashlib, json, time
def idem_key(inp, attempt=1):
"""lane + a hash of the input -> one stable key. Reuse it on every retry."""
seed = "\x00".join([
inp["apex"],
inp.get("context") or "",
str(inp.get("batch_size") or 200),
inp.get("org_type") or "",
])
digest = hashlib.sha256(seed.encode()).hexdigest()[:12]
return f"apex-desk:{inp['task']}:{digest}:a{attempt}"
key = idem_key(inp) # the lane is IN the key
job = call("/run", inp, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed", "cancelled"):
time.sleep(2)
job = call("/jobs/" + job["job_id"])
if job["status"] != "succeeded":
raise SystemExit(job.get("error") or job["status"])
result = json.loads(job["output"]["output"]) # step 7 does this properly
print(result["lane"], result["verdict"], job.get("charged_credits"))
import { createHash } from "node:crypto";
// lane + a hash of the input -> one stable key. Reuse it on every retry.
function idemKey(input, attempt = 1) {
const digest = createHash("sha256")
.update([
input.apex,
input.context || "",
String(input.batch_size || 200),
input.org_type || ""
].join("\u0000"))
.digest("hex")
.slice(0, 12);
return `apex-desk:${input.task}:${digest}:a${attempt}`;
}
const key = idemKey(input); // the lane is IN the key
let job = await call("/run", input, { headers: { "Idempotency-Key": key } });
while (!["succeeded", "failed", "cancelled"].includes(job.status)) {
await new Promise(r => setTimeout(r, 2000));
job = await call(`/jobs/${job.job_id}`);
}
if (job.status !== "succeeded") throw new Error(job.error || job.status);
const result = JSON.parse(job.output.output);
console.log(result.lane, result.verdict, job.charged_credits);
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
// lane + a hash of the input -> one stable key. Reuse it on every retry.
func idemKey(lane, apex, context string, batch int, org string, attempt int) string {
seed := strings.Join([]string{apex, context, strconv.Itoa(batch), org}, "\x00")
sum := sha256.Sum256([]byte(seed))
return fmt.Sprintf("apex-desk:%s:%s:a%d", lane, hex.EncodeToString(sum[:])[:12], attempt)
}
key := idemKey("bulkify", apexSource, contextText, 200, "production", 1)
job, err := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
for {
status, _ := job["status"].(string)
if status == "succeeded" || status == "failed" || status == "cancelled" {
break
}
time.Sleep(2 * time.Second)
job, _ = call("GET", "/jobs/"+job["job_id"].(string), nil, nil)
}
// job["output"].(map[string]any)["output"].(string) is the review, as a JSON string
import java.security.MessageDigest;
// lane + a hash of the input -> one stable key. Reuse it on every retry.
static String idemKey(String lane, String apex, String context,
int batch, String org, int attempt) throws Exception {
String seed = apex + "\0" + context + "\0" + batch + "\0" + org;
byte[] d = MessageDigest.getInstance("SHA-256").digest(seed.getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 6; i++) hex.append(String.format("%02x", d[i]));
return "apex-desk:" + lane + ":" + hex + ":a" + attempt;
}
String key = idemKey("bulkify", apexSource, contextText, 200, "production", 1);
String job = call("POST", "/run", body, Map.of("Idempotency-Key", key));
// Then poll GET /jobs/{job_id} on the same helper until status is terminal, and
// resend /run with THIS key — never a fresh one — if the POST itself failed.
require "digest"
# lane + a hash of the input -> one stable key. Reuse it on every retry.
def idem_key(input, attempt = 1)
seed = [input["apex"], input["context"].to_s,
(input["batch_size"] || 200).to_s, input["org_type"].to_s].join("\0")
"apex-desk:#{input['task']}:#{Digest::SHA256.hexdigest(seed)[0, 12]}:a#{attempt}"
end
key = idem_key(input) # the lane is IN the key
job = call("/run", input, headers: { "Idempotency-Key" => key })
until %w[succeeded failed cancelled].include?(job["status"])
sleep 2
job = call("/jobs/#{job['job_id']}")
end
abort(job["error"].to_s) unless job["status"] == "succeeded"
result = JSON.parse(job.dig("output", "output"))
puts result["lane"], result["verdict"], job["charged_credits"]
<?php
// lane + a hash of the input -> one stable key. Reuse it on every retry.
function idem_key(array $input, int $attempt = 1): string {
$seed = implode("\0", [
$input["apex"],
$input["context"] ?? "",
(string) ($input["batch_size"] ?? 200),
$input["org_type"] ?? "",
]);
$digest = substr(hash("sha256", $seed), 0, 12);
return "apex-desk:{$input['task']}:{$digest}:a{$attempt}";
}
$key = idem_key($input); // the lane is IN the key
$job = call("/run", $input, ["Idempotency-Key: $key"]);
while (!in_array($job["status"], ["succeeded", "failed", "cancelled"], true)) {
sleep(2);
$job = call("/jobs/" . $job["job_id"]);
}
if ($job["status"] !== "succeeded") {
throw new RuntimeException($job["error"] ?? $job["status"]);
}
$result = json_decode($job["output"]["output"], true);
echo $result["lane"], " ", $result["verdict"], PHP_EOL;
using System.Security.Cryptography;
using System.Text;
// lane + a hash of the input -> one stable key. Reuse it on every retry.
static string IdemKey(string lane, string apex, string context,
int batch, string org, int attempt = 1) {
var seed = string.Join("\0", new[] { apex, context, batch.ToString(), org });
var d = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"apex-desk:{lane}:{Convert.ToHexString(d)[..12].ToLowerInvariant()}:a{attempt}";
}
var key = IdemKey("bulkify", apexSource, contextText, 200, "production");
var job = await Call("/run", input, ("Idempotency-Key", key));
var jobId = job.GetProperty("job_id").GetString();
string status;
do {
await Task.Delay(2000);
job = await Call($"/jobs/{jobId}");
status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed" or "cancelled"));
var result = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
6. Or stream it
POST /run-stream is the same call, the same body and the same
Idempotency-Key, delivered as server-sent events. Each line of interest starts with
data: and carries one JSON event with a type:
job— arrives first, as soon as the job exists, carryingjob_id. Keep it: if the stream dies you can fall back to pollingGET /jobs/{job_id}for the same run rather than paying for a second one.delta— a text fragment of the reply. Appendevent.textto a buffer. Fragments are not JSON on their own and are not line-aligned; never try to parse one.job, terminal — the settled job at the end of the stream, withstatus,output,charged_creditsandtruncated. This is the authoritative record; the concatenated deltas are only a preview of it.
Streaming earns its keep here because of where the long tail sits in this contract.
lane, verdict, headline and exec_summary land in
the first few hundred characters, findings and metrics fill in next, and
then the expensive part arrives last: on refactor and testgen that is
artifact.body, a complete source file, and it is usually most of the wait. A caller
that renders the verdict and the findings as they arrive gives a reviewer something to read a long
way before the run settles. If the stream dies mid-flight, keep what arrived — step 7 closes a
truncated buffer rather than throwing the run away.
curl -s -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# data: {"type":"job","job_id":"job_7Kq2","status":"running"}
# data: {"type":"delta","text":"{\"lane\":\"bulkify\","}
# data: {"type":"delta","text":"\"unit_title\":\"AccountTrigger + AccountService\","}
# data: {"type":"delta","text":"\"verdict\":\"blocks-deploy\","}
# ...
# data: {"type":"job","job_id":"job_7Kq2","status":"succeeded","charged_credits":3120,
# "truncated":false,"output":{"output":"{...the whole review...}"}}
# Just the deltas, reassembled:
curl -s -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$BODY" \
| grep '^data: ' | sed 's/^data: //' \
| jq -j 'select(.type=="delta") | .text'
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(inp).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key) # the same key as /run
buf, job = "", None
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
evt = json.loads(line[5:])
if evt.get("type") == "delta":
buf += evt["text"] # a preview, not parseable yet
elif evt.get("type") == "job":
job = evt # first one has the id, last one settles
# Prefer the terminal job; fall back to the buffer if the stream died.
raw_result = job["output"]["output"] if job and job.get("output") else buf
print(job.get("charged_credits"), job.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key }, // the same key as /run
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let pending = "", buf = "", job = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop(); // keep the partial line
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5));
if (evt.type === "delta") buf += evt.text;
else if (evt.type === "job") job = evt; // id first, settled job last
}
}
const rawResult = job?.output?.output ?? buf;
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // the same key as /run
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var buf strings.Builder
var jobID string
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) // artifact bodies are large
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var evt struct {
Type string `json:"type"`
Text string `json:"text"`
JobID string `json:"job_id"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &evt)
switch evt.Type {
case "delta":
buf.WriteString(evt.Text)
case "job":
jobID = evt.JobID // keep it: a dead stream can be recovered by polling
}
}
HttpResponse<Stream<String>> res = HTTP.send(
HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key) // the same key as /run
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
res.body()
.filter(l -> l.startsWith("data:"))
.map(l -> l.substring(5))
.forEach(payload -> {
// parse payload with your JSON library:
// type "delta" -> buf.append(text)
// type "job" -> remember job_id; the terminal job settles the run
if (payload.contains("\"type\":\"delta\"")) buf.append(textOf(payload));
});
uri = URI(BASE + "/run-stream")
buf = +""
job = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key # the same key as /run
req.body = JSON.dump(input)
pending = +""
http.request(req) do |res|
res.read_body do |chunk|
pending << chunk
while (nl = pending.index("\n"))
line = pending.slice!(0, nl + 1).strip
next unless line.start_with?("data:")
evt = JSON.parse(line[5..])
buf << evt["text"] if evt["type"] == "delta"
job = evt if evt["type"] == "job"
end
end
end
end
raw_result = job && job["output"] ? job.dig("output", "output") : buf
<?php
$buf = "";
$pending = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: $key", // the same key as /run
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$pending) {
$pending .= $chunk;
while (($nl = strpos($pending, "\n")) !== false) {
$line = trim(substr($pending, 0, $nl));
$pending = substr($pending, $nl + 1);
if (strncmp($line, "data:", 5) !== 0) continue;
$evt = json_decode(substr($line, 5), true);
if (($evt["type"] ?? "") === "delta") $buf .= $evt["text"];
// ($evt["type"] ?? "") === "job" -> the id first, the settled job last
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(input)
};
msg.Headers.Add("Idempotency-Key", key); // the same key as /run
using var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
JsonElement? settled = null;
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var evt = JsonDocument.Parse(line[5..]).RootElement;
var type = evt.GetProperty("type").GetString();
if (type == "delta") buf.Append(evt.GetProperty("text").GetString());
else if (type == "job") settled = evt; // id first, settled job last
}
7. Parse the reply
output.output is a JSON string, not an object. The job
envelope carries the model's reply as text, and the review lives one JSON.parse deeper
than people expect. That single fact accounts for most first-integration confusion here — a caller
reads job.output.lane, gets undefined, and concludes the run failed.
Five moves, in this order, whichever route you took:
- Strip the fence before you parse. The model is instructed to emit one object and
nothing else — first character
{, last character}— and it normally does. Be tolerant anyway: slice from the first{to the last}, which removes a stray```jsonopener, a trailing```, and any leading newline in one move. The app's own client does exactly this, and it is the difference between a rare cosmetic slip and an outage. - Route on
lane, not on thetaskyou sent. An unrecognised task is answered by the nearest lane, andlanesays which. A mismatch is not an error, but it is worth logging: it means your task id is not one this app has. - Read
lane_detailagainst that lane's shape. There is one block and it belongs tolane—budget/hot_paths/bulk_safeforbulkify,access_matrix/sharing/injectionforsecurity,queriesforsoql,changes/structureforrefactor,test_plan/coveragefortestgen. Do not probe for keys. - Reconcile
coverage_checkagainst the flags you sent. EveryPS-nnninprescan_facts.flagsappears exactly once. A missing id, a duplicate or an id you never sent are each grounds to distrust the reply rather than render it. - Handle a truncated reply. When the job carries
"truncated": true, or when a stream died, close the buffer at the last complete structure and render what parsed.verdict,headlineand the first findings arrive early and are worth showing; throwing the whole run away becauseartifact.bodywas cut mid-class wastes a credit you have already spent. Do not repair truncated JSON by appending closing braces — that produces something that parses and is not what the model meant.
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_7Kq2" \
-H "Authorization: Bearer $TOKEN")
# .data.output.output is a STRING of JSON. `fromjson` is the second parse.
REVIEW=$(printf '%s' "$JOB" | jq -r '.data.output.output' \
| sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
printf '%s' "$REVIEW" | jq '{lane, verdict, headline, unit_title}'
printf '%s' "$REVIEW" | jq '.findings[] | {id, severity, rule, location}'
printf '%s' "$REVIEW" | jq '.coverage_check[] | select(.status=="confirmed") | .flag_id'
# Gate a build on it:
test "$(printf '%s' "$REVIEW" | jq -r '.verdict')" != "blocks-deploy"
# Write the artifact out when the lane produced one:
printf '%s' "$REVIEW" | jq -r 'select(.artifact.kind != "none") | .artifact.body' \
> "$(printf '%s' "$REVIEW" | jq -r '.artifact.filename')"
import json
def parse_review(raw):
"""output.output is a JSON string. Strip any fence, then parse."""
text = (raw or "").strip()
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
raise ValueError("no JSON object in the reply")
return json.loads(text[start:end + 1])
review = parse_review(job["output"]["output"])
# 2. Route on the lane that ran, not the one you asked for.
if review["lane"] != inp["task"]:
print(f"note: asked for {inp['task']}, got {review['lane']}")
# 3. One block, named by the lane.
detail = review["lane_detail"]
if review["lane"] == "bulkify":
print("bulk safe:", detail["bulk_safe"])
for row in detail["budget"]:
print(row["consumer"], row["projected"], "/", row["limit"], row["status"])
# 4. Every flag reconciled exactly once.
sent = {f["id"] for f in (facts or {}).get("flags", [])}
back = [c["flag_id"] for c in review["coverage_check"]]
assert sorted(back) == sorted(sent), f"coverage mismatch: {sent ^ set(back)}"
assert len(back) == len(set(back)), "a flag was reconciled twice"
# 5. Truncation is a prefix, not a failure.
if job.get("truncated"):
print("partial review — rendered what parsed")
if review["artifact"]["kind"] != "none":
with open(review["artifact"]["filename"], "w") as fh:
fh.write(review["artifact"]["body"])
function parseReview(raw) {
// output.output is a JSON string. Strip any fence, then parse.
const text = String(raw || "").trim();
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end <= start) throw new Error("no JSON object in the reply");
return JSON.parse(text.slice(start, end + 1));
}
const review = parseReview(job.output.output);
// 2. Route on the lane that ran.
if (review.lane !== input.task) console.warn(`asked ${input.task}, got ${review.lane}`);
// 3. One block, named by the lane.
const detail = review.lane_detail;
if (review.lane === "soql") {
for (const q of detail.queries) {
console.log(q.query_id, q.selectivity, q.keep_as_is ? "keep" : q.rewritten);
}
}
// 4. Every flag reconciled exactly once.
const sent = new Set((facts?.flags || []).map(f => f.id));
const back = review.coverage_check.map(c => c.flag_id);
if (back.length !== new Set(back).size) throw new Error("a flag was reconciled twice");
for (const id of sent) {
if (!back.includes(id)) throw new Error(`flag ${id} was never reconciled`);
}
// 5. Truncation is a prefix, not a failure.
if (job.truncated) console.warn("partial review — rendering what parsed");
if (review.artifact.kind !== "none") {
await writeFile(review.artifact.filename, review.artifact.body);
}
// output.output is a JSON string. Strip any fence, then unmarshal.
func parseReview(raw string) (map[string]any, error) {
text := strings.TrimSpace(raw)
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("no JSON object in the reply")
}
var out map[string]any
err := json.Unmarshal([]byte(text[start:end+1]), &out)
return out, err
}
raw := job["output"].(map[string]any)["output"].(string)
review, err := parseReview(raw)
if err != nil {
panic(err)
}
fmt.Println(review["lane"], review["verdict"])
// Every flag reconciled exactly once.
seen := map[string]int{}
for _, c := range review["coverage_check"].([]any) {
seen[c.(map[string]any)["flag_id"].(string)]++
}
for id, n := range seen {
if n != 1 {
panic("flag " + id + " reconciled " + strconv.Itoa(n) + " times")
}
}
// Write the artifact out when the lane produced one.
art := review["artifact"].(map[string]any)
if art["kind"] != "none" {
os.WriteFile(art["filename"].(string), []byte(art["body"].(string)), 0o644)
}
/** output.output is a JSON string. Strip any fence before parsing it. */
static String unfence(String raw) {
String text = raw == null ? "" : raw.trim();
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start < 0 || end <= start) throw new RuntimeException("no JSON object in the reply");
return text.substring(start, end + 1);
}
// job is the /jobs/{id} body; pull data.output.output out with your JSON library,
// pass it through unfence(), then parse THAT string into the review object.
String review = unfence(outputOutputOf(job));
// Then, on the parsed object:
// review.lane -> route on this, not on the task you sent
// review.verdict -> blocks-deploy | needs-work | ready
// review.lane_detail -> the block named by review.lane
// review.coverage_check -> one row per PS-nnn you sent, exactly once
// review.artifact -> kind "none" means there is no file to write
System.out.println(review.substring(0, Math.min(240, review.length())));
# output.output is a JSON string. Strip any fence, then parse.
def parse_review(raw)
text = raw.to_s.strip
start = text.index("{")
finish = text.rindex("}")
raise "no JSON object in the reply" if start.nil? || finish.nil? || finish <= start
JSON.parse(text[start..finish])
end
review = parse_review(job.dig("output", "output"))
warn "asked #{input['task']}, got #{review['lane']}" if review["lane"] != input["task"]
detail = review["lane_detail"]
if review["lane"] == "testgen"
detail["test_plan"].each { |t| puts "#{t['kind'].ljust(8)} #{t['method_name']}" }
puts "coverage ~#{detail.dig('coverage', 'estimated_percent')}%"
end
# Every flag reconciled exactly once.
back = review["coverage_check"].map { |c| c["flag_id"] }
raise "a flag was reconciled twice" unless back.uniq.size == back.size
(facts["flags"] || []).each do |f|
raise "flag #{f['id']} was never reconciled" unless back.include?(f["id"])
end
if review["artifact"]["kind"] != "none"
File.write(review["artifact"]["filename"], review["artifact"]["body"])
end
<?php
// output.output is a JSON string. Strip any fence, then parse.
function parse_review(?string $raw): array {
$text = trim((string) $raw);
$start = strpos($text, "{");
$end = strrpos($text, "}");
if ($start === false || $end === false || $end <= $start) {
throw new RuntimeException("no JSON object in the reply");
}
return json_decode(substr($text, $start, $end - $start + 1), true, 512,
JSON_THROW_ON_ERROR);
}
$review = parse_review($job["output"]["output"] ?? null);
if ($review["lane"] !== $input["task"]) {
fwrite(STDERR, "asked {$input['task']}, got {$review['lane']}\n");
}
// Every flag reconciled exactly once.
$back = array_column($review["coverage_check"], "flag_id");
if (count($back) !== count(array_unique($back))) {
throw new RuntimeException("a flag was reconciled twice");
}
foreach (($facts["flags"] ?? []) as $f) {
if (!in_array($f["id"], $back, true)) {
throw new RuntimeException("flag {$f['id']} was never reconciled");
}
}
if ($review["artifact"]["kind"] !== "none") {
file_put_contents($review["artifact"]["filename"], $review["artifact"]["body"]);
}
// output.output is a JSON string. Strip any fence, then parse.
static JsonElement ParseReview(string? raw) {
var text = (raw ?? "").Trim();
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start < 0 || end <= start) throw new Exception("no JSON object in the reply");
return JsonDocument.Parse(text[start..(end + 1)]).RootElement;
}
var review = ParseReview(
job.GetProperty("output").GetProperty("output").GetString());
Console.WriteLine($"{review.GetProperty("lane")} {review.GetProperty("verdict")}");
// Every flag reconciled exactly once.
var back = review.GetProperty("coverage_check").EnumerateArray()
.Select(c => c.GetProperty("flag_id").GetString()!).ToList();
if (back.Count != back.Distinct().Count()) throw new Exception("a flag was reconciled twice");
var art = review.GetProperty("artifact");
if (art.GetProperty("kind").GetString() != "none") {
await File.WriteAllTextAsync(art.GetProperty("filename").GetString()!,
art.GetProperty("body").GetString()!);
}
Five worked examples, one per lane
All five run over the same deployment unit — the bundled legacy example: a trigger that queries and
updates inside its own loop, a service class with a concatenated dynamic query, a hardcoded owner
Id, a callout in a loop and no test class. apex is elided in the middle for
readability; in a real call it is the whole source as one string with real newlines. The response
fragment under each request shows the part of the envelope that lane alone produces.
Lane 1 — bulkify
Request:
POST https://api.skillsafe.ai/v1/app-api/run
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Idempotency-Key: apex-desk:bulkify:9f3c1a77b204:a1
{"task":"bulkify",
"apex":"// file: AccountTrigger.trigger\ntrigger AccountTrigger on Account (before insert, after update) {\n for (Account a : Trigger.new) {\n List<Contact> kids = [SELECT Id, Email FROM Contact WHERE AccountId = :a.Id];\n for (Contact c : kids) { c.Description = 'Owner: ' + a.Name; update c; }\n }\n}\n…",
"context":"Production org, about 40k Accounts. A data load of 5,000 Accounts failed last week and we do not know which part gave up first.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":{ … the object from the section above … }}
Reply fragment — the budget projection and the hot paths:
{
"lane": "bulkify",
"verdict": "blocks-deploy",
"headline": "At a batch of 200 this trigger issues 202 queries against a limit of 100, and the DML in the inner loop fails long before that.",
"metrics": [
{"label": "SOQL queries per transaction", "value": "202", "limit": "100", "status": "breach",
"note": "Q1 runs once per Account in Trigger.new"},
{"label": "DML statements per transaction", "value": "40000", "limit": "150", "status": "breach",
"note": "the update sits in a loop nested inside the record loop"},
{"label": "Heap", "value": "unknown", "limit": "6 MB", "status": "unknown",
"note": "depends on how many Contacts each Account has, which is not knowable from source"}
],
"lane_detail": {
"budget": [
{"consumer": "SOQL queries", "outside_loops": 2, "inside_loops": 1,
"projected": 202, "limit": 100, "status": "breach",
"note": "Q1 in AccountTrigger.trigger:4 runs once per record"},
{"consumer": "DML statements", "outside_loops": 0, "inside_loops": 1,
"projected": 40000, "limit": 150, "status": "breach",
"note": "update c at line 7, inside a loop inside a loop"},
{"consumer": "CPU time", "outside_loops": null, "inside_loops": null,
"projected": null, "limit": 10000, "status": "unknown",
"note": "not computable from source"}
],
"hot_paths": [
{"path": "AccountTrigger -> for (Account a : Trigger.new) -> [SELECT ... FROM Contact]",
"why": "one query per record in the batch", "cost_at_batch": "200 queries"},
{"path": "AccountTrigger -> for (Account a) -> for (Contact c) -> update c",
"why": "one DML statement per child row", "cost_at_batch": "40,000 statements at 200 children each"}
],
"bulk_safe": false
},
"artifact": {"kind": "none", "filename": "", "language": "", "body": "",
"note": "the fixes are per-finding snippets; run the refactor lane for a whole file"}
}
Lane 2 — security
Request — same paste, one field changed:
{"task":"security",
"apex":"…the same source…",
"context":"This class is called from a Lightning web component that any authenticated community user can reach.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":{ … }}
Reply fragment — the access matrix, the sharing table and the injection vector:
{
"lane": "security",
"verdict": "blocks-deploy",
"headline": "An @AuraEnabled method builds SOQL by concatenation and the class declares no sharing, so a community user can read any Account in the org.",
"lane_detail": {
"access_matrix": [
{"sobject": "Account", "operation": "read", "enforced": "no",
"evidence": "Q2 in AccountService.cls:19 has no WITH SECURITY_ENFORCED and no isAccessible check precedes it",
"required": "yes - reached from an @AuraEnabled method"},
{"sobject": "Opportunity", "operation": "update", "enforced": "no",
"evidence": "closeStaleOpportunities updates records with no isUpdateable check",
"required": "yes - reached from an @AuraEnabled method"},
{"sobject": "Contact", "operation": "update", "enforced": "no",
"evidence": "the trigger updates Description with no FLS check",
"required": "no - trigger context, but FLS is still not enforced"}
],
"sharing": [
{"class_name": "AccountService", "declared": "none", "should_be": "with sharing",
"why": "entered directly from @AuraEnabled, so it runs in system context and ignores record access today"}
],
"injection": [
{"query_id": "Q2", "location": "AccountService.cls:19",
"vector": "the term parameter is concatenated into the query string, so a value of x%' OR Name LIKE '% returns every Account",
"fix": "Database.queryWithBinds('SELECT Id, Name FROM Account WHERE Name LIKE :term', new Map<String, Object>{'term' => '%' + term + '%'}, AccessLevel.USER_MODE)"}
]
},
"artifact": {"kind": "none", "filename": "", "language": "", "body": "",
"note": "corrected fragments are in each finding's snippet"}
}
Lane 3 — soql
Request:
{"task":"soql",
"apex":"…the same source…",
"context":"Opportunity has about 900k rows. AccountId is a lookup, StageName is a picklist and is not indexed.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":{ … }}
Reply fragment — one row per query, including the one that is fine:
{
"lane": "soql",
"verdict": "needs-work",
"headline": "Two of the three queries cannot use an index; the third is fine and should be left alone.",
"lane_detail": {
"queries": [
{"query_id": "Q1", "location": "AccountTrigger.trigger:4", "object": "Contact",
"selectivity": "selective",
"problem": "The filter itself is fine — AccountId is an indexed lookup. The defect is where it sits, not what it says.",
"rewritten": "SELECT Id, Email FROM Contact WHERE AccountId IN :accountIds WITH SECURITY_ENFORCED",
"expected_effect": "one query for the batch once it is hoisted out of the loop",
"keep_as_is": false},
{"query_id": "Q2", "location": "AccountService.cls:19", "object": "Account",
"selectivity": "risky",
"problem": "LIKE with a leading wildcard cannot use the Name index, and the string is assembled by concatenation.",
"rewritten": "SELECT Id, Name, Industry, Phone FROM Account WHERE Name LIKE :pattern WITH USER_MODE LIMIT 200",
"expected_effect": "bound and injection-proof; the leading wildcard still forces a scan, so cap it with LIMIT",
"keep_as_is": false},
{"query_id": "Q3", "location": "AccountService.cls:25", "object": "Opportunity",
"selectivity": "unbounded",
"problem": "No WHERE and no LIMIT against a 900k-row object; this exceeds the 50,000-row limit on its own.",
"rewritten": "SELECT Id, Name, StageName, CloseDate FROM Opportunity WHERE CloseDate < TODAY AND StageName != 'Closed Won' AND AccountId IN :accountIds WITH SECURITY_ENFORCED LIMIT 10000",
"expected_effect": "bounded by the batch and by CloseDate, which is a standard indexed field",
"keep_as_is": false}
]
},
"artifact": {
"kind": "soql", "filename": "rewritten-queries.soql", "language": "soql",
"body": "-- Q1 AccountTrigger.trigger:4\nSELECT Id, Email FROM Contact WHERE AccountId IN :accountIds WITH SECURITY_ENFORCED\n-- Q2 AccountService.cls:19\nSELECT Id, Name, Industry, Phone FROM Account WHERE Name LIKE :pattern WITH USER_MODE LIMIT 200\n-- Q3 AccountService.cls:25\nSELECT Id, Name, StageName, CloseDate FROM Opportunity WHERE CloseDate < TODAY AND StageName != 'Closed Won' AND AccountId IN :accountIds WITH SECURITY_ENFORCED LIMIT 10000",
"note": "the bind variables accountIds and pattern must exist in the calling scope; Q3 assumes you only care about the batch's accounts"
}
}
Lane 4 — refactor
Request, this time with a handoff from the bulkify run above:
{"task":"refactor",
"apex":"…the same source…",
"context":"We can add classes but we cannot change the trigger's name or its events; a managed package references AccountTrigger by name.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":{ … },
"handoff":{"from":"bulkify",
"findings":[{"id":"AD-001","severity":"critical","rule":"soql-in-loop",
"location":"AccountTrigger.trigger:4",
"problem":"Q1 runs once per record; 202 queries against a limit of 100."},
{"id":"AD-002","severity":"critical","rule":"dml-in-loop",
"location":"AccountTrigger.trigger:7",
"problem":"update inside a nested loop; 40,000 statements at a batch of 200."}]}}
Reply fragment — the change list and the file structure:
{
"lane": "refactor",
"verdict": "needs-work",
"headline": "The trigger becomes three lines that delegate; the query and the DML move out of the loop into one bulk pass in a new handler.",
"assumptions": ["AD-001 and AD-002 from the bulkify lane are taken as established and are the changes C1 and C2 below."],
"lane_detail": {
"changes": [
{"change_id": "C1", "area": "AccountTriggerHandler.updateContactDescriptions",
"before": "for (Account a : Trigger.new) { List<Contact> kids = [SELECT Id, Email FROM Contact WHERE AccountId = :a.Id]; ... }",
"after": "Map<Id, Account> byId = new Map<Id, Account>(accounts);\nList<Contact> kids = [SELECT Id, Email, AccountId FROM Contact WHERE AccountId IN :byId.keySet() WITH SECURITY_ENFORCED];",
"why": "one query for the whole batch instead of one per record",
"behaviour_preserved": true},
{"change_id": "C2", "area": "AccountTriggerHandler.updateContactDescriptions",
"before": "update c; // inside the inner loop",
"after": "if (!toUpdate.isEmpty()) { update toUpdate; }",
"why": "one DML statement for the collected list instead of one per child row",
"behaviour_preserved": true},
{"change_id": "C3", "area": "AccountTriggerHandler class declaration",
"before": "public class AccountService",
"after": "public with sharing class AccountTriggerHandler",
"why": "the class is reachable from an @AuraEnabled entry point and ran in system context",
"behaviour_preserved": false},
{"change_id": "C4", "area": "AccountTriggerHandler.assignBankingOwner",
"before": "a.OwnerId = '005xx0000012Q9zAAE';",
"after": "a.OwnerId = bankingOwnerId(); // reads a Custom Metadata record",
"why": "the hardcoded Id does not exist in any other org",
"behaviour_preserved": false}
],
"structure": [
{"file": "AccountTrigger.trigger", "role": "delegates to the handler and does nothing else"},
{"file": "AccountTriggerHandler.cls", "role": "context routing and bulk-safe logic"},
{"file": "AccountService.cls", "role": "the @AuraEnabled surface, now with binds and USER_MODE"}
]
},
"artifact": {
"kind": "apex", "filename": "AccountTriggerHandler.cls", "language": "apex",
"body": "// file: AccountTrigger.trigger\ntrigger AccountTrigger on Account (before insert, after update) {\n AccountTriggerHandler.handle(Trigger.operationType, Trigger.new, Trigger.oldMap);\n}\n\n// file: AccountTriggerHandler.cls\npublic with sharing class AccountTriggerHandler {\n …the complete rewritten source, every file that changed…\n}",
"note": "C3 and C4 change behaviour: with sharing will now hide records the old code returned, and bankingOwnerId() needs a Custom Metadata type you must create"
}
}
Lane 5 — testgen
Request:
{"task":"testgen",
"apex":"…the same source…",
"context":"Deployment is blocked at 61% coverage. The Account object has a required custom field Region__c with no default.",
"batch_size":200,
"org_type":"production",
"api_version":"62.0",
"prescan_facts":{ … }}
Reply fragment — the PNB plan and the coverage estimate:
{
"lane": "testgen",
"verdict": "needs-work",
"headline": "Six methods across positive, negative and bulk; the bulk one runs exactly 200 Accounts and is the one that will fail against the current trigger.",
"metrics": [
{"label": "SeeAllData", "value": "false", "limit": "false", "status": "ok",
"note": "all data is created in @testSetup"},
{"label": "Bulk method size", "value": "200", "limit": "200", "status": "ok",
"note": "exercises a full trigger batch"},
{"label": "Assertions", "value": "14", "limit": "unknown", "status": "ok",
"note": "every assertion carries a message; none is a bare System.assert(true)"}
],
"lane_detail": {
"test_plan": [
{"scenario": "an Account with child Contacts gets their Description stamped",
"kind": "positive", "method_name": "stampsDescriptionOnChildContacts",
"setup": "one Account with Region__c and two Contacts in @testSetup",
"asserts": ["both Contacts have Description starting 'Owner: '",
"no other Contact was touched"]},
{"scenario": "a Banking Account gets the configured owner",
"kind": "positive", "method_name": "assignsBankingOwner",
"setup": "one Account with Industry = 'Banking'",
"asserts": ["OwnerId equals the configured owner", "a non-Banking Account keeps its owner"]},
{"scenario": "the search method rejects an injection attempt",
"kind": "negative", "method_name": "searchRejectsInjection",
"setup": "two Accounts, one named 'Visible' and one named 'Hidden'",
"asserts": ["searching \"x%' OR Name LIKE '%\" returns zero rows",
"a QueryException or an empty list, never the whole table"]},
{"scenario": "a missing Region__c raises a DmlException",
"kind": "negative", "method_name": "requiresRegionOnInsert",
"setup": "an Account with no Region__c",
"asserts": ["DmlException is thrown", "its message names Region__c"]},
{"scenario": "200 Accounts in one batch stay inside the governor limits",
"kind": "bulk", "method_name": "handlesTwoHundredAccounts",
"setup": "200 Accounts, each with one Contact, inserted inside Test.startTest()",
"asserts": ["Limits.getQueries() is below 100", "Limits.getDmlStatements() is below 150",
"all 200 Contacts were stamped"]},
{"scenario": "a community user sees only their own Accounts",
"kind": "negative", "method_name": "communityUserSeesOwnAccountsOnly",
"setup": "System.runAs a minimum-access user with one owned Account",
"asserts": ["search returns exactly one row"]}
],
"coverage": {
"estimated_percent": "78-88",
"depends_on": "whether the Banking branch is reachable given the org's validation rules on Industry",
"uncovered": ["the empty catch in notifyOwners(), which needs Test.setMock and a forced CalloutException",
"the after update path, which the plan exercises only through the bulk method"]
}
},
"artifact": {
"kind": "test", "filename": "AccountTriggerHandlerTest.cls", "language": "apex",
"body": "@isTest\nprivate class AccountTriggerHandlerTest {\n @testSetup\n static void makeData() { … }\n …the complete, compilable class, one method per plan entry…\n}",
"note": "handlesTwoHundredAccounts will FAIL against the trigger as pasted — that is the point of it; it passes once the refactor lane's changes land"
}
}
Note what the last one is doing. The bulk test is written to fail against the current code, and the
artifact.note says so plainly rather than quietly producing a test that passes and
proves nothing. That is the same instinct as coverage_check reporting a
cleared flag with a reason: the contract is built so that an honest negative is
expressible, because a review where everything comes back green is not a review.
Rate limits and cost
30 requests per minute per IP on the app API, across every route. That is generous
for anything except a poll loop with no sleep in it, which is the only way most callers ever meet a
429. A run takes tens of seconds; poll once every one to two seconds, widen the gap on
each 429, and you will never see one again.
hold_credits is a reservation, not a price. It is reserved at submit
and sized for the worst case of the lane's output cap. charged_credits on the
settled job is the real cost, and it is usually far lower — often a fraction of the hold on
the three review lanes, where the reply is a page of findings rather than a file. The difference is
released as soon as the job settles. Budget against the hold so a run is never rejected in flight;
report and invoice against charged_credits.
Truncation
When the balance sits between min_credits and hold_credits,
the run is not refused. It executes with a reduced output cap and comes back with
"truncated": true on the settled job and on the terminal stream event. What you hold
then is a prefix of the review, not the review: verdict,
headline, exec_summary and the first findings are typically complete,
while coverage_check, next_steps, summary and — most
expensively — artifact.body may be missing or cut mid-string.
Check the flag before treating a review as complete, and be specific about what a truncated one is
good for. A truncated bulkify or security reply is often perfectly usable:
the findings that matter arrive first. A truncated refactor or testgen
reply usually is not, because the artifact is the deliverable and half a class is worse than none —
it compiles in the reader's head and not in the org. The right response is a retry after a top-up,
with the attempt suffix on the Idempotency-Key incremented so the new body is not read
as a replay of the old key. Never repair truncated JSON by appending closing braces.
Good manners, in one list
- Send the input object as the body. Not
{"input": {…}}, and never anX-App-Slugheader — the token carries the app. - Call
/estimatebefore every run, on the lane you are about to run, and compare it against/me. It is free and creates no job. Every lane has its own hold, andrefactorandtestgenhold several times whatbulkifydoes. - Put the lane id in the
Idempotency-Key. Five lanes over one paste are five runs; a key that hashes only the source will hand you the wrong lane's job and look like a success. - Reuse the key on every retry of the same
(lane, input, attempt). A retry with the same key returns the original job rather than billing twice. - Poll no faster than once a second, and back off on
429. - Send
prescan_factswhen you have it, and check that everyPS-nnnyou sent came back exactly once incoverage_check. - Separate files in
apexwith// file: Name.clsmarkers, clip on line boundaries, and mark any cut in-band. - Send
batch_sizeas a number andprescan_factsas an object. Both are commonVALIDATION_ERRORcauses and both are free to get right. - Route on
lane, validateverdictagainstblocks-deploy/needs-work/ready, and validateseverityagainstcritical/high/medium/lowbefore rendering anything. - Read
open_questionsandartifact.notebefore acting on a review. That is where the reviewer says what it could not see. - Guests can
/meand/estimatebut not/run.sponsor_enabledisfalsehere, so a guest run is a403. - Never claim the app ran anything. It cannot deploy, execute anonymous Apex, run a test, query an org or read a debug log — every statement in a review is derived from the text you pasted.
- Never put a token in client-side source or a repository. Read it from the environment.
Attribution
Derived from the agent skills @github/salesforce-apex-quality,
@forcedotcom/querying-soql, @forcedotcom/generating-apex,
@forcedotcom/generating-apex-test and
@forcedotcom/trigger-refactor-pipeline. Not affiliated with Salesforce, GitHub or the
authors of those skills.
Salesforce, Apex, SOQL and Lightning are trademarks of Salesforce, Inc. Governor limits change; the reviewer is instructed to say "check the current Apex Developer Guide limit" rather than guess a number it is unsure of, and you should treat any limit quoted in a reply the same way.