Drive Goo Forward from your own code
The one metered task, grow, is available over HTTP. It takes a vague aim, up to six lines each of who you are, what you know and whom you know, the hours and money you can afford to lose, a currency sign and a tone (playful or plain), plus page_facts - the page's numbered list of those means with their counts and caps. It returns a verdict (spread, probe or reshape) and 2 to 4 first moves, or a polite decline.
The division of labour first: the page numbers the means and works out its own verdict; the model grows the moves and writes the words; the page then re-checks every move - means that exist, hours and money under the caps, people asked from the list, at least two people asked in a spread, and the move to grow first (most means, then fewer hours, then less money, then earlier). Build page_facts the same way (see "Build the input yourself") so the numbers line up.
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":"goo-forward"}), 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 (task, aim, i_am, i_know, i_know_people, loss_hours, loss_money, currency, tone, page_facts) 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: growing moves 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, the status and the verdict
Every run sends task: grow (the one task is grow); a missing or different one is still answered as grow. The checks run in this order and stop at the first match: status declined when the aim involves harm, anything illegal, health treatment, gambling, investing or trading, crypto, borrowing or lending money, or handling other people's money; else verdict reshape when the aim names quitting a job, selling or remortgaging a home, moving to another country, or an amount above a cap; else probe with fewer than 3 means or no people; else spread.
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always grow. |
aim | yes | The vague aim, up to 160 characters. |
i_am, i_know, i_know_people | yes (may be empty) | Who you are, what you know, whom you know: one item per line, up to six each, repeats dropped, each up to 120 characters. At least one means in all. |
loss_hours, loss_money | yes | Whole numbers: the hours (1 or more) and money (0 or more) you can afford to lose across all moves together. |
currency | yes | One of $, £, €, ¥, ₹. |
tone | yes | playful (one slime-mold image allowed in the closing) or plain. |
page_facts | yes | The means numbered from 1 in the order who / know / whom, the counts, and the caps. |
clip_note | no | What the page left out, if anything. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "grow", "status": "ok", "reason": "", "verdict": "spread", "aim_label": "...", "smaller_aim": "",
"moves": [{"title": "...", "uses": [2, 3, 6], "hours": 2, "money": 0, "ask": 6, "commitment": "...", "why": "..."}, ...],
"thicken": 3, "thicken_why": "...", "pruned": [{"idea": "...", "reason": "needs_forecast"}],
"first_step": "...", "closing": "..."}
| Field | What it holds |
|---|---|
status, reason | ok or declined; for declined, one sentence of at most 25 words, and every other field empty. |
verdict | spread (3 or 4 moves, at least two asking different people), probe (2 moves, one going looking for a new means) or reshape (2 moves toward smaller_aim). |
aim_label, smaller_aim | The aim in at most 8 words; a smaller aim of at most 14 words, only for reshape. |
moves | Each: title (at most 8 words, starts with a verb), uses (means numbers), hours (1 or more), money, ask (a person's means number or 0), commitment (at most 14 words, empty when ask is 0) and why (at most 22 words). Hours and money add up to no more than the caps. |
thicken, thicken_why | The 1-based move to grow first: most uses, then fewer hours, then less money, then earlier; and why, in at most 22 words. |
pruned | 1 or 2 big conventional steps set aside, each with reason over_cap, needs_forecast or no_means. |
first_step, closing | The first action of the move to grow first (at most 22 words) and a closing line (at most 18 words). |
The page then checks the reply (GooKit.check) against its own verdict, the numbered means and the caps, and lists any disagreement under "The page's check".
Worked examples
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every outcome - a bike-repair side gig (spread), teaching grown-ups to juggle (probe), a record shop (reshape) and evening crypto trading (declined).
A bike-repair side gig - spread
Input:
{
"task": "grow",
"aim": "start a small bike-repair side gig",
"i_am": "patient at explaining things\nown a full bike toolbox",
"i_know": "fixing punctures, brakes and gears\nfour years working in a bike shop",
"i_know_people": "my neighbour who runs the community centre\nmy sister, who rides a cargo bike to work\nmy old manager at the bike shop",
"loss_hours": "12",
"loss_money": "60",
"currency": "$",
"tone": "playful",
"page_facts": "Means (numbered):\n1. (who) patient at explaining things\n2. (who) own a full bike toolbox\n3. (know) fixing punctures, brakes and gears\n4. (know) four years working in a bike shop\n5. (whom) my neighbour who runs the community centre\n6. (whom) my sister, who rides a cargo bike to work\n7. (whom) my old manager at the bike shop\nCounts: who you are 2, what you know 2, whom you know (people) 3, total 7.\nCaps: at most 12 hours and $60 in total across all moves."
}
Reply:
{
"task": "grow",
"status": "ok",
"reason": "",
"verdict": "spread",
"aim_label": "Start a small bike-repair side gig",
"smaller_aim": "",
"moves": [
{
"title": "Fix your sister's cargo bike first",
"uses": [
2,
3,
6
],
"hours": 2,
"money": 0,
"ask": 6,
"commitment": "A yes to being your first practice fix.",
"why": "Your full toolbox and puncture, brake and gear skills let you tune your sister's cargo bike cheaply."
},
{
"title": "Offer a repair-tips talk at the centre",
"uses": [
1,
3,
5
],
"hours": 3,
"money": 5,
"ask": 5,
"commitment": "A slot to host a short repair talk.",
"why": "Your patience explaining things and puncture, brake and gear knowledge suit a short talk your neighbour could host."
},
{
"title": "Message your old manager for a recommendation",
"uses": [
4,
7
],
"hours": 1,
"money": 0,
"ask": 7,
"commitment": "A yes to pointing people your way.",
"why": "Four years working at the shop and your manager there make this request easy to try."
}
],
"thicken": 1,
"thicken_why": "It draws on three means and costs the fewest hours among the tied options, so grow this first.",
"pruned": [
{
"idea": "Renting a dedicated repair shop space",
"reason": "over_cap"
},
{
"idea": "Forecasting steady demand before starting",
"reason": "needs_forecast"
}
],
"first_step": "Message your sister to set up a time to look at her cargo bike.",
"closing": "These small steps can grow like a slime mold feeling its way outward."
}
Teach grown-ups to juggle - probe
Input:
{
"task": "grow",
"aim": "teach grown-ups to juggle",
"i_am": "can juggle five balls",
"i_know": "",
"i_know_people": "",
"loss_hours": "6",
"loss_money": "0",
"currency": "£",
"tone": "plain",
"page_facts": "Means (numbered):\n1. (who) can juggle five balls\nCounts: who you are 1, what you know 0, whom you know (people) 0, total 1.\nCaps: at most 6 hours and £0 in total across all moves."
}
Reply:
{
"task": "grow",
"status": "ok",
"reason": "",
"verdict": "probe",
"aim_label": "Teach grown-ups to juggle",
"smaller_aim": "",
"moves": [
{
"title": "Offer a grown-up a juggling taster",
"uses": [
1
],
"hours": 2,
"money": 0,
"ask": 0,
"commitment": "",
"why": "Your five-ball juggling lets you show a grown-up a trick and invite them to try."
},
{
"title": "Practice explaining your juggling technique aloud",
"uses": [
1
],
"hours": 3,
"money": 0,
"ask": 0,
"commitment": "",
"why": "Turning your five-ball juggling into spoken steps builds a teachable method without needing anything else."
}
],
"thicken": 1,
"thicken_why": "This move ties on means used but costs the fewest hours, so you grow it first.",
"pruned": [
{
"idea": "Renting a venue for group classes",
"reason": "over_cap"
},
{
"idea": "Writing a full course curriculum upfront",
"reason": "needs_forecast"
}
],
"first_step": "Ask a grown-up to watch you juggle and then try it themselves.",
"closing": "Small steps to find a first learner and sharpen your teaching before spreading further."
}
A record shop - reshape
Input:
{
"task": "grow",
"aim": "quit my job and open a second-hand vinyl record shop",
"i_am": "own about 900 records\ngood at chatting to strangers",
"i_know": "how to grade a record's condition\nwhich pressings collectors look for",
"i_know_people": "a friend who runs a café with a spare corner\nmy cousin who sells at a market every month",
"loss_hours": "20",
"loss_money": "150",
"currency": "£",
"tone": "playful",
"page_facts": "Means (numbered):\n1. (who) own about 900 records\n2. (who) good at chatting to strangers\n3. (know) how to grade a record's condition\n4. (know) which pressings collectors look for\n5. (whom) a friend who runs a café with a spare corner\n6. (whom) my cousin who sells at a market every month\nCounts: who you are 2, what you know 2, whom you know (people) 2, total 6.\nCaps: at most 20 hours and £150 in total across all moves."
}
Reply:
{
"task": "grow",
"status": "ok",
"reason": "",
"verdict": "reshape",
"aim_label": "Quit your job, open a vinyl record shop",
"smaller_aim": "Sell some records at the market with your cousin",
"moves": [
{
"title": "Sell records at the market with your cousin",
"uses": [
1,
3,
4,
6
],
"hours": 6,
"money": 0,
"ask": 6,
"commitment": "a slot at the market to sell some records",
"why": "You own records, know how to grade them and which pressings collectors look for, and your cousin sells at a market."
},
{
"title": "Display records and chat at café corner",
"uses": [
1,
2,
5
],
"hours": 4,
"money": 0,
"ask": 5,
"commitment": "a spare corner to display some records",
"why": "You own records, are good at chatting to strangers, and your friend runs a café with a spare corner."
}
],
"thicken": 1,
"thicken_why": "This move draws on the most you have: your records, their condition, pressings knowledge and your cousin at the market.",
"pruned": [
{
"idea": "Renting a shop before any sales are made",
"reason": "over_cap"
},
{
"idea": "Stocking wide inventory before knowing what sells",
"reason": "needs_forecast"
}
],
"first_step": "Ask your cousin for a market slot to sell some of your records.",
"closing": "Even slime molds test a thread before committing to the whole dish - try the smaller shape first."
}
Evening crypto trading - declined
Input:
{
"task": "grow",
"aim": "make extra money trading crypto in the evenings",
"i_am": "good with spreadsheets",
"i_know": "basic chart reading",
"i_know_people": "a coworker who trades",
"loss_hours": "10",
"loss_money": "500",
"currency": "$",
"tone": "plain",
"page_facts": "Means (numbered):\n1. (who) good with spreadsheets\n2. (know) basic chart reading\n3. (whom) a coworker who trades\nCounts: who you are 1, what you know 1, whom you know (people) 1, total 3.\nCaps: at most 10 hours and $500 in total across all moves."
}
Reply:
{
"task": "grow",
"status": "declined",
"reason": "This aim involves trading crypto, which isn't something this approach can help you with.",
"verdict": "",
"aim_label": "",
"smaller_aim": "",
"moves": [],
"thicken": 0,
"thicken_why": "",
"pruned": [],
"first_step": "",
"closing": ""
}
Build the input yourself
Load the engine in node (global.window = global; require("./goo.js"); require("./dish.js")), then:
const K = GooKit, D = GooDish;
const built = K.buildInput({ aim: "start a small bike-repair side gig", am: "own a full bike toolbox",
know: "fixing punctures, brakes and gears", people: "my neighbour who runs the community centre",
hours: "12", money: "60", currency: "$", tone: "playful" });
built.parsed.means; // [{n: 1, group: "am", text: "own a full bike toolbox"}, ...]
built.prescan.verdict; // the page's own verdict: "probe" here (fewer than 3 means)
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.thickenOf(reply.moves) // the move to grow first, by the rule
// D.svg({ means: built.parsed.means, moves: reply.moves, thicken: K.thickenOf(reply.moves), pruned: reply.pruned })
// K.md(built, reply) // the grown dish as Markdown
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://goo-forward.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://goo-forward.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"]
// goo-forward.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://goo-forward.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://goo-forward.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
}
// GooForward.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 GooForward {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://goo-forward.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://goo-forward.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://goo-forward.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://goo-forward.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":"goo-forward"}' | 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": "goo-forward"})["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: "goo-forward" })).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": "goo-forward"}, 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", "goo-forward"), 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: "goo-forward" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "goo-forward"])["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 = "goo-forward" }))!["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": "grow", "aim": "...", "i_am": "...\n...", "i_know": "...", "i_know_people": "...", "loss_hours": "12", "loss_money": "60", "currency": "$", "tone": "playful", "page_facts": "Means (numbered):\n1. (who) ..."} - see the field table
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": "grow", "aim": "...", "i_am": "...\n...", "i_know": "...", "i_know_people": "...", "loss_hours": "12", "loss_money": "60", "currency": "$", "tone": "playful", "page_facts": "Means (numbered):\n1. (who) ..."} - see the field table
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: "grow", aim, i_am, i_know, i_know_people, loss_hours, loss_money, currency, tone, page_facts} (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 goo-forward:<task>:<inputHash>:a<attempt> (the one task is grow), 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="goo-forward:grow:$(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 = "goo-forward:" + 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("status"))
import { createHash } from "node:crypto";
const key = `goo-forward:${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.status);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "goo-forward:" + 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 = "goo-forward:" + 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 = "goo-forward:#{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["status"]].inspect
$key = "goo-forward:" . $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["status"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"goo-forward:{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: goo-forward:grow:$(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": "grow", "aim": "...", "i_am": "...\n...", "i_know": "...", "i_know_people": "...", "loss_hours": "12", "loss_money": "60", "currency": "$", "tone": "playful", "page_facts": "Means (numbered):\n1. (who) ..."} - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "goo-forward:grow:" + 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": `goo-forward:grow:${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", "goo-forward:grow:"+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", "goo-forward:grow:" + 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" => "goo-forward:grow:#{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: goo-forward:grow:" . 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", $"goo-forward:grow:{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);