← Handoff Desk / API
Tokens

Drive Handoff Desk from your own code

A document, not an action. Nothing on your systems is touched: the reply is a handoff for the incoming engineer to read. Your raw notes travel in notes (up to 24,000 characters; longer notes keep the head and the tail and drop the middle with a marker), so strip secrets, credentials and customer data before you send them.

Everything the web page does is available over HTTP. Send the outgoing engineer's raw notes (Slack scraps, alert lines, deploy logs, half sentences) with who is handing over to whom, and get the same handoff back: a readiness verdict (ready, gaps, blocked), a one-sentence headline, a short TL;DR, the lane's body (active incidents, investigations, changes, known issues, upcoming events and escalations for a shift; status, current state, what we know, what we did, next actions, people and comms for an incident), a watch list where every item says when to act, the gaps with the exact question to ask before the outgoing engineer logs off, a checklist for the incoming engineer, and one answer per browser flag. The natural use is a rotation hook: collect the shift's notes, run them through here, and post the handoff in the on-call channel.

The model does not do the reading alone. Ticket, PR, version, person and severity references, the seven handoff sections, silenced alerts with no expiry, active incidents with no owner or next step, sections that are neither filled nor explicitly "none", clock times with no timezone, "keep an eye on" items with no trigger and the outgoing engineer's availability are all worked out first by the page's free prescan in handoffkit.js and sent as facts, a JSON string. See the facts string.

Two lanes: the task field

Every request names its lane in task. Handoff Desk has two, with two different reply contracts.

taskwhat it is forlane body in the reply
shiftThe end-of-shift handoff: the outgoing on-call engineer hands the pager to the next one. What is broken now, what is still being debugged, what was fixed, what changed, what is known and worked around, what is coming up and who to escalate to.active_incidents, investigations, resolved, changes, known_issues, upcoming, escalations.
incidentThe mid-incident handoff: the incident commander role changes hands while the incident is still open. Where it stands, what is known and done, what the incoming commander does next and on what condition, who holds which role, and what has been communicated.incident, current_state, what_we_know, what_we_did, next_actions, people, comms.

Both lanes share one envelope (lane, readiness, headline, tldr, watch_list, gaps, incoming_checklist, prescan_responses); see the output contract. If task is missing or is any other value, the model chooses the closer lane from the notes, answers with that lane's contract (never a blend of the two) and names the lane it chose in lane. Do not rely on that: always send "shift" or "incident". The page's own guard (HandoffKit.mustBeObject) refuses any other value, and its checks flag a reply whose lane differs from the task asked.

The prescan is lane-aware too, so compute facts for the same lane you send in task (facts.lane says which): a shift prescan looks for missing sections and unowned active incidents, an incident prescan for a current status, a next step, a severity, a start time, named roles and comms. The same notes run in the two lanes are two different runs, with two different Idempotency-Keys.

Input fields

The body is one flat JSON object, built in the page by HandoffKit.buildInput. Every value is a string. Required: task, notes, facts.

fieldtyperequiredwhat it holds
taskstringyes"shift" or "incident"; see the lanes.
teamstringnoThe team or rotation ("Platform"). Context only. Cut at 160 characters.
outgoingstringnoThe engineer handing over ("@maya"). Cut at 80 characters. The page counts it as a person the handoff may name.
incomingstringnoThe engineer taking over ("@tomas"). Cut at 80 characters. Also counts as a known person.
handoff_atstringnoWhen the handoff happens, as written ("Mon 09:00", "10:40 UTC"). Cut at 80 characters.
notesstringyesThe raw notes, newline-separated. At most 24,000 characters: longer notes are cut on whole lines, keeping about the first 35% and the tail, with a marker line in between: [... N lines from the middle of the notes not sent; their ticket, PR and version references are in facts ...]. The model is told the marker may appear.
factsstringyesA JSON string (the output of JSON.stringify), never an object. In the page it is the browser's free prescan of the WHOLE notes, including any clipped middle; its keys are listed below.
questionstringnoYour own question, answered inside tldr as a bullet starting "Answer:". Cut at 1,200 characters (the cut is marked [question cut]); the page sends "" when empty.
retry_notestringnoOnly on a reformat retry, after a reply that could not be parsed: say what was wrong. Never on a first run.

The facts string

In the web page, facts is computed by the browser before you pay for anything: the free prescan reads the notes, extracts every reference, sorts lines into the handoff sections, raises the flags you see on the page and serializes the result. An API caller has two options: build the same object with handoffkit.js, which runs unchanged in Node (see building the body), or compute and send a minimal one yourself.

