Drive Pen or Prompt from your own code
The one metered task, tool-sort, is available over HTTP. It takes up to 6 small recurring annoyances (what it is, how often it comes round, the minutes it costs each time, what a fix would need to do) with the page's sort, and returns a reason and a first move for each, plus a build card for the item worth a tiny app.
The division of labour first: the page works out the minutes a year, a fixed rule sorts each item and the page picks the one to build first; the model explains them. After a run the page checks that every verdict matches the rule, that the build card is for the page's pick, that every number comes from the list and that no ids leak into the prose.
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": "..." } }
The token is minted for this app (the guest call takes {"slug":"pen-or-prompt"}), 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, kit, items, 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: a reading is metered and needs a personal sign-in. |
| 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 input and the sort
Every run sends task: tool-sort (the one task is tool-sort); a missing or different one is still answered as tool-sort. Minutes a year = times a year (every day 365, every week 52, every month 12, a few times a year 4) x minutes each time. The rule, first match wins: under 60 minutes a year skip (but builtin, with a kept: note on its line, when the annoyance touches health, safety, money or a deadline); needs a nudge builtin; needs a fixed list or steps paper; needs a running record or numbers under 600 minutes a year sheet; otherwise vibe. Build first is the vibe item with the most minutes a year, the earlier row on a tie.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always tool-sort. |
kit | yes | What the person already has to hand, up to 120 characters, or none. |
items | yes | 1 line per counted item: item 1 | annoyance: ... | how often: every week (52 times a year) | minutes lost each time: 5 | a fix would need: ... | minutes a year: 260 (about 4.3 hours) | page verdict: paper. |
facts | yes | The page's lines: build first:, total minutes a year:, sorted:, no tiny app needed:. |
The reply
job.output.output is a JSON string:
{"task": "tool-sort", "headline": "...", "items": [{"item": 1, "verdict": "builtin", "why": "...", "first_move": "..."}, ...], "build": {"item": 2, "must": ["...", "...", "..."], "leave_out": "...", "first_prompt": "..."}, "closing": "..."}
| Field | What it holds |
|---|---|
headline | How many items need no tiny app, at most 16 words. |
items | 1 entry per counted item, in order: item, verdict (skip, builtin, paper, sheet or vibe, copied from the page), why (at most 30 words) and first_move (at most 35 words). |
build | null when nothing is sorted to a tiny app; otherwise the build-first item, 3 must lines, leave_out and first_prompt (at most 70 words) to paste into an AI app builder. |
closing | 1 light sentence, at most 20 words. |
The page then checks the reply (PenKit.check) and lists any disagreement under "The page's check".
Worked examples - every sort
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free.
Fern, gym bag, freezer, spare key - builtin, paper, sheet, skip; no build card
Input:
{
"task": "tool-sort",
"kit": "a phone, a pen and a corkboard",
"items": "item 1 | annoyance: forgetting to water the fern on the landing | how often: every week (52 times a year) | minutes lost each time: 2 | a fix would need: a nudge at the right time | minutes a year: 104 (about 1.7 hours) | page verdict: builtin\nitem 2 | annoyance: packing the gym bag and leaving something behind | how often: every week (52 times a year) | minutes lost each time: 5 | a fix would need: a fixed list or steps to follow | minutes a year: 260 (about 4.3 hours) | page verdict: paper\nitem 3 | annoyance: not knowing what is left in the chest freezer | how often: every week (52 times a year) | minutes lost each time: 5 | a fix would need: a running record of what happened | minutes a year: 260 (about 4.3 hours) | page verdict: sheet\nitem 4 | annoyance: hunting for the spare bike lock key | how often: a few times a year (4 times a year) | minutes lost each time: 10 | a fix would need: a fixed list or steps to follow | minutes a year: 40 (about 0.7 hours) | page verdict: skip",
"facts": "build first: none\ntotal minutes a year: 664 (about 11 hours)\nsorted: skip 1, builtin 1, paper 1, sheet 1, vibe 0\nno tiny app needed: 4 of 4 items"
}
Reply:
{
"task": "tool-sort",
"headline": "4 of 4 items need no tiny app - nothing here calls for a build.",
"items": [
{
"item": 1,
"verdict": "builtin",
"why": "Watering the fern on the landing happens every week and costs 104 minutes a year, and a nudge at the right time is exactly what a reminder provides.",
"first_move": "Set a phone reminder to repeat every week with words like 'water the fern on the landing.'"
},
{
"item": 2,
"verdict": "paper",
"why": "Packing the gym bag and leaving something behind comes round every week, racking up 260 minutes a year, and a list of steps is what covers it.",
"first_move": "Write a list of everything that goes in the gym bag and keep it with the bag so nothing gets left behind."
},
{
"item": 3,
"verdict": "sheet",
"why": "Not knowing what is left in the chest freezer comes round every week, adding up to 260 minutes a year, and a running record fits a simple sheet best.",
"first_move": "Column headings for the chest freezer sheet: item, date added, date used."
},
{
"item": 4,
"verdict": "skip",
"why": "Hunting for the spare bike lock key happens only a few times a year and costs 40 minutes a year - a small enough loss to leave alone.",
"first_move": "Let the spare bike lock key hunt go for now - 40 minutes a year is too little to fix with anything."
}
],
"build": null,
"closing": "4 light fixes, no tiny app needed - pick a first move and give it a go, whenever suits you."
}
Run times, meter reading, stretching, remote - vibe, sheet, builtin, skip; build card
Input:
{
"task": "tool-sort",
"kit": "a laptop and a phone",
"items": "item 1 | annoyance: writing down my run times and finding my best on each route | how often: every day (365 times a year) | minutes lost each time: 5 | a fix would need: a running record of what happened | minutes a year: 1825 (about 30 hours) | page verdict: vibe\nitem 2 | annoyance: comparing the electricity meter reading with last month | how often: every month (12 times a year) | minutes lost each time: 10 | a fix would need: adding up or comparing numbers | minutes a year: 120 (about 2 hours) | page verdict: sheet\nitem 3 | annoyance: remembering to stretch after sitting | how often: every day (365 times a year) | minutes lost each time: 1 | a fix would need: a nudge at the right time | minutes a year: 365 (about 6.1 hours) | page verdict: builtin\nitem 4 | annoyance: the TV remote slipping down the sofa | how often: a few times a year (4 times a year) | minutes lost each time: 5 | a fix would need: a fixed list or steps to follow | minutes a year: 20 (about 0.3 hours) | page verdict: skip",
"facts": "build first: item 1 (1825 minutes a year)\ntotal minutes a year: 2330 (about 39 hours)\nsorted: skip 1, builtin 1, paper 0, sheet 1, vibe 1\nno tiny app needed: 3 of 4 items"
}
Reply:
{
"task": "tool-sort",
"headline": "3 of 4 items need no tiny app; a tiny app suits what's left.",
"items": [
{
"item": 1,
"verdict": "vibe",
"why": "You write down run times and best on each route every day (365 times a year), for 1825 minutes a year - too much for a sheet, needing a tiny app.",
"first_move": "This fits a tiny app - look at the build card below for the first concrete step to set it up."
},
{
"item": 2,
"verdict": "sheet",
"why": "Comparing your electricity meter reading with last month happens every month, 12 times a year, 120 minutes a year - adding and comparing numbers that a simple sheet handles well.",
"first_move": "Try a few columns: month, electricity meter reading, difference from last month."
},
{
"item": 3,
"verdict": "builtin",
"why": "Remembering to stretch after sitting comes round every day, 365 times a year, costing 365 minutes a year - just a nudge at the right time, which a built-in reminder gives.",
"first_move": "Set a daily reminder on your phone or laptop, repeating every day, with the words 'stretch after sitting'."
},
{
"item": 4,
"verdict": "skip",
"why": "The TV remote slipping down the sofa happens a few times a year, just 20 minutes a year - small enough to let it be.",
"first_move": "Let the TV remote slipping down the sofa be - it costs so little it's fine to let it be, no tool needed."
}
],
"build": {
"item": 1,
"must": [
"Log each run's time and route.",
"Keep a running record of past run times and routes.",
"Show the best time found so far for each route."
],
"leave_out": "Leave out sharing your times with other people for the first version.",
"first_prompt": "Build a small app that logs each run's time and route, keeps a running record of past run times and routes, and shows the best time found so far for each route, all on a single screen, with data kept only on this device, no sign-in, no payments and no other people's personal details."
},
"closing": "Whenever you're ready, picking any fix here is a good place to start - no rush."
}
Library books, pages read, bread - 2 vibe items compete; the bigger one is built first
Input:
{
"task": "tool-sort",
"kit": "none",
"items": "item 1 | annoyance: keeping track of library books, due dates and which shelf they go back to | how often: every week (52 times a year) | minutes lost each time: 10 | a fix would need: several of these at once | minutes a year: 520 (about 8.7 hours) | page verdict: vibe\nitem 2 | annoyance: tracking how many pages I read and my monthly total | how often: every day (365 times a year) | minutes lost each time: 2 | a fix would need: adding up or comparing numbers | minutes a year: 730 (about 12 hours) | page verdict: vibe\nitem 3 | annoyance: the bread recipe steps I keep mixing up | how often: every week (52 times a year) | minutes lost each time: 5 | a fix would need: a fixed list or steps to follow | minutes a year: 260 (about 4.3 hours) | page verdict: paper",
"facts": "build first: item 2 (730 minutes a year)\ntotal minutes a year: 1510 (about 25 hours)\nsorted: skip 0, builtin 0, paper 1, sheet 0, vibe 2\nno tiny app needed: 1 of 3 items"
}
Reply:
{
"task": "tool-sort",
"headline": "1 of 3 items needs no tiny app; the rest point toward building a tiny app.",
"items": [
{
"item": 1,
"verdict": "vibe",
"why": "Library books, due dates and which shelf they go back to need several things at once - more than a list or a sheet - every week for 520 minutes a year.",
"first_move": "This waits until the other tiny app is finished and working, then you could set up something similar for this annoyance."
},
{
"item": 2,
"verdict": "vibe",
"why": "Tracking how many pages you read and your monthly total happens every day, 730 minutes a year - enough numbers to outgrow a sheet and be worth a tiny app.",
"first_move": "This is the annoyance picked to turn into a tiny app now - look below for what it must do and a request ready to paste into an AI app builder."
},
{
"item": 3,
"verdict": "paper",
"why": "The bread recipe steps you keep mixing up just need a fixed list to follow, not tracking - this happens every week and costs 260 minutes a year.",
"first_move": "Write out the bread recipe steps in order on paper and keep it right where you use them."
}
],
"build": {
"item": 2,
"must": [
"Log how many pages you read each day",
"Add up your daily pages into a monthly total",
"Let you compare your monthly totals"
],
"leave_out": "Skip charts, streaks or goals when starting out.",
"first_prompt": "Build a small app that logs how many pages you read each day, adds them up into a monthly total, and lets you compare your monthly totals, on a single screen, with data kept only on this device, and no sign-in, payments or other people's personal details."
},
"closing": "Whenever you're ready, picking up a pen or starting that new tiny app could feel surprisingly easy."
}
Build the input yourself
Load the engine in node (global.window = global; require("./penkit.js")), then:
const K = PenKit;
const built = K.buildInput(K.form({ kit: "a phone",
items: [{ what: "forgetting to water the fern", often: "weekly", minutes: 2, needs: "nudge" },
{ what: "logging my run times per route", often: "daily", minutes: 5, needs: "log" }] }));
built.input.facts; // the page's lines: build first, totals, the sort counts
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// built.facts.items[0].verdict // the sort the page's rule gives
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, "my list") // the list 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://pen-or-prompt.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://pen-or-prompt.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"]
// pen-or-prompt.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://pen-or-prompt.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://pen-or-prompt.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
}
// PenOrPrompt.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 PenOrPrompt {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://pen-or-prompt.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://pen-or-prompt.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://pen-or-prompt.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://pen-or-prompt.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":"pen-or-prompt"}' | 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": "pen-or-prompt"})["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: "pen-or-prompt" })).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": "pen-or-prompt"}, 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", "pen-or-prompt"), 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: "pen-or-prompt" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "pen-or-prompt"])["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 = "pen-or-prompt" }))!["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": "tool-sort", "kit": "a laptop and a phone", "items": "item 1 | annoyance: ...", "facts": "build first: item 1 ..."}
# - 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": "tool-sort", "kit": "a laptop and a phone", "items": "item 1 | annoyance: ...", "facts": "build first: item 1 ..."}
# - 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: "tool-sort", kit, items, 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 pen-or-prompt:<task>:<inputHash>:a<attempt> (the one task is tool-sort), 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="pen-or-prompt:tool-sort:$(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 = "pen-or-prompt:" + 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("build"))
import { createHash } from "node:crypto";
const key = `pen-or-prompt:${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.build);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "pen-or-prompt:" + 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 = "pen-or-prompt:" + 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 = "pen-or-prompt:#{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["build"]].inspect
$key = "pen-or-prompt:" . $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"], " ", json_encode($reply["build"]), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"pen-or-prompt:{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: pen-or-prompt:tool-sort:$(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": "tool-sort", "kit": "a laptop and a phone", "items": "item 1 | annoyance: ...", "facts": "build first: item 1 ..."}
# - 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": "pen-or-prompt:tool-sort:" + 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": `pen-or-prompt:tool-sort:${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", "pen-or-prompt:tool-sort:"+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", "pen-or-prompt:tool-sort:" + 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" => "pen-or-prompt:tool-sort:#{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: pen-or-prompt:tool-sort:" . 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", $"pen-or-prompt:tool-sort:{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);