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.
| task | what it is for | lane body in the reply |
|---|---|---|
shift | The 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. |
incident | The 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.
| field | type | required | what it holds |
|---|---|---|---|
task | string | yes | "shift" or "incident"; see the lanes. |
team | string | no | The team or rotation ("Platform"). Context only. Cut at 160 characters. |
outgoing | string | no | The engineer handing over ("@maya"). Cut at 80 characters. The page counts it as a person the handoff may name. |
incoming | string | no | The engineer taking over ("@tomas"). Cut at 80 characters. Also counts as a known person. |
handoff_at | string | no | When the handoff happens, as written ("Mon 09:00", "10:40 UTC"). Cut at 80 characters. |
notes | string | yes | The 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. |
facts | string | yes | A 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. |
question | string | no | Your 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_note | string | no | Only 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.
| key | what it holds |
|---|---|
lane | The lane the prescan ran for: shift or incident. Match it to task. |
line_count, words | Non-empty lines and words in the notes. |
references | tickets (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. |
components | One 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 "". |
flags | Every 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_readiness | The 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. |
clipped | null, 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 field | what it holds |
|---|---|
lane | shift or incident (anything else is analyzed as shift). Becomes task. |
team | The team or rotation. |
outgoing, incoming | Who hands over and who takes over, usually @handles. |
handoff_at | When, as written. |
notes | The 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
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The 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. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "handoff-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://handoff-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "handoff-desk";
// Paste the token from https://handoff-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "handoff-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://handoff-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class HandoffDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "handoff-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "handoff-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://handoff-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "handoff-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class HandoffDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "handoff-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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"}}
# Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "handoff-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "handoff-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"handoff-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"handoff-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "handoff-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "handoff-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://handoff-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"handoff-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await HandoffDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
INPUT = json.load(open("body.json")) # built by make-body.js above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("shift", "incident")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("notes", "facts"))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || !["shift", "incident"].includes(INPUT.task)) throw new Error("task must be shift or incident");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["notes", "facts"]) if (!INPUT[k].trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "shift" && input["task"] != "incident" {
panic("task must be shift or incident")
}
for _, k := range []string{"notes", "facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(shift|incident)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task shift or incident");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(shift|incident)\".*", "$1");
for (String k : new String[] {"notes", "facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be shift or incident" unless %w[shift incident].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[notes facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || !in_array($input["task"] ?? "", ["shift", "incident"], true)) { throw new Exception("task must be shift or incident"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["notes", "facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "shift" && lane != "incident") throw new Exception("task must be shift or incident");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await HandoffDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"handoff-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `handoff-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("handoff-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "handoff-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "handoff-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "handoff-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"handoff-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await HandoffDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
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
import re
t = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
reply = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(reply["lane"], reply["readiness"], reply["headline"])
print([(g["ref"], g["ask"]) for g in reply["gaps"]])
print([(w["item"], w["trigger"]) for w in reply["watch_list"]])
if reply["lane"] == "shift":
print([(c["kind"], c["what"]) for c in reply["changes"]])
else:
print(reply["incident"]["status"], [(a["action"], a["if"]) for a in reply["next_actions"]])
let t = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
const reply = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
console.log(reply.lane, reply.readiness, reply.headline);
console.log(reply.gaps.map((g) => [g.ref, g.ask]));
console.log(reply.watch_list.map((w) => `${w.item} -> ${w.trigger || "(no trigger)"}`));
if (reply.lane === "shift") console.log(reply.changes.map((c) => [c.kind, c.what]));
else console.log(reply.incident.status, reply.next_actions.map((a) => [a.action, a.if]));
t := strings.TrimSpace(jobOutput)
t = strings.TrimPrefix(strings.TrimPrefix(t, "```json"), "```")
t = strings.TrimSuffix(strings.TrimSpace(t), "```")
start, end := strings.Index(t, "{"), strings.LastIndex(t, "}")
var reply struct {
Lane string `json:"lane"`
Readiness string `json:"readiness"`
Headline string `json:"headline"`
TLDR []string `json:"tldr"`
WatchList []struct {
Item, Trigger, Action string
} `json:"watch_list"`
Gaps []struct {
Ref, Gap, Ask string
} `json:"gaps"`
// shift lane
Changes []struct {
When, Kind, What, Ref string
} `json:"changes"`
// incident lane
Incident struct {
Title, Severity, Started, Status, Ref string
} `json:"incident"`
NextActions []struct {
Action, If, Owner string
} `json:"next_actions"`
}
if err := json.Unmarshal([]byte(t[start:end+1]), &reply); err != nil {
panic(err)
}
fmt.Println(reply.Lane, reply.Readiness, reply.Headline)
for _, g := range reply.Gaps {
fmt.Println(g.Ref, g.Ask)
}
if reply.Lane == "incident" {
fmt.Println(reply.Incident.Status, len(reply.NextActions), "next actions")
}
// output is data.output.output from step 5: a string holding the reply JSON.
String t = output.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```\\s*$", "");
String json = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
// With Jackson: JsonNode r = new ObjectMapper().readTree(json);
// r.get("lane"): shift | incident; r.get("readiness"): ready | gaps | blocked
// r.get("gaps"), r.get("watch_list"), r.get("incoming_checklist"), r.get("prescan_responses")
// shift: r.get("changes"), r.get("investigations") ... incident: r.get("incident"), r.get("next_actions") ...
System.out.println(json);
t = text.strip.sub(/\A```(?:json)?\s*/i, "").sub(/\s*```\s*\z/, "")
reply = JSON.parse(t[t.index("{")..t.rindex("}")])
puts reply["lane"], reply["readiness"], reply["headline"]
reply["gaps"].each { |g| puts "#{g['ref']} #{g['gap']} ASK: #{g['ask']}" }
reply["watch_list"].each { |w| puts "#{w['item']} when: #{w['trigger']}" }
if reply["lane"] == "shift"
reply["changes"].each { |c| puts "#{c['kind']} #{c['what']}" }
else
reply["next_actions"].each { |a| puts "#{a['action']} if: #{a['if']}" }
end
<?php
$t = preg_replace('/\s*```\s*$/', "", preg_replace('/^```(?:json)?\s*/i', "", trim($text)));
$reply = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $reply["lane"], " ", $reply["readiness"], " ", $reply["headline"], PHP_EOL;
foreach ($reply["gaps"] as $g) { echo $g["ref"], " ", $g["gap"], " ASK: ", $g["ask"], PHP_EOL; }
if ($reply["lane"] === "shift") {
foreach ($reply["changes"] as $c) { echo $c["kind"], " ", $c["what"], PHP_EOL; }
} else {
foreach ($reply["next_actions"] as $a) { echo $a["action"], " if: ", $a["if"], PHP_EOL; }
}
var t = System.Text.RegularExpressions.Regex.Replace(output.Trim(), @"^```(?:json)?\s*", "");
t = System.Text.RegularExpressions.Regex.Replace(t, @"\s*```\s*$", "");
var json2 = t.Substring(t.IndexOf('{'), t.LastIndexOf('}') - t.IndexOf('{') + 1);
using var parsed = JsonDocument.Parse(json2);
var r = parsed.RootElement;
Console.WriteLine($"{r.GetProperty("lane")} {r.GetProperty("readiness")} {r.GetProperty("headline")}");
foreach (var g in r.GetProperty("gaps").EnumerateArray())
Console.WriteLine($"{g.GetProperty("ref")} {g.GetProperty("ask")}");
if (r.GetProperty("lane").GetString() == "incident")
Console.WriteLine(r.GetProperty("incident").GetProperty("status"));
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:
- It parses: the reply is one JSON object (after stripping any fence), and every key of the lane's contract is present.
- Lane:
laneequals thetaskyou sent. A different lane means the model answered the other contract. - Flags: every flag in
facts.flagshas exactly oneprescan_responsesentry; no response names a flag that was not sent; everyverdictisconfirmedordismissed, and a dismissal says where the notes show it is wrong (read it and decide whether you agree). - Gaps: every confirmed
highormediumflag has agapsentry whoserefnames it. Extra gaps withref""are allowed. - Readiness: one of
ready,gaps,blocked, and never looser than the flags left standing (those not dismissed): anyhighmeansblocked, else anymediummeans at leastgaps. - Nothing invented: every ticket, PR, version and
@personin the handoff (outsideprescan_responses) is infacts.references, or is theoutgoingorincomingengineer. The refs in the reply are a subset of the refs in the notes. - Nothing dropped: every ticket, PR and version in
facts.referencesappears somewhere in the handoff. - Watch list: every item has a
trigger; an item withtrigger""is allowed only with a gap asking for one. - Incident lane:
incident.statusis one of the five values,incident.severityis""or a severity the notes gave, andnext_actionsis not empty.
# 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":"..."}]}
| key | shape | what it holds |
|---|---|---|
lane | string | The lane answered: your task, or the closer lane when task was missing or unknown. |
readiness | enum | Can the incoming engineer take over now; see the next table. |
headline | string | One sentence: the state of the pager in plain words. |
tldr | array of strings | 2-5 bullets. When you sent a question, one bullet starts "Answer:". |
watch_list | array 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 "". |
gaps | array 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_checklist | array of strings | 3-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_responses | array 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":"@..."}]}
| key | what it holds |
|---|---|
active_incidents | What is broken right now. ref is a ticket id or ""; severity as written (SEV2, P1) or ""; owner an @person from the notes or "". |
investigations | Issues still being debugged or monitored, with what is known so far (context) and next_steps. |
resolved | Fixed during the shift; follow_ups are the ticket ids or actions left. |
changes | Deploys, config, infrastructure and feature flag changes, the version in what. |
known_issues | Things that look broken but are known, with the workaround. |
upcoming | Maintenance, releases, campaigns and freezes during the incoming shift. |
escalations | Who 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":"..."}]}
| key | what it holds |
|---|---|
incident | The incident itself. severity exactly as the notes give it, or ""; status one of five values. |
current_state | The numbers and symptoms right now (error rate, latency, what is down). |
what_we_know | Established facts about cause and trigger; suspicion is labelled as suspicion. |
what_we_did | Mitigations already applied, in order. |
next_actions | What the incoming commander does next. if is the deciding condition from the notes, or "". |
people | Incident commander (outgoing and incoming), comms lead, technical lead, as named in the notes. |
comms | Status page, customer support, executives, incident channel: what has been said and when. |
Readiness
| readiness | meaning |
|---|---|
blocked | The 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. |
gaps | Usable, but questions must be answered before the outgoing engineer leaves (confirmed medium flags). |
ready | Nothing 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
| where | values | the page's fallback |
|---|---|---|
lane | shift, incident | the lane asked |
readiness | ready, gaps, blocked | gaps |
changes[].kind | deploy, config, infra, flag, other | other |
incident.status | investigating, identified, mitigating, monitoring, resolved | "" |
prescan_responses[].verdict | confirmed, dismissed | confirmed |
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.