← Sort the Post
API

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, wants, want_count, mail, mail_count, my_address) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the filters run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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.

FieldRequiredWhat it holds
taskyesAlways filters.
wantsyesNumbered 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_countyesHow many wishes, as a string (3).
mailyesNumbered 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_countyesHow many emails, as a string (7).
my_addressyesThe person's own address in lower case, or none. Filters must never match it; to: searches are tested against it.
retry_notenoOnly 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": "..."}
FieldWhat it holds
verdictready, partial or unclear.
filters[].wantThe number of the wish this filter is for (a JSON number).
filters[].queryOne 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[].labelThe label to apply, at most 30 characters, or "" when the wish asks for none.
filters[].actionsDrawn 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[].catchesThe numbers of the pasted emails the reply expects the search to match.
filters[].whyOne 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.
noteOne 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):

WishSearchDoesReply says it catchesThe page's test catches
receipts and order updates in a Receipts label, out of my inboxfrom:(orders@shopnow.example OR receipts@coffeeco.example)label Receipts, skip the inbox1, 2, 71, 2, 7
newsletters in a Reading label, already marked as readfrom:(hello@weeklyreads.example OR digest@citynews.example)label Reading, mark as read3, 43, 4
star anything from my bankfrom:northbank.examplestar it55

Left in the inbox (PostKit.sortMail):

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):

WishSearchDoesReply says it catchesThe page's test catches
build failures from the CI bot in a Builds labelfrom:ci@builds.example subject:failedlabel Builds11
calendar invitations should skip the inboxfrom:calendar-noreply@calendar.exampleskip the inbox3, 43, 4

Too vague to filter:

Left in the inbox (PostKit.sortMail):

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:

Left in the inbox (PostKit.sortMail):

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"}
}

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')

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}' 

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}' 

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}' 

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