Drive Tell Desk from your own code
The page does its free work in the browser: it splits a draft into blocks, sets aside code, quotations, block
quotes and URLs, picks a context profile, matches the tiered vocabulary, runs the catalogue's patterns, measures
rhythm and raises flags TD-01..TD-39. Its two paid lanes are available over HTTP. You send one JSON object with
task set to "audit" or "rewrite", and the model replies with one JSON
object. The easiest way to build the facts string is the page itself:
Tellkit.buildInput(lane, Tellkit.analyze(draft, {context, voice}), {note}) in
tellkit.js, which also runs under Node.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what the model does |
|---|---|
"audit" | Detect only. Answers every flag in lane_flags (confirmed or dismissed, a clear problem or a judgment call), finds judgment-only patterns a regex cannot catch, reads the rhythm, runs the reshuffle and treadmill tests, and names what already reads like a person. |
"rewrite" | Rewrites the draft without the tells and without adding first person, dashes, stock hooks or invented specifics; lists the changes, what it left alone, the gaps only you can fill, and a second pass over its own rewrite. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | audit or rewrite |
draft | required | The draft, verbatim. Past 24,000 characters the page cuts the middle on a paragraph boundary and leaves a [tell-desk: ...] marker. |
context | required | blog, linkedin, technical-blog, investor-email, docs or casual - the profile the facts were built with. |
facts | required | A JSON string: lane, context (profile, chosen_by, why), voice, summary (words, sentences, paragraphs, headings, protected spans, first_person, em_dashes), rhythm, rebuild_signal, vocab (the four tiers with counts), flags (uid, id, name, sev, band, tolerance, text, fix, quotes), lane_flags, protected, skipped_for_context, notes. It is Tellkit.factsOf(lane, A). |
voice | rewrite only | casual, professional, technical, warm or blunt. |
note | optional | Your instruction, up to 1,500 characters. |
audit_handoff | rewrite only | A JSON string of an audit reply's verdict, core_point, confirmed flag uids and its clear-problem findings, for the same draft. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, verdict (clean, patch or rebuild), context{profile, why}, headline, flag_answers[{flag, status: confirmed|dismissed, call: clear_problem|judgment_call, reason}], core_point (required for rebuild, otherwise null), questions[], summary.
task: "audit"
findings[{quote, pattern, severity: P0|P1|P2, kind: authorship|clarity|fingerprint|rhythm, call, fix}], rhythm{reading, fix}, structure_tests{reshuffle: pass|fail, treadmill: pass|fail, note}, keep[] (verbatim quotes).
task: "rewrite"
rewrite (the full text), changes[{before, after, why}], left_alone[{quote, why}], gaps[{quote, need}], second_pass[{quote, pattern, fixed}].
Worked examples
These are the page's own bundled examples (invented companies), with the input as app.js builds it and the saved model reply the page replays for free.
Audit: a monthly investor update
Input (draft and facts shortened here):
{
"task": "audit",
"draft": "Hi everyone,\n\nI'm thrilled to share our September update — it has been a pivotal month for Harbourline.\n\nRevenue: MRR grew to $48,200, up from $41,500 in August. This represents a significant shift in our trajectory and underscores the robust demand for our cold-chain monitoring platform. We signed 3 new logistics customers, bringing the total to 14.\n\nProduct: We shipped the new alerting engine, which seamlessly integrates with our customers' existing warehouse systems. Experts believe real-time... (1352 characters in all)",
"context": "investor-email",
"facts": "(a JSON string; decoded: context investor-email chosen by page, 218 words, 16 sentences (spread 0.43), 14 flags - TD-02, TD-03, TD-06, TD-07, TD-09, TD-10, TD-12, TD-16, TD-37 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14)"
}
Reply:
{
"task": "audit",
"verdict": "patch",
"context": {
"profile": "investor-email",
"why": "A monthly update to existing investors with MRR, runway and an ask is exactly the investor-email register, so the page's choice stands and promotion, inflation and generic closers are read extra strict."
},
"headline": "Cut the promotional sentences wrapped around your numbers, fill in the signature, and let the MRR, runway and sales-cycle figures carry the update on their own.",
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"call": "judgment_call",
"reason": "The stock sign-off repeats what the Asks section already does and adds nothing an investor needs; in an update this short, end on the ask or the signature."
},
{
"flag": "F2",
"status": "confirmed",
"call": "clear_problem",
"reason": "The signature is an unfilled template slot; sending it as is tells every investor the email came from a template."
},
{
"flag": "F3",
"status": "confirmed",
"call": "clear_problem",
"reason": "No expert, report or figure is named, and investors read an unnamed authority as padding for a market claim the update cannot back."
},
{
"flag": "F4",
"status": "confirmed",
"call": "judgment_call",
"reason": "One em dash in 218 words is over the density limit, and it joins the \"thrilled to share\" opener to the \"pivotal month\" label; the whole clause after it goes when the opener is replaced by the September numbers, so the dash goes with it."
},
{
"flag": "F5",
"status": "confirmed",
"call": "clear_problem",
... (shortened here; the page shows it whole)
Rewrite: the same update, carrying the audit
Input (with the audit handed over):
{
"task": "rewrite",
"draft": "Hi everyone,\n\nI'm thrilled to share our September update — it has been a pivotal month for Harbourline.\n\nRevenue: MRR grew to $48,200, up from $41,500 in August. This represents a significant shift in our trajectory and underscores the robust demand for our cold-chain monitoring platform. We signed 3 new logistics customers, bringing the total to 14.\n\nProduct: We shipped the new alerting engine, which seamlessly integrates with our customers' existing warehouse systems. Experts believe real-time... (1352 characters in all)",
"context": "investor-email",
"facts": "(a JSON string; decoded: context investor-email chosen by page, 218 words, 16 sentences (spread 0.43), 14 flags - TD-02, TD-03, TD-06, TD-07, TD-09, TD-10, TD-12, TD-16, TD-37 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14)",
"voice": "professional",
"audit_handoff": "{\"verdict\":\"patch\",\"core_point\":null,\"confirmed\":[\"F1\",\"F2\",\"F3\",\"F4\",\"F5\",\"F6\",\"F7\",\"F8\",\"F9\",\"F10\",\"F11\",\"F12\",\"F13\",\"F14\"],\"findings\":[{\"quote\":\"remains longer than planned, averaging 94 days\",\"pattern\":\"Comparison without its baseline\",\"fix\":\"State the plan next to the actual: \\\"averaging 94 days against the N we p..."
}
Reply:
{
"task": "rewrite",
"verdict": "patch",
"context": {
"profile": "investor-email",
"why": "A salutation, labelled metric sections and an ask to investors make this a monthly investor update, so the investor-email profile fits."
},
"headline": "The figures carry this update; cut the promotional sentences around them and fill in the sign-off name before sending.",
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"call": "clear_problem",
"reason": "The closing offer adds nothing to an update investors can already reply to; it was cut."
},
{
"flag": "F2",
"status": "confirmed",
"call": "clear_problem",
"reason": "The sign-off still reads [Founder Name]; the name is not in the draft, so it is kept and listed as a gap for the writer to fill."
},
{
"flag": "F3",
"status": "confirmed",
"call": "clear_problem",
"reason": "No expert or study is named; the sentence was cut rather than given an invented source."
},
{
"flag": "F4",
"status": "confirmed",
"call": "clear_problem",
"reason": "The one em dash sat in the opening sentence, which was rewritten without it."
},
{
"flag": "F5",
"status": "confirmed",
"call": "clear_problem",
"reason": "\"could potentially\" stacks two hedges on a test already under way; the rewrite states the test."
},
{
"flag": "F6",
"status": "confirmed",
"call": "clear_problem",
"reason": "Both the market prediction and the bright-future closer make no checkable claim; both were cut."
},
{
"flag": "F7",
"status": "confirmed",
"call": "cl
... (shortened here; the page shows it whole)
Rewrite: a blog post pasted from a chat window (rebuild)
Input:
{
"task": "rewrite",
"draft": "# The Hidden Cost of Meetings\n\nIn today's rapidly evolving workplace landscape, meetings have become a cornerstone of how teams collaborate. However, as organisations navigate the complexities of hybrid work, it is important to note that not all meetings are created equal. Let's delve into the intricate interplay between meeting culture and productivity.\n\nStudies show that the average employee spends a significant portion of their week in meetings. Moreover, many of these meetings lack a clear a... (1824 characters in all)",
"context": "blog",
"facts": "(a JSON string; decoded: context blog chosen by page, 248 words, 17 sentences (spread 0.23), 28 flags - TD-04, TD-05, TD-06, TD-07, TD-09, TD-11, TD-12, TD-13, TD-14, TD-16, TD-19, TD-28, TD-29, TD-31, TD-32, TD-34, TD-37, TD-38 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, F24, F25, F26, F27, F28)"
}
Reply:
{
"task": "rewrite",
"verdict": "rebuild",
"context": {
"profile": "blog",
"why": "A short opinion post with a heading and a quoted colleague; blog fits, so every rule applies at full strength."
},
"headline": "Cut the backdrop opener, the unsourced \"Studies show\" line and the closing paragraph, and build the post around the Tavi Orlenfeld quote.",
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"call": "clear_problem",
"reason": "The citeturn0search3 token is leftover chat citation markup after the context-switching sentence; stripped."
},
{
"flag": "F2",
"status": "confirmed",
"call": "clear_problem",
"reason": "The report URL carried utm_source=chatgpt.com; the parameter is stripped and the rest of the URL kept."
},
{
"flag": "F3",
"status": "confirmed",
"call": "clear_problem",
"reason": "\"Studies show\" names no study and \"a significant portion\" gives no figure; the sentence is cut and the missing source is listed in gaps."
},
{
"flag": "F4",
"status": "confirmed",
"call": "clear_problem",
"reason": "\"Let's delve into\" announces a discussion instead of having it; the whole sentence is cut."
},
{
"flag": "F5",
"status": "confirmed",
"call": "clear_problem",
"reason": "\"workplace landscape\" is backdrop, not news; the opener now states what meetings are for teams."
},
{
"flag": "F6",
"status": "confirmed",
"call": "clear_problem",
"reason": "\"pivotal role\" inflates what the paragraph then says plainly; replaced with what leaders actually do."
},
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js): the profile, every flag answered once, fingerprints never dismissed, the verdict against what the reply confirms, every quote verbatim, no authorship claims - and a rewrite rescanned with the same free check for flags left, dashes, first person, numbers and URLs added, and code and quotations preserved byte for byte. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same draft into the page.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://tell-desk.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://tell-desk.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"]
// tell-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://tell-desk.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://tell-desk.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
}
// TellDesk.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 TellDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://tell-desk.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://tell-desk.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://tell-desk.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://tell-desk.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":"tell-desk"}' | 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": "tell-desk"})["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: "tell-desk" })).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": "tell-desk"}, 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", "tell-desk"), 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: "tell-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "tell-desk"])["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 = "tell-desk" }))!["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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "audit", "draft": "...", "context": "blog", "facts": "..."}
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": "audit", "draft": "...", "context": "blog", "facts": "...", ...}
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, draft, context, facts, ...}
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 tell-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="tell-desk:audit:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "tell-desk:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `tell-desk:${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 := "tell-desk:" + 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 = "tell-desk:" + 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 = "tell-desk:#{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["verdict"]].inspect
$key = "tell-desk:" . $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["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"tell-desk:{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 (rewrite)
The same run as Server-Sent Events. rewrite.json is a rewrite input, with audit_handoff when it follows an audit.
# 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: tell-desk:rewrite:$(shasum -a 256 rewrite.json | cut -c1-16):a1" --data @rewrite.json
rev = json.load(open("rewrite.json")) # {"task": "rewrite", "draft": "...", "context": "...", "facts": "...", "voice": "...", "audit_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "tell-desk:rewrite:" + 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("rewrite.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": `tell-desk:rewrite:${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("rewrite.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", "tell-desk:rewrite:"+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("rewrite.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", "tell-desk:rewrite:" + 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("rewrite.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "tell-desk:rewrite:#{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("rewrite.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: tell-desk:rewrite:" . 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("rewrite.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", $"tell-desk:rewrite:{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);