← OKR Desk
API
Tokens

Run OKR Desk from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page checks the OKR set for free - key results that are deliverables or activity counts, no target, a target without a baseline, no evidence source or deadline, numbers in the objective, more than three objectives, growth with no guardrail, individual OKRs and scores tied to pay - and, once actual values are in, scores every key result the way its type demands (aspirational on the 0 to 1 scale, committed pass or fail, compliance met or not met, operational health against its band, learning validated or not), with guardrails kept apart and open windows deferred. That is okscan.js, and every finding carries its rule id, line and fix. The run rewrites the set into outcome-based OKRs (or drafts one from a strategy or a cycle review), or grades the closed cycle; the page then holds the reply to its own check (okreply.js): it re-checks a returned set, refuses a baseline or target marked given that is not in the input, and holds every score to its own arithmetic unless the reply disputes it.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"okr-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, facts) is missing or not a string. Send every field as a string; okrs and context may be "".
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - "write" (the OW- rules of the OKR writer: a verdict on the pasted set, findings with fixes, a call on every scan finding, and the whole set rewritten, with an eleven-criterion quality audit) and "grade" (the OG- rules of the OKR grader: a verdict on the closed cycle, a scorecard with one entry per key result, a read per objective, guardrails, evidence problems, initiatives, learnings and next-cycle calls). OK- rules belong to both. A missing or unknown task is answered as write, with an open question saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyes"write" or "grade". The model answers that task and only that task.
okrsnothe OKR set as the team wrote it, in the input format. The page sends one team's set of up to 20,000 characters. A "grade" run needs actual values on the key results (| actual: 26%). In "write" it may be "": the run then drafts a set from context.
contextnothe strategy, the team and what is already committed - and, after a grading, the cycle review's learnings and next-cycle calls (see next cycle). Up to 6,000 characters on the page. May be empty; when given, the summary says how it changed the answer.
factsyesthe page's own check of the same set: a header, every key result with its type, indicator, baseline, target, deadline, source, actual and the page's score with its calculation, a read per objective, the initiatives, and every finding as rule | severity | line | KR | text. See the facts format.
retry_notenopresent only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise.

The input format

okrs is plain text, one item per line. The parts the check reads:

Team: Customer Support
Cycle: 2026-Q3
Source of truth: Support OKR sheet, Q3 tab

O1: Customers get their problem solved the first time they ask
KR1.1: Raise first-contact resolution from 62% to 72% by 2026-09-30 | type: aspirational | indicator: lagging | source: helpdesk resolution report | actual: 69%
KR1.3: Customer satisfaction on solved tickets stays at or above 90% | type: operational_health | indicator: guardrail | source: post-ticket CSAT survey | actual: 91.5%

Initiatives:
- Macro library rewrite -> KR1.1 | shipped

An open window is written as an actual such as not yet observable (60-day interim 19%); a key result with no type is scored as aspirational (a guardrail as operational_health) and flagged.

The facts format

facts is the page's own check, written as plain text. The page builds it from the set with okscan.js, and the model scores against it: in "grade" each scorecard score is the page score from the facts unless the reply disputes it (call: "disagree", with the reason), and each objective's average is the page read's average. A client calling the API should send its own summary of the set - the team, the cycle, each key result with its type, baseline, target, actual and the score you computed, and what you already know is wrong - or, better, the same per-KR line format (from okscan.js run under node - see Build the input yourself). The check's format gets the most out of a run: the model answers every error and warn rule id of its lane's family (and every OK- id) it finds there, with one prescan entry per id, and uses the KR ids it lists. The format, line by line:

Set: <team>, <cycle|cycle not stated>; <n> objectives, <n> key results, <n> initiatives; <source of truth: ...|no source of truth named>.
Strategic input: <only when a Strategy: line is given>.
Empowerment (as written): <only when an Empowerment: line is given>.
Actual values: <present - the cycle can be graded.|none - this is a draft or a plan, not a closed cycle.>
Prescan: write verdict <ready|revise|rework|none> (<n> errors, <n> warnings); grade rules <n> errors, <n> warnings.
O1: <objective>
  page read: aspirational primary average <0.72 over 2 KRs|none>; guardrails apart: <KR1.3 pass|none>; committed misses: <...|none>; deferred: <...|none>.
