Candor Desk — API

Describe the situation, get a structured conversation prep plan.

API tokens Open the app

Build conversation prep plans from your own scripts

Send a described workplace situation — what happened, who it is with, what you want — and get back one JSON object: a readiness verdict (ready, prepare-first or escalate-instead), a situation read that separates observable facts from assumptions, desired/minimum/tradeable goals, a verbatim opening, SBI-grounded talking points each with the exact first-person line to deliver it, anticipated reactions with responses, phrases to keep out of the room, an escalation check and a dated follow-up plan. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into whatever tooling you like: an HR-team helper, a manager's 1:1 prep bot, a coaching workflow. Pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug candor-desk. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The plan itself is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one described situation in, one prep plan out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest running a very long input).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered plan runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"candor-desk"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "candor-desk"})["token"]
const { token } = await api("POST", "/guest", { slug: "candor-desk" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "candor-desk"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"candor-desk"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "candor-desk" })["token"]
$token = api("POST", "/guest", ["slug" => "candor-desk"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "candor-desk" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:candor-desk, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before building a plan from a long pasted thread.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are pasting a long email thread into situation and want a ceiling before spending credits.

Input fieldTypeNotes
situationstring, requiredWhat happened, in the user's own words — events, dates, who was there, what was said. A pasted email or chat excerpt is fine. This is the model's only evidence about the events: talking points cite only what appears here (and in constraints), never invented detail. Inputs longer than 24,000 characters are clipped middle-out, with a [... clipped ...] line showing where. At least 60 characters are needed for a plan.
relationshipstringdirect-report | peer | manager | skip-level | client-or-partner | other — who the conversation is with, from the user's side. It changes the power dynamics, what is safe to say, and what escalation means.
conversation_typestringperformance | conflict | feedback-to-manager | compensation | sensitive-news | boundary — which part of the method carries the weight.
goalstringThe outcome the user wants, one sentence. May be empty — the plan proposes one and flags the gap.
feelingstringcalm | frustrated | angry | anxious | hurt | dreading — the emotion check is applied: angry caps readiness at prepare-first (the 24-hour rule) unless the verdict is escalate-instead for other reasons.
constraintsstring, optionalHistory and context: prior attempts and how they went, politics, timing, upcoming reviews. Clipped at 8,000 characters.
prescan_factsobject, optionalWhat a client-side scanner mechanically matched in the situation text: {"specifics": [], "flags": []}. Each entry is {id, label}. Specific ids look like spec:when/last-tuesday, spec:count/three-times or spec:quote/...; flag ids are <check>:<phrase>absolute-language:always, character-label:arrogant, mind-reading:on-purpose, hot-emotion:furious, hearsay:i-heard, escalation-signal:harassment, no-goal:goal, vague-situation:none. Every flag id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the two empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
cat > situation.txt <<'TEXT'
My direct report has missed the last three sprint deadlines. On Friday the
API migration slipped again and I only found out in standup. He said "it was
basically done" two weeks ago. Good engineer, but I can no longer plan
around his estimates and my manager is asking why the team is late.
TEXT

jq -n --rawfile situation situation.txt \
  '{situation: $situation,
    relationship: "direct-report",
    conversation_type: "performance",
    goal: "Deadlines hold, and I hear about slips before the deadline, not after",
    feeling: "frustrated",
    constraints: "First formal conversation about this; his last review was strong.",
    prescan_facts: {specifics: [], flags: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
SITUATION = """My direct report has missed the last three sprint deadlines. On Friday the
API migration slipped again and I only found out in standup. He said "it was
basically done" two weeks ago. Good engineer, but I can no longer plan
around his estimates and my manager is asking why the team is late."""

payload = {
    "situation": SITUATION,
    "relationship": "direct-report",
    "conversation_type": "performance",
    "goal": "Deadlines hold, and I hear about slips before the deadline, not after",
    "feeling": "frustrated",
    "constraints": "First formal conversation about this; his last review was strong.",
    "prescan_facts": {"specifics": [], "flags": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const situation = [
  "My direct report has missed the last three sprint deadlines. On Friday the",
  "API migration slipped again and I only found out in standup. He said \"it was",
  "basically done\" two weeks ago. Good engineer, but I can no longer plan",
  "around his estimates and my manager is asking why the team is late.",
].join("\n");

const payload = {
  situation,
  relationship: "direct-report",
  conversation_type: "performance",
  goal: "Deadlines hold, and I hear about slips before the deadline, not after",
  feeling: "frustrated",
  constraints: "First formal conversation about this; his last review was strong.",
  prescan_facts: { specifics: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const situation = `My direct report has missed the last three sprint deadlines. On Friday the
API migration slipped again and I only found out in standup. He said "it was
basically done" two weeks ago. Good engineer, but I can no longer plan
around his estimates and my manager is asking why the team is late.`

payload := map[string]any{
	"situation":         situation,
	"relationship":      "direct-report",
	"conversation_type": "performance",
	"goal":    "Deadlines hold, and I hear about slips before the deadline, not after",
	"feeling": "frustrated",
	"constraints": "First formal conversation about this; his last review was strong.",
	"prescan_facts": map[string]any{
		"specifics": []any{}, "flags": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String situation = """
    My direct report has missed the last three sprint deadlines. On Friday the
    API migration slipped again and I only found out in standup. He said "it was
    basically done" two weeks ago. Good engineer, but I can no longer plan
    around his estimates and my manager is asking why the team is late.
    """;

String jsonPayload = """
    {"situation": %s,
     "relationship": "direct-report",
     "conversation_type": "performance",
     "goal": "Deadlines hold, and I hear about slips before the deadline, not after",
     "feeling": "frustrated",
     "constraints": "First formal conversation about this; his last review was strong.",
     "prescan_facts": {"specifics": [], "flags": []}}
    """.formatted(toJsonString(situation));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
SITUATION = <<~TEXT
  My direct report has missed the last three sprint deadlines. On Friday the
  API migration slipped again and I only found out in standup. He said "it was
  basically done" two weeks ago. Good engineer, but I can no longer plan
  around his estimates and my manager is asking why the team is late.
TEXT

payload = { situation: SITUATION,
            relationship: "direct-report",
            conversation_type: "performance",
            goal: "Deadlines hold, and I hear about slips before the deadline, not after",
            feeling: "frustrated",
            constraints: "First formal conversation about this; his last review was strong.",
            prescan_facts: { specifics: [], flags: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$situation = <<<'TEXT'
My direct report has missed the last three sprint deadlines. On Friday the
API migration slipped again and I only found out in standup. He said "it was
basically done" two weeks ago. Good engineer, but I can no longer plan
around his estimates and my manager is asking why the team is late.
TEXT;

$payload = [
    "situation"         => $situation,
    "relationship"      => "direct-report",
    "conversation_type" => "performance",
    "goal"        => "Deadlines hold, and I hear about slips before the deadline, not after",
    "feeling"     => "frustrated",
    "constraints" => "First formal conversation about this; his last review was strong.",
    "prescan_facts" => ["specifics" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var situation = """
    My direct report has missed the last three sprint deadlines. On Friday the
    API migration slipped again and I only found out in standup. He said "it was
    basically done" two weeks ago. Good engineer, but I can no longer plan
    around his estimates and my manager is asking why the team is late.
    """;

var payload = new {
    situation,
    relationship = "direct-report",
    conversation_type = "performance",
    goal = "Deadlines hold, and I hear about slips before the deadline, not after",
    feeling = "frustrated",
    constraints = "First formal conversation about this; his last review was strong.",
    prescan_facts = new {
        specifics = Array.Empty<object>(), flags = Array.Empty<object>(),
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

prescan_facts.flags is how you make the plan answer for the language you already know is risky. Send {"specifics": [{"id": "spec:count/three-times", "label": "Count/three times"}], "flags": [{"id": "absolute-language:always", "label": "an absolute — always"}]} and every flag id comes back in coverage_check — addressed by the plan, or set aside with the reason. Nothing you flag is silently dropped.

Step 4 — Run the plan and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s, since every talking point carries exact phrasing). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The plan is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the readiness verdict, the opening, the talking points and the follow-up, then save the whole object to plan.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: cd-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# unwrap the plan once, then read it
echo "$JOB" | jq -r '.data.output.output' > plan.json

jq -r '
  "\(.plan_name) [\(.readiness)]: \(.verdict)",
  "",
  "OPEN WITH",
  "  \(.opening)",
  "",
  "TALKING POINTS",
  (.talking_points[] | "  \(.id) \(.point)\n    say: \(.say)"),
  "",
  "IF THEY REACT",
  (.reactions[] | "  [\(.likelihood)] \(.reaction)\n    say: \(.say)"),
  "",
  "AVOID",
  (.phrases_to_avoid[] | "  \(.avoid)  ->  \(.instead)"),
  "",
  "FOLLOW-UP",
  (.followup[] | "  (\(.when)) \(.action)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "ok" else "SET ASIDE" end) - \(.note)")' \
  plan.json

# branch on the verdict, e.g. stop a workflow when escalation is the answer
jq -e '.readiness != "escalate-instead"' plan.json > /dev/null \
  || echo "escalate-instead: route to HR guidance, not a direct conversation"
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "cd-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
plan = json.loads(raw) if isinstance(raw, str) else raw

print(f'{plan["plan_name"]} [{plan["readiness"]}]: {plan["verdict"]}')
print("open with:", plan["opening"])
for p in plan["talking_points"]:
    print(f'  {p["id"]} {p["point"]}')
    print(f'    S: {p["situation"]} / B: {p["behavior"]} / I: {p["impact"]}')
    print(f'    say: {p["say"]}')
for r in plan["reactions"]:
    print(f'  [{r["likelihood"]}] {r["reaction"]} -> {r["say"]}')
for a in plan["phrases_to_avoid"]:
    print(f'  avoid: "{a["avoid"]}" -> "{a["instead"]}"')
ec = plan["escalation_check"]
print("escalate instead:", ec["escalate"], "-", ec["note"])
for f in plan["followup"]:
    print(f'  ({f["when"]}) {f["action"]}')
for c in plan["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("plan.json", "w", encoding="utf-8") as fh:
    json.dump(plan, fh, indent=2)

if plan["readiness"] == "escalate-instead":
    raise SystemExit("escalate-instead: route to HR guidance, not a direct conversation")
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const plan = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${plan.plan_name} [${plan.readiness}]: ${plan.verdict}`);
console.log(`open with: ${plan.opening}`);
for (const p of plan.talking_points) {
  console.log(`  ${p.id} ${p.point}`);
  console.log(`    say: ${p.say}`);
}
for (const r of plan.reactions) {
  console.log(`  [${r.likelihood}] ${r.reaction} -> ${r.say}`);
}
for (const a of plan.phrases_to_avoid) {
  console.log(`  avoid: "${a.avoid}" -> "${a.instead}"`);
}
console.log(`escalate instead: ${plan.escalation_check.escalate} - ${plan.escalation_check.note}`);
for (const f of plan.followup) console.log(`  (${f.when}) ${f.action}`);
for (const c of plan.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("plan.json", JSON.stringify(plan, null, 2));

if (plan.readiness === "escalate-instead") process.exitCode = 1;
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Plan struct {
	PlanName      string   `json:"plan_name"`
	Readiness     string   `json:"readiness"`
	Verdict       string   `json:"verdict"`
	SituationRead string   `json:"situation_read"`
	Assumptions   []string `json:"assumptions"`
	OpenQuestions []string `json:"open_questions"`
	Goals struct {
		Desired, Minimum, Tradeable string
	} `json:"goals"`
	Opening       string `json:"opening"`
	TalkingPoints []struct {
		ID, Point, Situation, Behavior, Impact, Say string
	} `json:"talking_points"`
	Reactions []struct {
		ID, Reaction, Likelihood, Response, Say string
	} `json:"reactions"`
	PhrasesToAvoid []struct {
		Avoid, Because, Instead string
	} `json:"phrases_to_avoid"`
	EscalationCheck struct {
		Escalate bool     `json:"escalate"`
		Triggers []string `json:"triggers"`
		Note     string   `json:"note"`
	} `json:"escalation_check"`
	CoverageCheck []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	Followup []struct {
		When, Action string
	} `json:"followup"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var plan Plan
json.Unmarshal([]byte(wrapper.Output), &plan)

fmt.Printf("%s [%s]: %s\n", plan.PlanName, plan.Readiness, plan.Verdict)
fmt.Println("open with:", plan.Opening)
for _, p := range plan.TalkingPoints {
	fmt.Printf("  %s %s\n    say: %s\n", p.ID, p.Point, p.Say)
}
for _, r := range plan.Reactions {
	fmt.Printf("  [%s] %s -> %s\n", r.Likelihood, r.Reaction, r.Say)
}
os.WriteFile("plan.json", []byte(wrapper.Output), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The plan is at data.output.output as a JSON string — parse it again, then read
// plan_name, readiness, verdict, situation_read, assumptions[], open_questions[],
// goals (desired/minimum/tradeable), opening,
// talking_points[] (id/point/situation/behavior/impact/say),
// reactions[] (id/reaction/likelihood/response/say),
// phrases_to_avoid[] (avoid/because/instead),
// escalation_check (escalate/triggers[]/note),
// coverage_check[] (id/addressed/note), followup[] (when/action) and summary.
// Finally keep the plan on disk:
//   Files.writeString(Path.of("plan.json"), planJson);
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
plan = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{plan["plan_name"]} [#{plan["readiness"]}]: #{plan["verdict"]}"
puts "open with: #{plan["opening"]}"
plan["talking_points"].each do |p|
  puts "  #{p["id"]} #{p["point"]}"
  puts "    say: #{p["say"]}"
end
plan["reactions"].each { |r| puts "  [#{r["likelihood"]}] #{r["reaction"]} -> #{r["say"]}" }
plan["phrases_to_avoid"].each { |a| puts "  avoid: #{a["avoid"]} -> #{a["instead"]}" }
plan["followup"].each { |f| puts "  (#{f["when"]}) #{f["action"]}" }
plan["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("plan.json", JSON.pretty_generate(plan))
exit 1 if plan["readiness"] == "escalate-instead"
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$plan = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$plan['plan_name']} [{$plan['readiness']}]: {$plan['verdict']}\n";
echo "open with: {$plan['opening']}\n";
foreach ($plan["talking_points"] as $p) {
    echo "  {$p['id']} {$p['point']}\n    say: {$p['say']}\n";
}
foreach ($plan["reactions"] as $r) {
    echo "  [{$r['likelihood']}] {$r['reaction']} -> {$r['say']}\n";
}
foreach ($plan["phrases_to_avoid"] as $a) {
    echo "  avoid: {$a['avoid']} -> {$a['instead']}\n";
}
foreach ($plan["followup"] as $f) {
    echo "  ({$f['when']}) {$f['action']}\n";
}
foreach ($plan["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("plan.json", json_encode($plan, JSON_PRETTY_PRINT));
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var plan = doc.RootElement;

Console.WriteLine($"{plan.GetProperty("plan_name")} " +
                  $"[{plan.GetProperty("readiness")}]: {plan.GetProperty("verdict")}");
Console.WriteLine($"open with: {plan.GetProperty("opening")}");
foreach (var p in plan.GetProperty("talking_points").EnumerateArray())
{
    Console.WriteLine($"  {p.GetProperty("id")} {p.GetProperty("point")}");
    Console.WriteLine($"    say: {p.GetProperty("say")}");
}
foreach (var r in plan.GetProperty("reactions").EnumerateArray())
{
    Console.WriteLine($"  [{r.GetProperty("likelihood")}] {r.GetProperty("reaction")}");
}
foreach (var f in plan.GetProperty("followup").EnumerateArray())
{
    Console.WriteLine($"  ({f.GetProperty("when")}) {f.GetProperty("action")}");
}

await File.WriteAllTextAsync("plan.json", rawText!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The plan object — output schema

One JSON object, always the same shape. Every array is present, and the plan is grounded in the supplied text alone: talking points cite only events, dates and quotes that appear in situation or constraints, every say line is deliverable as written (first person, no placeholders), and where the input is silent on something that changes the verdict you get an entry in assumptions and, if it matters, in open_questions. Expect three to six talking points and two to four anticipated reactions — fewer, sharper items rather than an exhaustive list, and talking_points is never empty.

FieldTypeMeaning
plan_namestringA short title naming the conversation — e.g. missed deadlines — direct report.
readinessstringready | prepare-first | escalate-instead. See the table below.
verdictstringOne sentence justifying the readiness call and naming the single most important step before the conversation.
situation_readstringTwo or three paragraphs, separated by blank lines: the observable facts, the impact, the most plausible other-side perspective, and what is assumption rather than fact.
assumptionsstring[]Assumptions the user is making that the plan treats as unverified. Read these first — a wrong one changes the plan.
open_questionsstring[]Things the user cannot answer yet and should find out or ask in the room.
goalsobject{desired, minimum, tradeable} — the outcome to aim for, the floor not to go below, and what can be conceded.
openingstringThe verbatim opening, 2–4 sentences: neutral, fact-first, states intent to understand. Rehearse it aloud.
talking_pointsarray{id, point, situation, behavior, impact, say} — ids TP-1, TP-2, … in sequence, at least one entry. SBI grounding plus the exact first-person line. For an escalate-instead verdict these prepare the escalation conversation instead of the direct one.
reactionsarray{id, reaction, likelihood, response, say} — ids RX-1, …; likelihood is low | medium | high; response is the move, say the exact phrase.
phrases_to_avoidarray{avoid, because, instead} — the framings to keep out of the room, usually lifted from the user's own description, each with a replacement.
escalation_checkobject{escalate, triggers, note} — the method's explicit gate: safety risk, legal exposure (harassment, discrimination, retaliation), repeated failed conversations, or power dynamics. When escalate is true the readiness is escalate-instead and the note says what to document.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below.
followuparray{when, action}when is one of in the meeting, within 24 hours, within a week, ongoing. The app turns the check-in into a downloadable calendar event.
summarystringClosing paragraph: the one thing to do before, the one thing to hold onto during, and what success looks like after.

The three readiness values:

readinessWhat it means
readyThe facts, the goal and the emotional state support having the conversation now. Remaining findings are polish: rehearse the opening, book the room, go.
prepare-firstThe conversation is right but a named gap comes first — cool-down time (the 24-hour rule when feeling is angry), missing specifics to collect, an unclear goal to decide, a rehearsal. The verdict names the gap.
escalate-insteadOne or more escalation triggers apply — safety, legal exposure, repeated failed conversations, unsafe power dynamics. The direct conversation is the wrong lane; the talking points prepare the escalation conversation (with HR or the appropriate manager) and the note says what to document.

coverage_check semantics:

CaseWhat you get
Every flag id you sentEach prescan_facts.flags id appears in coverage_check exactly once. Nothing you flagged is silently dropped. Ids in prescan_facts.specifics are not reconciled here — they anchor the talking points instead.
addressed: trueThe plan corrects or works around the flag; note says how — e.g. the absolutes in your description were replaced with the dated instances in every say line.
addressed: falseThe flag was deliberately set aside; note gives the reason — e.g. a hot-emotion match inside a quoted line someone else said, not the user's own framing.
Nothing sentOmit prescan_facts, or send the two empty arrays, and coverage_check comes back empty. The rest of the plan is unaffected.

A small, realistic result for the payload above, trimmed for length:

{
  "plan_name": "missed sprint deadlines — direct report",
  "readiness": "ready",
  "verdict": "The facts are specific and dated and the goal is concrete — decide your minimum
              acceptable outcome before the meeting and this is ready to have this week.",
  "situation_read": "Three consecutive sprint deadlines have slipped, most recently the API
                     migration on Friday, and the pattern the user can state as fact is not the
                     lateness alone but the silence: each slip surfaced at the deadline, not
                     before it. Two weeks ago the report described the migration as 'basically
                     done', which makes the estimate-versus-reality gap the concrete, discussable
                     behavior.

                     The most plausible other-side view is not carelessness: a strong engineer
                     who under-reports slippage is often overloaded, blocked on something he
                     considers temporary, or reluctant to disappoint. Whether the estimates are
                     wrong or the interruptions are invisible is unknown — that is the first
                     thing to find out in the room, not assume.",
  "assumptions": [
    "The deadlines themselves were realistic when set, since the user plans around them.",
    "No prior formal conversation about this pattern has happened — this is the first."
  ],
  "open_questions": [
    "Is something upstream (reviews, dependencies, unplanned work) eating his sprint time?",
    "Does he know the team's lateness is being escalated to the user's manager?"
  ],
  "goals": {
    "desired": "Deadlines hold, and slips are raised the day they become likely — not at standup
                after the fact.",
    "minimum": "An agreed early-warning norm: any at-risk deliverable is flagged within a day.",
    "tradeable": "The deadline dates themselves — if estimates are the problem, re-planning
                  together is acceptable."
  },
  "opening": "I want to talk about the last three sprint deadlines — the API migration on Friday
              was the third. This isn't about your ability; your work is strong. What I need to
              understand is what's happening with the estimates, because I'm planning around
              them and finding out at standup.",
  "talking_points": [
    { "id": "TP-1", "point": "The pattern, stated as dated fact",
      "situation": "The last three sprints, most recently Friday",
      "behavior": "The deliverable slipped and it surfaced at the deadline",
      "impact": "The user can no longer plan the team's commitments around the estimates",
      "say": "The last three sprint deadlines have slipped, and each time I learned about it at
              the deadline. Friday's migration was the third." },
    { "id": "TP-2", "point": "The estimate-reality gap, using his own words",
      "situation": "Two weeks ago",
      "behavior": "He described the migration as 'basically done'; it then slipped again",
      "impact": "The user's manager is now asking why the team is late",
      "say": "Two weeks ago you told me the migration was basically done. I repeated that
              upward, and now I'm the one explaining why it wasn't." }
  ],
  "reactions": [
    { "id": "RX-1", "reaction": "Explanation: 'the interruptions aren't my fault'",
      "likelihood": "high",
      "response": "Agree to examine it — it may be true, and it changes the fix, not the norm.",
      "say": "If unplanned work is eating the sprint, I want to see it — let's look at where the
              time went. Either way, I need to hear about the risk before the deadline." }
  ],
  "phrases_to_avoid": [
    { "avoid": "You always blow your estimates",
      "because": "An absolute invites a counterexample hunt and turns a pattern into an attack",
      "instead": "The last three sprint deadlines have slipped — Friday was the third" }
  ],
  "escalation_check": { "escalate": false, "triggers": [],
    "note": "A first direct conversation between a manager and their report about a work
             pattern — exactly the case for handling it directly. Note the agreed norm and the
             dates afterwards so a repeat has a record." },
  "coverage_check": [],
  "followup": [
    { "when": "in the meeting", "action": "Agree the early-warning norm and the next sprint's
      checkpoint dates." },
    { "when": "within 24 hours", "action": "Send a short written recap of what was agreed." },
    { "when": "within a week", "action": "Check the first at-risk flag actually arrives — praise
      it when it does." }
  ],
  "summary": "Decide your minimum before you book the room: the early-warning norm is the thing
             not to leave without. In the room, hold onto the three dated slips and his own
             'basically done' — they carry the conversation without any character judgment. Success
             is not an apology; it is the next slip being flagged two days early."
}

This is AI-generated coaching from the text you sent, not legal or HR advice: it sees only your account of events, not the other person's, and serious matters — safety, harassment, discrimination, retaliation — belong with HR, a works council or a lawyer, which is exactly what the escalate-instead verdict says. Check assumptions and open_questions before you act on the phrasing, and keep your own judgment in the loop.

Step 5 — Stream the plan as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because a full plan with talking points and reaction scripts makes for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "plan_name", "situation_read", "goals", "talking_points", "coverage_check" and "followup" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the plan from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: cd-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"plan_name\":\"missed"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":540,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "cd-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

plan = json.loads(result["output"]["output"])            # authoritative
print("charged:", result["charged_credits"], "-", plan["plan_name"])
print("readiness:", plan["readiness"])
for p in plan["talking_points"]:
    print(f'  {p["id"]} {p["point"]}: {p["say"]}')
with open("plan.json", "w", encoding="utf-8") as fh:
    json.dump(plan, fh, indent=2)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const plan = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${plan.plan_name} [${plan.readiness}]`);
for (const p of plan.talking_points) console.log(`  ${p.id} ${p.point}`);
writeFileSync("plan.json", JSON.stringify(plan, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "cd-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the plan JSON —
// unmarshal it into the Plan struct from step 4, then write it to plan.json.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "cd-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// plan_name, readiness, verdict, goals, opening, talking_points[], reactions[],
// phrases_to_avoid[], escalation_check, coverage_check[], followup[] and the rest.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "cd-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

plan = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{plan["plan_name"]} [#{plan["readiness"]}]"
plan["talking_points"].each { |p| puts "  #{p["id"]} #{p["point"]}" }
File.write("plan.json", JSON.pretty_generate(plan))
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: cd-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$plan = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$plan['plan_name']} [{$plan['readiness']}]\n";
foreach ($plan["talking_points"] as $p) {
    echo "  {$p['id']} {$p['point']}\n";
}
file_put_contents("plan.json", json_encode($plan, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "cd-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var planDoc = JsonDocument.Parse(text!);
var plan = planDoc.RootElement;
Console.WriteLine($"{plan.GetProperty("plan_name")} [{plan.GetProperty("readiness")}]");
foreach (var p in plan.GetProperty("talking_points").EnumerateArray())
    Console.WriteLine($"  {p.GetProperty("id")} {p.GetProperty("point")}");
await File.WriteAllTextAsync("plan.json", text!);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.