keywhat it holds
laneThe lane the prescan ran for: shift or incident. Match it to task.
line_count, wordsNon-empty lines and words in the notes.
referencestickets (ENG-4471), prs (#2218), versions (v5.12.0), people (lower-cased @handles), severities (SEV2, P1), each deduplicated in first-seen order, and links, a count of URLs. The model must carry every ticket, PR and version somewhere in the handoff and add none that is not here.
componentsOne entry per handoff section (active, investigations, resolved, changes, known, upcoming, escalation): lines (how many lines look like it) and none_stated (the notes explicitly said "none").
silenced_alerts{total, open}: silenced, muted or acknowledged alerts, and how many have no expiry or re-enable time.
watch_items{total, vague}: "keep an eye on" lines, and how many give no threshold or trigger.
times{with_tz, without_tz, declared}: clock times with and without a timezone on their line, and a zone the notes declare for all times ("All times UTC"), else "".
flagsEvery flag: id (F1..), severity (high, medium, low), category, message and up to three examples ("L14: ..."). Categories: silenced_alert (high), active_unowned (high, shift), missing_active, missing_investigations, missing_changes, missing_known, missing_upcoming (medium, shift), no_status and no_next (high, incident), no_severity, no_start, no_roles, no_comms (medium, incident), no_timezone and vague_watch (medium), no_escalation (low, shift) and no_availability (low).
browser_readinessThe prescan's hint: blocked (any high flag), gaps (any medium), else ready. The model's readiness is never looser unless it dismissed the flags that set it.
clippednull, or {total_lines, dropped_lines} when the middle of the notes was cut.

The flags drive the reply. Every flag must come back exactly once in prescan_responses (confirmed or dismissed), and every confirmed high or medium flag must become an entry in gaps with that flag's id in ref. An empty flags list is allowed: there is then nothing to confirm or dismiss, prescan_responses comes back [], and the gaps and the readiness rest on the model's own reading of the notes under the same rules (a silenced alert with no expiry is still blocked). A minimal facts, before JSON.stringify, for a caller that runs no prescan:

{"lane":"shift","references":{"tickets":[],"prs":[],"versions":[],"people":[],"severities":[]},"flags":[],"browser_readiness":"ready"}

Fill references with the ids you can extract from the notes if you can: the model is told to carry every one of them, and your own checks compare against them. Keep browser_readiness at "ready" when you send no flags, so it sets no floor the notes do not justify.

Building the body

The surest way to match the page is to run the page's own engine. Load handoffkit.js (it runs unchanged in Node via require()) and give analyze the same fields the page's form has; buildInput then clips the notes and the text fields and serializes the facts:

set fieldwhat it holds
laneshift or incident (anything else is analyzed as shift). Becomes task.
teamThe team or rotation.
outgoing, incomingWho hands over and who takes over, usually @handles.
handoff_atWhen, as written.
notesThe raw notes.
// make-body.js - build the run body with the SAME engine the web page uses.
// Save handoffkit.js from https://handoff-desk.skillsafe.ai/handoffkit.js next to this file.
// Usage: node make-body.js notes.txt shift      (or: incident)
const fs = require("fs");
const K = require("./handoffkit.js");

const notes = fs.readFileSync(process.argv[2] || "notes.txt", "utf8");
const A = K.analyze({
  lane: process.argv[3] || "shift",   // shift or incident
  team: "Platform",
  outgoing: "@maya",
  incoming: "@tomas",
  handoff_at: "Mon 09:00",
  notes: notes
});
// Pass the raw notes as opts.notes, as the page does; without it buildInput re-joins only the
// non-empty lines, which is a different body and a different hash.
const body = K.buildInput(A, { notes: notes, question: "" });   // a non-empty question is answered in tldr
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(A.flags.length, "flags; browser readiness", A.hint);
console.log("Idempotency-Key: handoff-desk:" + body.task + ":" + K.hashInput(body) + ":a1");

Run on the two notes from the page's examples, this produces the worked requests below: the platform shift (hash 1jtbulgsblat2) and the payments incident (hash 1yri9opwps9ev). mustBeObject is the page's own guard: it throws unless the body is a plain object with task shift or incident and non-empty notes. From another language, send the same field names and build facts with the keys above.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"handoff-desk"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body is answered with an unknown field 'input' warning, and the model never sees your notes.

Error codes

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="handoff-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://handoff-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"handoff-desk"}, can call /me and /estimate; the run is metered, so /run and /run-stream need a personal token.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://handoff-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"handoff-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra and markup_bps 1000 (a 10% markup). hold_credits is a reservation, not the price: it is held against your balance while the run executes and released afterwards. min_credits is the least balance that can start a run. What you actually pay is charged_credits, reported on the finished job and in the done event, and it is usually far lower than the hold. The body is the input object itself, with no {"input": …} wrapper. /estimate does not validate the body, so check the shape yourself: an object whose every value is a string, task equal to shift or incident, notes and facts non-empty, and facts a JSON string that parses to an object.

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("shift","incident") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("notes","facts")) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: JSON.parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, handoff-desk:<lane>:<hash>:a<attempt> (for example handoff-desk:shift:1jtbulgsblat2:a1), so a retried request returns the same job instead of billing a second run. Use one key per distinct input: changed notes or changed facts are a new hash, the same notes in the other lane are a new key, and replaying an old key with a different body is a 409. The page uses HandoffKit.hashInput(body) for the hash (make-body.js prints that key); any stable digest of the body works from other languages. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')   # shift or incident
KEY="handoff-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"shift\",\"readiness\":\"blocked\",\"headline\":\"...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"shift\",\"readiness\":\"blocked\",\"headline\":\"No active"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object, delivered as a string in data.output.output; you must JSON.parse it. The model is told to send no code fences, but tolerate them: strip a leading ```json and a trailing ```, keep everything from the first { to the last }, and parse that outer object. Then branch on lane: the envelope keys are the same for both lanes, the body keys are not.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json, re
t = open("reply.json").read().strip()
t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["lane"], r["readiness"], "-", r["headline"])
for g in r["gaps"]:
    print("GAP", g["ref"] or "-", "|", g["gap"], "| ask:", g["ask"])
for w in r["watch_list"]:
    print("WATCH", w["item"], "| when:", w["trigger"] or "(no trigger)", "| do:", w["action"])