- KR1.1 (O1) | type <type>[ [default]] | indicator <...> | baseline <...> | target <...> | deadline <...> | source <...> | actual <...> | page score <label> [<calculation>]
  <the key result as written>
Initiatives:
- <name> -> <KR ids|no KR named>[ | <status>]
Findings (rule | severity | line | KR | text):
- <rule id> | <error|warn|info> | line <n> | <KR id|-> | <title>[ (note)][ | the source line]

A value the set does not give is written -. The page read line and the actual / page score parts appear only when actual values are present. A set with no findings gets the single line - none, and an empty okrs gets the whole facts No OKR set pasted - draft one from the context. Both lanes receive the same facts: OW- findings belong to write, OG- findings to grade, OK- to both. The prescan verdict is rework at two write-lane errors, or one error with errors or warnings in three of the areas (outcomes, measurement, objectives, focus, guardrails, integrity, scores, evidence); revise on any write-lane error or warning; ready otherwise. A real example - the facts the page sends for the support team's Q3 set used in the worked example below:

Set: Customer Support, 2026-Q3; 2 objectives, 5 key results, 3 initiatives; source of truth: Support OKR sheet, Q3 tab.
Strategic input: Cut the cost of a solved problem without making customers wait or feel brushed off.
Actual values: present - the cycle can be graded.
Prescan: write verdict ready (0 errors, 0 warnings); grade rules 0 errors, 0 warnings.
O1: Customers get their problem solved the first time they ask
  page read: aspirational primary average 0.72 over 2 KRs; guardrails apart: KR1.3 pass; committed misses: none; deferred: none.
- KR1.1 (O1) | type aspirational | indicator lagging | baseline 62% | target 72% | deadline 2026-09-30 | source helpdesk resolution report | actual 69% | page score 0.70 [(69% - 62%) / (72% - 62%) = 0.70]
  Raise first-contact resolution from 62% to 72% by 2026-09-30
- KR1.2 (O1) | type aspirational | indicator leading | baseline 4.2 h | target 2 h | deadline 2026-09-30 | source helpdesk SLA report | actual 2.6 h | page score 0.73 [(2.6 h - 4.2 h) / (2 h - 4.2 h) = 0.73]
  Cut median time to first reply from 4.2 h to 2 h by 2026-09-30
- KR1.3 (O1) | type operational_health | indicator guardrail | baseline - | target at least 90% | deadline - | source post-ticket CSAT survey | actual 91.5% | page score pass [91.5% against at least 90%]
  Customer satisfaction on solved tickets stays at or above 90%
O2: Answers customers can find without writing to us
  page read: aspirational primary average 0.67 over 1 KR; guardrails apart: none; committed misses: none; deferred: none.
- KR2.1 (O2) | type aspirational | indicator leading | baseline 31% | target 40% | deadline 2026-09-30 | source help center analytics | actual 37% | page score 0.67 [(37% - 31%) / (40% - 31%) = 0.67]
  Increase help-center self-serve rate from 31% to 40% by 2026-09-30
- KR2.2 (O2) | type learning | indicator evidence_generation | baseline - | target - | deadline - | source A/B test on the billing contact form | actual validated - 11% fewer billing tickets in the test arm | page score validated [learning KR - graded by what it settled]
  Learn whether in-product answer suggestions deflect billing tickets
Initiatives:
- Macro library rewrite -> KR1.1 | shipped
- Follow-the-sun shift pattern -> KR1.2 | shipped
- Suggested answers in the contact form -> KR2.1, KR2.2 | shipped
Findings (rule | severity | line | KR | text):
- none

Rule ids the check uses, with their severity and lane:

