← Apex Desk / API
Tokens

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.

taskWhat it doesartifact.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.

CodeHTTPWhat causes it hereWhat to do
VALIDATION_ERROR400 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.
UNAUTHORIZED401 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.
FORBIDDEN403 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_FOUND404 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_REQUIRED402 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_LIMITED429 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.
INTERNAL500 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": []}
}
FieldTypeRequiredNotes
taskstringyes One of bulkify, security, soql, refactor, testgen. An unrecognised value routes to the nearest lane rather than failing.
apexstringyes The pasted Apex source, as one string with real newlines. Multiple files are separated by // file: Name.cls marker lines.
contextstringno 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_sizeintegerno 1–200, default 200. The batch every projection in bulkify is computed at. Send a number, not a string.
org_typestringno production, sandbox, scratch or managed-package. Changes what the review demands of you — see below.
api_versionstringno The API version of the metadata, e.g. "62.0". Omit it and the reviewer says so rather than assuming a version.
prescan_factsobjectno, 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.
handoffobjectno {"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:

KeyWhat it carries
titleA human name for the unit, derived from the trigger or the primary class.
filesThe file paths found from the // file: markers.
api_versionThe version read from the paste, or "unknown".
mode"synchronous" or "asynchronous" — which limit table applies.
batch_sizeThe 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.
calloutsHow many HTTP callouts were found.
async[]The distinct async mechanisms present: future, queueable, batch, schedulable.
recursion_guardBoolean: 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.

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"
}
FieldTypeWhat it holds
lanestringThe lane that actually ran. Route on this, not on the task you sent.
unit_titlestringA human name for the deployment unit, usually the trigger plus its handler.
verdictenumblocks-deploy, needs-work or ready. Nothing else is legal.
headlinestringOne sentence, written to be pasted into a pull request comment.
exec_summarystringTwo to four sentences. When the lane was chosen for you, the choice is named in the first one.
factsobjecttriggers, classes, soql, dml (integers), api_version, sharing, mode (strings). What the reviewer believes it read — compare it against your prescan.
findings[]arraySequential AD-001, AD-002, … most severe first, each with severity, rule, location, problem, impact, fix, snippet.
metrics[]arrayThree to eight rows: label, value, limit, status, note.
lane_detailobjectThe lane's own block. Shape is decided by lane — see below.
artifactobjectkind, filename, language, body, note. A file you can write to disk, or kind: "none".
coverage_check[]arrayOne 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.
summarystringTwo 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

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:

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.

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'
}

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}}

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:

It also returns hold_credits, min_credits and sponsor_enabledfalse 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}}

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'

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:

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'

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:

  1. 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 ```json opener, 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.
  2. Route on lane, not on the task you sent. An unrecognised task is answered by the nearest lane, and lane says which. A mismatch is not an error, but it is worth logging: it means your task id is not one this app has.
  3. Read lane_detail against that lane's shape. There is one block and it belongs to lanebudget/hot_paths/bulk_safe for bulkify, access_matrix/sharing/injection for security, queries for soql, changes/structure for refactor, test_plan/coverage for testgen. Do not probe for keys.
  4. Reconcile coverage_check against the flags you sent. Every PS-nnn in prescan_facts.flags appears exactly once. A missing id, a duplicate or an id you never sent are each grounds to distrust the reply rather than render it.
  5. 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, headline and the first findings arrive early and are worth showing; throwing the whole run away because artifact.body was 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')"

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

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.