if r["lane"] == "shift":
    for c in r["changes"]:
        print("CHANGE", c["when"], c["kind"], c["what"])
else:
    print("INCIDENT", r["incident"]["ref"], r["incident"]["severity"], r["incident"]["status"])
    for a in r["next_actions"]:
        print("NEXT", a["action"], "| if:", a["if"])
EOF

Invariants worth asserting

The web page holds every reply to the browser's facts and to the notes before it shows it (recon.js, reconcile()). Do the same before you post a handoff to a channel:

# assert-reply.py - the core checks, for body.json and reply.json from the steps above.
import json, re
body = json.load(open("body.json")); facts = json.loads(body["facts"])
t = open("reply.json").read(); r = json.loads(t[t.index("{"):t.rindex("}") + 1])
COMMON = ["lane", "readiness", "headline", "tldr", "watch_list", "gaps", "incoming_checklist", "prescan_responses"]
BODY = {"shift": ["active_incidents", "investigations", "resolved", "changes", "known_issues", "upcoming", "escalations"],
        "incident": ["incident", "current_state", "what_we_know", "what_we_did", "next_actions", "people", "comms"]}
assert r["lane"] == body["task"], "answered as " + r["lane"] + ", asked for " + body["task"]
missing = [k for k in COMMON + BODY[r["lane"]] if k not in r]
assert not missing, "keys missing: " + ", ".join(missing)
# Flags: each answered exactly once, none invented, confirmed high/medium carried as gaps.
flags = {f["id"]: f for f in facts.get("flags", [])}
answered = [i for p in r["prescan_responses"] for i in re.split(r"[\s,]+", p["ref"].upper()) if i]
assert sorted(answered) == sorted(flags), "every flag answered exactly once"
assert all(p["verdict"] in ("confirmed", "dismissed") for p in r["prescan_responses"])
dismissed = {p["ref"].upper() for p in r["prescan_responses"] if p["verdict"] == "dismissed"}
standing = [f for i, f in flags.items() if i not in dismissed]
gap_refs = {i for g in r["gaps"] for i in re.split(r"[\s,]+", g["ref"].upper()) if i}
assert all(f["id"] in gap_refs for f in standing if f["severity"] != "low"), "a confirmed high/medium flag has no gap"
# Readiness: never looser than the flags left standing.
rank = ["ready", "gaps", "blocked"]
floor = 2 if any(f["severity"] == "high" for f in standing) else 1 if any(f["severity"] == "medium" for f in standing) else 0
assert rank.index(r["readiness"]) >= floor, "readiness looser than the flags left standing"
# References: the same extraction on both sides. Nothing invented, nothing dropped.
def refs(text):
    tickets = {x for x in re.findall(r"\b[A-Z][A-Z0-9]{1,9}-\d{1,6}\b", text)
               if not re.match(r"(SEV|P|UTC|GMT|HTTP|TLS|SHA|ISO|RFC|UTF)-", x, re.I)}
    prs = {"#" + n for n in re.findall(r"(?:^|[\s(])#(\d{2,6})\b", text, re.M)}
    versions = set(re.findall(r"\bv\d+(?:\.\d+){1,3}(?:-[0-9A-Za-z.]+)?\b", text))
    people = {"@" + h.rstrip(".-").lower() for _, h in
              re.findall(r"(^|[^A-Za-z0-9_./])@([A-Za-z0-9][A-Za-z0-9_.-]{0,38}[A-Za-z0-9])", text, re.M)}
    return tickets | prs | versions, people
def strings(v):
    if isinstance(v, str): yield v
    elif isinstance(v, list):
        for x in v: yield from strings(x)
    elif isinstance(v, dict):
        for k, x in v.items():
            if k != "prescan_responses": yield from strings(x)
out_ids, out_people = refs("\n".join(strings(r)))
R = facts.get("references", {})
in_ids = {x.lower() for k in ("tickets", "prs", "versions") for x in R.get(k, [])}
in_people = {p.lower() for p in R.get("people", [])} | {"@" + body.get(k, "").lstrip("@").lower() for k in ("outgoing", "incoming") if body.get(k)}
invented = sorted(x for x in out_ids if x.lower() not in in_ids) + sorted(p for p in out_people if p not in in_people)
assert not invented, "not in the notes: " + ", ".join(invented)
dropped = sorted(x for x in in_ids if x not in {y.lower() for y in out_ids})
assert not dropped, "dropped from the handoff: " + ", ".join(dropped)
# Watch list: an item without a trigger needs a gap that asks for one.
assert all(w["trigger"] for w in r["watch_list"]) or r["gaps"], "a watch item has no trigger and no gap asks"
# Lane specifics.
if r["lane"] == "shift":
    assert all(c["kind"] in ("deploy", "config", "infra", "flag", "other") for c in r["changes"])
else:
    inc = r["incident"]
    assert inc["status"] in ("investigating", "identified", "mitigating", "monitoring", "resolved")
    sev = inc["severity"].replace(" ", "").replace("-", "").upper()
    assert not sev or sev in R.get("severities", []), "severity " + sev + " is not in the notes"
    assert r["next_actions"], "no next actions for the incoming commander"
print("ok:", r["lane"], r["readiness"], len(r["gaps"]), "gaps")

Both worked examples below pass every one of these checks.

The output contract

Every key of the lane's contract is always present. Arrays may be empty ([], never a filler such as "None") and strings may be "" when the notes do not say. An enum is written "a|b|c": the reply carries exactly one of the values. Text fields are plain prose, each under 300 characters: no Markdown, no emoji. Times are copied as written, with the timezone the notes gave and never converted.