RuleSeverityLaneTitle
OK-COMPerrorbothOKR scores tied to pay or performance ratings
OW-OUTPUTerrorwriteKey result is a deliverable, not an outcome
OW-TASKCOUNTwarnwriteKey result counts activity
OW-NOTARGETerrorwriteNo target
OW-NOBASEwarnwriteTarget without a baseline
OW-NOSOURCEwarnwriteNo evidence source
OW-NODATEinfowriteNo deadline or cycle
OW-OBJMETRICwarnwriteNumbers in the objective
OW-VAGUEOBJinfowriteObjective leans on empty adjectives
OW-FOCUSwarnwriteToo many (or too few) objectives or key results
OW-NOGUARDwarnwriteGrowth or speed key result with no guardrail
OW-INDIVIDUALwarnwriteIndividual OKRs
OW-NOTYPEinfowriteKey result type not stated
OG-NOACTUALwarngradeNo actual value to grade
OG-COMMITMISSerrorgradeCommitted key result missed
OG-GUARDFAILerrorgradeGuardrail broken
OG-LOWSTRETCHwarngradeAspirational key result below 0.4
OG-PARTIALwarngradeCommitted scope only partly covered
OG-RETARGETwarngradeTarget or baseline changed mid-cycle
OG-SANDBAGinfogradeAspirational key result above 0.85
OG-DEFERREDinfogradeNot yet observable

A finding the check cannot see (a vanity metric, a key result the team cannot influence, cascading theater, a lag-only set, confounded initiatives) comes back with the rule NEW.

The standard the model applies

The two source skills - @product-on-purpose/foundation-okr-writer (the write lane) and @product-on-purpose/measure-okr-grader (the grade lane), from product-on-purpose/pm-skills (Apache-2.0) - are the only standard:

House rules

The replies

Both lanes carry verdict, summary, findings (rule, severity high / medium / low, title, where, why, fix) and prescan (rule, call agree or disagree, note). The rest differs by lane.

write

verdict on the pasted set: ready (nothing in it fails the rules), revise or rework (output key results, no targets, compensation coupling, a roadmap in OKR clothes). mode is rewrite (a set was pasted), draft (only a strategy in context) or next-cycle (the context carries a cycle review).

  1. summary - two or three sentences: what the set is for, the verdict, the biggest change.
  2. empowerment - empowered, feature-team, mixed or unknown; disclosure is the Disclosure text for feature-team or mixed, and "" otherwise.
  3. findings and prescan - OW- and OK- rule ids from the facts, or NEW.
  4. set - the whole set to use: context (team, cycle, scope, source_of_truth, strategy), objectives (1 to 3, each id, title and 2 to 4 krs) and initiatives (name, krs, hypothesis). Each key result: id, statement, type, indicator, baseline and target with baseline_basis and target_basis (given, assumption or placeholder), deadline, source, owner, confidence.
  5. audit - eleven entries in this order: Strategic fit, Objective quality, KR outcome quality, Measurement quality, Product influence, Focus, Guardrails, Alignment, Operating rhythm, Integrity, Empowered-team Disclosure - each with rating pass, risk or fail (not-applicable only for the Disclosure when empowered) and a one-line why.
  6. open_questions (every assumption and placeholder introduced), next_step (one sentence), caveats.
{
  "task": "write",
  "mode": "rewrite",
  "verdict": "revise",
  "empowerment": "empowered",
  "summary": "",
  "findings": [{"rule": "OW-OUTPUT", "severity": "high", "title": "", "where": "KR1.1", "why": "", "fix": ""}],
  "prescan": [{"rule": "OW-OUTPUT", "call": "agree", "note": ""}],
  "set": {"context": {"team": "", "cycle": "", "scope": "team", "source_of_truth": "", "strategy": ""}, "objectives": [{"id": "O1", "title": "", "krs": [{"id": "KR1.1", "statement": "", "type": "aspirational", "indicator": "leading", "baseline": "", "baseline_basis": "given", "target": "", "target_basis": "assumption", "deadline": "", "source": "", "owner": "", "confidence": "medium"}]}], "initiatives": [{"name": "", "krs": ["KR1.1"], "hypothesis": ""}]},
  "audit": [{"criterion": "Strategic fit", "rating": "pass", "why": ""}],
  "disclosure": "",
  "open_questions": [""],
  "next_step": "",
  "caveats": [""]
}

grade

