Drive AI Life Coach from your own code
Base URL https://api.skillsafe.ai/v1/app-api. There is one lane, so there is no
task field — you post a goal and what has actually happened, and you get a
plan back. Pick a language once and every example on the page follows it.
Before you build on this: AI Life Coach is a goal and habit coach. It is
not therapy, it does not diagnose, and it does not give medical advice. The browser app runs
a client-side triage before it spends anything and refuses to plan when someone may not be
safe, when another person in the account is dangerous or controlling, or when the goal is a
medical matter rather than a habit. If you wrap this API in something of your own, carry that
screen across — and handle route_to_help,
which is the model doing the same thing from its side.
The envelope
Every response is wrapped. Success is {"ok":true,"data":{...}}. Failure is
{"ok":false,"error":{...},"meta":{...}}, where error carries
code, message, status and details, and
meta carries request_id and timestamp. Quote the
request_id if you ever need to ask about a specific call. Write the unwrapping
once, in step 1, and never think about it again.
Errors
| HTTP | error.code | What it means |
|---|---|---|
400 | validation_error | The request itself was malformed — bad JSON, a missing path segment, a header the API cannot read. Note what this is not: it is not a complaint about the fields inside your run body. The run body is never validated. See step 4. |
401 | unauthorized | No token, or a token that has expired or been revoked. Mint a guest token, or sign in at /tokens.html for a personal one. On a first-ever visit this is the correct response, not a fault. |
402 | insufficient_credits | The balance is below the hold the run needs. Call /estimate first and compare it against /me — a 402 after the user has pressed submit is a failure of the client, not of the user. |
404 | not_found | Wrong path, or a job id that does not belong to the calling subject. Every guest token is its own subject, so a job started under one token is invisible to the next. |
429 | rate_limited | Too many calls. Back off and retry; do not retry in a tight loop, and do not retry a metered run without checking whether the first one actually landed. |
500 | server_error | The run started and did not complete. If a stream died mid-object, keep the bytes you have — a cut plan is often nearly whole and worth repairing rather than throwing away. |
1. A tiny client
Every call below is the same shape: a POST or GET to
https://api.skillsafe.ai/v1/app-api with an Authorization: Bearer header,
returning the envelope from above. Write the unwrapping once and the rest of this page is one line
per call.
# Everything here uses these two values.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="ai-life-coach"
TOKEN="YOUR_TOKEN" # from step 2, or from /tokens.html
# A helper that unwraps the envelope and fails loudly on {"ok":false}.
call() { # call METHOD PATH [BODY]
curl -sS -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+--data "$3"} \
| python3 -c 'import sys,json
e = json.load(sys.stdin)
if not e.get("ok"): raise SystemExit("API error: %s" % e["error"])
print(json.dumps(e["data"]))'
}import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "ai-life-coach"
TOKEN = "YOUR_TOKEN" # from step 2, or from /tokens.html
class ApiError(RuntimeError):
def __init__(self, err):
self.code = err.get("code")
self.request_id = err.get("request_id")
super().__init__("%s: %s" % (self.code, err.get("message")))
def call(method, path, body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + (token or TOKEN))
# The API returns a Cloudflare 1010 to the default urllib user agent.
req.add_header("User-Agent", "ai-life-coach-client/1.0")
with urllib.request.urlopen(req) as r:
env = json.loads(r.read())
if not env.get("ok"):
raise ApiError(env.get("error") or {})
return env["data"]const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "ai-life-coach";
let TOKEN = "YOUR_TOKEN"; // from step 2, or from /tokens.html
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) {
const e = env.error || {};
const err = new Error(e.code + ": " + e.message);
err.code = e.code;
err.requestId = (env.meta || {}).request_id;
throw err;
}
return env.data;
}package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const Base = "https://api.skillsafe.ai/v1/app-api"
const Slug = "ai-life-coach"
// Read the token from wherever you keep secrets; /tokens.html will print one.
var Token = os.Getenv("SKILLSAFE_TOKEN")
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
func call(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, Base+path, rdr)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+Token)
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("API error %s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}import java.net.URI;
import java.net.http.*;
public class LifeCoach {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "ai-life-coach";
static String token = "YOUR_TOKEN"; // from step 2, or from /tokens.html
static final HttpClient http = HttpClient.newHttpClient();
static String call(String method, String path, String body) throws Exception {
HttpRequest.BodyPublisher pub = (body == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.method(method, pub)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
String s = res.body();
if (s.contains("\"ok\":false")) {
throw new RuntimeException("API error: " + s);
}
return s; // parse the {"ok":true,"data":...} envelope with your JSON library
}
}require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "ai-life-coach"
TOKEN = "YOUR_TOKEN" # from step 2, or from /tokens.html
ApiError = Class.new(StandardError)
def call(method, path, body = nil, token: TOKEN)
uri = URI(BASE + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
unless env["ok"]
raise ApiError, "#{env.dig("error", "code")}: #{env.dig("error", "message")}"
end
env["data"]
end<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "ai-life-coach";
$TOKEN = "YOUR_TOKEN"; // from step 2, or from /tokens.html
function call(string $method, string $path, $body = null) {
global $TOKEN;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $TOKEN,
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) {
throw new RuntimeException("API error: " . json_encode($env["error"] ?? null));
}
return $env["data"];
}using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class LifeCoach {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "ai-life-coach";
static string Token = "YOUR_TOKEN"; // from step 2, or from /tokens.html
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(HttpMethod method, string path, object body = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (body != null) {
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var env = JsonSerializer.Deserialize<JsonElement>(await res.Content.ReadAsStringAsync());
if (!env.GetProperty("ok").GetBoolean()) {
throw new Exception("API error: " + env.GetProperty("error").ToString());
}
return env.GetProperty("data");
}
}
2. Get a token
A guest token is minted with no sign-in and is enough for
/me and /estimate. Building a plan is metered, so it needs a
personal token — sign in at /tokens.html and copy
it from there. Guest identities are per-token: mint a second guest token and the first one's jobs
are no longer yours to poll.
TOKEN=$(curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"$SLUG\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
echo "${TOKEN:0:8}..." # a personal token comes from /tokens.html insteaddef guest_token():
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": SLUG}).encode(),
method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "ai-life-coach-client/1.0")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["data"]["token"]
TOKEN = guest_token()
print(TOKEN[:8] + "...")async function guestToken() {
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG })
});
const env = await res.json();
if (!env.ok) throw new Error(JSON.stringify(env.error));
return env.data.token;
}
TOKEN = await guestToken();func guestToken() (string, error) {
b, _ := json.Marshal(map[string]string{"slug": Slug})
res, err := http.Post(Base+"/guest", "application/json", bytes.NewReader(b))
if err != nil {
return "", err
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
if !env.OK {
return "", fmt.Errorf("could not mint a guest token")
}
return env.Data.Token, nil
}static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"" + SLUG + "\"}"))
.header("Content-Type", "application/json")
.build();
String s = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
// {"ok":true,"data":{"token":"..."}} - read data.token with your JSON library
return s;
}def guest_token
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, JSON.dump({ "slug" => SLUG }),
"Content-Type" => "application/json")
JSON.parse(res.body)["data"]["token"]
end
token = guest_token
puts token[0, 8] + "..."<?php
function guest_token(): string {
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => SLUG]),
]);
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
return $env["data"]["token"];
}
$TOKEN = guest_token();static async Task<string> GuestToken() {
var body = new StringContent(
"{\"slug\":\"" + Slug + "\"}", Encoding.UTF8, "application/json");
var res = await Http.PostAsync(Base + "/guest", body);
var env = JsonSerializer.Deserialize<JsonElement>(
await res.Content.ReadAsStringAsync());
return env.GetProperty("data").GetProperty("token").GetString();
}
3. Check the session — GET /me
Returns exactly three fields: subject_type, subject_id
and credits. There is no name or email on it, so the signed-in test is
subject_type === "user" and nothing else. A 401 here on a token you never
minted is the correct answer, not a fault. Read credits before you offer the user a
button that spends them.
call GET /me
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
# subject_type is "user" or "guest". Those three fields are the whole payload.me = call("GET", "/me")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user";raw, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)String me = call("GET", "/me", null);
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
signed_in = me["subject_type"] == "user"<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
$signedIn = $me["subject_type"] === "user";var me = await LifeCoach.Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt64());
4. Price it first — POST /estimate
Free, and it creates no job. It returns hold_credits (what is
reserved, priced against the full output cap), min_credits and the model binding
— gpt-terra, which resolves to gpt-5.6-terra. Compare
min_credits against the credits from step 3 before you show anyone a
submit button.
The body you post IS the input object. Post the object itself — never a
bare string, and never an {"input": ...} wrapper, which returns
ok:true while quietly hiding every field from the model. And note what the server
does with it: nothing. /estimate and /run perform no validation on
this body at all. A bare string, null, [] or 42 each come
back ok:true with a correct model binding and a plausible hold. There is no
400 waiting to catch your typo. Validate on your side, here, before you send.
# The whole input object. There is one lane, so there is no "task" field.
cat > input.json <<'JSON'
{
"goal": "I want to stop skipping the gym after work.",
"so_far": "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle": "By the time I get home I am done for the day.",
"register": "plain",
"horizon": "this_week",
"scan": {
"kind": "action",
"observable": false,
"quantified": false,
"cadence": true,
"deadline": false,
"attempt_recorded": true,
"intent_only": false,
"circumstance_named": true,
"trait_framed_obstacle": false,
"so_far_words": 17,
"obstacle_given": true,
"asks": ["restate_as_observable"]
}
}
JSON
call POST /estimate "$(cat input.json)"
# {"hold_credits":900,"min_credits":900,"model":"gpt-5.6-terra"}payload = {
"goal": "I want to stop skipping the gym after work.",
"so_far": "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle": "By the time I get home I am done for the day.",
"register": "plain", # plain | blunt | warm
"horizon": "this_week", # this_week | this_month
"scan": {
"kind": "action", # action | state | mixed | unclear
"observable": False,
"quantified": False,
"cadence": True,
"deadline": False,
"attempt_recorded": True,
"intent_only": False,
"circumstance_named": True,
"trait_framed_obstacle": False,
"so_far_words": 17,
"obstacle_given": True,
"asks": ["restate_as_observable"],
},
}
def must_be_valid(p):
"""The server will not do this for you."""
if not isinstance(p, dict) or not p.get("goal", "").strip():
raise ValueError("goal is required")
if len(p["goal"]) > 2000: raise ValueError("goal is over 2000 chars")
if len(p.get("so_far", "")) > 3000: raise ValueError("so_far is over 3000 chars")
if len(p.get("obstacle", "")) > 1500: raise ValueError("obstacle is over 1500 chars")
if p.get("register") not in ("plain", "blunt", "warm"):
raise ValueError("register must be plain, blunt or warm")
if p.get("horizon") not in ("this_week", "this_month"):
raise ValueError("horizon must be this_week or this_month")
return p
est = call("POST", "/estimate", must_be_valid(payload))
print(est["hold_credits"], est["min_credits"], est["model"])const payload = {
goal: "I want to stop skipping the gym after work.",
so_far: "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
obstacle: "By the time I get home I am done for the day.",
register: "plain", // plain | blunt | warm
horizon: "this_week", // this_week | this_month
scan: {
kind: "action", // action | state | mixed | unclear
observable: false,
quantified: false,
cadence: true,
deadline: false,
attempt_recorded: true,
intent_only: false,
circumstance_named: true,
trait_framed_obstacle: false,
so_far_words: 17,
obstacle_given: true,
asks: ["restate_as_observable"]
}
};
// The server will not do this for you.
function mustBeValid(p) {
if (!p || typeof p !== "object" || !String(p.goal || "").trim()) {
throw new Error("goal is required");
}
if (p.goal.length > 2000) throw new Error("goal is over 2000 chars");
if ((p.so_far || "").length > 3000) throw new Error("so_far is over 3000 chars");
if ((p.obstacle || "").length > 1500) throw new Error("obstacle is over 1500 chars");
if (!["plain", "blunt", "warm"].includes(p.register)) throw new Error("bad register");
if (!["this_week", "this_month"].includes(p.horizon)) throw new Error("bad horizon");
return p;
}
const est = await call("POST", "/estimate", mustBeValid(payload));
console.log(est.hold_credits, est.min_credits, est.model);payload := map[string]any{
"goal": "I want to stop skipping the gym after work.",
"so_far": "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle": "By the time I get home I am done for the day.",
"register": "plain", // plain | blunt | warm
"horizon": "this_week", // this_week | this_month
"scan": map[string]any{
"kind": "action",
"observable": false,
"quantified": false,
"cadence": true,
"deadline": false,
"attempt_recorded": true,
"intent_only": false,
"circumstance_named": true,
"trait_framed_obstacle": false,
"so_far_words": 17,
"obstacle_given": true,
"asks": []string{"restate_as_observable"},
},
}
// The server will not do this for you.
func mustBeValid(p map[string]any) map[string]any {
goal, _ := p["goal"].(string)
if strings.TrimSpace(goal) == "" {
panic("goal is required")
}
if len(goal) > 2000 {
panic("goal is over 2000 chars")
}
return p
}
raw, err := call("POST", "/estimate", mustBeValid(payload))
if err != nil {
panic(err)
}
var est struct {
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
Model string `json:"model"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.HoldCredits, est.MinCredits, est.Model)// Build the input object with your JSON library; this is the literal shape.
String payload = """
{
"goal": "I want to stop skipping the gym after work.",
"so_far": "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle": "By the time I get home I am done for the day.",
"register": "plain",
"horizon": "this_week",
"scan": {
"kind": "action", "observable": false, "quantified": false,
"cadence": true, "deadline": false, "attempt_recorded": true,
"intent_only": false, "circumstance_named": true,
"trait_framed_obstacle": false, "so_far_words": 17,
"obstacle_given": true, "asks": ["restate_as_observable"]
}
}
""";
// The server will not do this for you.
static String mustBeValid(String goal, String register, String horizon) {
if (goal == null || goal.isBlank()) throw new IllegalArgumentException("goal is required");
if (goal.length() > 2000) throw new IllegalArgumentException("goal is over 2000 chars");
if (!java.util.List.of("plain", "blunt", "warm").contains(register))
throw new IllegalArgumentException("bad register");
if (!java.util.List.of("this_week", "this_month").contains(horizon))
throw new IllegalArgumentException("bad horizon");
return goal;
}
String est = call("POST", "/estimate", payload);
System.out.println(est);
// {"ok":true,"data":{"hold_credits":900,"min_credits":900,"model":"gpt-5.6-terra"}}payload = {
"goal" => "I want to stop skipping the gym after work.",
"so_far" => "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle" => "By the time I get home I am done for the day.",
"register" => "plain", # plain | blunt | warm
"horizon" => "this_week", # this_week | this_month
"scan" => {
"kind" => "action", "observable" => false, "quantified" => false,
"cadence" => true, "deadline" => false, "attempt_recorded" => true,
"intent_only" => false, "circumstance_named" => true,
"trait_framed_obstacle" => false, "so_far_words" => 17,
"obstacle_given" => true, "asks" => ["restate_as_observable"]
}
}
# The server will not do this for you.
def must_be_valid(p)
raise ArgumentError, "goal is required" if p["goal"].to_s.strip.empty?
raise ArgumentError, "goal is over 2000 chars" if p["goal"].length > 2000
raise ArgumentError, "bad register" unless %w[plain blunt warm].include?(p["register"])
raise ArgumentError, "bad horizon" unless %w[this_week this_month].include?(p["horizon"])
p
end
est = call("POST", "/estimate", must_be_valid(payload))
puts "#{est["hold_credits"]} #{est["min_credits"]} #{est["model"]}"<?php
$payload = [
"goal" => "I want to stop skipping the gym after work.",
"so_far" => "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
"obstacle" => "By the time I get home I am done for the day.",
"register" => "plain", // plain | blunt | warm
"horizon" => "this_week", // this_week | this_month
"scan" => [
"kind" => "action", "observable" => false, "quantified" => false,
"cadence" => true, "deadline" => false, "attempt_recorded" => true,
"intent_only" => false, "circumstance_named" => true,
"trait_framed_obstacle" => false, "so_far_words" => 17,
"obstacle_given" => true, "asks" => ["restate_as_observable"],
],
];
// The server will not do this for you.
function must_be_valid(array $p): array {
if (trim($p["goal"] ?? "") === "") throw new InvalidArgumentException("goal is required");
if (strlen($p["goal"]) > 2000) throw new InvalidArgumentException("goal is over 2000 chars");
if (!in_array($p["register"] ?? "", ["plain", "blunt", "warm"], true))
throw new InvalidArgumentException("bad register");
if (!in_array($p["horizon"] ?? "", ["this_week", "this_month"], true))
throw new InvalidArgumentException("bad horizon");
return $p;
}
$est = call("POST", "/estimate", must_be_valid($payload));
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], PHP_EOL;var payload = new {
goal = "I want to stop skipping the gym after work.",
so_far = "I joined in January and went four times. Most days I mean to go and then it is 9pm.",
obstacle = "By the time I get home I am done for the day.",
register = "plain", // plain | blunt | warm
horizon = "this_week", // this_week | this_month
scan = new {
kind = "action", observable = false, quantified = false,
cadence = true, deadline = false, attempt_recorded = true,
intent_only = false, circumstance_named = true,
trait_framed_obstacle = false, so_far_words = 17,
obstacle_given = true, asks = new[] { "restate_as_observable" }
}
};
// The server will not do this for you.
static T MustBeValid<T>(T p, string goal, string register, string horizon) {
if (string.IsNullOrWhiteSpace(goal)) throw new ArgumentException("goal is required");
if (goal.Length > 2000) throw new ArgumentException("goal is over 2000 chars");
if (register is not ("plain" or "blunt" or "warm")) throw new ArgumentException("bad register");
if (horizon is not ("this_week" or "this_month")) throw new ArgumentException("bad horizon");
return p;
}
var est = await LifeCoach.Call(HttpMethod.Post, "/estimate",
MustBeValid(payload, payload.goal, payload.register, payload.horizon));
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
Console.WriteLine(est.GetProperty("model").GetString());
5. Run it — POST /run, then poll
Metered, so it needs a personal token. Send an Idempotency-Key
derived from the body: a network blip must not bill twice, and a retry that carries the same key
returns the first result rather than starting a second run. Same body as
/estimate, and the same warning applies — nothing on the server checks it.
The plan comes back as data.output_text, which is a JSON
string holding the output object. Parse it; do not show it to anyone raw. A long run may
answer with a job instead of a result, in which case data.status is
queued or running and you poll GET /jobs/{job_id} with the
same token until it reads succeeded or failed. Parse the result only
after you have checked for route_to_help — step 7.
KEY="lcd-$(shasum input.json | cut -c1-12)-1"
job=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json)
echo "$job" | python3 -c 'import sys,json
d = json.load(sys.stdin)["data"]
if d.get("status") in ("queued", "running"):
raise SystemExit("still running - poll /jobs/" + d["job_id"])
plan = json.loads(d["output_text"])
if plan.get("route_to_help"): raise SystemExit("route_to_help - see step 7")
print(plan["goal_observable"])
print(plan["next_action"]["what"])'
# Polling, when it did not finish inline:
# call GET /jobs/job_abc123
# -> {"job_id":"job_abc123","status":"succeeded","output_text":"{...}"}import hashlib, time
key = "lcd-" + hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()[:12] + "-1"
req = urllib.request.Request(
BASE + "/run", data=json.dumps(must_be_valid(payload)).encode(), method="POST"
)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key) # a network blip must not bill twice
req.add_header("User-Agent", "ai-life-coach-client/1.0")
with urllib.request.urlopen(req) as r:
out = json.loads(r.read())["data"]
# A long run answers with a job. Poll it with the same token.
while out.get("status") in ("queued", "running"):
time.sleep(2)
out = call("GET", "/jobs/" + out["job_id"])
if out.get("status") == "failed":
raise RuntimeError(out.get("error") or "the run failed")
plan = json.loads(out["output_text"])
if plan.get("route_to_help"):
raise SystemExit("route_to_help - stop here, see step 7")
print(plan["goal_observable"])
print(plan["next_action"]["what"], "|", plan["next_action"]["when"])
print("when you miss:", plan["when_you_miss"]["do"])const body = JSON.stringify(mustBeValid(payload));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
const key = "lcd-" + [...new Uint8Array(digest)].slice(0, 6)
.map(b => b.toString(16).padStart(2, "0")).join("") + "-1";
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key // a network blip must not bill twice
},
body
});
const env = await res.json();
if (!env.ok) throw new Error(JSON.stringify(env.error));
// A long run answers with a job. Poll it with the same token.
let out = env.data;
while (out.status === "queued" || out.status === "running") {
await new Promise(r => setTimeout(r, 2000));
out = await call("GET", "/jobs/" + out.job_id);
}
if (out.status === "failed") throw new Error(out.error || "the run failed");
const plan = JSON.parse(out.output_text);
if (plan.route_to_help) throw new Error("route_to_help - stop here, see step 7");
console.log(plan.goal_observable);
console.log(plan.next_action.what, "|", plan.next_action.when);body, _ := json.Marshal(mustBeValid(payload))
sum := sha256.Sum256(body)
key := "lcd-" + hex.EncodeToString(sum[:])[:12] + "-1"
req, _ := http.NewRequest("POST", Base+"/run", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+Token)
req.Header.Set("Idempotency-Key", key) // a network blip must not bill twice
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
type jobData struct {
JobID string `json:"job_id"`
Status string `json:"status"`
OutputText string `json:"output_text"`
}
var env struct {
OK bool `json:"ok"`
Data jobData `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
// A long run answers with a job. Poll it with the same token.
out := env.Data
for out.Status == "queued" || out.Status == "running" {
time.Sleep(2 * time.Second)
raw, _ := call("GET", "/jobs/"+out.JobID, nil)
json.Unmarshal(raw, &out)
}
var plan map[string]any
json.Unmarshal([]byte(out.OutputText), &plan)
if _, stop := plan["route_to_help"]; stop {
panic("route_to_help - stop here, see step 7")
}
fmt.Println(plan["goal_observable"])import java.security.MessageDigest;
import java.util.HexFormat;
byte[] hash = MessageDigest.getInstance("SHA-256").digest(payload.getBytes());
String key = "lcd-" + HexFormat.of().formatHex(hash).substring(0, 12) + "-1";
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.POST(HttpRequest.BodyPublishers.ofString(payload))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key) // a network blip must not bill twice
.build();
String s = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
// data.status is "succeeded", or "queued"/"running" with a job_id to poll:
// call("GET", "/jobs/" + jobId, null)
// data.output_text is a JSON *string* holding the output object. Parse it,
// and check for route_to_help before you read any other field.
System.out.println(s);require "digest"
body = JSON.dump(must_be_valid(payload))
key = "lcd-" + Digest::SHA256.hexdigest(body)[0, 12] + "-1"
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key # a network blip must not bill twice
req.body = body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
out = JSON.parse(res.body)["data"]
# A long run answers with a job. Poll it with the same token.
while %w[queued running].include?(out["status"])
sleep 2
out = call("GET", "/jobs/#{out["job_id"]}")
end
raise "the run failed" if out["status"] == "failed"
plan = JSON.parse(out["output_text"])
raise "route_to_help - stop here, see step 7" if plan["route_to_help"]
puts plan["goal_observable"]
puts "#{plan["next_action"]["what"]} | #{plan["next_action"]["when"]}"<?php
$body = json_encode(must_be_valid($payload));
$key = "lcd-" . substr(hash("sha256", $body), 0, 12) . "-1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: " . $key, // a network blip must not bill twice
],
CURLOPT_POSTFIELDS => $body,
]);
$out = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
// A long run answers with a job. Poll it with the same token.
while (in_array($out["status"] ?? "", ["queued", "running"], true)) {
sleep(2);
$out = call("GET", "/jobs/" . $out["job_id"]);
}
if (($out["status"] ?? "") === "failed") throw new RuntimeException("the run failed");
$plan = json_decode($out["output_text"], true);
if (!empty($plan["route_to_help"])) throw new RuntimeException("route_to_help - see step 7");
echo $plan["goal_observable"], PHP_EOL;
echo $plan["next_action"]["what"], " | ", $plan["next_action"]["when"], PHP_EOL;using System.Security.Cryptography;
var body = JsonSerializer.Serialize(payload);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(body));
var key = "lcd-" + Convert.ToHexString(hash)[..12].ToLowerInvariant() + "-1";
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", key); // a network blip must not bill twice
var res = await Http.SendAsync(req);
var env = JsonSerializer.Deserialize<JsonElement>(await res.Content.ReadAsStringAsync());
var outData = env.GetProperty("data");
// A long run answers with a job. Poll it with the same token.
while (outData.TryGetProperty("status", out var st) &&
(st.GetString() == "queued" || st.GetString() == "running")) {
await Task.Delay(2000);
outData = await LifeCoach.Call(
HttpMethod.Get, "/jobs/" + outData.GetProperty("job_id").GetString());
}
var plan = JsonSerializer.Deserialize<JsonElement>(
outData.GetProperty("output_text").GetString());
if (plan.TryGetProperty("route_to_help", out _))
throw new Exception("route_to_help - stop here, see step 7");
Console.WriteLine(plan.GetProperty("goal_observable").GetString());
Console.WriteLine(plan.GetProperty("next_action").GetProperty("what").GetString());
6. Stream it — POST /run-stream
POST /run-stream is the same run, same body, same Idempotency-Key,
delivered as server-sent events. Frames are separated by a blank line and carry a named event:
an event: line, then a data: line, then the blank line. The event
names are job, delta, done, pending and
error. The name is always on the event: line — the
data: payload of a delta frame carries text and nothing else, so read
the frame name from event: and never from a field inside data.
Accumulate the text of every delta frame; that concatenation is the
plan, as the same JSON string that /run would have returned in
output_text. The done frame carries charged_credits
— the real price, normally well below the hold — along with job_id,
status and truncated. An error frame is terminal and
carries code and message. A pending frame in place of
done means the run is continuing out of band; poll the job id from step 5.
On an idempotent replay the server may answer with plain JSON instead of
text/event-stream. Check the content-type before you start reading
lines, and fall back to the envelope path if it is not an event stream.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# The wire format, exactly as it arrives:
#
# event: job
# data: {"job_id":"job_9f2c","status":"running"}
#
# event: delta
# data: {"text":"{\"goal_observable\":\"Be in the gym"}
#
# event: delta
# data: {"text":" twice this week\",\"why_it_counts\":\""}
#
# event: done
# data: {"job_id":"job_9f2c","status":"succeeded","charged_credits":812,"truncated":false}
#
# Concatenate the .text of every `delta`; that concatenation is the plan JSON.
# The frame name comes from the `event:` line.req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(must_be_valid(payload)).encode(),
method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
req.add_header("User-Agent", "ai-life-coach-client/1.0")
out, done, event = "", None, "message"
with urllib.request.urlopen(req) as r:
# An idempotent replay answers with plain JSON instead of a stream.
if "text/event-stream" not in r.headers.get("content-type", ""):
out = json.loads(r.read())["data"]["output_text"]
else:
for raw in r:
line = raw.decode("utf-8").rstrip("\r\n")
if line == "":
event = "message"
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
evt = json.loads(line[5:].strip())
if event == "delta":
out += evt.get("text", "")
elif event in ("done", "pending"):
done = evt
elif event == "error":
raise RuntimeError("%s: %s" % (evt.get("code"), evt.get("message")))
if done and done.get("status") == "succeeded":
print("charged", done["charged_credits"], "truncated", done["truncated"])
plan = json.loads(out)const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key,
"Accept": "text/event-stream"
},
body: JSON.stringify(mustBeValid(payload))
});
// An idempotent replay answers with plain JSON instead of a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const env = await res.json();
return JSON.parse(env.data.output_text);
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += dec.decode(chunk.value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i);
buf = buf.slice(i + 2);
let name = "message", data = "";
for (const l of frame.split("\n")) {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
}
if (!data) continue;
const evt = JSON.parse(data);
if (name === "delta") out += evt.text || "";
else if (name === "done" || name === "pending") done = evt;
else if (name === "error") throw new Error(evt.code + ": " + evt.message);
}
}
if (done) console.log(done.charged_credits, done.truncated);
const plan = JSON.parse(out);req, _ := http.NewRequest("POST", Base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+Token)
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 out strings.Builder
name := "message"
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case line == "":
name = "message"
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
payload := strings.TrimSpace(line[5:])
switch name {
case "delta":
var evt struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(payload), &evt)
out.WriteString(evt.Text)
case "done", "pending":
fmt.Println(payload) // charged_credits, status, truncated
case "error":
panic("stream error: " + payload)
}
}
}
var plan map[string]any
json.Unmarshal([]byte(out.String()), &plan)HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder out = new StringBuilder();
String[] name = { "message" };
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(raw -> {
String line = raw.replaceAll("\\r$", "");
if (line.isEmpty()) { name[0] = "message"; return; }
if (line.startsWith("event:")) { name[0] = line.substring(6).trim(); return; }
if (!line.startsWith("data:")) return;
String payload = line.substring(5).trim();
switch (name[0]) {
case "delta":
// {"text":"..."} - the only field a delta carries
out.append(new JSONObject(payload).optString("text", ""));
break;
case "done":
case "pending":
System.out.println(payload); // charged_credits, status, truncated
break;
case "error":
throw new RuntimeException("stream error: " + payload);
}
});
JSONObject plan = new JSONObject(out.toString());require "net/http"
require "json"
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = body.to_json
out = +""
name = "message"
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 |raw|
line = raw.chomp
if line.empty?
name = "message"
elsif line.start_with?("event:")
name = line[6..].strip
elsif line.start_with?("data:")
payload = line[5..].strip
case name
when "delta" then out << JSON.parse(payload).fetch("text", "")
when "done", "pending" then puts payload # charged_credits, status, truncated
when "error" then raise "stream error: #{payload}"
end
end
end
end
end
end
plan = JSON.parse(out)<?php
$ch = curl_init("$BASE/run-stream");
$out = "";
$name = "message";
$buf = "";
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer $TOKEN",
"Idempotency-Key: $key",
"Accept: text/event-stream",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out, &$name, &$buf) {
$buf .= $chunk;
while (($nl = strpos($buf, "\n")) !== false) {
$line = rtrim(substr($buf, 0, $nl), "\r");
$buf = substr($buf, $nl + 1);
if ($line === "") {
$name = "message";
} elseif (str_starts_with($line, "event:")) {
$name = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$payload = trim(substr($line, 5));
if ($name === "delta") {
$out .= json_decode($payload, true)["text"] ?? "";
} elseif ($name === "done" || $name === "pending") {
echo $payload, "\n"; // charged_credits, status, truncated
} elseif ($name === "error") {
throw new RuntimeException("stream error: $payload");
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$plan = json_decode($out, true);var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
var name = "message";
while (!reader.EndOfStream)
{
var line = (await reader.ReadLineAsync())?.TrimEnd('\r');
if (line is null) break;
if (line.Length == 0) { name = "message"; continue; }
if (line.StartsWith("event:")) { name = line[6..].Trim(); continue; }
if (!line.StartsWith("data:")) continue;
var payload = line[5..].Trim();
switch (name)
{
case "delta":
sb.Append(JsonDocument.Parse(payload).RootElement
.GetProperty("text").GetString());
break;
case "done":
case "pending":
Console.WriteLine(payload); // charged_credits, status, truncated
break;
case "error":
throw new Exception($"stream error: {payload}");
}
}
var plan = JsonDocument.Parse(sb.ToString());
7. Handle route_to_help — the shape that is not a plan
The app runs a safety triage in the browser before it ever calls the API, and it stops there. An API caller has no such client, so the model carries its own copy of the same boundary: when the input describes a crisis, someone else being dangerous or controlling, or a goal that is a medical matter rather than a habit, it returns this and nothing else.
{"route_to_help": true}
It carries no other fields. It is not an error, not a truncated plan and not something to retry — a retry produces the same answer and bills you again. Treat it as a terminal, successful outcome that means do not present a plan, and show real support resources instead. Every example below checks for it before reading any other field, because the failure mode worth avoiding is rendering an empty plan card next to somebody who has just told you something serious.
# Every response must be checked for the refusal shape BEFORE it is rendered.
# {"route_to_help": true} is returned instead of a plan when the input should
# not have been planned at all. It carries no other fields.
RESP=$(curl -s -X POST "$BASE/run" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d "$BODY")
if echo "$RESP" | grep -q '"route_to_help"[[:space:]]*:[[:space:]]*true'; then
echo "This input routes to real support, not to a plan."
echo "Show crisis and support resources. Do not render a plan."
exit 0
fi
echo "$RESP" | jq '.data.output'
plan = json.loads(output_text)
# The refusal shape. It is not an error and it is not a partial plan - it is
# the model declining to plan something that should not be planned. Check for
# it before touching any other field.
if plan.get("route_to_help") is True:
show_support_resources() # crisis / safety / medical, per your own UI
raise SystemExit(0)
# Only now is it safe to read the plan fields.
print(plan["goal_observable"])
print(plan["next_action"]["what"])
# obstacle.in_their_words is contractually a verbatim substring of the input.
# Verify it rather than trusting it - a quotation that is not in the source is
# a fabrication, and the reference client surfaces that to the user.
quoted = plan["obstacle"]["in_their_words"]
source = " ".join([body["goal"], body["so_far"], body["obstacle"]]).lower()
if quoted and quoted.lower() not in source:
print("WARNING: the plan attributed a quotation that is not in the input")
const plan = JSON.parse(outputText);
// The refusal shape, checked first and unconditionally.
if (plan.route_to_help === true) {
showSupportResources(); // crisis / safety / medical
return;
}
console.log(plan.goal_observable);
console.log(plan.next_action.what);
// obstacle.in_their_words must be a verbatim substring of what was sent.
const quoted = plan.obstacle?.in_their_words ?? "";
const source = [body.goal, body.so_far, body.obstacle].join(" ").toLowerCase();
if (quoted && !source.includes(quoted.toLowerCase())) {
console.warn("the plan attributed a quotation that is not in the input");
}
// left_open may legitimately be empty, and may legitimately have four items.
// An empty track.what is also valid - it means no metric is worth keeping yet.
for (const item of plan.left_open ?? []) console.log("open:", item);
var plan struct {
RouteToHelp bool `json:"route_to_help"`
GoalObservable string `json:"goal_observable"`
Obstacle struct {
Named string `json:"named"`
InTheirWords string `json:"in_their_words"`
Kind string `json:"kind"`
} `json:"obstacle"`
NextAction struct {
What string `json:"what"`
When string `json:"when"`
HowSmall string `json:"how_small"`
} `json:"next_action"`
LeftOpen []string `json:"left_open"`
}
if err := json.Unmarshal([]byte(outputText), &plan); err != nil {
panic(err)
}
// The refusal shape comes first. Nothing below it may run.
if plan.RouteToHelp {
showSupportResources()
return
}
fmt.Println(plan.GoalObservable)
fmt.Println(plan.NextAction.What)
source := strings.ToLower(body.Goal + " " + body.SoFar + " " + body.Obstacle)
if q := plan.Obstacle.InTheirWords; q != "" &&
!strings.Contains(source, strings.ToLower(q)) {
fmt.Println("WARNING: quotation not present in the input")
}
JSONObject plan = new JSONObject(outputText);
// The refusal shape. Check before rendering anything else.
if (plan.optBoolean("route_to_help", false)) {
showSupportResources();
return;
}
System.out.println(plan.getString("goal_observable"));
System.out.println(plan.getJSONObject("next_action").getString("what"));
String quoted = plan.getJSONObject("obstacle").optString("in_their_words", "");
String source = (body.goal + " " + body.soFar + " " + body.obstacle).toLowerCase();
if (!quoted.isEmpty() && !source.contains(quoted.toLowerCase())) {
System.out.println("WARNING: quotation not present in the input");
}
// obstacle.kind is a closed set. Anything else should be treated as unknown.
Set<String> KINDS = Set.of("circumstance", "competing_demand",
"plan_defect", "cost", "unknown");
String kind = plan.getJSONObject("obstacle").optString("kind", "unknown");
if (!KINDS.contains(kind)) kind = "unknown";
plan = JSON.parse(output_text)
# The refusal shape, first and unconditionally.
if plan["route_to_help"] == true
show_support_resources
exit 0
end
puts plan["goal_observable"]
puts plan.dig("next_action", "what")
quoted = plan.dig("obstacle", "in_their_words").to_s
source = [body[:goal], body[:so_far], body[:obstacle]].join(" ").downcase
if !quoted.empty? && !source.include?(quoted.downcase)
warn "the plan attributed a quotation that is not in the input"
end
# An empty left_open and an empty track are both valid answers, not failures.
Array(plan["left_open"]).each { |item| puts "open: #{item}" }
<?php
$plan = json_decode($outputText, true);
// The refusal shape. Nothing below this line may run if it is set.
if (($plan["route_to_help"] ?? false) === true) {
show_support_resources();
exit(0);
}
echo $plan["goal_observable"], "\n";
echo $plan["next_action"]["what"], "\n";
$quoted = $plan["obstacle"]["in_their_words"] ?? "";
$source = strtolower(implode(" ", [$body["goal"], $body["so_far"], $body["obstacle"]]));
if ($quoted !== "" && !str_contains($source, strtolower($quoted))) {
fwrite(STDERR, "the plan attributed a quotation that is not in the input\n");
}
$KINDS = ["circumstance", "competing_demand", "plan_defect", "cost", "unknown"];
$kind = $plan["obstacle"]["kind"] ?? "unknown";
if (!in_array($kind, $KINDS, true)) { $kind = "unknown"; }
using var doc = JsonDocument.Parse(outputText);
var root = doc.RootElement;
// The refusal shape, checked before any plan field is read.
if (root.TryGetProperty("route_to_help", out var r) && r.GetBoolean())
{
ShowSupportResources();
return;
}
Console.WriteLine(root.GetProperty("goal_observable").GetString());
Console.WriteLine(root.GetProperty("next_action").GetProperty("what").GetString());
var quoted = root.GetProperty("obstacle").GetProperty("in_their_words").GetString() ?? "";
var source = string.Join(" ", body.Goal, body.SoFar, body.Obstacle).ToLowerInvariant();
if (quoted.Length > 0 && !source.Contains(quoted.ToLowerInvariant()))
{
Console.Error.WriteLine("quotation not present in the input");
}
// left_open is allowed to be empty and allowed to be long. Do not treat an
// empty array as a parse failure.
foreach (var item in root.GetProperty("left_open").EnumerateArray())
Console.WriteLine($"open: {item.GetString()}");