The common envelope (both lanes)

{"lane":"shift|incident",
 "readiness":"ready|gaps|blocked",
 "headline":"...",
 "tldr":["...","..."],
 ...the lane body...,
 "watch_list":[{"item":"...","trigger":"...","action":"..."}],
 "gaps":[{"ref":"F1","gap":"...","ask":"..."}],
 "incoming_checklist":["..."],
 "prescan_responses":[{"ref":"F1","verdict":"confirmed|dismissed","note":"..."}]}
keyshapewhat it holds
lanestringThe lane answered: your task, or the closer lane when task was missing or unknown.
readinessenumCan the incoming engineer take over now; see the next table.
headlinestringOne sentence: the state of the pager in plain words.
tldrarray of strings2-5 bullets. When you sent a question, one bullet starts "Answer:".
watch_listarray of {item, trigger, action}What to watch. trigger is the observable condition that means act (a number, a time, an alert name), taken from the notes, or "" with a gap asking for one; action is what to do when it fires, or "".
gapsarray of {ref, gap, ask}One per confirmed high or medium flag (ref is the flag id), plus any the model found in the notes (ref ""). ask is the exact question to put to the outgoing engineer before they log off.
incoming_checklistarray of strings3-7 concrete first actions, always including confirming that paging reaches the incoming engineer; in the incident lane also announcing the change of incident commander in the incident channel.
prescan_responsesarray of {ref, verdict, note}Exactly one per flag in facts.flags: confirmed (a real gap) or dismissed (the note says where the notes show it is wrong or harmless). Empty when no flags were sent.

The shift body

{"active_incidents":[{"title":"...","ref":"ENG-1234","severity":"SEV2","status":"...","impact":"...","owner":"@alice","next_steps":["..."]}],
 "investigations":[{"title":"...","ref":"...","status":"...","since":"...","impact":"...","context":["..."],"next_steps":["..."]}],
 "resolved":[{"title":"...","ref":"...","duration":"19 min","cause":"...","resolution":"...","follow_ups":["ENG-1235"]}],
 "changes":[{"when":"thu 14:00","kind":"deploy|config|infra|flag|other","what":"api-gateway v3.8.1 (...)","ref":"..."}],
 "known_issues":[{"issue":"...","workaround":"...","ref":"..."}],
 "upcoming":[{"when":"...","event":"...","impact":"...","contact":"@dba-oncall"}],
 "escalations":[{"issue_type":"...","first":"@...","second":"@..."}]}
keywhat it holds
active_incidentsWhat is broken right now. ref is a ticket id or ""; severity as written (SEV2, P1) or ""; owner an @person from the notes or "".
investigationsIssues still being debugged or monitored, with what is known so far (context) and next_steps.
resolvedFixed during the shift; follow_ups are the ticket ids or actions left.
changesDeploys, config, infrastructure and feature flag changes, the version in what.
known_issuesThings that look broken but are known, with the workaround.
upcomingMaintenance, releases, campaigns and freezes during the incoming shift.
escalationsWho to call, first and second, per kind of issue: only contacts named in the notes.

The incident body

{"incident":{"title":"...","severity":"SEV2","started":"08:15 UTC","status":"investigating|identified|mitigating|monitoring|resolved","ref":"INC-2291"},
 "current_state":["error rate on /checkout 12% ..."],
 "what_we_know":["...","Suspicion (not confirmed): ..."],
 "what_we_did":["...in order..."],
 "next_actions":[{"action":"...","if":"error rate is not dropping by 11:15 UTC","owner":"@..."}],
 "people":[{"role":"incident commander (incoming)","who":"@..."}],
 "comms":[{"channel":"status page","state":"..."}]}
keywhat it holds
incidentThe incident itself. severity exactly as the notes give it, or ""; status one of five values.
current_stateThe numbers and symptoms right now (error rate, latency, what is down).
what_we_knowEstablished facts about cause and trigger; suspicion is labelled as suspicion.
what_we_didMitigations already applied, in order.
next_actionsWhat the incoming commander does next. if is the deciding condition from the notes, or "".
peopleIncident commander (outgoing and incoming), comms lead, technical lead, as named in the notes.
commsStatus page, customer support, executives, incident channel: what has been said and when.

Readiness

readinessmeaning
blockedThe incoming engineer cannot safely take the pager yet: a silenced alert with no expiry, an active incident with no owner or no next step, no current status or next step mid-incident, or any confirmed high flag.
gapsUsable, but questions must be answered before the outgoing engineer leaves (confirmed medium flags).
readyNothing confirmed at high or medium.

The model may be stricter than facts.browser_readiness, never looser, unless it dismissed the flags that set it.

Enums

wherevaluesthe page's fallback
laneshift, incidentthe lane asked
readinessready, gaps, blockedgaps
changes[].kinddeploy, config, infra, flag, otherother
incident.statusinvestigating, identified, mitigating, monitoring, resolved""
prescan_responses[].verdictconfirmed, dismissedconfirmed

Worked example: shift