verdict on the closed cycle: hit (every committed and compliance key result met, guardrails held, aspirational scores in or above the sweet spot), mixed, or missed (committed misses or broken guardrails the primary scores cannot excuse).

  1. summary - two or three sentences: what the cycle was for, the verdict, the one thing next cycle must change.
  2. scorecard - one entry per key result in the facts: kr, actual, score (the page's label unless disputed), call, confidence, interpretation.
  3. objectives - id, read (qualitative, never a naive average over guardrails, learning or deferred key results), average (as the facts give it, or null).
  4. guardrails (kr, signal, note) and evidence (kr, issue, fix).
  5. initiatives - name, krs, shipped (yes / no / partial), moved (yes / no / unclear, kept apart from shipping), verdict (continue / rework / retire), note.
  6. learning (validated, invalidated, surprises) and next_cycle - one per objective: objective, action (continue / revise / retire / escalate), why, draft.
  7. risks, findings and prescan (OG- and OK- rule ids, or NEW), source_of_truth.
{
  "task": "grade",
  "verdict": "mixed",
  "summary": "",
  "scorecard": [{"kr": "KR1.1", "actual": "", "score": "0.86", "call": "agree", "confidence": "high", "interpretation": ""}],
  "objectives": [{"id": "O1", "read": "", "average": 0.86}],
  "guardrails": [{"kr": "KR1.3", "signal": "fail", "note": ""}],
  "evidence": [{"kr": "", "issue": "", "fix": ""}],
  "initiatives": [{"name": "", "krs": ["KR1.1"], "shipped": "yes", "moved": "yes", "verdict": "continue", "note": ""}],
  "learning": {"validated": [""], "invalidated": [""], "surprises": [""]},
  "next_cycle": [{"objective": "O1", "action": "continue", "why": "", "draft": ""}],
  "risks": [""],
  "findings": [{"rule": "NEW", "severity": "medium", "title": "", "where": "KR1.2", "why": "", "fix": ""}],
  "prescan": [{"rule": "OG-GUARDFAIL", "call": "agree", "note": ""}],
  "source_of_truth": ""
}

The page holds either reply to its own check: every error and warning of the lane's family answered by rule id, no rule id the check did not report other than NEW, no ready or hit over an undisputed error. For write it writes the returned set back in the input format and re-checks it (an error still present or newly introduced is a note), refuses a baseline or target marked given that is not in the input, wants an open question for every assumption, all eleven audit criteria and the Disclosure exactly when the work is pre-committed. For grade it wants every key result scored, each score equal to its own (or disputed), no number on a deferred, committed or compliance key result, and each objective's average equal to its read. Each miss is shown next to the reply as a note.

Reading the reply

The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in okreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'grade' - no prose, no code fences." and a new attempt number in the key (see step 5). In "write", the set can be written back in the input format - header lines from set.context, an O1: line per objective, a KR1.1: statement | type: ... | indicator: ... line per key result, and an Initiatives: list - which is what the page shows and re-checks, and what step 5 prints; that text can go straight back in as okrs. Confirm every baseline or target whose basis is assumption or placeholder before the cycle starts. In "grade", the scorecard is the answer; the OKR tracker named in source_of_truth stays the record.

Drafting a set, and the next cycle

A "write" run with an empty okrs drafts a set from context alone (mode: "draft"): describe the strategy, the team and what is already committed in context, and send facts as No OKR set pasted - draft one from the context. (the page asks for at least a sentence or two of context). After a "grade" run, the grading's learnings can be passed as context to a "write" run, with the graded set as okrs or with okrs empty: the reply comes back as mode: "next-cycle" and says which recommendations it followed. The page builds that context from the grade reply, one line each: Cycle review of ... (OKR Desk grade, verdict hit): <summary>, Validated: ..., Invalidated: ..., Surprises: ..., Next cycle O1: <action> - <why> Candidate: <draft> and Initiative <name>: <verdict> - <note>, clipped to fit the 6,000-character context.

Worked example - grading the support team's Q3

A clean closed cycle: two objectives, three aspirational key results (first-contact resolution 62% to 72%, actual 69%; median first reply 4.2 h to 2 h, actual 2.6 h; self-serve rate 31% to 40%, actual 37%), a CSAT guardrail that must stay at or above 90% (actual 91.5%), a learning key result about answer suggestions, and three shipped initiatives. The check finds nothing wrong, and scores 0.70, 0.73, pass, 0.67 and validated. The input below is exactly what the page sends for the "grade" lane; for the "write" lane only task changes to "write", and the same facts go along. Input:

{
 "task": "grade",
 "okrs": "Team: Customer Support\nCycle: 2026-Q3\nSource of truth: Support OKR sheet, Q3 tab\nStrategy: Cut the cost of a solved problem without making customers wait or feel brushed off\n\nO1: Customers get their problem solved the first time they ask\nKR1.1: Raise first-contact resolution from 62% to 72% by 2026-09-30 | type: aspirational | indicator: lagging | source: helpdesk resolution report | actual: 69%\nKR1.2: Cut median time to first reply from 4.2 h to 2 h by 2026-09-30 | type: aspirational | indicator: leading | source: helpdesk SLA report | actual: 2.6 h\nKR1.3: Customer satisfaction on solved tickets stays at or above 90% | type: operational_health | indicator: guardrail | source: post-ticket CSAT survey | actual: 91.5%\n\nO2: Answers customers can find without writing to us\nKR2.1: Increase help-center self-serve rate from 31% to 40% by 2026-09-30 | type: aspirational | indicator: leading | source: help center analytics | actual: 37%\nKR2.2: Learn whether in-product answer suggestions deflect billing tickets | type: learning | indicator: evidence_generation | source: A/B test on the billing contact form | actual: validated - 11% fewer billing tickets in the test arm\n\nInitiatives:\n- Macro library rewrite -> KR1.1 | shipped\n- Follow-the-sun shift pattern -> KR1.2 | shipped\n- Suggested answers in the contact form -> KR2.1, KR2.2 | shipped",
 "context": "",
 "facts": "Set: Customer Support, 2026-Q3; 2 objectives, 5 key results, 3 initiatives; source of truth: Support OKR sheet, Q3 tab.\nStrategic input: Cut the cost of a solved problem without making customers wait or feel brushed off.\nActual values: present - the cycle can be graded.\nPrescan: write verdict ready (0 errors, 0 warnings); grade rules 0 errors, 0 warnings.\nO1: Customers get their problem solved the first time they ask\n  page read: aspirational primary average 0.72 over 2 KRs; guardrails apart: KR1.3 pass; committed misses: none; deferred: none.\n- KR1.1 (O1) | type aspirational | indicator lagging | baseline 62% | target 72% | deadline 2026-09-30 | source helpdesk resolution report | actual 69% | page score 0.70 [(69% - 62%) / (72% - 62%) = 0.70]\n  Raise first-contact resolution from 62% to 72% by 2026-09-30\n- KR1.2 (O1) | type aspirational | indicator leading | baseline 4.2 h | target 2 h | deadline 2026-09-30 | source helpdesk SLA report | actual 2.6 h | page score 0.73 [(2.6 h - 4.2 h) / (2 h - 4.2 h) = 0.73]\n  Cut median time to first reply from 4.2 h to 2 h by 2026-09-30\n- KR1.3 (O1) | type operational_health | indicator guardrail | baseline - | target at least 90% | deadline - | source post-ticket CSAT survey | actual 91.5% | page score pass [91.5% against at least 90%]\n  Customer satisfaction on solved tickets stays at or above 90%\nO2: Answers customers can find without writing to us\n  page read: aspirational primary average 0.67 over 1 KR; guardrails apart: none; committed misses: none; deferred: none.\n- KR2.1 (O2) | type aspirational | indicator leading | baseline 31% | target 40% | deadline 2026-09-30 | source help center analytics | actual 37% | page score 0.67 [(37% - 31%) / (40% - 31%) = 0.67]\n  Increase help-center self-serve rate from 31% to 40% by 2026-09-30\n- KR2.2 (O2) | type learning | indicator evidence_generation | baseline - | target - | deadline - | source A/B test on the billing contact form | actual validated - 11% fewer billing tickets in the test arm | page score validated [learning KR - graded by what it settled]\n  Learn whether in-product answer suggestions deflect billing tickets\nInitiatives:\n- Macro library rewrite -> KR1.1 | shipped\n- Follow-the-sun shift pattern -> KR1.2 | shipped\n- Suggested answers in the contact form -> KR2.1, KR2.2 | shipped\nFindings (rule | severity | line | KR | text):\n- none"
}

Reply - the real saved reply the page shows for this sample, abbreviated: long strings are cut and end in ..., and evidence, initiatives, risks and findings keep only their first entries (the full reply has 3 evidence entries, 3 initiatives, 4 risks and 3 findings):

{
 "task": "grade",
 "verdict": "hit",
 "summary": "Customer Support's 2026-Q3 cycle aimed to cut the cost of a solved problem without making customers wait or feel brushed off. Verdict: hit - the three aspirational KRs scored 0.70, 0.73 and 0.67, inside or just above the 0.6 to 0.7 sweet spot, the CSAT ...",
 "scorecard": [
  {
   "kr": "KR1.1",
   "actual": "69%",
   "score": "0.70",
   "call": "agree",
   "confidence": "high",
   "interpretation": "First-contact resolution rose from 62% to 69% against a 72% target: (69 - 62) / (72 - 62) = 0.70, in the sweet spot for an aspirational KR. It says ..."
  },
  {
   "kr": "KR1.2",
   "actual": "2.6 h",
   "score": "0.73",
   "call": "agree",
   "confidence": "high",
   "interpretation": "Median time to first reply fell from 4.2 h to 2.6 h against a 2 h target: (2.6 - 4.2) / (2 - 4.2) = 0.73, just above the sweet spot and far below the ..."
  },
  {
   "kr": "KR1.3",
   "actual": "91.5%",
   "score": "pass",
   "call": "agree",
   "confidence": "medium",
   "interpretation": "CSAT on solved tickets ended at 91.5%, above the 90% floor, so the guardrail held while O1 pushed speed and resolution. With no baseline recorded, it ..."
  },
  {
   "kr": "KR2.1",
   "actual": "37%",
   "score": "0.67",
   "call": "agree",
   "confidence": "high",
   "interpretation": "Help-center self-serve rate rose from 31% to 37% against a 40% target: (37 - 31) / (40 - 31) = 0.67, in the sweet spot. A higher self-serve rate ..."
  },
  {
   "kr": "KR2.2",
   "actual": "validated - 11% fewer billing tickets in the test arm",
   "score": "validated",
   "call": "agree",
   "confidence": "medium",
   "interpretation": "The A/B test on the billing contact form settled the question: suggested answers deflected billing tickets, with 11% fewer in the test arm. This is a ..."
  }
 ],
 "objectives": [
  {
   "id": "O1",
   "read": "Strong cycle: resolution and reply speed both moved most of the way to stretch targets (0.70 and 0.73) while CSAT on solved tickets held above its ...",
   "average": 0.72
  },
  {
   "id": "O2",
   "read": "Good progress with real learning: self-serve rose 31% to 37% (0.67) and the deflection experiment validated its hypothesis for billing. The KR2.2 ...",
   "average": 0.67
  }
 ],
 "guardrails": [
  {
   "kr": "KR1.3",
   "signal": "pass",
   "note": "91.5% against a floor of at least 90%; held while O1 pushed reply speed and first-contact resolution. It surveys solved tickets only, so it does not ..."
  }
 ],
 "evidence": [
  {
   "kr": "KR1.3",
   "issue": "No baseline: the floor is known but the starting CSAT is not, so a drop that stays above 90% would be invisible.",
   "fix": "Record CSAT on solved tickets at cycle start next to the floor."
  }
 ],
 "initiatives": [
  {
   "name": "Macro library rewrite",
   "krs": [
    "KR1.1"
   ],
   "shipped": "yes",
   "moved": "yes",
   "verdict": "continue",
   "note": "Shipped, and first-contact resolution moved from 62% to 69%. That is apparent contribution, not causal proof: there was no control, and the ..."
  }
 ],
 "learning": {
  "validated": [
   "In-product answer suggestions on the billing contact form deflect billing tickets: 11% fewer in the test arm of the A/B test (KR2.2)."
  ],
  "invalidated": [],
  "surprises": [
   "None recorded in the input; every scored KR landed between 0.67 and 0.73 and the guardrail held."
  ]
 },
 "next_cycle": [
  {
   "objective": "O1",
   "action": "continue",
   "why": "Both KRs moved most of the way and remain short of target (69% vs 72%, 2.6 h vs 2 h) with CSAT intact, so the objective still has room and the bets ...",
   "draft": "Keep O1; carry first-contact resolution from 69% and median first reply from 2.6 h as next cycle's baselines, set new targets with the team, and keep ..."
  },
  {
   "objective": "O2",
   "action": "revise",
   "why": "The learning KR is settled, so it should give way to an outcome KR on the validated bet, and the self-serve push needs its own guardrail.",
   "draft": "Replace KR2.2 with an outcome KR on ticket deflection from suggested answers beyond the billing form (baseline to be measured before a target is ..."
  }
 ],
 "risks": [
  "A 0.73 above the 0.6 to 0.7 band could read as a beaten target; KR1.2 still missed 2 h, and 0.73 is well short of the 0.85 sandbagging signal."
 ],
 "findings": [
  {
   "rule": "NEW",
   "severity": "medium",
   "title": "Self-serve push has no guardrail of its own",
   "where": "KR2.1",
   "why": "Self-serve rate is a volume push and the strategy warns against customers feeling brushed off; KR1.3 surveys solved tickets only, so it cannot see a ...",
   "fix": "Add a guardrail on whether self-serve sessions solved the problem next cycle, baseline measured first."
  }
 ],
 "prescan": [],
 "source_of_truth": "Support OKR sheet, Q3 tab"
}

Sent to "write", the same set comes back ready in mode: "rewrite", essentially as written: the blanks the format requires are filled (a deadline on KR1.3 and KR2.2, recommended-to-measure for the CSAT baseline, a team owner) with open questions asking the team to confirm them. Passing this grading's learnings as context instead makes it a next-cycle run, carrying the grading's calls: replace KR2.2 with an outcome key result and give the self-serve push a guardrail of its own.

Build the input yourself

The checker and the reply checker are plain scripts that run under node (global.window = global; require("./okscan.js"); require("./okreply.js")), then:

const S = OkScan, R = OkReply;
const st = { okrs: fs.readFileSync("okrs.txt", "utf8"), context: "" };
const scan = S.scan(st);                        // findings, counts, byLane, verdict, krs with their scores, reads
const b = S.buildInput(st, "grade");            // or "write"; b.ok is false (with b.note) when the lane cannot run
const wire = S.toWire(b.input);                 // task, okrs, context, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "grade");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong
// R.sheet(reply)                               // a write reply's set in the input format
// R.handoff(reply, "Support Q3")              // a grade reply as the context of a next-cycle write run

Get a token for the steps below from /tokens.html.

Step 1 - a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://okr-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"}
}

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":"okr-desk"}' | jq -r '.data.token')

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

