Drive RevOps Desk from your own code
The page does its free work in the browser: it reads a pasted lead process and its context, names the lifecycle
stages and the MQL definition, reads every scoring rule with its points (fit, engagement, negative) and the threshold,
the routing method and fallback owner, the SLA and the speed-to-lead, computes lead-to-MQL, MQL-to-SQL,
SQL-to-opportunity and win rate against the revops skill's benchmarks, finds the six areas (lifecycle, scoring,
routing, handoff, pipeline, data) and raises flags RO-01..RO-25. Its two paid lanes are available over HTTP. You send
one JSON object with task set to "audit" or "spec", and the model replies with
one JSON object. The easiest way to build the facts string is the page itself:
RevKit.buildInput(lane, RevKit.analyze(process, {context, motion, question}), {auditHandoff}) in
revopskit.js, which also runs under Node.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what the model does |
|---|---|
"audit" | The lead process audit: the process (motion, CRM, a one-line read); the six areas (lifecycle, scoring, routing, handoff, pipeline, data) each good, weak, bad or unknown with a finding, quotes and a fix; the funnel (the page's four rates, the speed-to-lead and the weakest stage); the issues as finding, impact and fix with the page's flag ids; 3 to 6 fixes; a verdict sound, fix_first or rebuild. |
"spec" | The RevOps spec to implement: lifecycle stages with entry, exit and owner; an mql_definition needing fit and engagement; scoring (fit, engagement with decay, negative, threshold, weighting, recalibration); routing (method, ordered rules, fallback, capacity); sla rows with escalations; recycling reason codes; pipeline stages with exit criteria and required fields; metrics; automations. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | audit or spec |
process | required | The lead process as pasted, or what the business has today when nothing is set up. Past 6,000 characters the page cuts the middle on line boundaries and leaves a [revops-desk: ...] marker. |
facts | required | A JSON string: motion, motion_basis, planned, crm, acv, cycle_days, stages, mql_definition, mql_any_download, areas (lifecycle, scoring, routing, handoff, pipeline, data: found, evidence, note), found, scoring, routing, handoff, pipeline, data, counts, metrics, bench, weakest, flags, notes. It is RevKit.factsOf(A). |
context | optional | The business, the motion, the ACV and sales cycle, the CRM and the team. Cut past 4,000 characters. |
motion | optional | The go-to-market motion when the user chose it: plg, sales_led or hybrid. |
question | optional | What the user wants to know. |
audit_handoff | spec only | A JSON string of an audit's verdict, bad[] (areas), issues[{flag, finding, fix}] and fixes[], for the same input. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, headline, questions[], summary.
task: "audit"
process{motion: plg|sales_led|hybrid|unclear, crm, read}, areas[{area: lifecycle|scoring|routing|handoff|pipeline|data, status: good|weak|bad|unknown, finding, evidence[], fix}] (exactly six, in order), funnel{lead_to_mql, mql_to_sql, sql_to_opp, win_rate, speed_to_lead, weakest: lead_to_mql|mql_to_sql|sql_to_opp|win_rate|none, read}, issues[{flag, kind: lifecycle|scoring|routing|handoff|pipeline|data|performance, severity: high|medium|low, finding, impact, evidence[], fix}], fixes[] (3 to 6), verdict (sound|fix_first|rebuild).
task: "spec"
source (audit handoff|from the input), motion, lifecycle[{stage: subscriber|lead|mql|sql|opportunity|customer|evangelist, entry, exit, owner}], mql_definition, scoring{fit[{attribute, points}], engagement[{signal, points, decay}], negative[{signal, points}], threshold, weighting, recalibration: monthly|quarterly|semi-annually}, routing{method: round_robin|territory|account|skill|hybrid, rules[], fallback, capacity}, sla[{handoff, target, escalation}], recycling[{code, reason, action}], pipeline[{stage, exit_criteria, required_fields[]}], metrics[{metric, formula, target}], automations[].
Worked examples
These are the page's own bundled examples (invented businesses and numbers), with the input as app.js builds it and the saved model reply the page replays for free. One worked example per lane, and the audit-to-spec handoff.
Audit: a logistics SaaS where one download makes an MQL and 18% are accepted
Input (process and facts shortened here):
{
"task": "audit",
"process": "How leads get from marketing to sales at Brindlecove today\n\nLifecycle: Subscriber -> Lead -> MQL -> Opportunity -> Customer.\nAn MQL is anyone who downloads a piece of gated content or registers for a webinar.\n\nLead scoring (set up in HubSpot in 2022, never changed since):\n- Ebook or whitepaper download: +25\n- Webinar registration: +20\n- Email click: +5\n- Any page visit: +2 per page view\n- Demo request: +20\n- MQL thresh... (998 characters in all)",
"facts": "(a JSON string; decoded: motion hybrid, 6 stages named, 5 scoring rules (threshold 25), routing queue, speed-to-lead 2 days, 6 of 6 areas stated, lead-to-MQL 23.96%, MQL-to-SQL 18%, SQL-to-opportunity 46.38%, win rate 21.88%, weakest mql_to_sql, flags RO-03, RO-04, RO-07, RO-11, RO-15, RO-20, RO-06, RO-14, RO-16, RO-22, RO-09, RO-19, RO-21)",
"context": "Brindlecove Analytics - reporting software for mid-market logistics companies (B2B SaaS, hybrid: free trial plus a sales team).\nACV around $18,000; sales cycle about 45 days.\nStack: HubSpot for marketing and the CRM; Calendly for demos. 4 SDRs and 6 AEs.\n",
"question": "Sales says our MQLs are junk. Is it the scoring or the follow-up?"
}
Reply:
{
"task": "audit",
"process": {
"motion": "hybrid",
"crm": "HubSpot",
"read": "Any gated download or webinar sign-up makes an MQL in HubSpot, MQLs wait in a shared inbox until an SDR picks them up about 2 days later, and rejected MQLs are closed as \"bad lead\" and never touched again."
},
"areas": [
{
"area": "lifecycle",
"status": "bad",
"finding": "The MQL is defined by one content action with no fit criteria, the lifecycle skips SQL even though sales accepts SQLs, and marketing and sales openly disagree on what a good lead is.",
"evidence": [
"An MQL is anyone who downloads a piece of gated content or registers for a webinar.",
"Subscriber -> Lead -> MQL -> Opportunity -> Customer.",
"Sales says the MQLs are junk and mostly students and job seekers"
],
"fix": "Agree a written MQL definition with sales that needs fit (mid-market logistics company, relevant role) AND engagement, and add an SQL stage with entry and exit criteria and an owner."
},
{
"area": "scoring",
"status": "bad",
"finding": "Every rule is engagement, nothing scores fit or subtracts bad signals, one download alone reaches the 25-point threshold, and a demo request scores less than an ebook.",
"evidence": [
"Ebook or whitepaper download: +25",
"Demo request: +20",
"MQL threshold: 25 points"
],
"fix": "Rebuild the model as a 50/50 fit and engagement split with a threshold of 60-65, negative scoring, and pricing page and demo request weighted above content."
},
{
"area": "routing",
"status": "bad",
"finding": "MQLs have no owner: they sit in a shared inbox until an SDR has time, with no fallback and no capacity rule.",
"evidence": [
"new MQLs land in a shared inbox in HubSpot and the SDRs grab them when they
... (shortened here; the page shows it whole)
Spec: the same business, carrying the audit
Input (with the audit handed over):
{
"task": "spec",
"process": "How leads get from marketing to sales at Brindlecove today\n\nLifecycle: Subscriber -> Lead -> MQL -> Opportunity -> Customer.\nAn MQL is anyone who downloads a piece of gated content or registers for a webinar.\n\nLead scoring (set up in HubSpot in 2022, never changed since):\n- Ebook or whitepaper download: +25\n- Webinar registration: +20\n- Email click: +5\n- Any page visit: +2 per page view\n- Demo request: +20\n- MQL thresh... (998 characters in all)",
"facts": "(a JSON string; decoded: motion hybrid, 6 stages named, 5 scoring rules (threshold 25), routing queue, speed-to-lead 2 days, 6 of 6 areas stated, lead-to-MQL 23.96%, MQL-to-SQL 18%, SQL-to-opportunity 46.38%, win rate 21.88%, weakest mql_to_sql, flags RO-03, RO-04, RO-07, RO-11, RO-15, RO-20, RO-06, RO-14, RO-16, RO-22, RO-09, RO-19, RO-21)",
"context": "Brindlecove Analytics - reporting software for mid-market logistics companies (B2B SaaS, hybrid: free trial plus a sales team).\nACV around $18,000; sales cycle about 45 days.\nStack: HubSpot for marketing and the CRM; Calendly for demos. 4 SDRs and 6 AEs.\n",
"question": "Sales says our MQLs are junk. Is it the scoring or the follow-up?",
"audit_handoff": "{\"verdict\":\"rebuild\",\"bad\":[\"lifecycle\",\"scoring\",\"routing\",\"handoff\"],\"issues\":[{\"flag\":\"RO-03\",\"finding\":\"Marketing and sales do not agree on what a qualified lead is.\",\"fix\":\"Write the MQL definition jointly with the SDR and AE leads, sign it off, and review it against rejection reasons each quarter.\"},{\"flag\":\"RO-0..."
}
Reply:
{
"task": "spec",
"source": "audit handoff",
"motion": "hybrid",
"lifecycle": [
{
"stage": "subscriber",
"entry": "Signs up for the newsletter or blog updates without asking for anything else.",
"exit": "Gives company details on a form, downloads content or starts a free trial.",
"owner": "Marketing"
},
{
"stage": "lead",
"entry": "A known contact with a company email who has filled in a form, registered for a webinar or started a free trial.",
"exit": "Reaches 65 points with fit and engagement both scored, or requests a demo from a fit company.",
"owner": "Marketing"
},
{
"stage": "mql",
"entry": "Meets the MQL definition and is auto-assigned to one SDR in HubSpot.",
"exit": "The SDR accepts it as an SQL or rejects it with a reason code within 48 hours.",
"owner": "SDR"
},
{
"stage": "sql",
"entry": "The SDR has spoken to the contact and confirmed a logistics reporting need, a buyer role and interest in a demo.",
"exit": "The AE holds discovery and creates an opportunity, or returns it with a reason code.",
"owner": "SDR until discovery is booked, then AE"
},
{
"stage": "opportunity",
"entry": "The AE has run discovery and created a deal in the Discovery stage with the required fields filled.",
"exit": "Closed-won or Closed-lost with a loss reason.",
"owner": "AE"
},
{
"stage": "customer",
"entry": "The deal is Closed-won and the contract is signed.",
"exit": "Becomes a reference or referral source, or churns.",
"owner": "[account manager or CS owner]"
}
],
"mql_definition": "A contact at a mid-market logistics company in a buyer role (fit) who reaches 65 points through buying intent such as the pricing page, a free trial or repeat visits (engagement); a demo request b
... (shortened here; the page shows it whole)
Audit: an enterprise security vendor with sound scoring and stalled deals
Input:
{
"task": "audit",
"process": "Ostrevane Security lead process (Salesforce)\n\nStages: Lead -> MQL -> SAL -> SQL -> Opportunity -> Customer.\nMQL: a fit score of 40+ and an engagement score of 35+, so 75 points in total.\n\nFit scoring:\n- Company size 500+ employees: +20\n- Target industry (financial services, healthcare): +15\n- VP or above in security or IT: +20\n- Uses a competitor product: +10\nEngagement scoring:\n- Demo request: +30\n- Pricing page visite... (1386 characters in all)",
"facts": "(a JSON string; decoded: motion sales_led, 6 stages named, 11 scoring rules (threshold 75), routing territory, speed-to-lead 4 business hours, 6 of 6 areas stated, lead-to-MQL 10.83%, MQL-to-SQL 45%, SQL-to-opportunity 63.25%, win rate 17.57%, weakest win_rate, flags RO-18, RO-15, RO-19, RO-23)",
"context": "Ostrevane Security - cloud security posture management for enterprises (sales-led).\nACV $65K; sales cycle about 120 days. Salesforce is the CRM, Marketo for marketing automation.\n",
"motion": "sales_led"
}
Reply:
{
"task": "audit",
"process": {
"motion": "sales_led",
"crm": "Salesforce",
"read": "Marketo scores leads on fit and engagement, named accounts and territories route MQLs to an SDR who has 4 business hours to call and 48 hours to accept or reject, and accepted deals then move through six Salesforce pipeline stages."
},
"areas": [
{
"area": "lifecycle",
"status": "good",
"finding": "Six stages are named, including an SAL step where the SDR accepts or rejects, and the MQL needs both fit and engagement.",
"evidence": [
"Stages: Lead -> MQL -> SAL -> SQL -> Opportunity -> Customer",
"a fit score of 40+ and an engagement score of 35+"
],
"fix": ""
},
{
"area": "scoring",
"status": "weak",
"finding": "The model has fit, engagement and negative rules and a quarterly recalibration, but a demo request alone (+30) cannot clear the 35-point engagement bar, so the strongest buying signal waits on a second action.",
"evidence": [
"Demo request: +30",
"an engagement score of 35+"
],
"fix": "Make a demo request from a lead who meets the fit bar an immediate MQL, outside the points threshold."
},
{
"area": "routing",
"status": "good",
"finding": "Routing goes most specific first, named account then territory, with a team queue as the fallback owner.",
"evidence": [
"named accounts go to the account owner; otherwise leads are routed by territory",
"the lead goes to the team queue with a 1-hour SLA"
],
"fix": ""
},
{
"area": "handoff",
"status": "weak",
"finding": "There is a first-contact SLA, an accept-or-reject deadline, an escalation and recycling with reason codes, but the SLA is 4 business hours and the first escalation only fires at 4 hours.",
"evidence": [
"the SD
... (shortened here; the page shows it whole)
Spec: a PLG notes app, from scratch
Input:
{
"task": "spec",
"process": "We don't have lead scoring or a lifecycle yet - starting from scratch.\nToday: people sign up for the free plan on their own. Our two AEs look at the signup list every Monday and email whoever looks like a big company.\nWhat we can see in the product: account created, onboarding finished, notes shared with a teammate, teammates invited, hitting the 3-project limit on the free plan, pricing page visits.\nWe want sales to... (509 characters in all)",
"facts": "(a JSON string; decoded: motion plg, 0 stages named, 0 scoring rules (threshold none), routing queue, speed-to-lead unknown, 2 of 6 areas stated, lead-to-MQL unknown, MQL-to-SQL unknown, SQL-to-opportunity unknown, win rate unknown, weakest none, flags RO-11, RO-25)",
"context": "Quillmesh Notes - a shared notes app for product teams (PLG, free plan, Team plan $10 per seat a month).\nAbout 3,000 signups a month. HubSpot free CRM. Two AEs, no SDRs.\n",
"motion": "plg",
"question": "Which signups should our two AEs call first?"
}
Reply:
{
"task": "spec",
"source": "from the input",
"motion": "plg",
"lifecycle": [
{
"stage": "subscriber",
"entry": "A visitor joins the newsletter or blog list without creating an account.",
"exit": "Creates a free Quillmesh Notes account.",
"owner": "Marketing"
},
{
"stage": "lead",
"entry": "Account created on the free plan.",
"exit": "Score reaches the MQL threshold, or the account is archived after no activity in 12+ months.",
"owner": "Marketing (self-serve onboarding)"
},
{
"stage": "mql",
"entry": "Score reaches 60 with both fit and team engagement points, assigned to one named AE in HubSpot.",
"exit": "The AE accepts it as an SQL or rejects it with a reason code within 48 hours.",
"owner": "AE assigned by routing"
},
{
"stage": "sql",
"entry": "The AE has spoken with the workspace owner and confirmed a team that will need the paid plan.",
"exit": "A Team plan conversation is opened as a deal, or the lead is recycled with a reason code.",
"owner": "AE"
},
{
"stage": "opportunity",
"entry": "A HubSpot deal exists with seat count and decision maker recorded.",
"exit": "Closed won on the Team plan or closed lost with a reason.",
"owner": "AE"
},
{
"stage": "customer",
"entry": "The workspace pays for the Team plan.",
"exit": "Becomes an evangelist, or churns back to the free plan and re-enters as a lead.",
"owner": "[customer owner, an AE until CS exists]"
},
{
"stage": "evangelist",
"entry": "A paying customer refers another team or agrees to a case study.",
"exit": "Stays while the account remains active.",
"owner": "Marketing"
}
],
"mql_definition": "A free-plan account becomes an MQL when it shows fit (a work email domain and a team of 11 or more peopl
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js): for an audit, the motion you chose; six areas in order, none called good under a high or medium flag and every high flag's area called bad, nothing claimed for an area the paste never states without a quote; every funnel rate, the weakest stage and the speed-to-lead equal to the page's arithmetic; every high and medium flag carried and a high one never downgraded; rebuild when two high flags stand or three areas are bad, and no "sound" while a high flag stands. For a spec, the lifecycle in the skill's order with Lead, MQL, SQL, Opportunity and Customer; an MQL definition needing fit and engagement; fit, engagement and negative scoring with signed points and a threshold no single download reaches; routing rules with a fallback; a first-contact SLA within 4 hours and a qualification decision within 48 hours, each with an escalation; reason codes; pipeline stages with exit criteria; MQL-to-SQL against 30-50%; the handed-off audit answered. In both, quotes are checked word for word and no count, rate, price or window may be invented beyond the skill's constants. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same input into the page.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://revops-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://revops-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// revops-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://revops-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://revops-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
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 env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// RevopsDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class RevopsDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://revops-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://revops-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://revops-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://revops-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"revops-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "revops-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "revops-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "revops-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "revops-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "revops-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "revops-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "revops-desk" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "audit", "process": "...", "context": "...", "facts": "..."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "audit", "process": "...", "context": "...", "facts": "...", ...}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, process, context, motion, question, facts, ...}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses revops-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="revops-desk:audit:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "revops-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `revops-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "revops-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "revops-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "revops-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
$key = "revops-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"revops-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream (spec)
The same run as Server-Sent Events. spec.json is a spec input, with audit_handoff when it follows an audit.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: revops-desk:spec:$(shasum -a 256 spec.json | cut -c1-16):a1" --data @spec.json
rev = json.load(open("spec.json")) # {"task": "spec", "process": "...", "facts": "...", "audit_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "revops-desk:spec:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("spec.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `revops-desk:spec:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("spec.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "revops-desk:spec:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("spec.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "revops-desk:spec:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("spec.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "revops-desk:spec:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("spec.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: revops-desk:spec:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("spec.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"revops-desk:spec:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);