The platform team's end-of-week notes from the page's examples, handed from @maya to @tomas. The prescan raised four flags: F1 high (the disk-usage alert on db-replica-3 is silenced with no expiry), F2 medium (five clock times with no timezone), F3 medium (the v6.0 release has "keep an eye on the gateway" with no trigger) and F4 low (the outgoing engineer's availability is not stated), so browser_readiness is blocked. This request is complete and sendable as it stands; its Idempotency-Key from the page is handoff-desk:shift:1jtbulgsblat2:a1.

The request body:

{
 "task": "shift",
 "team": "Platform",
 "outgoing": "@maya",
 "incoming": "@tomas",
 "handoff_at": "Mon 09:00",
 "notes": "platform oncall wk 38 - maya -> tomas\n\nno active incidents right now, pager has been quiet since sat\n\nENG-4471 api p99 spikes - still investigating. spikes line up with the nightly backup 02:00-03:00, suspect lock contention on orders db. added slow query logging in PR #2218 (deployed thu). next: check the logs after tonight's backup, if confirmed move backup window\nENG-4480 auth-svc memory creeping ~4%/day, no leak in heap profile yet. restart if it goes over 80%\n\nwed: checkout 502s for 19 min, resolved. rolled back cart-svc v5.12.0 -> v5.11.3, pool exhaustion. postmortem PM-112, follow ups ENG-4476 ENG-4477\n\ndeploys: api-gateway v3.8.1 thu 14:00 (header parsing fix), notifications v2.4.0 fri 11:30\nconfig: bumped orders db pool max 60 -> 90 on wed\nadded 2 nodes to the prod-eu cluster\n\nsilenced the disk-usage alert on db-replica-3 - it's noisy, disk is fine\ngrafana slow on monday mornings, just wait for cache warmup\n\ntue 02:00 db maintenance, 5 min read only (@dba-oncall)\nthu v6.0 release - keep an eye on the gateway\n\nescalate payments stuff to @payments-oncall then @lena\ndb problems -> @dba-oncall",
 "facts": "{\"lane\":\"shift\",\"line_count\":14,\"words\":181,\"references\":{\"tickets\":[\"ENG-4471\",\"ENG-4480\",\"PM-112\",\"ENG-4476\",\"ENG-4477\"],\"prs\":[\"#2218\"],\"versions\":[\"v5.12.0\",\"v5.11.3\",\"v3.8.1\",\"v2.4.0\",\"v6.0\"],\"people\":[\"@dba-oncall\",\"@payments-oncall\",\"@lena\"],\"severities\":[],\"links\":0},\"components\":{\"active\":{\"lines\":0,\"none_stated\":true},\"investigations\":{\"lines\":2,\"none_stated\":false},\"resolved\":{\"lines\":1,\"none_stated\":false},\"changes\":{\"lines\":4,\"none_stated\":false},\"known\":{\"lines\":1,\"none_stated\":false},\"upcoming\":{\"lines\":3,\"none_stated\":false},\"escalation\":{\"lines\":1,\"none_stated\":false}},\"silenced_alerts\":{\"total\":1,\"open\":1},\"watch_items\":{\"total\":1,\"vague\":1},\"times\":{\"with_tz\":0,\"without_tz\":5,\"declared\":\"\"},\"flags\":[{\"id\":\"F1\",\"severity\":\"high\",\"category\":\"silenced_alert\",\"message\":\"1 silenced, muted or acknowledged alert with no expiry or re-enable time. The incoming engineer will not be paged for it.\",\"examples\":[\"L14: silenced the disk-usage alert on db-replica-3 - it's noisy, disk is fine\"]},{\"id\":\"F2\",\"severity\":\"medium\",\"category\":\"no_timezone\",\"message\":\"5 clock times with no timezone (02:00, 03:00, 14:00, 11:30). Handoffs cross timezones; say UTC or state one zone for the whole note.\",\"examples\":[\"L5: ENG-4471 api p99 spikes - still investigating. spikes line up with the nightly backup 02:00-03:00, suspect lock contention on orders db. ...\",\"L10: deploys: api-gateway v3.8.1 thu 14:00 (header parsing fix), notifications v2.4.0 fri 11:30\",\"L17: tue 02:00 db maintenance, 5 min read only (@dba-oncall)\"]},{\"id\":\"F3\",\"severity\":\"medium\",\"category\":\"vague_watch\",\"message\":\"1 \\\"keep an eye on\\\" item with no threshold or trigger - the incoming engineer cannot tell when to act.\",\"examples\":[\"L18: thu v6.0 release - keep an eye on the gateway\"]},{\"id\":\"F4\",\"severity\":\"low\",\"category\":\"no_availability\",\"message\":\"The outgoing engineer's availability after handoff is not stated (the skill asks for 15 minutes on Slack).\",\"examples\":[]}],\"browser_readiness\":\"blocked\",\"clipped\":null}",
 "question": ""
}

Its facts, decoded:

{
 "lane": "shift",
 "line_count": 14,
 "words": 181,
 "references": {"tickets":["ENG-4471","ENG-4480","PM-112","ENG-4476","ENG-4477"],"prs":["#2218"],"versions":["v5.12.0","v5.11.3","v3.8.1","v2.4.0","v6.0"],"people":["@dba-oncall","@payments-oncall","@lena"],"severities":[],"links":0},
 "components": {
  "active": {"lines":0,"none_stated":true},
  "investigations": {"lines":2,"none_stated":false},
  "resolved": {"lines":1,"none_stated":false},
  "changes": {"lines":4,"none_stated":false},
  "known": {"lines":1,"none_stated":false},
  "upcoming": {"lines":3,"none_stated":false},
  "escalation": {"lines":1,"none_stated":false}
 },
 "silenced_alerts": {"total":1,"open":1},
 "watch_items": {"total":1,"vague":1},
 "times": {"with_tz":0,"without_tz":5,"declared":""},
 "flags": [
  {"id":"F1","severity":"high","category":"silenced_alert","message":"1 silenced, muted or acknowledged alert with no expiry or re-enable time. The incoming engineer will not be paged for it.","examples":["L14: silenced the disk-usage alert on db-replica-3 - it's noisy, disk is fine"]},
  {"id":"F2","severity":"medium","category":"no_timezone","message":"5 clock times with no timezone (02:00, 03:00, 14:00, 11:30). Handoffs cross timezones; say UTC or state one zone for the whole note.","examples":["L5: ENG-4471 api p99 spikes - still investigating. spikes line up with the nightly backup 02:00-03:00, suspect lock contention on orders db. ...","L10: deploys: api-gateway v3.8.1 thu 14:00 (header parsing fix), notifications v2.4.0 fri 11:30","L17: tue 02:00 db maintenance, 5 min read only (@dba-oncall)"]},
  {"id":"F3","severity":"medium","category":"vague_watch","message":"1 \"keep an eye on\" item with no threshold or trigger - the incoming engineer cannot tell when to act.","examples":["L18: thu v6.0 release - keep an eye on the gateway"]},
  {"id":"F4","severity":"low","category":"no_availability","message":"The outgoing engineer's availability after handoff is not stated (the skill asks for 15 minutes on Slack).","examples":[]}
 ],
 "browser_readiness": "blocked",
 "clipped": null
}

The reply, parsed from data.output.output and abbreviated (3 of 5 TL;DR bullets, 1 of 2 investigations, 3 of 7 changes, 3 of 4 watch items, 2 of 4 gaps and 3 of 6 checklist items are shown; every other key is complete). The model confirmed all four flags, carried every one as a gap (the low F4 too, which is allowed), kept blocked, and left the gateway watch item's trigger empty because the notes give none, with F3 asking for one.

{
 "lane": "shift",
 "readiness": "blocked",
 "headline": "No active incidents and the pager has been quiet since Sat, but the db-replica-3 disk-usage alert is silenced with no expiry, so do not take the pager until that is settled.",
 "tldr": [
  "No active incidents; pager quiet since Sat.",
  "Two open investigations: ENG-4471 api p99 spikes during the 02:00-03:00 backup, and ENG-4480 auth-svc memory creeping ~4%/day (restart if over 80%).",
  "Blocking: disk-usage alert on db-replica-3 is silenced with no re-enable time."
 ],
 "active_incidents": [],
 "investigations": [
  {
   "title": "api p99 spikes",
   "ref": "ENG-4471",
   "status": "investigating",
   "since": "",
   "impact": "api p99 latency spikes during the nightly backup 02:00-03:00",
   "context": [
    "Spikes line up with the nightly backup 02:00-03:00.",
    "Suspect lock contention on orders db (not confirmed).",
    "Slow query logging added in PR #2218, deployed thu."
   ],
   "next_steps": [
    "Check the slow query logs after tonight's backup.",
    "If lock contention is confirmed, move the backup window."
   ]
  }
 ],
 "resolved": [
  {
   "title": "checkout 502s (wed)",
   "ref": "PM-112",
   "duration": "19 min",
   "cause": "Pool exhaustion after cart-svc v5.12.0.",
   "resolution": "Rolled back cart-svc v5.12.0 -> v5.11.3.",
   "follow_ups": [
    "PM-112 postmortem",
    "ENG-4476",
    "ENG-4477"
   ]
  }
 ],
 "changes": [
  {
   "when": "wed",
   "kind": "deploy",
   "what": "Rolled back cart-svc v5.12.0 -> v5.11.3 (checkout 502s).",
   "ref": "PM-112"
  },
  {
   "when": "thu 14:00",
   "kind": "deploy",
   "what": "api-gateway v3.8.1 (header parsing fix).",
   "ref": ""
  },
  {
   "when": "",
   "kind": "config",
   "what": "Silenced the disk-usage alert on db-replica-3 (called noisy, disk said to be fine); no expiry given.",
   "ref": ""
  }
 ],
 "known_issues": [
  {
   "issue": "Grafana is slow on monday mornings.",
   "workaround": "Wait for the cache warmup.",
   "ref": ""
  }
 ],
 "upcoming": [
  {
   "when": "tue 02:00",
   "event": "db maintenance",
   "impact": "5 min read only",
   "contact": "@dba-oncall"
  },
  {
   "when": "thu",
   "event": "v6.0 release",
   "impact": "Keep an eye on the gateway.",
   "contact": ""
  }
 ],
 "escalations": [
  {
   "issue_type": "payments",
   "first": "@payments-oncall",
   "second": "@lena"
  },
  {
   "issue_type": "db problems",
   "first": "@dba-oncall",
   "second": ""
  }
 ],
 "watch_list": [
  {
   "item": "auth-svc memory (ENG-4480)",
   "trigger": "memory over 80%",
   "action": "Restart auth-svc."
  },
  {
   "item": "gateway during the thu v6.0 release",
   "trigger": "",
   "action": ""
  },
  {
   "item": "disk usage on db-replica-3 (alert silenced)",
   "trigger": "",
   "action": ""
  }
 ],
 "gaps": [
  {
   "ref": "F1",
   "gap": "The disk-usage alert on db-replica-3 is silenced with no expiry or re-enable time; nothing will page on that disk.",
   "ask": "When does the db-replica-3 disk-usage alert come back on, and who re-enables it? Can you set an expiry before you log off?"
  },
  {
   "ref": "F3",
   "gap": "The thu v6.0 release says keep an eye on the gateway but gives no metric, threshold or action.",
   "ask": "For the thu v6.0 release, what gateway signal means act, at what level, what do I do, and who owns the release?"
  }
 ],
 "incoming_checklist": [
  "Confirm the pager routes to you: have @maya fire a test alert before she logs off.",
  "Get an expiry on the db-replica-3 disk-usage silence or unmute it.",
  "After tonight's backup, check the PR #2218 slow query logs for ENG-4471."
 ],
 "prescan_responses": [
  {
   "ref": "F1",
   "verdict": "confirmed",
   "note": "L14 silences the db-replica-3 disk-usage alert with no expiry or re-enable time."
  },
  {
   "ref": "F2",
   "verdict": "confirmed",
   "note": "No timezone is stated anywhere in the notes."
  },
  {
   "ref": "F3",
   "verdict": "confirmed",
   "note": "L18 says keep an eye on the gateway with no threshold or action."
  },
  {
   "ref": "F4",
   "verdict": "confirmed",
   "note": "The notes never say how long @maya stays reachable."
  }
 ]
}

Worked example: incident

A checkout degradation, part-way through: @ravi hands incident command to @jo. The notes give a status, a severity, a start time in UTC and conditional next steps, but name no roles and say nothing about comms, so the prescan raised two medium flags (F1 no_roles, F2 no_comms) and browser_readiness is gaps. Its Idempotency-Key from the page is handoff-desk:incident:1yri9opwps9ev:a1.

The request body:

{
 "task": "incident",
 "team": "Payments",
 "outgoing": "@ravi",
 "incoming": "@jo",
 "handoff_at": "10:40 UTC",
 "notes": "INC-2291 checkout errors SEV2 - started 08:15 UTC\nstatus: mitigating\nerror rate on /checkout 12% now, was 38% at peak (09:05 UTC). p99 2.4s\n\nwhat we know: payment-svc pods OOMing under ~3x normal traffic (partner promo went live 08:00 UTC)\nthe new fraud-score query from v7.4.2 is doing a full scan, suspect that is the memory hog (not confirmed)\n\ndone so far:\n- scaled payment-svc 6 -> 18 pods\n- rate limited /checkout at 400 rps per region\n- turned off the recommendations widget flag on the cart page\n\nnext:\n- watch the error rate, should be under 1% within ~20 min of the scale-out finishing\n- if it isn't dropping by 11:15 UTC roll back payment-svc to v7.4.1\n- once stable open a follow up for the fraud-score query (ENG-5102 is the existing ticket)\n\ni'm on slack till 11:30 UTC",
 "facts": "{\"lane\":\"incident\",\"line_count\":14,\"words\":141,\"references\":{\"tickets\":[\"INC-2291\",\"ENG-5102\"],\"prs\":[],\"versions\":[\"v7.4.2\",\"v7.4.1\"],\"people\":[],\"severities\":[\"SEV2\"],\"links\":0},\"components\":{\"active\":{\"lines\":1,\"none_stated\":false},\"investigations\":{\"lines\":1,\"none_stated\":false},\"resolved\":{\"lines\":0,\"none_stated\":false},\"changes\":{\"lines\":1,\"none_stated\":false},\"known\":{\"lines\":0,\"none_stated\":false},\"upcoming\":{\"lines\":0,\"none_stated\":false},\"escalation\":{\"lines\":0,\"none_stated\":false}},\"silenced_alerts\":{\"total\":0,\"open\":0},\"watch_items\":{\"total\":1,\"vague\":0},\"times\":{\"with_tz\":5,\"without_tz\":0,\"declared\":\"\"},\"flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"no_roles\",\"message\":\"No roles named (incident commander, comms lead, technical lead).\",\"examples\":[]},{\"id\":\"F2\",\"severity\":\"medium\",\"category\":\"no_comms\",\"message\":\"Nothing about communication: status page, customers, support or executives.\",\"examples\":[]}],\"browser_readiness\":\"gaps\",\"clipped\":null}",
 "question": ""
}

Its facts, decoded:

{
 "lane": "incident",
 "line_count": 14,
 "words": 141,
 "references": {"tickets":["INC-2291","ENG-5102"],"prs":[],"versions":["v7.4.2","v7.4.1"],"people":[],"severities":["SEV2"],"links":0},
 "components": {
  "active": {"lines":1,"none_stated":false},
  "investigations": {"lines":1,"none_stated":false},
  "resolved": {"lines":0,"none_stated":false},
  "changes": {"lines":1,"none_stated":false},
  "known": {"lines":0,"none_stated":false},
  "upcoming": {"lines":0,"none_stated":false},
  "escalation": {"lines":0,"none_stated":false}
 },
 "silenced_alerts": {"total":0,"open":0},
 "watch_items": {"total":1,"vague":0},
 "times": {"with_tz":5,"without_tz":0,"declared":""},
 "flags": [
  {"id":"F1","severity":"medium","category":"no_roles","message":"No roles named (incident commander, comms lead, technical lead).","examples":[]},
  {"id":"F2","severity":"medium","category":"no_comms","message":"Nothing about communication: status page, customers, support or executives.","examples":[]}
 ],
 "browser_readiness": "gaps",
 "clipped": null
}

The reply, parsed from data.output.output and abbreviated (2 of 4 TL;DR bullets and 3 of 5 checklist items are shown; every other key is complete). Both flags are confirmed and carried as gaps, the model added two gaps of its own (ref ""), the severity is the SEV2 the notes gave, and comms is [] because the notes are silent about it.

{
 "lane": "incident",
 "readiness": "gaps",
 "headline": "INC-2291 SEV2 checkout errors is being mitigated: /checkout errors are down to 12% from a 38% peak, with a rollback decision due at 11:15 UTC.",
 "tldr": [
  "INC-2291 SEV2, started 08:15 UTC, status mitigating; /checkout error rate 12% (peak 38% at 09:05 UTC), p99 2.4s.",
  "Decision point: if the error rate is not dropping by 11:15 UTC, roll back payment-svc to v7.4.1."
 ],
 "incident": {
  "title": "checkout errors",
  "severity": "SEV2",
  "started": "08:15 UTC",
  "status": "mitigating",
  "ref": "INC-2291"
 },
 "current_state": [
  "Error rate on /checkout is 12%, down from 38% at peak (09:05 UTC).",
  "p99 is 2.4s.",
  "payment-svc scaled 6 -> 18 pods; /checkout rate limited at 400 rps per region."
 ],
 "what_we_know": [
  "payment-svc pods are OOMing under ~3x normal traffic.",
  "The traffic comes from a partner promo that went live 08:00 UTC.",
  "Suspicion (not confirmed): the new fraud-score query from v7.4.2 does a full scan and is the memory hog."
 ],
 "what_we_did": [
  "Scaled payment-svc 6 -> 18 pods.",
  "Rate limited /checkout at 400 rps per region.",
  "Turned off the recommendations widget flag on the cart page."
 ],
 "next_actions": [
  {
   "action": "Watch the /checkout error rate; expect under 1% within ~20 min of the scale-out finishing.",
   "if": "",
   "owner": ""
  },
  {
   "action": "Roll back payment-svc to v7.4.1.",
   "if": "error rate is not dropping by 11:15 UTC",
   "owner": ""
  },
  {
   "action": "Open a follow up for the fraud-score query, linked to the existing ticket ENG-5102.",
   "if": "once stable",
   "owner": ""
  }
 ],
 "people": [
  {
   "role": "outgoing (on Slack till 11:30 UTC)",
   "who": "@ravi"
  },
  {
   "role": "incoming",
   "who": "@jo"
  }
 ],
 "comms": [],
 "watch_list": [
  {
   "item": "/checkout error rate",
   "trigger": "not dropping by 11:15 UTC",
   "action": "Roll back payment-svc to v7.4.1."
  },
  {
   "item": "/checkout error rate after the scale-out",
   "trigger": "under 1% within ~20 min of the scale-out finishing",
   "action": "Once stable, open the fraud-score follow up (ENG-5102)."
  }
 ],
 "gaps": [
  {
   "ref": "F1",
   "gap": "No incident commander, comms lead or technical lead is named in the notes.",
   "ask": "Am I taking over as incident commander from you, and who are the comms lead and technical lead?"
  },
  {
   "ref": "F2",
   "gap": "Nothing about the status page, customer support, executives or the incident channel.",
   "ask": "What has been posted to the status page, support and executives, when, and when is the next update due?"
  },
  {
   "ref": "",
   "gap": "No owner is named for the rollback to v7.4.1 or for the fraud-score follow up.",
   "ask": "Who executes the payment-svc rollback if it comes to that, and who owns the ENG-5102 follow up?"
  },
  {
   "ref": "",
   "gap": "The notes do not say whether the scale-out has finished, so the ~20 min window has no start time.",
   "ask": "When did the payment-svc scale-out to 18 pods finish?"
  }
 ],
 "incoming_checklist": [
  "Confirm the pager routes to you: have @ravi fire a test alert before 11:30 UTC.",
  "Announce in the incident channel that @jo is taking over as incident commander from @ravi.",
  "Be ready to roll back payment-svc to v7.4.1 at 11:15 UTC if the error rate is not dropping."
 ],
 "prescan_responses": [
  {
   "ref": "F1",
   "verdict": "confirmed",
   "note": "The notes name no incident commander, comms lead or technical lead; only the outgoing/incoming fields give @ravi and @jo."
  },
  {
   "ref": "F2",
   "verdict": "confirmed",
   "note": "The notes say nothing about status page, customers, support or executives."
  }
 ]
}

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and reports truncated: true, in the done event of /run-stream and on the job from GET /jobs/{id}. What you hold is then a prefix of the reply, and it will not parse as it stands. The web page closes the cut-off JSON (Recon.closeJson in recon.js: close an open string, drop a dangling comma or key, close every open array and object, and if that still does not parse, cut back to the previous comma and try again), parses what is left, and shows the sections that arrived as "N of M sections recovered", out of the lane's 15 keys: the envelope's eight plus the lane body's seven. A stream ended early error gets the same treatment on the deltas received so far.

The keys arrive in contract order, so a cut usually costs the tail: gaps, incoming_checklist and prescan_responses are the first to go. A truncated handoff can therefore look ready while its gaps are missing, so never post one as final: check the flag, top up, and resubmit with the attempt suffix on the Idempotency-Key incremented (handoff-desk:shift:<hash>:a2).

If a complete reply will not parse as one JSON object, the page retries once, as the next attempt, with a retry_note saying what was wrong and asking for only the JSON object for task shift or incident, with every key present. Do the same: keep the hash, bump the attempt, add retry_note.