Drive Dog-Eared from your own code
The one metered lane, menu, is available over HTTP. It takes the book's title, how far everyone has read, how many lines were held back, and the members' lines that are left, and returns a verdict - full-table, light-bite or needs-more - with an easy starter, two or three main courses (each built on one to three members' lines, with a Socratic move, an opening question and two follow-ups), a steel-man side and a closing dessert. A natural use is a reading-group bot that collects lines in a chat during the month and posts the menu the day before the meeting.
The division of labour first: the page owns the spoiler fence, the tally and the minutes; the model writes the questions; the page re-checks the reply. The line reader, the fence and the clipping come from dogkit.js, which runs in node as well as in the browser. Run DogKit.buildInput on anything you send: it drops every line that points past read_to, so the model never sees a spoiler. Minutes are never asked of the model - DogKit.minutes(length, mains) sets them (starter 10%, side 15%, dessert 15%, the rest shared by the mains). DogKit.check holds each reply to the lines: the course count matches the verdict, every course is built on lines that were sent, no line feeds two courses, the moves are distinct, and no capitalised name, number or pronoun turns up that the lines do not hold.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"dog-eared"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field such as task, book or lines is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the menu lane is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdicts
Every run sends task: menu, the only lane; a missing or unknown task is treated as menu. A line is discussable when it gives an opinion, a reading, a reaction with a reason, a question about the book, or quoted words; a bare chapter number, logistics and a one- or two-word reaction are not. The verdict is decided by three ordered, non-overlapping rules - the first that applies wins:
| Order | Verdict | When |
|---|---|---|
| 1 | needs-more | Fewer than three discussable lines. No courses; asks holds two or three questions to send members. |
| 2 | light-bite | Three, four or five discussable lines: exactly two main courses. |
| 3 | full-table | Six or more: exactly three main courses. |
The declared input schema carries scalars only, so the array-valued lines field travels JSON-encoded as a string (DogKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | menu. Required. |
book | string | The title as the host typed it, author optional. Up to 120 characters. Required. |
read_to | string | How far everyone has read ("chapter 16", "page 210", "the whole book"), or empty. |
held_back | string | How many lines the page withheld for pointing past read_to; "0" when none. |
lines | string (JSON) | [{id, who, kind, ref, text}] - ids L1.., up to 24 lines of up to 360 characters; who from a "Name:" prefix; kind is quote, take or question from punctuation; ref like ch. 5 or p. 88. Required. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, starter ({question, lines}), mains ([{id, lines, dish, claim, move, question, follow_ups}]; ids M1..; move is one of clarify, assumptions, evidence, implications, perspectives, never twice; follow_ups holds two questions), side ({question, lines} - the steel man, with the line ids it pushes against), dessert ({question, lines}) and asks. Parse with DogKit.parseReply, then normalize(reply), then check(reply, form), which returns the page's notes.
Worked examples
Replies produced against the app's prompt for the three sample nights (made-up clubs reading public-domain classics; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict.
menu - Frankenstein, read to chapter 16, one line held back: full-table
Input:
{
"task": "menu",
"book": "Frankenstein by Mary Shelley",
"read_to": "chapter 16",
"held_back": "1",
"lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"},{\"id\":\"L3\",\"who\":\"Ines\",\"kind\":\"quote\",\"ref\":\"ch. 10\",\"text\":\"\\\"I was benevolent and good; misery made me a fien ... (1145 characters)"
}
Reply:
{
"task": "menu",
"verdict": "full-table",
"title": "Frankenstein: who is the real monster",
"summary": "Tonight's table is full: the group sent in plenty to chew on, from who really deserves blame to what the creature decides about Satan and Adam. That's enough for three full courses, with room for a side question too. One line is waiting past chapter 16, so tonight stays spoiler-free.",
"starter": {
"question": "Why do you think Victor never gives him a name?",
"lines": [
"L7"
]
},
"mains": [
{
"id": "M1",
"lines": [
"L1",
"L2"
],
"dish": "The real monster",
"claim": "Priya says Victor is the real monster for running from the creature the moment it opens its eyes; Tom answers that the creature still chose to kill William, and nobody made him do it.",
"move": "clarify",
"question": "What does calling Victor 'the real monster' really mean here?",
"follow_ups": [
"How does the creature's choice to kill William change who deserves to be called a monster?",
"What would it mean for both Victor and the creature to share that name?"
]
},
{
"id": "M2",
"lines": [
"L3",
"L6"
],
"dish": "Misery, guilt, or excuse",
"claim": "Ines quotes the creature saying misery made him a fiend and asks whether that is true or an excuse; Tom says Victor knew the truth at Justine's trial and said nothing.",
"move": "assumptions",
"question": "What does the idea that misery can turn someone into a fiend take for granted?",
"follow_ups": [
"What does Victor's silence at the trial take for granted about what he owed Justine?",
"How far could the excuse in Ines's quote stretch to cover Victor's silence too?"
]
},
"... (3 in all)"
],
"side": {
"question": "What is the strongest case that the creature, not Victor, is the one who chose to kill William?",
"lines": [
"L1",
"L6"
]
},
"dessert": {
"question": "After tonight's arguments about monsters, excuses, and Satan or Adam, what would you most want to ask the author?",
"lines": []
},
"asks": []
}
menu - The Great Gatsby, read to chapter 5: light-bite
Input:
{
"task": "menu",
"book": "The Great Gatsby",
"read_to": "chapter 5",
"held_back": "0",
"lines": "[{\"id\":\"L1\",\"who\":\"Rosa\",\"kind\":\"question\",\"ref\":\"ch. 1\",\"text\":\"Gatsby reaching out toward the green light at the end of Daisy's dock (ch. 1) - romantic or creepy?\"},{\"id\":\"L2\",\"who\":\"Dev\",\"kind\":\"take\",\"ref\":\"ch. 3\",\"text\":\"Nick calls himself one of the few honest people he has ever known. I don't trust him at all (ch. 3)\"},{\"id\":\"L3\",\"who\":\"Rosa\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"The shirts scene made me cry and ... (684 characters)"
}
Reply:
{
"task": "menu",
"verdict": "light-bite",
"title": "Honesty, trust, and the green light",
"summary": "Tonight's table turns on two threads: how to feel about reaching for the green light and the shirts scene, and whether Nick or the host can really be trusted. A handful of lines gave enough for two courses, so we're keeping it to a light bite rather than a full spread.",
"starter": {
"question": "What's one moment from the book so far that has stuck with you?",
"lines": []
},
"mains": [
{
"id": "M1",
"lines": [
"L1",
"L3"
],
"dish": "Reactions to Gatsby",
"claim": "Rosa wonders whether Gatsby reaching for the green light is romantic or creepy, and says the shirts scene brought tears they can't explain.",
"move": "clarify",
"question": "When you call reaching for the green light 'romantic' or 'creepy,' what exactly are you reacting to?",
"follow_ups": [
"What one detail would tip that reach for the light from romantic into creepy, or back again?",
"What might those unexplained tears over the shirts actually be about?"
]
},
{
"id": "M2",
"lines": [
"L2",
"L4"
],
"dish": "Knowing the host",
"claim": "Dev says Nick's claim to be one of the few honest people does not earn trust, and Sam says the parties are fun even though nobody there actually knows the host.",
"move": "assumptions",
"question": "What do we expect from someone before we're willing to call their claim of honesty true?",
"follow_ups": [
"What would it take to actually know a host whose parties you enjoy?",
"Why might enjoying a party not require knowing the person who threw it?"
]
}
],
"side": {
"question": "What's the strongest case that you can really know Nick or the host just by watching what they do?",
"lines": [
"L2",
"L4"
]
},
"dessert": {
"question": "Between the gesture toward the green light and the question of who to really trust, what will stick with you after tonight?",
"lines": []
},
"asks": []
}
menu - "loved it", "meh", snacks and a page number: needs-more
Input:
{
"task": "menu",
"book": "Little Women",
"read_to": "page 120",
"held_back": "0",
"lines": "[{\"id\":\"L1\",\"who\":\"Ana\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"loved it\"},{\"id\":\"L2\",\"who\":\"Ben\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"meh\"},{\"id\":\"L3\",\"who\":\"Ana\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"I'll bring the tea and scones\"},{\"id\":\"L4\",\"who\":\"Cleo\",\"kind\":\"take\",\"ref\":\"p. 88\",\"text\":\"page 88\"}]"
}
Reply:
{
"task": "menu",
"verdict": "needs-more",
"title": "Little Women discussion night",
"summary": "The lines sent in for Little Women are quick reactions, a snack offer, and a page number, not enough to build tonight's board. A few more thoughts about what members are reading would give the group something to dig into.",
"starter": {
"question": "",
"lines": []
},
"mains": [],
"side": {
"question": "",
"lines": []
},
"dessert": {
"question": "",
"lines": []
},
"asks": [
"What part of the story has stayed with you so far?",
"Is there a moment you disagreed with or want to talk through?",
"What's a line that stuck with you, and why?"
]
}
Build the input yourself
Load the script in node (global.window = global; require("./dogkit.js")), then:
const K = DogKit;
const form = K.form({ book: "Frankenstein", read_to: "chapter 16", length: "60", members: "Priya, Tom, Jo",
lines: "Priya: Victor runs the moment it opens its eyes (ch. 5)\nTom: the creature still chose to kill (ch. 16)\nTom: the ending (ch. 20)" });
console.log(K.flags(form), K.lean(form), K.items(form).held); // the flags, the page's lean, the lines held back
const wire = K.toWire(K.buildInput("menu", form).input); // POST this to /estimate and /run
// then: const menu = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(menu, form); // the page's re-check of the reply
// K.minutes(form.length, menu.mains.length) // the minutes for each course
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://dog-eared.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://dog-eared.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// dog-eared.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://dog-eared.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://dog-eared.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// DogEared.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class DogEared {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://dog-eared.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://dog-eared.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://dog-eared.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://dog-eared.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"dog-eared"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "dog-eared"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "dog-eared" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "dog-eared"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "dog-eared"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "dog-eared" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "dog-eared"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "dog-eared" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "menu", "book": "Frankenstein by Mary Shelley", "read_to": "chapter 16", "held_back": "1", "lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"}, ...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "menu", "book": "Frankenstein by Mary Shelley", "read_to": "chapter 16", "held_back": "1", "lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"}, ...]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "menu", book, read_to, held_back, lines, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses dog-eared:<task>:<inputHash>:a<attempt> (the one lane is menu), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="dog-eared:menu:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "dog-eared:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `dog-eared:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "dog-eared:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "dog-eared:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "dog-eared:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "dog-eared:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"dog-eared:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: dog-eared:menu:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "menu", "book": "Frankenstein by Mary Shelley", "read_to": "chapter 16", "held_back": "1", "lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"}, ...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "dog-eared:menu:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `dog-eared:menu:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "dog-eared:menu:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "dog-eared:menu:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "dog-eared:menu:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: dog-eared:menu:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"dog-eared:menu:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);