Drive Sort the Post from your own code
The one metered task, filters, is available over HTTP. It takes a few pasted emails (sender and subject, numbered), the person's wishes for where each kind of mail should go (numbered, in their words) and their own address, and returns one Gmail filter per wish it can act on - a search, a label, the actions to take, an optional inbox tab, which of the pasted emails it expects to catch and why - plus the wishes too vague to filter, each with what to add, and a verdict.
The division of labour first: the page parses the pasted mail, tests any search, draws the post-room rack, re-tests the reply and builds the import file; the model turns each wish into a search plus a label and actions from senders and words that were pasted, and says which wishes are too vague. The page reads one email per line (Name <address> | subject), keeps up to 30 emails and 8 wishes, and sends them numbered. After the reply it keeps only the filters it trusts (a real wish, a query, known actions and tabs - it never deletes or forwards), runs every search through its own matcher against the pasted emails, compares that with what the reply said each filter catches, and writes the filter file Gmail imports. Your code can do the same with the engine files the page loads (see "Build the input yourself").
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":"sort-the-post"}), 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, wants, want_count, mail, mail_count, my_address) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the filters run 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 rules
Every run sends task: filters (the one task is filters); a missing or different one is still answered as filters. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always filters. |
wants | yes | Numbered lines N. wish, one wish per line in the person's words, at most 8 wishes of 140 characters each: 1. receipts in a Receipts label, out of my inbox. PostKit.buildInput strips bullets and numbering before it renumbers. |
want_count | yes | How many wishes, as a string (3). |
mail | yes | Numbered lines N. from: Name <address> | subject: the subject, one per pasted email, at most 30. A line with no sender reads from: (no sender). |
mail_count | yes | How many emails, as a string (7). |
my_address | yes | The person's own address in lower case, or none. Filters must never match it; to: searches are tested against it. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The page needs at least one wish and at least two emails before it will send. A pasted line can be Name <address> | subject, address | subject, address - subject or a tab between sender and subject; lines without an address still go, but a filter cannot use their sender.
A wish can be a filter when it names a kind of mail that a sender, a sender's domain or words in the subject can pick out - in the wish itself or in the pasted emails it clearly points to. A wish about feelings or the inbox as a whole ("less noise", "the important stuff") cannot. The reply's verdict is ready when every wish became a filter, partial when some did and some did not, and unclear when none did.
The reply
One JSON object:
{"task": "filters", "verdict": "partial", "filters": [{"want": 1, "query": "from:ci@builds.example subject:failed", "label": "Builds", "actions": [], "category": "", "catches": [1], "why": "..."}], "unclear": [{"want": 3, "why": "..."}], "note": "..."}
| Field | What it holds |
|---|---|
verdict | ready, partial or unclear. |
filters[].want | The number of the wish this filter is for (a JSON number). |
filters[].query | One Gmail search using only from:, to:, subject:, plain words, "quoted phrases", OR, a leading -, {a b} (any of) and parentheses, built only from addresses, domains and words that appear in the pasted emails or the wish, never matching the person's own address. |
filters[].label | The label to apply, at most 30 characters, or "" when the wish asks for none. |
filters[].actions | Drawn only from archive (skip the inbox), mark_read (mark as read), star (star it), never_spam (never send to spam), important (mark as important), not_important (never mark as important); [] when the wish asks only for a label. Never delete or forward. |
filters[].category | "", or one of promotions, social, updates, forums when the wish names that Gmail tab. |
filters[].catches | The numbers of the pasted emails the reply expects the search to match. |
filters[].why | One short sentence naming the emails it is for by sender or subject. |
unclear[] | {want, why}, one per wish that cannot be a filter, saying what to add (a sender, an address or a subject word). [] when every wish became a filter. |
note | One sentence: how many filters there are and which wish, if any, could not be one. |
The page reads the reply with ReplyKit.normalize, keeps the filters it trusts with ReplyKit.trusted(reply, form) (a wish number that exists, a query, no duplicate; unknown actions dropped, an unknown tab cleared, the label clipped to 40 characters), and tests each search with its own matcher, PostKit.catches(query, mail, me). The matcher understands from:, to: (tested against the person's own address), subject:, plain words, quoted phrases, OR, -, {} and parentheses; any other operator (has:, label:, older_than: and the like) counts as matching, so the page never hides mail it cannot judge. PostKit.sortMail(filters, mail, me) gives what each filter catches, where each email lands on the rack (the first filter that catches it), the emails caught by more than one filter with different labels or actions, and the emails left in the inbox.
ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that disagrees with what the trusted filters imply (ReplyKit.expected); filters left out for having no query or no matching wish; a wish nothing came back for, or one with both a filter and an unclear entry; an action the page does not use (delete and forward are never used); a tab that is not one of the four; operators the page cannot test; a search that catches none of the pasted emails, or different ones from the reply's catches; a sender in the search that appears in none of the emails or wishes; a search that matches the person's own address; a long label; a filter that does nothing; overlaps; a sentence that names an internal field or refers to a wish or an email by number; and words such as "still" or "always" the person never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Every wish is a filter", "Some wishes need more" or "Nothing to filter on yet".
The import file (PostKit.toXML(filters, stamp)) is the Atom feed Gmail reads at Settings > See all settings > Filters and Blocked Addresses > Import filters. Each filter's whole search goes in hasTheWord; the label in label; the actions as shouldArchive, shouldMarkAsRead, shouldStar, shouldNeverSpam, shouldAlwaysMarkAsImportant and shouldNeverMarkAsImportant; the tab as smartLabelToApply - the property names of the open-source gmailctl exporter.
Worked examples - every sample
These are the free samples on the page: the exact input the page sends, the reply that replays for free, and what the page does with it - each trusted filter with the emails the page's own matcher catches, the emails left in the inbox, the verdict it expects and its own check. Together they cover the verdicts ready, partial, unclear.
Receipts, newsletters and the bank
Input:
{
"task": "filters",
"wants": "1. receipts and order updates in a Receipts label, out of my inbox\n2. newsletters in a Reading label, already marked as read\n3. star anything from my bank",
"want_count": "3",
"mail": "1. from: Shop Now <orders@shopnow.example> | subject: Your order #1234 has shipped\n2. from: Coffee Co <receipts@coffeeco.example> | subject: Your receipt from Coffee Co\n3. from: Weekly Reads <hello@weeklyreads.example> | subject: This week in books\n4. from: City News <digest@citynews.example> | subject: Morning briefing: Tuesday\n5. from: North Bank <alerts@northbank.example> | subject: Payment received\n6. from: Jo Brindlewick <jo.rivers@post.example> | subject: Lunch on Sunday?\n7. from: Shop Now <orders@shopnow.example> | subject: Your order #1250 is out for delivery",
"mail_count": "7",
"my_address": "sam@home.example"
}
Reply:
{
"task": "filters",
"verdict": "ready",
"filters": [
{
"want": 1,
"query": "from:(orders@shopnow.example OR receipts@coffeeco.example)",
"label": "Receipts",
"actions": [
"archive"
],
"category": "",
"catches": [
1,
2,
7
],
"why": "For order updates from Shop Now and the receipt from Coffee Co."
},
{
"want": 2,
"query": "from:(hello@weeklyreads.example OR digest@citynews.example)",
"label": "Reading",
"actions": [
"mark_read"
],
"category": "",
"catches": [
3,
4
],
"why": "For the newsletter from Weekly Reads and the morning briefing from City News."
},
{
"want": 3,
"query": "from:northbank.example",
"label": "",
"actions": [
"star"
],
"category": "",
"catches": [
5
],
"why": "For the payment alert from North Bank."
}
],
"unclear": [],
"note": "Three filters were created, covering receipts and order updates, newsletters, and bank alerts."
}
The pasted emails, as the page numbers them:
1. Shop Now <orders@shopnow.example> | Your order #1234 has shipped
2. Coffee Co <receipts@coffeeco.example> | Your receipt from Coffee Co
3. Weekly Reads <hello@weeklyreads.example> | This week in books
4. City News <digest@citynews.example> | Morning briefing: Tuesday
5. North Bank <alerts@northbank.example> | Payment received
6. Jo Brindlewick <jo.rivers@post.example> | Lunch on Sunday?
7. Shop Now <orders@shopnow.example> | Your order #1250 is out for delivery
The filters the page keeps (ReplyKit.trusted) and what its own matcher catches (PostKit.catches):
| Wish | Search | Does | Reply says it catches | The page's test catches |
|---|---|---|---|---|
| receipts and order updates in a Receipts label, out of my inbox | from:(orders@shopnow.example OR receipts@coffeeco.example) | label Receipts, skip the inbox | 1, 2, 7 | 1, 2, 7 |
| newsletters in a Reading label, already marked as read | from:(hello@weeklyreads.example OR digest@citynews.example) | label Reading, mark as read | 3, 4 | 3, 4 |
| star anything from my bank | from:northbank.example | star it | 5 | 5 |
Left in the inbox (PostKit.sortMail):
- 6. Jo Brindlewick <jo.rivers@post.example> | Lunch on Sunday?
The verdict the page expects (ReplyKit.expected): ready; the reply says ready - "Every wish is a filter".
The page's check (ReplyKit.check): kept.
No notes.
Build bots and invites
Input:
{
"task": "filters",
"wants": "1. build failures from the CI bot in a Builds label\n2. calendar invitations should skip the inbox\n3. the important stuff should stand out",
"want_count": "3",
"mail": "1. from: CI Bot <ci@builds.example> | subject: Build failed: main #482\n2. from: CI Bot <ci@builds.example> | subject: Build passed: main #483\n3. from: Calendar <calendar-noreply@calendar.example> | subject: Invitation: Sprint review\n4. from: Calendar <calendar-noreply@calendar.example> | subject: Updated invitation: Team lunch\n5. from: Priya Marlowby <priya@team.example> | subject: Can you review my change?\n6. from: HR Team <hr@team.example> | subject: Reminder: submit your timesheet",
"mail_count": "6",
"my_address": "dev@team.example"
}
Reply:
{
"task": "filters",
"verdict": "partial",
"filters": [
{
"want": 1,
"query": "from:ci@builds.example subject:failed",
"label": "Builds",
"actions": [],
"category": "",
"catches": [
1
],
"why": "Matches CI Bot's failed build notice, excluding its build passed message."
},
{
"want": 2,
"query": "from:calendar-noreply@calendar.example",
"label": "",
"actions": [
"archive"
],
"category": "",
"catches": [
3,
4
],
"why": "Matches both calendar messages, the sprint review and team lunch invitations."
}
],
"unclear": [
{
"want": 3,
"why": "Say which senders or subject words make a message important so it can become a filter."
}
],
"note": "Two filters were made; wanting important mail to stand out has no sender or subject to match."
}
The pasted emails, as the page numbers them:
1. CI Bot <ci@builds.example> | Build failed: main #482
2. CI Bot <ci@builds.example> | Build passed: main #483
3. Calendar <calendar-noreply@calendar.example> | Invitation: Sprint review
4. Calendar <calendar-noreply@calendar.example> | Updated invitation: Team lunch
5. Priya Marlowby <priya@team.example> | Can you review my change?
6. HR Team <hr@team.example> | Reminder: submit your timesheet
The filters the page keeps (ReplyKit.trusted) and what its own matcher catches (PostKit.catches):
| Wish | Search | Does | Reply says it catches | The page's test catches |
|---|---|---|---|---|
| build failures from the CI bot in a Builds label | from:ci@builds.example subject:failed | label Builds | 1 | 1 |
| calendar invitations should skip the inbox | from:calendar-noreply@calendar.example | skip the inbox | 3, 4 | 3, 4 |
Too vague to filter:
- the important stuff should stand out - Say which senders or subject words make a message important so it can become a filter.
Left in the inbox (PostKit.sortMail):
- 2. CI Bot <ci@builds.example> | Build passed: main #483
- 5. Priya Marlowby <priya@team.example> | Can you review my change?
- 6. HR Team <hr@team.example> | Reminder: submit your timesheet
The verdict the page expects (ReplyKit.expected): partial; the reply says partial - "Some wishes need more".
The page's check (ReplyKit.check): kept.
No notes.
Just less noise
Input:
{
"task": "filters",
"wants": "1. less noise please\n2. make my inbox feel calmer",
"want_count": "2",
"mail": "1. from: Shop Now <orders@shopnow.example> | subject: Your order has shipped\n2. from: Weekly Reads <hello@weeklyreads.example> | subject: This week in books\n3. from: Jo Brindlewick <jo.rivers@post.example> | subject: Lunch on Sunday?",
"mail_count": "3",
"my_address": "none"
}
Reply:
{
"task": "filters",
"verdict": "unclear",
"filters": [],
"unclear": [
{
"want": 1,
"why": "The wish describes a feeling about the inbox but names no sender, address, or subject word to match specific mail."
},
{
"want": 2,
"why": "The wish asks for a calmer inbox but gives no sender, address, or subject word to match mail by."
}
],
"note": "No filters were made because the wishes ask for a quieter, calmer inbox without naming a sender, address, or subject word to filter by."
}
The pasted emails, as the page numbers them:
1. Shop Now <orders@shopnow.example> | Your order has shipped
2. Weekly Reads <hello@weeklyreads.example> | This week in books
3. Jo Brindlewick <jo.rivers@post.example> | Lunch on Sunday?
No filters to keep.
Too vague to filter:
- less noise please - The wish describes a feeling about the inbox but names no sender, address, or subject word to match specific mail.
- make my inbox feel calmer - The wish asks for a calmer inbox but gives no sender, address, or subject word to match mail by.
Left in the inbox (PostKit.sortMail):
- 1. Shop Now <orders@shopnow.example> | Your order has shipped
- 2. Weekly Reads <hello@weeklyreads.example> | This week in books
- 3. Jo Brindlewick <jo.rivers@post.example> | Lunch on Sunday?
The verdict the page expects (ReplyKit.expected): unclear; the reply says unclear - "Nothing to filter on yet".
The page's check (ReplyKit.check): kept.
No notes.
The import file for the first sample
What PostKit.toXML(filters, stamp) writes for the trusted filters of the first sample above, with a fixed stamp. Save it as a .xml file and import it in Gmail at Settings > See all settings > Filters and Blocked Addresses > Import filters; Gmail lists the filters before it creates them.
<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:apps='http://schemas.google.com/apps/2006'>
<title>Mail Filters</title>
<id>tag:mail.google.com,2008:filters:</id>
<updated>2026-10-28T00:00:00.000Z</updated>
<entry>
<category term='filter'></category>
<title>Mail Filter</title>
<content></content>
<apps:property name='hasTheWord' value='from:(orders@shopnow.example OR receipts@coffeeco.example)'/>
<apps:property name='label' value='Receipts'/>
<apps:property name='shouldArchive' value='true'/>
</entry>
<entry>
<category term='filter'></category>
<title>Mail Filter</title>
<content></content>
<apps:property name='hasTheWord' value='from:(hello@weeklyreads.example OR digest@citynews.example)'/>
<apps:property name='label' value='Reading'/>
<apps:property name='shouldMarkAsRead' value='true'/>
</entry>
<entry>
<category term='filter'></category>
<title>Mail Filter</title>
<content></content>
<apps:property name='hasTheWord' value='from:northbank.example'/>
<apps:property name='shouldStar' value='true'/>
</entry>
</feed>
Build the input yourself
Load the engine in node (global.window = global; require("./postkit.js"); require("./replykit.js")), then:
const K = PostKit, R = ReplyKit;
const form = K.form({ me: "sam@home.example", wants: "order updates in a Receipts label, out of my inbox",
mail: "Shop Now <orders@shopnow.example> | Your order has shipped\nWeekly Reads <hello@weeklyreads.example> | This week in books" });
const built = K.buildInput(form); // built.ok is false, with built.note, until there is a wish and two emails
const wire = K.toWire(built.input); // POST this to /estimate and /run
// K.catches("from:shopnow.example", form.mail, form.me) // test any search for free: [1]
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const fs = R.trusted(reply, form) // the filters the page keeps
// K.sortMail(fs, form.mail, form.me) // {hits, where, overlaps, inbox}
// R.check(reply, built) // {status: kept or flagged, notes}
// K.toXML(fs, new Date().toISOString()) // the filter file Gmail imports
Form keys: mail (one email per line, Name <address> | subject), wants (one wish per line) and me (the person's own address, or empty).
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://sort-the-post.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://sort-the-post.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"]
// sort-the-post.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://sort-the-post.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://sort-the-post.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
}
// SortThePost.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 SortThePost {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://sort-the-post.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://sort-the-post.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://sort-the-post.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://sort-the-post.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":"sort-the-post"}' | 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": "sort-the-post"})["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: "sort-the-post" })).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": "sort-the-post"}, 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", "sort-the-post"), 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: "sort-the-post" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sort-the-post"])["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 = "sort-the-post" }))!["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": "filters", "wants": "1. order updates in a Receipts label, out of my inbox", "want_count": "1", "mail": "1. from: Shop Now <orders@shopnow.example> | subject: Your order has shipped\n2. from: Weekly Reads <hello@weeklyreads.example> | subject: This week in books", "mail_count": "2", "my_address": "sam@home.example"}
# - 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": "filters", "wants": "1. order updates in a Receipts label, out of my inbox", "want_count": "1", "mail": "1. from: Shop Now <orders@shopnow.example> | subject: Your order has shipped\n2. from: Weekly Reads <hello@weeklyreads.example> | subject: This week in books", "mail_count": "2", "my_address": "sam@home.example"}
# - 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: "filters", wants, want_count, mail, mail_count, my_address} (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 sort-the-post:<task>:<inputHash>:a<attempt> (the one task is filters), 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="sort-the-post:filters:$(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 = "sort-the-post:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"), len(reply.get("filters") or []))
import { createHash } from "node:crypto";
const key = `sort-the-post:${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, (reply.filters || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "sort-the-post:" + 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 }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
filters, _ := reply["filters"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"], len(filters))
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "sort-the-post:" + 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.get("verdict") + " " + reply.path("filters").size());
require "digest"
key = "sort-the-post:#{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"], (reply["filters"] || []).size].inspect
$key = "sort-the-post:" . $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"] ?? ""), " ", count($reply["filters"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"sort-the-post:{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!["verdict"]} {(reply["filters"] as JsonArray)?.Count ?? 0}");
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: sort-the-post:filters:$(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": "filters", "wants": "1. order updates in a Receipts label, out of my inbox", "want_count": "1", "mail": "1. from: Shop Now <orders@shopnow.example> | subject: Your order has shipped\n2. from: Weekly Reads <hello@weeklyreads.example> | subject: This week in books", "mail_count": "2", "my_address": "sam@home.example"}
# - 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": "sort-the-post:filters:" + 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": `sort-the-post:filters:${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", "sort-the-post:filters:"+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", "sort-the-post:filters:" + 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" => "sort-the-post:filters:#{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: sort-the-post:filters:" . 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", $"sort-the-post:filters:{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);