Drive Headcount Desk from your own code
The page does its free work in the browser: it reads the company facts and the team table and prices every row
against the skill's salary bands with the location adjustment, works out the fully-loaded cost, when recruiting must
start and when each hire is productive, the month-by-month burn and cash, the runway with and without the plan, the
first-12-month budget and its share of revenue, equity against the stage ranges and the option pool after the plan,
and raises its findings. Its two paid lanes are available over HTTP. You send one JSON object with task
set to "review" or "memo", and the model replies with one JSON object. The easiest way to
build the facts string is the page itself:
TeamKit.buildInput(lane, TeamKit.analyze(factsText, teamText, {stage, company, question}), {handoff})
in teamkit.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 salary and equity figures are the skill's US 2024 benchmarks, not market data.
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 |
|---|---|
"review" | Is this the right team, in the right order, at the right price, for the milestone and the cash: one answer per page finding (confirmed or disputed, the arithmetic ones confirmed); every planned hire in the order to make it, with an action (keep, start recruiting now, move earlier, move later, hire as contractor, hold, cut), a start month and why; pay and grant changes; up to three gaps; one to five risks; and the verdict the lists imply (hire as planned, hire with changes, resequence the plan, cannot fund this plan). |
"memo" | The board memo for the plan: a title and the ask, five sections (where we are, the hiring plan, what it costs, equity, risks and what we will watch), every hire put forward in start order with why now, and one to five things to watch by month. With review_handoff it describes the reviewed plan. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | review or memo |
stage | required | pre-seed, seed or series A. |
company | optional | What the company does - one line. |
facts_text | required | The company facts as the page read them, one per line: cash, monthly non-people costs, monthly revenue and growth, plan start, milestone, option pool. |
team | required | One line per row: R7: Engineering Manager | engineering | manager | fte | base $175,000 | equity 0.8% | Austin | start 2026-12. A row reading on payroll now is the current team. Up to 60 rows. |
facts | required | A JSON string: stage, plan_start, milestone, milestone_date, cash, monthly_costs, monthly_revenue, rows[{id, role, function, level, type, now, start, base, variable, loaded_cost, cost_first_12, benchmark, band, equity_pct, equity_range, recruit_by, productive_by, late}], burn, runway{cash_out, cash_out_current_team, runway_months, cash_at_milestone, out_before_milestone}, budget_first_12_months, headcount, mix, pool, findings[{id, kind, severity, rows[], text}], notes[]. |
question | optional | What you want to know; answered in summary. |
review_handoff | memo, optional | A JSON string of the review the memo follows: verdict, sequence[{row, action, start}], comp[{row, base, equity}], gaps[{role, when}], risks[{risk, rows[]}] (the high ones). |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, task_note, headline, questions[], summary.
task: "review"
verdict, findings[{finding, response: confirmed|disputed, note}] (one per page finding, in order), sequence[{row, action, start, why}] (every planned hire once, first hire first), comp[{row, base: keep|raise|lower, equity: keep|raise|lower, note}], gaps[{role, function, why, when}] (0-3), risks[{risk, rows[], severity: high|medium, mitigation}] (1-5).
task: "memo"
source (review handoff|from the input), title, ask, sections[{heading, body}] (Where we are, The hiring plan, What it costs, Equity, Risks and what we will watch), hires[{row, role, start, why_now}] (in start order), watch[{metric, threshold, by}] (1-5).
Worked examples
These are the page's own bundled examples (invented companies, people and numbers), with the input as app.js builds it and the saved model reply the page replays for free: a review of a seed-stage plan and its board memo after the handoff, a review of a pre-seed plan that runs out of cash, and a memo for a Series A plan.
Review: a seed-stage SaaS adding six hires
Input (team and facts shown as lines; facts shortened here):
{
"task": "review",
"stage": "seed",
"company": "Pellwick - shift scheduling software for independent pharmacies",
"facts_text": [
"company: Pellwick",
"cash: $4,200,000",
"monthly non-people costs: $45,000",
"monthly revenue: $95,000 growing 4% a month",
"plan start: 2026-11",
"... (8 lines in all)"
],
"team": [
"R1: Co-founder & CEO | founder | founder | founder | base $150,000 | equity 48% | Austin | on payroll now",
"R2: Co-founder & CTO | founder | founder | founder | base $150,000 | equity 32% | Austin | on payroll now | reports to Co-founder & CEO",
"R3: Senior Engineer | engineering | senior | fte | base $165,000 | equity 0.6% | Austin | on payroll now | reports to Co-founder & CTO",
"R4: Engineer | engineering | mid | fte | base $128,000 | equity 0.3% | Remote | on payroll now | reports to Co-founder & CTO",
"R5: Engineer | engineering | mid | fte | base $135,000 | equity 0.25% | Austin | on payroll now | reports to Co-founder & CTO",
"... (12 lines in all)"
],
"facts": "(a JSON string; decoded: 12 rows, 6 planned; runway: cash out past 2028-04 with the plan, milestone 2027-12; first 12 months 1826542; pool after the plan -0.55%; findings: HC-01 pool/high, HC-02 velocity/high, HC-03 velocity/high, HC-04 velocity/high, HC-05 comp/medium, HC-06 equity/medium, HC-07 equity/medium)",
"question": "Can we make all six hires before the Series A, and in what order?"
}
Reply:
{
"task": "review",
"task_note": "",
"verdict": "resequence the plan",
"findings": [
{
"finding": "HC-01",
"response": "confirmed",
"note": "Planned grants of 3.05% exceed the 2.5% left in the 12% pool by 0.55 points; bring R7 and R9 to the top of their range and ask the board to top up the pool before offers go out."
},
{
"finding": "HC-02",
"response": "confirmed",
"note": "R7 Engineering Manager needed recruiting from 2026-08 for a 2026-12 start; move the start to 2027-02 and open the search now."
},
{
"finding": "HC-03",
"response": "confirmed",
"note": "R8 Account Executive needed recruiting by 2026-10 for a 2027-01 start; open the search this month."
},
{
"finding": "HC-04",
"response": "confirmed",
"note": "R9 Senior Engineer needed recruiting by 2026-10 for a 2027-02 start; open the search this month."
},
{
"finding": "HC-05",
"response": "confirmed",
"note": "R8 has no variable pay, so its $143,000 loaded cost leaves out the $80,000-$120,000 Account Executive commission; add a commission plan and re-run the page."
},
{
"finding": "HC-06",
"response": "confirmed",
"note": "R7 at 0.8% is above the seed Senior IC range of 0.1-0.5%; grant 0.5%."
},
{
"finding": "HC-07",
"response": "confirmed",
"note": "R9 at 0.6% is above the seed Senior IC range of 0.1-0.5%; grant 0.5%."
}
],
"sequence": [
{
"row": "R8",
"action": "start recruiting now",
"start": "2027-01",
"why": "At 8-12 weeks to hire, recruiting was due by 2026-10; the first Account Executive is productive by 2027-05 and carries the revenue push toward $2.5M ARR by 2027-12."
},
{
"row": "R7",
"action": "move later",
"start": "2027-02",
"why": "At 12-16 weeks to hire, recruiting should have started by 2026-08, so a 2026-12 start cannot be met; open the search now and plan for 2027-02, ahead of or alongside R9 who reports to this role."
},
{
"row": "R9",
"action": "start recruiting now",
"start": "2027-02",
"why": "At 12-16 weeks to hire, recruiting was due by 2026-10; with 1-3 months of ramp this engineer is productive by 2027-05, well before the 2027-12 milestone."
},
{
"row": "R10",
"action": "keep",
"start": "2027-03",
"why": "Recruiting is due by 2026-12 and the Product Manager is productive by 2027-07, in time to shape the product before
... (shortened here; the page shows it whole)
Memo: the same plan, with the review handed over
Input (with the review handed over; shown as lines):
{
"task": "memo",
"stage": "seed",
"company": "Pellwick - shift scheduling software for independent pharmacies",
"facts_text": [
"company: Pellwick",
"cash: $4,200,000",
"monthly non-people costs: $45,000",
"monthly revenue: $95,000 growing 4% a month",
"plan start: 2026-11",
"... (8 lines in all)"
],
"team": [
"R1: Co-founder & CEO | founder | founder | founder | base $150,000 | equity 48% | Austin | on payroll now",
"R2: Co-founder & CTO | founder | founder | founder | base $150,000 | equity 32% | Austin | on payroll now | reports to Co-founder & CEO",
"R3: Senior Engineer | engineering | senior | fte | base $165,000 | equity 0.6% | Austin | on payroll now | reports to Co-founder & CTO",
"R4: Engineer | engineering | mid | fte | base $128,000 | equity 0.3% | Remote | on payroll now | reports to Co-founder & CTO",
"R5: Engineer | engineering | mid | fte | base $135,000 | equity 0.25% | Austin | on payroll now | reports to Co-founder & CTO",
"... (12 lines in all)"
],
"facts": "(a JSON string; decoded: 12 rows, 6 planned; runway: cash out past 2028-04 with the plan, milestone 2027-12; first 12 months 1826542; pool after the plan -0.55%; findings: HC-01 pool/high, HC-02 velocity/high, HC-03 velocity/high, HC-04 velocity/high, HC-05 comp/medium, HC-06 equity/medium, HC-07 equity/medium)",
"question": "Can we make all six hires before the Series A, and in what order?",
"review_handoff": "{\"verdict\":\"resequence the plan\",\"sequence\":[{\"row\":\"R8\",\"action\":\"start recruiting now\",\"start\":\"2027-01\"},{\"row\":\"R7\",\"action\":\"move later\",\"start\":\"2027-02\"},{\"row\":\"R9\",\"action\":\"start recruiting now\",\"start\":\"2027-02\"},{\"row\":\"R10\",\"action\":\"keep\",\"start\":\"2027-03\"},{\"row\":\"R11\",\"action\":\"keep\",\"start\":\"2027-05\"},..."
}
Reply:
{
"task": "memo",
"task_note": "",
"source": "review handoff",
"title": "Pellwick: seed hiring plan to the Series A, as reviewed",
"ask": "Approve the six reviewed hires from 2027-01 to 2027-06, the lower equity grants for R7 and R9, and a decision on the option pool before the first grant is made.",
"sections": [
{
"heading": "Where we are",
"body": "Pellwick is a seed-stage company selling shift scheduling software to independent pharmacies. The team today is 6 people: the two co-founders, three engineers (R3, R4, R5) and one customer success rep (R6). We hold $4.2M in cash, spend $45,000 a month outside payroll and bring in $95,000 a month in revenue, growing 4% a month. The milestone is a Series A at $2.5M ARR by 2027-12."
},
{
"heading": "The hiring plan",
"body": "The reviewed plan takes the team from 6 to 12 people, adding six hires between 2027-01 and 2027-06. R8 Account Executive starts 2027-01 and recruiting starts now, because it is already late against an 8-12 week hiring time. R7 Engineering Manager moves later from 2026-12 to 2027-02, and R9 Senior Engineer keeps 2027-02 with recruiting started now, so the manager and the senior engineer who reports to the manager arrive together. R10 Product Manager joins in 2027-03, R11 Account Executive in 2027-05 and R12 Head of Marketing in 2027-06, all as planned. The sequence puts the first sales hire and the engineering build-out first, since revenue growth to the Series A depends on both."
},
{
"heading": "What it costs",
"body": "Loaded cost is base x 1.3 plus variable pay, from $143,000 for R8 to $227,500 for R7, and $226,500 for R11 with its $90,000 variable pay. People cost rises from $85,583 in the first month to $183,542 by the end of the horizon. The first-12-month people budget is $1.83M against $1.43M of revenue, or 128% of revenue, inside the seed band of 120-150%. With the plan, cash does not run out before 2028-04, the end of the page's horizon, and neither does it with the current team alone. We expect $3.11M in cash at the 2027-12 milestone."
},
{
"heading": "Equity",
"body": "The option pool is 12%, of which 9.5% is already granted, leaving 2.5%. The planned grants come to 3.05%, so as pasted the plan is short of the pool by 0.55 points. The review lowers R7 Engineering Manager from 0.8% and R9 Senior Engineer from 0.6% to the top of the seed S
... (shortened here; the page shows it whole)
Review: a pre-seed plan that spends the cash before the seed round
Input (shown as lines):
{
"task": "review",
"stage": "pre-seed",
"company": "Skelmor Notes - AI visit notes for small clinics",
"facts_text": [
"company: Skelmor Notes",
"cash: $900,000",
"monthly non-people costs: $18,000",
"monthly revenue: $6,000",
"plan start: 2026-11",
"... (8 lines in all)"
],
"team": [
"R1: Founder & CEO | founder | founder | founder | base $90,000 | equity 55% | Denver | on payroll now",
"R2: Founder & CTO | founder | founder | founder | base $90,000 | equity 35% | Denver | on payroll now",
"R3: Founding Engineer | engineering | senior | fte | base $160,000 | equity 1.5% | Denver | on payroll now",
"R4: Engineer | engineering | mid | fte | base $140,000 | equity 0.8% | Remote | start 2026-12",
"R5: Product Designer | product | mid | contract | base $120,000 | equity 0.5% | Remote | start 2027-01",
"... (7 lines in all)"
],
"facts": "(a JSON string; decoded: 7 rows, 4 planned; runway: cash out 2027-09 with the plan, milestone 2027-09; first 12 months 888666; pool after the plan 6.3%; findings: HC-01 runway/high, HC-02 velocity/high, HC-03 comp/medium, HC-04 comp/medium, HC-05 size/medium)"
}
Reply:
{
"task": "review",
"task_note": "",
"verdict": "cannot fund this plan",
"findings": [
{
"finding": "HC-01",
"response": "confirmed",
"note": "With the plan cash runs out in 2027-09 with cash at the milestone of about -$37,542, so hires that are not on the critical path to $40k MRR have to wait."
},
{
"finding": "HC-02",
"response": "confirmed",
"note": "R4 Engineer recruiting should have started by 2026-09; open the role now to keep the 2026-12 start."
},
{
"finding": "HC-03",
"response": "confirmed",
"note": "Offer R4 Engineer at the top of the $96,000-$135,000 band instead of $140,000."
},
{
"finding": "HC-04",
"response": "confirmed",
"note": "R6 Account Executive needs a commission plan in the $80,000-$120,000 range, and the loaded cost and runway should be rerun with it included."
},
{
"finding": "HC-05",
"response": "confirmed",
"note": "The plan ends at 7 people against the skill's pre-seed team of 2-5; holding R5 and R7 keeps the team inside that range until the seed round."
}
],
"sequence": [
{
"row": "R4",
"action": "start recruiting now",
"start": "2026-12",
"why": "The second engineer is on the critical path to the product that must reach $40k MRR by 2027-09, and at 8-12 weeks to hire recruiting is already late against the 2026-09 recruit-by month."
},
{
"row": "R6",
"action": "keep",
"start": "2027-02",
"why": "Revenue is $6,000 a month against a $40k MRR milestone, so the Account Executive is the hire that moves the milestone; recruiting by 2026-11 gets them productive by 2027-06."
},
{
"row": "R5",
"action": "hold",
"start": "",
"why": "The plan runs out of cash in 2027-09, and a designer productive only by 2027-05 does not move MRR; hold until the seed round closes or the page shows the runway carrying past the milestone without it."
},
{
"row": "R7",
"action": "hold",
"start": "",
"why": "A growth marketer productive only by 2027-07 adds little before the 2027-09 milestone and the cash cannot carry it; the skill puts pre-seed marketing on contract, so hold until the seed round closes."
}
],
"comp": [
{
"row": "R4",
"base": "lower",
"equity": "keep",
"note": "Lower the base from $140,000 to the top of the Mid-Level Engineer band for Remote / other US cities, $135,000; keep 0.8%, inside the fir
... (shortened here; the page shows it whole)
Memo: a Series A company's next six hires
Input (shown as lines):
{
"task": "memo",
"stage": "series A",
"company": "Brindleway Customs - customs brokerage software for freight forwarders",
"facts_text": [
"company: Brindleway Customs",
"cash: $14,000,000",
"monthly non-people costs: $160,000",
"monthly revenue: $330,000 growing 3.5% a month",
"plan start: 2027-01",
"... (8 lines in all)"
],
"team": [
"R1: CEO | founder | founder | founder | base $190,000 | equity 38% | Chicago | on payroll now",
"R2: CTO | founder | founder | founder | base $185,000 | equity 22% | Chicago | on payroll now | reports to CEO",
"R3: VP Engineering | engineering | vp | fte | base $215,000 | equity 1.2% | Chicago | on payroll now | reports to CTO",
"R4: Engineering Manager | engineering | manager | fte | base $180,000 | equity 0.4% | Chicago | on payroll now | reports to VP Engineering",
"R5: Senior Engineer | engineering | senior | fte | base $170,000 | equity 0.3% | Chicago | on payroll now | reports to Engineering Manager",
"... (22 lines in all)"
],
"facts": "(a JSON string; decoded: 22 rows, 6 planned; runway: cash out past 2028-07 with the plan, milestone 2028-06; first 12 months 4084208; pool after the plan 6.56%; findings: HC-01 comp/medium, HC-02 founders/medium)"
}
Reply:
{
"task": "memo",
"task_note": "",
"source": "from the input",
"title": "Brindleway Customs: Series A hiring plan from 2027-01 to the Series B milestone",
"ask": "Approve the planned hires R17 to R22, starting between 2027-04 and 2027-09, taking the team from 16 to 22 people on the way to a Series B at $10M ARR by 2028-06.",
"sections": [
{
"heading": "Where we are",
"body": "Brindleway Customs is a Series A company building customs brokerage software for freight forwarders, with 16 people on payroll today across founders, engineering, sales, product, marketing and customer success. We hold $14,000,000 in cash, spend $160,000 a month on non-people costs and bring in $330,000 a month in revenue, growing 3.5% a month. The plan starts in 2027-01 and the milestone is a Series B at $10M ARR by 2028-06."
},
{
"heading": "The hiring plan",
"body": "In 2027-04 an Account Executive (R18) adds sales capacity and a CS Representative (R19) supports a growing customer base. In 2027-05 a Senior Engineer (R17) joins the engineering team, followed in 2027-06 by a Finance Lead (R20) to run finance ahead of the Series B. A Product Manager (R21) joins in 2027-07 under the Head of Product, and a Content Marketer (R22) joins in 2027-09 under the Marketing Manager. The page shows none of these hires as late, and every one is productive by 2028-01 at the latest, well before the milestone."
},
{
"heading": "What it costs",
"body": "People cost runs from $287,292 in the first month to $374,167 in the last month of the plan, with fully loaded costs per new hire from $91,000 (R19) to $236,500 (R18). The first-12-month people budget is $4.08M against $4.82M of revenue, or 84.8% of revenue, inside the Series A band of 70-100%. Net burn is $117,292 in the first month and turns into a net inflow of $78,805 by the last month. Neither the plan nor the current team runs out of cash before 2028-07, and the company holds about $12.88M at the milestone in 2028-06."
},
{
"heading": "Equity",
"body": "The option pool is 15%, with 8% granted and 7% remaining. The new grants (R17 to R22) come to 0.44% and each sits inside its Series A range: R17 and R20 at 0.15% against the senior IC range of 0.05-0.2%, R18, R21 and R22 at 0.05%, 0.05% and 0.03% against the mid-level range of 0.01-0.1%, and R19 at 0.01% against the junior range of 0.005-0.05%. After the p
... (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 a review: one answer per page finding, in order, with the arithmetic findings (runway, pool, recruiting lead time) confirmed; every planned hire sequenced once and no one already on payroll; each action's start month consistent with the planned one (keep and start recruiting now keep it, move earlier and move later change it, hold and cut leave it empty); every late hire acted on; when the cash runs out by the milestone, at least one hire moved later, held or cut; pay changes on real rows, never a base cut below its band; zero to three gaps; one to five risks; the verdict the lists imply. For a memo: the source as sent; the five sections in order; exactly the hires the plan (or the review) keeps, at their months, in start order; one to five things to watch with a month. In both, every row and finding id must be the page's, every month one the input or the reply's own plan contains, and no figure the input, the page's arithmetic or the skill do not contain may appear (rounding allowed). 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://headcount-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://headcount-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"]
// headcount-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://headcount-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://headcount-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
}
// HeadcountDesk.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 HeadcountDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://headcount-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://headcount-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://headcount-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://headcount-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":"headcount-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": "headcount-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: "headcount-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": "headcount-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", "headcount-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: "headcount-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" => "headcount-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 = "headcount-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": "review", "stage": "...", "facts_text": "...", "team": "...", "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": "review", "stage": "...", "facts_text": "...", "team": "...", "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, stage, company, facts_text, team, facts, question, ...}
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 headcount-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="headcount-desk:review:$(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 = "headcount-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("headline"))
import { createHash } from "node:crypto";
const key = `headcount-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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "headcount-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 = "headcount-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 = "headcount-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["headline"]].inspect
$key = "headcount-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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"headcount-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 (memo)
The same run as Server-Sent Events. memo.json is a memo input, with review_handoff when it follows a review.
# 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: headcount-desk:memo:$(shasum -a 256 memo.json | cut -c1-16):a1" --data @memo.json
rev = json.load(open("memo.json")) # {"task": "memo", "stage": "...", "facts_text": "...", "team": "...", "facts": "...", "review_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": "headcount-desk:memo:" + 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("memo.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": `headcount-desk:memo:${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("memo.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", "headcount-desk:memo:"+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("memo.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", "headcount-desk:memo:" + 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("memo.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "headcount-desk:memo:#{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("memo.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: headcount-desk:memo:" . 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("memo.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", $"headcount-desk:memo:{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);