Step 4 - POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-example input above as input.json (with task set to "grade" or "write") to follow along.

# input.json: the run input object - {"task": "grade", "okrs": "Team: Customer Support ...", "context": "", "facts": "Set: Customer Support, 2026-Q3; ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'

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 okr-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a write and a grade run of the same set are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. The code below parses the JSON object out of the output and prints the verdict and the summary; for "grade" it prints each key result's score and call, and for "write" it prints the returned set in the input format, ready to paste into the page or send back as okrs.

KEY="okr-desk:$(jq -r .task input.json):$(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 reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '"verdict: \(.verdict)", .summary,
  if .task == "grade" then (.scorecard[]? | "\(.kr)  \(.score)  \(.call)")
  else (.set.context // {} | (["Team","team"], ["Cycle","cycle"], ["Scope","scope"], ["Source of truth","source_of_truth"], ["Strategy","strategy"]) as $h
          | select((.[$h[1]] // "") != "") | "\($h[0]): \(.[$h[1]])"),
       (.set.objectives[]? | "", "\(.id): \(.title)", (.krs[]? | ([.statement] + [("type","indicator","baseline","target","deadline","source","owner","confidence") as $a
          | select((.[$a] // "") != "") | "\($a): \(.[$a])"] | join(" | ")) as $l | "\(.id): \($l)")),
       "", "Initiatives:", (.set.initiatives[]? | "- \(.name) -> \(.krs | join(", "))")
  end' reply.json   # write: the set back in the input format, ready to paste or send as "okrs"

Step 6 - POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: okr-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json