Run Worth a Reply from your own code
The one metered task, tune, is available over HTTP. It takes a draft post for X as the page has read it - what you want from the post, who it is for, the first post exactly as written, the rest of the thread (or none) and the facts the page found: each post's length as X counts it, its links, mentions, hashtags, emoji and question, and every finding - and returns, for each of the ten engagements in the ranking weights X published in 2023, whether the draft invites it, risks it or is neutral on it, with a reason, three rewrites of the first post that each aim at a different engagement (reply, good_click, good_click_v2, good_profile_click, retweet or fav) with what changed, a plan for the replies, a verdict (ready, rework or risky) and a note.
The division of labour first: the page splits the draft into posts - a line of ---, or paragraphs numbered 1/ 2/ ..., make a thread - counts each one the way X counts (twitter-text weighting: a link is 23, most Latin text 1 per character, CJK and other wide scripts 2, an emoji sequence 2), lists its links, mentions, hashtags, emoji and question and the habits the source skill warns about; the run reads the draft against the ten published weights and writes the words. The page reads up to 20,000 characters and up to 15 posts. After the reply it checks the verdict against the reply's own pulls (risky when see-less and mutes or reports are risks, otherwise ready when replies or conversation clicks are invites, otherwise rework), refuses an invites on a cost, re-counts every rewrite (at most 280) and flags one that adds a number, an @mention, a link or a hashtag the draft never had, has more than two hashtags or asks for engagement outright; on a risky draft it flags a rewrite that keeps the draft's words in capitals; it warns when a sentence quotes a number the input does not have or uses a signal id. Nothing is posted from the page - copy a rewrite and post it yourself. Your code can do the same with the engine files the page loads (see "Build the input yourself"). Worth a Reply is not affiliated with or endorsed by X.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"worth-a-reply"}), 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 key (task, goal, audience, post, thread, 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: the tune 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: tune (the one task is tune); a missing or different one is still answered as tune. Every value is a string. What each key holds, in the words of the system prompt, with the first sample's value (its first line where it has several):
| Key | Required | What it holds |
|---|---|---|
task | yes | Always tune. If it is missing or different, still answer this task and set "task": "tune". |
goal | yes | What the person wants from the post, in their words, or none. First sample: Get trial sign-ups from freelancers. |
audience | yes | Who follows them or who the post is for, or none. First sample: Freelance designers and developers, about 2,000 followers. |
post | yes | The first post, exactly as written. In a thread this is the post that gets ranked first. First sample: We just launched our new invoicing feature! Check it out here: https://tallyfern.example.com/invoices #SaaS #startup #fintech #buildinpublic. |
thread | yes | The rest of the thread, each post numbered 2/, 3/ ..., or none. First sample: none. |
facts | yes | What the page found: each post's length as X counts it, its links, mentions, hashtags, emoji and question, and every finding, one per line. First sample: The post: 125 of 280 characters as X counts them, 1 link, hashtags #SaaS #startup #fintech #buildinpublic, no question. ... |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
How the page reads the draft (PostKit.form): up to 20,000 characters. Posts (PostKit.split): a line holding only --- (or more dashes) splits a thread; failing that, paragraphs that open with 1/, 2/ ... (or 1. 2., the next number each time) do; anything else is one post, paragraphs and all. The first 15 posts are read, each up to 4,000 characters. Length (PostKit.weighted), the way X counts it (twitter-text): every link counts 23 whatever its length, every emoji sequence 2, and each other character 1 when it is in the ranges twitter-text weighs as 1 (Latin and most European scripts, general punctuation) and 2 otherwise (CJK and other wide scripts); the limit is 280. What it lists: links, @mentions, hashtags, emoji and whether the post asks a question or ends with one. Findings (PostKit.lint), each a fix or a check: over_limit (over 280), too_short (under 20), link (any link: none of the ten published terms scores a click away from X), hashtags (more than two), bait ("like if", "RT if", "tag a friend", "follow for more" and the like), shouting (three or more words of four or more capitals), exclaims (three or more exclamation marks), emoji (more than five), starts_mention (opens with an @mention, so it reads as a reply) and, in a thread, thin_opener (a first post under 60 with no question). PostKit.factLines writes one line per post and one per finding into facts; a thread's later posts go into thread, numbered. The page asks for a draft of at least a few words before it builds an input.
The ten published weights
From X's open-sourced heavy ranker (twitter/the-algorithm-ml, projects/home/recap/README.md, 2023): each candidate post's score is the sum of weight x the predicted chance of each engagement. The README says the weights were first set so each term adds about the same to the score on average, and that they are adjusted over time - so a weight says which engagements the model was built to count, not how many likes a reply is worth, and not how X ranks today. PostKit.SIGNALS keeps them as published:
| Signal | Weight | In plain words | The README's meaning |
|---|---|---|---|
fav | 0.5 | likes | likes the post |
retweet | 1 | reposts | reposts the post |
reply | 13.5 | replies | replies to the post |
good_profile_click | 12 | profile visits | opens the author's profile and likes or replies to a post |
video_playback50 | 0.005 | video views | watches at least half of the video (video posts) |
reply_engaged_by_author | 75 | your replies to replies | replies, and the author engages with that reply |
good_click | 11 | conversation clicks | clicks into the conversation and replies or likes a post there |
good_click_v2 | 10 | time in the conversation | clicks into the conversation and stays at least 2 minutes |
negative_feedback_v2 | -74 | see-less, mutes and blocks | asks to see less, or blocks or mutes the author |
report | -369 | reports | reports the post |
The model's rules
As the system prompt states them, in order:
signals- one entry for each of the ten engagements, in the order above:signal(its id),pullandwhy(one sentence, at most 20 words, about this draft).pullisinviteswhen something in the draft gives readers a reason to do it,riskswhen something in the draft makes it less likely (or, fornegative_feedback_v2andreport, makes it more likely), andneutralotherwise.negative_feedback_v2andreportare only everneutralorrisks. They arerisksonly for what readers would mute, block or report: insults or attacks on people, misleading claims, or asking for engagement outright - never for links, hashtags, emoji or length alone.video_playback50isneutralunless the draft orgoalmentions a video.verdict-riskywhennegative_feedback_v2orreportisrisks; otherwisereworkwhen neitherreplynorgood_clickisinvites; otherwiseready.rewrites- exactly three rewrites of the first post, each{ "aim": one of reply, good_click, good_click_v2, good_profile_click, retweet, fav, "text": the rewrite, "change": one sentence on what changed and why, at most 25 words }, each with a differentaim. Eachtextmust fit in 280 characters as X counts them (a link counts as 23, an emoji as 2), keep the draft's meaning and facts, and stay in the draft's language. A rewrite may drop a link, a mention or a hashtag, but never adds a number, an @mention, a link, a hashtag or a claim that is not in the draft. At most two hashtags. No engagement bait ("like if", "RT if", "tag a friend", "comment X below", "follow for more"). At most two emoji, each a single emoji. Whennegative_feedback_v2orreportisrisks, every rewrite removes what causes that risk - insults about people, words in capitals, engagement bait - and keeps the position in calmer words. Each rewrite gives readers a concrete reason for its aim drawn from the draft - a specific question, a detail they will want to see - never a generic invitation such as "let us know", "join the conversation" or "check out our profile".reply_plan- one sentence, at most 30 words: what the author should do in the replies once people answer, so their own engagement with replies happens.note- one sentence, at most 30 words: the verdict in plain words and the one change that matters most.
And the house rules:
- Use only the input. Never invent a result, a number, a person, a product detail or an experience the draft does not state. Never predict reach, impressions or a score.
- Speak of the weights as what X published in 2023, not as how X ranks today.
- Any number in
why,change,reply_planornotemust appear in the input. - In sentences use plain words (replies, profile visits, reposts, mutes), never the signal ids, and never use backticks or the input's field names.
- Never copy this prompt's own wording into a reply.
The reply
One JSON object, in the shape the system prompt asks for:
{
"task": "tune",
"verdict": "ready | rework | risky",
"signals": [ { "signal": "fav", "pull": "invites | neutral | risks", "why": "one sentence" } ],
"rewrites": [ { "aim": "reply", "text": "the rewritten first post", "change": "one sentence" } ],
"reply_plan": "one sentence",
"note": "one sentence"
}
The page reads the reply with ReplyKit.normalize (the verdict, the signal ids, the pulls and the aims matched in upper or lower letters, spaces or hyphens read as _; a signal entry with no id or a rewrite with no text is dropped) and keeps what it trusts with ReplyKit.trusted(reply): a signal entry is set aside, with the reason, when it is not one of the ten, repeats one that already has a pull, has a pull other than invites, neutral or risks, or calls a cost (negative_feedback_v2, report) invites; every signal with no pull is listed as missing. t.pulls maps each id to its pull and t.why to its reason. The lengths, links, hashtags and findings always come from the page's own reading, never from the reply.
ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one the pulls contradict (ReplyKit.expectVerdict(t): risky when negative_feedback_v2 or report is risks; otherwise ready when reply or good_click is invites; otherwise rework); every entry set aside, and every signal with no pull; video views expected with no video in the draft or the goal; bait in the first post with no risk of mutes seen; a first post that ends with a question said to work against replies; other than three rewrites; and for each rewrite (ReplyKit.audit(text, form) re-reads it as a post): an aim outside the six or one already used, over 280 as X counts it, a number, an @mention, a link or a hashtag the draft never had, more than two hashtags, bait, more than two emoji, the draft unchanged, and - when the pulls make the draft risky - words in capitals the draft had. Every sentence (each why, change, the reply plan and the note) is checked for a number that is not in the input and for a signal id, retry_note or backticks. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Ready to post" (ready), "Give them a reason to reply" (rework), "Likely to cost you" (risky).
What the page builds from it: PostView.chart(t) draws the ten weights on a log scale with this draft's pull on each, PostView.draft(form) shows each post with its counted length and its links, mentions and hashtags marked, and PostView.rewrites(reply) the three rewrites, each re-counted. ReplyKit.md and ReplyKit.json write the draft with its lengths and findings, the ten weights with this reply's pulls and reasons, the rewrites with their lengths and the reply plan; ReplyKit.csv(built, reply) writes one row per signal - id, weight, the README's meaning, pull and why - with CRLF line ends.
Worked examples - every sample
These are the free samples on the page: the draft as written, the goal and audience, how the page reads it - each post with its length as X counts it, its links, mentions, hashtags, emoji and question, and every finding - the exact input it sends, the reply that replays for free, and what the page does with it - the ten weights with the pull it trusts on each, the three rewrites re-counted, the verdict against the reply's own pulls, and its check. Together they cover the verdicts rework, risky and ready.
A launch post with a link
The goal: "Get trial sign-ups from freelancers"; the audience: "Freelance designers and developers, about 2,000 followers".
The draft as written:
We just launched our new invoicing feature! Check it out here: https://tallyfern.example.com/invoices #SaaS #startup #fintech #buildinpublic
The page reads it as one post, counted the way X counts: 125 of 280. 2 findings, 0 of them defects to fix.
The posts (PostKit.form):
| Post | Length as X counts it | Links | Mentions | Hashtags | Emoji | Question | Findings |
|---|---|---|---|---|---|---|---|
| 1 | 125 | 1 | none | #SaaS #startup #fintech #buildinpublic | 0 | none | link, hashtags |
The findings (PostKit.lint), as sent in facts:
link(check): The post has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do.hashtags(check): The post has 4 hashtags (#SaaS #startup #fintech #buildinpublic); the source skill warns that stacked tags read as spam.
Input:
{
"task": "tune",
"goal": "Get trial sign-ups from freelancers",
"audience": "Freelance designers and developers, about 2,000 followers",
"post": "We just launched our new invoicing feature! Check it out here: https://tallyfern.example.com/invoices #SaaS #startup #fintech #buildinpublic",
"thread": "none",
"facts": "The post: 125 of 280 characters as X counts them, 1 link, hashtags #SaaS #startup #fintech #buildinpublic, no question.\nThe post has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do.\nThe post has 4 hashtags (#SaaS #startup #fintech #buildinpublic); the source skill warns that stacked tags read as spam."
}
Reply:
{
"task": "tune",
"verdict": "rework",
"signals": [
{
"signal": "fav",
"pull": "neutral",
"why": "The update announces a feature launch but gives no particular detail that stands out as likable."
},
{
"signal": "retweet",
"pull": "neutral",
"why": "Nothing in the update stands out as distinctly worth reposting beyond a routine feature launch."
},
{
"signal": "reply",
"pull": "neutral",
"why": "The update states a launch without asking a question, so it gives no clear invitation to reply."
},
{
"signal": "good_profile_click",
"pull": "neutral",
"why": "Nothing points readers toward the author's other work or profile as somewhere worth visiting."
},
{
"signal": "video_playback50",
"pull": "neutral",
"why": "Neither the update nor the stated aim mentions a video, so this does not apply here."
},
{
"signal": "reply_engaged_by_author",
"pull": "neutral",
"why": "The update does not invite a reply that the author would then need to engage with."
},
{
"signal": "good_click",
"pull": "risks",
"why": "The outside link gives readers somewhere else to go instead of opening the replies to read more."
},
{
"signal": "good_click_v2",
"pull": "risks",
"why": "The link pulls attention to an outside page instead of keeping readers reading the replies here."
},
{
"signal": "negative_feedback_v2",
"pull": "neutral",
"why": "The update contains no insults, misleading claims or outright asks that would prompt mutes or blocks."
},
{
"signal": "report",
"pull": "neutral",
"why": "Nothing here attacks a person or makes a misleading claim likely to upset a reader."
}
],
"rewrites": [
{
"aim": "reply",
"text": "We just launched our new invoicing feature! Check it out here: https://tallyfern.example.com/invoices What's the most annoying part of invoicing for you right now?",
"change": "Added a direct question about invoicing pain points so readers have a reason to reply."
},
{
"aim": "good_click",
"text": "We just launched our new invoicing feature! Full details on what's new are in our reply below: https://tallyfern.example.com/invoices",
"change": "Promised more detail in the first reply so readers open the replies to see it."
},
{
"aim": "good_click_v2",
"text": "We just launched our new invoicing feature! Open the replies for a step-by-step look at exactly what's new: https://tallyfern.example.com/invoices",
"change": "Promised a step-by-step look in the replies to keep readers in the conversation longer."
}
],
"reply_plan": "Reply to each person's answer about their invoicing pain points, and add the feature's step-by-step details as the first reply to keep the conversation going.",
"note": "This update is not ready yet: add a direct question about invoicing pain points so readers have a reason to reply instead of only clicking the link."
}
The ten published weights with the pull the page trusts on each (ReplyKit.trusted):
| Engagement | Weight | Pull | Why |
|---|---|---|---|
likes (fav) | 0.5 | neutral | The update announces a feature launch but gives no particular detail that stands out as likable. |
reposts (retweet) | 1 | neutral | Nothing in the update stands out as distinctly worth reposting beyond a routine feature launch. |
replies (reply) | 13.5 | neutral | The update states a launch without asking a question, so it gives no clear invitation to reply. |
profile visits (good_profile_click) | 12 | neutral | Nothing points readers toward the author's other work or profile as somewhere worth visiting. |
video views (video_playback50) | 0.005 | neutral | Neither the update nor the stated aim mentions a video, so this does not apply here. |
your replies to replies (reply_engaged_by_author) | 75 | neutral | The update does not invite a reply that the author would then need to engage with. |
conversation clicks (good_click) | 11 | risks | The outside link gives readers somewhere else to go instead of opening the replies to read more. |
time in the conversation (good_click_v2) | 10 | risks | The link pulls attention to an outside page instead of keeping readers reading the replies here. |
see-less, mutes and blocks (negative_feedback_v2) | -74 | neutral | The update contains no insults, misleading claims or outright asks that would prompt mutes or blocks. |
reports (report) | -369 | neutral | Nothing here attacks a person or makes a misleading claim likely to upset a reader. |
The rewrites of the first post, each re-counted (ReplyKit.audit):
- For replies (
reply), 148 of 280 as X counts it: "We just launched our new invoicing feature! Check it out here: https://tallyfern.example.com/invoices What's the most annoying part of invoicing for you right now?" - Added a direct question about invoicing pain points so readers have a reason to reply. - For conversation clicks (
good_click), 118 of 280 as X counts it: "We just launched our new invoicing feature! Full details on what's new are in our reply below: https://tallyfern.example.com/invoices" - Promised more detail in the first reply so readers open the replies to see it. - For time in the conversation (
good_click_v2), 131 of 280 as X counts it: "We just launched our new invoicing feature! Open the replies for a step-by-step look at exactly what's new: https://tallyfern.example.com/invoices" - Promised a step-by-step look in the replies to keep readers in the conversation longer.
In the replies: "Reply to each person's answer about their invoicing pain points, and add the feature's step-by-step details as the first reply to keep the conversation going."
The verdict: rework - "Give them a reason to reply"; it agrees with the reply's own pulls (ReplyKit.expectVerdict).
The note: "This update is not ready yet: add a direct question about invoicing pain points so readers have a reason to reply instead of only clicking the link."
The page's check (ReplyKit.check): kept.
No notes.
A hot take
The goal: "Start a debate about hybrid work"; the audience: "People who work in tech".
The draft as written:
Unpopular opinion: RETURN TO OFFICE managers are LAZY bosses who can't trust adults!!! RT if you agree 🤡 #RTO
The page reads it as one post, counted the way X counts: 110 of 280. 3 findings, 1 of them a defect to fix.
The posts (PostKit.form):
| Post | Length as X counts it | Links | Mentions | Hashtags | Emoji | Question | Findings |
|---|---|---|---|---|---|---|---|
| 1 | 110 | 0 | none | #RTO | 1 | none | bait, shouting, exclaims |
The findings (PostKit.lint), as sent in facts:
bait(fix): The post asks for engagement outright ("RT if") - the source skill calls this bait that costs credibility.shouting(check): The post has 3 words in capitals (RETURN, OFFICE, LAZY).exclaims(check): The post has 3 exclamation marks.
Input:
{
"task": "tune",
"goal": "Start a debate about hybrid work",
"audience": "People who work in tech",
"post": "Unpopular opinion: RETURN TO OFFICE managers are LAZY bosses who can't trust adults!!! RT if you agree 🤡 #RTO",
"thread": "none",
"facts": "The post: 110 of 280 characters as X counts them, no link, hashtags #RTO, 1 emoji, no question.\nThe post asks for engagement outright (\"RT if\") - the source skill calls this bait that costs credibility.\nThe post has 3 words in capitals (RETURN, OFFICE, LAZY).\nThe post has 3 exclamation marks."
}
Reply:
{
"task": "tune",
"verdict": "risky",
"signals": [
{
"signal": "fav",
"pull": "invites",
"why": "The blunt opinion about office-return managers gives readers who agree an easy way to show it with a like."
},
{
"signal": "retweet",
"pull": "invites",
"why": "The draft directly asks readers to repost it if they agree, a clear invitation to repost."
},
{
"signal": "reply",
"pull": "invites",
"why": "Calling this an unpopular opinion about bosses invites readers to agree or push back in the replies."
},
{
"signal": "good_profile_click",
"pull": "neutral",
"why": "Nothing in the draft points readers toward the author's profile or other posts."
},
{
"signal": "video_playback50",
"pull": "neutral",
"why": "Neither the draft nor the stated goal mentions any video to watch."
},
{
"signal": "reply_engaged_by_author",
"pull": "neutral",
"why": "The draft gives no sign the author plans to personally answer the replies it gets."
},
{
"signal": "good_click",
"pull": "invites",
"why": "The heated claim about managers is likely to pull readers into the conversation to reply or like there."
},
{
"signal": "good_click_v2",
"pull": "invites",
"why": "A claim this divisive can keep readers reading the back-and-forth in the conversation for a while."
},
{
"signal": "negative_feedback_v2",
"pull": "risks",
"why": "The insults, the capital letters, the exclamation marks and the direct repost request risk mutes or blocks."
},
{
"signal": "report",
"pull": "neutral",
"why": "Calling a group of managers lazy is a strong opinion, not conduct platforms usually treat as abuse."
}
],
"rewrites": [
{
"aim": "reply",
"text": "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. Where do you land on this?",
"change": "Replaced the insults, capital letters and repost request with a calmer claim and a direct question, so people reply with their own view."
},
{
"aim": "retweet",
"text": "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. #RTO",
"change": "Dropped the capital letters, exclamation marks, repost request and emoji, leaving one calm declarative line people can repost as is."
},
{
"aim": "good_click_v2",
"text": "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. What's the strongest argument against this? #RTO",
"change": "Kept the same position but swapped the repost request for a question asking for the strongest counterargument, inviting longer reading in the replies."
}
],
"reply_plan": "Reply personally and calmly to people who disagree, swapping insults for specific reasons, so the conversation keeps going and they reply back too.",
"note": "This draft is risky because the insults, the capital letters and the direct repost request could make readers mute or block the author; drop them first."
}
The ten published weights with the pull the page trusts on each (ReplyKit.trusted):
| Engagement | Weight | Pull | Why |
|---|---|---|---|
likes (fav) | 0.5 | invites | The blunt opinion about office-return managers gives readers who agree an easy way to show it with a like. |
reposts (retweet) | 1 | invites | The draft directly asks readers to repost it if they agree, a clear invitation to repost. |
replies (reply) | 13.5 | invites | Calling this an unpopular opinion about bosses invites readers to agree or push back in the replies. |
profile visits (good_profile_click) | 12 | neutral | Nothing in the draft points readers toward the author's profile or other posts. |
video views (video_playback50) | 0.005 | neutral | Neither the draft nor the stated goal mentions any video to watch. |
your replies to replies (reply_engaged_by_author) | 75 | neutral | The draft gives no sign the author plans to personally answer the replies it gets. |
conversation clicks (good_click) | 11 | invites | The heated claim about managers is likely to pull readers into the conversation to reply or like there. |
time in the conversation (good_click_v2) | 10 | invites | A claim this divisive can keep readers reading the back-and-forth in the conversation for a while. |
see-less, mutes and blocks (negative_feedback_v2) | -74 | risks | The insults, the capital letters, the exclamation marks and the direct repost request risk mutes or blocks. |
reports (report) | -369 | neutral | Calling a group of managers lazy is a strong opinion, not conduct platforms usually treat as abuse. |
The rewrites of the first post, each re-counted (ReplyKit.audit):
- For replies (
reply), 149 of 280 as X counts it: "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. Where do you land on this?" - Replaced the insults, capital letters and repost request with a calmer claim and a direct question, so people reply with their own view. - For reposts (
retweet), 127 of 280 as X counts it: "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. #RTO" - Dropped the capital letters, exclamation marks, repost request and emoji, leaving one calm declarative line people can repost as is. - For time in the conversation (
good_click_v2), 171 of 280 as X counts it: "Unpopular opinion: managers who demand a return to office don't trust the adults on their teams to work well on their own. What's the strongest argument against this? #RTO" - Kept the same position but swapped the repost request for a question asking for the strongest counterargument, inviting longer reading in the replies.
In the replies: "Reply personally and calmly to people who disagree, swapping insults for specific reasons, so the conversation keeps going and they reply back too."
The verdict: risky - "Likely to cost you"; it agrees with the reply's own pulls (ReplyKit.expectVerdict).
The note: "This draft is risky because the insults, the capital letters and the direct repost request could make readers mute or block the author; drop them first."
The page's check (ReplyKit.check): kept.
No notes.
A question for juniors
The goal: "A conversation with junior developers - I want replies I can answer"; the audience: "Junior developers and bootcamp grads".
The draft as written:
I've reviewed 40 junior dev portfolios this month. The ones that got interviews all had one thing: a README that explains a decision they'd make differently now.
What's one decision in your last project you'd undo?
The page reads it as one post, counted the way X counts: 215 of 280. 0 findings, 0 of them defects to fix.
The posts (PostKit.form):
| Post | Length as X counts it | Links | Mentions | Hashtags | Emoji | Question | Findings |
|---|---|---|---|---|---|---|---|
| 1 | 215 | 0 | none | none | 0 | ends with one | none |
Input:
{
"task": "tune",
"goal": "A conversation with junior developers - I want replies I can answer",
"audience": "Junior developers and bootcamp grads",
"post": "I've reviewed 40 junior dev portfolios this month. The ones that got interviews all had one thing: a README that explains a decision they'd make differently now.\n\nWhat's one decision in your last project you'd undo?",
"thread": "none",
"facts": "The post: 215 of 280 characters as X counts them, no link, ends with a question.\nThe page found nothing to fix."
}
Reply:
{
"task": "tune",
"verdict": "ready",
"signals": [
{
"signal": "fav",
"pull": "invites",
"why": "A concrete insight from reviewing 40 portfolios reads as a useful tip people like to signal they agree with."
},
{
"signal": "retweet",
"pull": "neutral",
"why": "The advice is tied to a personal question for this writer, not a generic line someone would repost elsewhere."
},
{
"signal": "reply",
"pull": "invites",
"why": "The closing question asks directly for a personal story, which is the clearest invitation for someone to respond."
},
{
"signal": "good_profile_click",
"pull": "invites",
"why": "Claiming to have reviewed 40 portfolios signals authority, which can make readers curious enough to check the writer's profile."
},
{
"signal": "video_playback50",
"pull": "neutral",
"why": "Nothing here mentions a video, so this engagement has no reason to move either way."
},
{
"signal": "reply_engaged_by_author",
"pull": "invites",
"why": "The writer wants a conversation they can personally answer, so replying to each response fits the stated aim."
},
{
"signal": "good_click",
"pull": "invites",
"why": "The open, personal question invites readers to click into the conversation and add their own story."
},
{
"signal": "good_click_v2",
"pull": "neutral",
"why": "One short question does not promise enough back-and-forth on its own to hold a reader for long."
},
{
"signal": "negative_feedback_v2",
"pull": "neutral",
"why": "Nothing combative, promotional or spammy appears here, so there is little reason to expect people to mute it."
},
{
"signal": "report",
"pull": "neutral",
"why": "Nothing abusive, deceptive or policy-breaking appears, so there is little reason for anyone to flag it."
}
],
"rewrites": [
{
"aim": "reply",
"text": "I've reviewed 40 junior dev portfolios this month. The ones that got interviews all shared one thing: a README that explains a decision they'd make differently now.\n\nYour turn: what's one decision in your last project you'd undo, and what would you do instead?",
"change": "Added a direct invitation and a follow-up prompt so readers feel personally asked to share their own decision."
},
{
"aim": "good_click_v2",
"text": "I've reviewed 40 junior dev portfolios this month. The ones that got interviews all had one thing: a README that explains a decision they'd make differently now.\n\nWhat's one decision in your last project you'd undo? The replies below might shape your next README.",
"change": "Added a line pointing to the replies below, giving readers a reason to open the conversation and keep reading."
},
{
"aim": "retweet",
"text": "After reviewing 40 junior dev portfolios this month, one thing stood out: every candidate who got interviews had a README that explained a decision they'd make differently now.\n\nWhat's one decision in your last project you'd undo?",
"change": "Rewrote the opening as a standalone insight about the 40 portfolios, making it easier to share out of context."
}
],
"reply_plan": "Reply to each answer with a short, specific follow-up question about that person's decision, since junior developers come back when the writer responds personally.",
"note": "This draft is ready to publish as is; replying personally to each answer is the one habit that turns the question into a real conversation."
}
The ten published weights with the pull the page trusts on each (ReplyKit.trusted):
| Engagement | Weight | Pull | Why |
|---|---|---|---|
likes (fav) | 0.5 | invites | A concrete insight from reviewing 40 portfolios reads as a useful tip people like to signal they agree with. |
reposts (retweet) | 1 | neutral | The advice is tied to a personal question for this writer, not a generic line someone would repost elsewhere. |
replies (reply) | 13.5 | invites | The closing question asks directly for a personal story, which is the clearest invitation for someone to respond. |
profile visits (good_profile_click) | 12 | invites | Claiming to have reviewed 40 portfolios signals authority, which can make readers curious enough to check the writer's profile. |
video views (video_playback50) | 0.005 | neutral | Nothing here mentions a video, so this engagement has no reason to move either way. |
your replies to replies (reply_engaged_by_author) | 75 | invites | The writer wants a conversation they can personally answer, so replying to each response fits the stated aim. |
conversation clicks (good_click) | 11 | invites | The open, personal question invites readers to click into the conversation and add their own story. |
time in the conversation (good_click_v2) | 10 | neutral | One short question does not promise enough back-and-forth on its own to hold a reader for long. |
see-less, mutes and blocks (negative_feedback_v2) | -74 | neutral | Nothing combative, promotional or spammy appears here, so there is little reason to expect people to mute it. |
reports (report) | -369 | neutral | Nothing abusive, deceptive or policy-breaking appears, so there is little reason for anyone to flag it. |
The rewrites of the first post, each re-counted (ReplyKit.audit):
- For replies (
reply), 260 of 280 as X counts it: "I've reviewed 40 junior dev portfolios this month. The ones that got interviews all shared one thing: a README that explains a decision they'd make differently now.
Your turn: what's one decision in your last project you'd undo, and what would you do instead?" - Added a direct invitation and a follow-up prompt so readers feel personally asked to share their own decision. - For time in the conversation (
good_click_v2), 263 of 280 as X counts it: "I've reviewed 40 junior dev portfolios this month. The ones that got interviews all had one thing: a README that explains a decision they'd make differently now.
What's one decision in your last project you'd undo? The replies below might shape your next README." - Added a line pointing to the replies below, giving readers a reason to open the conversation and keep reading. - For reposts (
retweet), 230 of 280 as X counts it: "After reviewing 40 junior dev portfolios this month, one thing stood out: every candidate who got interviews had a README that explained a decision they'd make differently now.
What's one decision in your last project you'd undo?" - Rewrote the opening as a standalone insight about the 40 portfolios, making it easier to share out of context.
In the replies: "Reply to each answer with a short, specific follow-up question about that person's decision, since junior developers come back when the writer responds personally."
The verdict: ready - "Ready to post"; it agrees with the reply's own pulls (ReplyKit.expectVerdict).
The note: "This draft is ready to publish as is; replying personally to each answer is the one habit that turns the question into a real conversation."
The page's check (ReplyKit.check): kept.
No notes.
A four-post thread
The goal: "Get platform engineers to read the write-up"; the audience: "Platform and infrastructure engineers".
The draft as written:
1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. Three changes did most of it 🧵
2/ Moved the nightly batch jobs to spot instances. They already retried on failure, so interruptions cost us nothing.
3/ Deleted 14 TB of logs nobody had opened since 2023 and set a 30-day retention.
4/ Right-sized the database: it never went above 22% CPU. Full write-up: https://example.com/cloud-bill
The page reads it as a thread of 4 posts, counted the way X counts: 116, 117, 81, 96 of 280. 1 finding, 0 of them defects to fix.
The posts (PostKit.form):
| Post | Length as X counts it | Links | Mentions | Hashtags | Emoji | Question | Findings |
|---|---|---|---|---|---|---|---|
| 1 | 116 | 0 | none | none | 1 | none | none |
| 2 | 117 | 0 | none | none | 0 | none | none |
| 3 | 81 | 0 | none | none | 0 | none | none |
| 4 | 96 | 1 | none | none | 0 | none | link |
The findings (PostKit.lint), as sent in facts:
link(check): Post 4 has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do.
Input:
{
"task": "tune",
"goal": "Get platform engineers to read the write-up",
"audience": "Platform and infrastructure engineers",
"post": "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. Three changes did most of it 🧵",
"thread": "2/ Moved the nightly batch jobs to spot instances. They already retried on failure, so interruptions cost us nothing.\n\n3/ Deleted 14 TB of logs nobody had opened since 2023 and set a 30-day retention.\n\n4/ Right-sized the database: it never went above 22% CPU. Full write-up: https://example.com/cloud-bill",
"facts": "Post 1: 116 of 280 characters as X counts them, no link, 1 emoji, no question.\nPost 2: 117 of 280 characters as X counts them, no link, no question.\nPost 3: 81 of 280 characters as X counts them, no link, no question.\nPost 4: 96 of 280 characters as X counts them, 1 link, no question.\nPost 4 has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do."
}
Reply:
{
"task": "tune",
"verdict": "ready",
"signals": [
{
"signal": "fav",
"pull": "invites",
"why": "The concrete drop from $9,400 to $3,100 a month gives readers an easy, likable win to applaud."
},
{
"signal": "retweet",
"pull": "invites",
"why": "The three concrete changes read like a reusable checklist other engineers would want to repost."
},
{
"signal": "reply",
"pull": "neutral",
"why": "The thread lists results and steps but never asks readers a question or for their reaction."
},
{
"signal": "good_profile_click",
"pull": "neutral",
"why": "The post explains its own three changes directly, giving no separate reason to visit the profile."
},
{
"signal": "video_playback50",
"pull": "neutral",
"why": "Neither the draft nor the stated goal mentions any video, so this engagement does not apply."
},
{
"signal": "reply_engaged_by_author",
"pull": "neutral",
"why": "The post recaps finished changes without inviting a discussion the author would personally reply back into."
},
{
"signal": "good_click",
"pull": "invites",
"why": "The thread mark after three changes did most of it invites a click to see what they were."
},
{
"signal": "good_click_v2",
"pull": "invites",
"why": "The full thread with specific numbers and a final write-up link rewards staying to read everything."
},
{
"signal": "negative_feedback_v2",
"pull": "neutral",
"why": "The draft states its own results plainly, with no insults, no capitals, and no request for engagement."
},
{
"signal": "report",
"pull": "neutral",
"why": "Nothing here resembles an attack, a false claim, or bait that would push someone to flag or report it."
}
],
"rewrites": [
{
"aim": "good_click",
"text": "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. See which three changes did it 🧵",
"change": "Turns the generic tease into a direct invitation to see the three specific changes behind the savings."
},
{
"aim": "reply",
"text": "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. Before I share the three changes, which would you guess mattered most?",
"change": "Replaces the flat statement with a direct question so readers reply with their guess before the reveal."
},
{
"aim": "good_click_v2",
"text": "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product: spot instances, a log cleanup and a database resize, explained in the thread below 🧵",
"change": "Names the three fixes directly so readers know there is detailed explanation worth staying in the thread for."
}
],
"reply_plan": "Reply to questions about the spot-instance retries, the 2023 log cleanup, or the 22% CPU headroom so the author's own replies keep the conversation going.",
"note": "This thread is ready to post as is; naming the three changes directly, as in the rewrites, would draw readers into the thread fastest."
}
The ten published weights with the pull the page trusts on each (ReplyKit.trusted):
| Engagement | Weight | Pull | Why |
|---|---|---|---|
likes (fav) | 0.5 | invites | The concrete drop from $9,400 to $3,100 a month gives readers an easy, likable win to applaud. |
reposts (retweet) | 1 | invites | The three concrete changes read like a reusable checklist other engineers would want to repost. |
replies (reply) | 13.5 | neutral | The thread lists results and steps but never asks readers a question or for their reaction. |
profile visits (good_profile_click) | 12 | neutral | The post explains its own three changes directly, giving no separate reason to visit the profile. |
video views (video_playback50) | 0.005 | neutral | Neither the draft nor the stated goal mentions any video, so this engagement does not apply. |
your replies to replies (reply_engaged_by_author) | 75 | neutral | The post recaps finished changes without inviting a discussion the author would personally reply back into. |
conversation clicks (good_click) | 11 | invites | The thread mark after three changes did most of it invites a click to see what they were. |
time in the conversation (good_click_v2) | 10 | invites | The full thread with specific numbers and a final write-up link rewards staying to read everything. |
see-less, mutes and blocks (negative_feedback_v2) | -74 | neutral | The draft states its own results plainly, with no insults, no capitals, and no request for engagement. |
reports (report) | -369 | neutral | Nothing here resembles an attack, a false claim, or bait that would push someone to flag or report it. |
The rewrites of the first post, each re-counted (ReplyKit.audit):
- For conversation clicks (
good_click), 118 of 280 as X counts it: "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. See which three changes did it 🧵" - Turns the generic tease into a direct invitation to see the three specific changes behind the savings. - For replies (
reply), 155 of 280 as X counts it: "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product. Before I share the three changes, which would you guess mattered most?" - Replaces the flat statement with a direct question so readers reply with their guess before the reveal. - For time in the conversation (
good_click_v2), 170 of 280 as X counts it: "1/ We cut our cloud bill from $9,400 to $3,100 a month without touching the product: spot instances, a log cleanup and a database resize, explained in the thread below 🧵" - Names the three fixes directly so readers know there is detailed explanation worth staying in the thread for.
In the replies: "Reply to questions about the spot-instance retries, the 2023 log cleanup, or the 22% CPU headroom so the author's own replies keep the conversation going."
The verdict: ready - "Ready to post"; it agrees with the reply's own pulls (ReplyKit.expectVerdict).
The note: "This thread is ready to post as is; naming the three changes directly, as in the rewrites, would draw readers into the thread fastest."
The page's check (ReplyKit.check): kept.
No notes.
The weights CSV for the first sample
What ReplyKit.csv(built, reply) writes for "A launch post with a link" above: one row per published signal, in the README's order, with its weight, the README's meaning and the trusted pull and reason (lines end in CRLF).
signal,weight,meaning,pull,why
fav,0.5,likes the post,neutral,The update announces a feature launch but gives no particular detail that stands out as likable.
retweet,1,reposts the post,neutral,Nothing in the update stands out as distinctly worth reposting beyond a routine feature launch.
reply,13.5,replies to the post,neutral,"The update states a launch without asking a question, so it gives no clear invitation to reply."
good_profile_click,12,opens the author's profile and likes or replies to a post,neutral,Nothing points readers toward the author's other work or profile as somewhere worth visiting.
video_playback50,0.005,watches at least half of the video (video posts),neutral,"Neither the update nor the stated aim mentions a video, so this does not apply here."
reply_engaged_by_author,75,"replies, and the author engages with that reply",neutral,The update does not invite a reply that the author would then need to engage with.
good_click,11,clicks into the conversation and replies or likes a post there,risks,The outside link gives readers somewhere else to go instead of opening the replies to read more.
good_click_v2,10,clicks into the conversation and stays at least 2 minutes,risks,The link pulls attention to an outside page instead of keeping readers reading the replies here.
negative_feedback_v2,-74,"asks to see less, or blocks or mutes the author",neutral,"The update contains no insults, misleading claims or outright asks that would prompt mutes or blocks."
report,-369,reports the post,neutral,Nothing here attacks a person or makes a misleading claim likely to upset a reader.
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({ draft: "Dark mode is live on the dashboard today: https://example.com/changelog #buildinpublic\nWhich screen should get it next?",
goal: "Hear which screen people want next",
audience: "People who use our small analytics tool" });
// form.posts: [{n, text, weighted, over, urls, mentions, tags, emoji, question, endsQuestion, bait, caps, bangs, finds}]
// form.thread: true when the draft split into several posts (the first 15 are read); form.findings: [{code, post, level, text}]
K.weighted("any text") // its length as X counts it: a link 23, an emoji 2, CJK 2
const built = K.buildInput(form); // built.ok is false, with built.note, until the draft has a few words
// built.warn: what the page warns about
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply) // {pulls: {reply: "invites", ...}, why, dropped, missing}
// R.expectVerdict(t) // the verdict the pulls themselves imply
// R.audit(reply.rewrites[0].text, form) // a rewrite re-counted: {weighted, over, numbers, mentions, hashtags, links, bait, ...}
// R.check(reply, built) // {status: kept or flagged, notes}
// R.csv(built, reply) // the ten weights with this reply's pulls
Draft keys: draft (the post, or a thread split by --- lines or 1/ 2/ numbering), goal (what you want from the post, or empty) and audience (who it is for, or empty); an empty goal or audience is sent as none. The engine sends nothing anywhere: it only reads the text you give it.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. 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://worth-a-reply.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://worth-a-reply.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"]
// worth-a-reply.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://worth-a-reply.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://worth-a-reply.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
}
// WorthAReply.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 WorthAReply {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://worth-a-reply.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://worth-a-reply.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://worth-a-reply.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://worth-a-reply.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":"worth-a-reply"}' | 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": "worth-a-reply"})["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: "worth-a-reply" })).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": "worth-a-reply"}, 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", "worth-a-reply"), 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: "worth-a-reply" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "worth-a-reply"])["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 = "worth-a-reply" }))!["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": "tune", "goal": "Hear which screen people want next", "audience": "People who use our small analytics tool", "post": "Dark mode is live on the dashboard today: https://example.com/changelog #buildinpublic\nWhich screen should get it next?", "thread": "none", "facts": "The post: 113 of 280 characters as X counts them, 1 link, hashtags #buildinpublic, ends with a question.\nThe post has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do."}
# - see the input 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": "tune", "goal": "Hear which screen people want next", "audience": "People who use our small analytics tool", "post": "Dark mode is live on the dashboard today: https://example.com/changelog #buildinpublic\nWhich screen should get it next?", "thread": "none", "facts": "The post: 113 of 280 characters as X counts them, 1 link, hashtags #buildinpublic, ends with a question.\nThe post has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do."}
# - see the input 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: "tune", goal, audience, post, thread, facts} (see the input 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 worth-a-reply:<task>:<inputHash>:a<attempt> (the one task is tune), 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="worth-a-reply:tune:$(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}'
jq -r '.verdict, .note, (.rewrites[] | "\(.aim) \(.text)")' reply.json
import hashlib, time
key = "worth-a-reply:" + 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"))
print(reply.get("note", ""))
for p in reply.get("rewrites") or []:
print(p["aim"], p["text"])
import { createHash } from "node:crypto";
const key = `worth-a-reply:${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);
console.log(reply.note);
for (const p of reply.rewrites || []) console.log(p.aim, p.text);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "worth-a-reply:" + 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)
rewrites, _ := reply["rewrites"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["note"])
for _, x := range rewrites {
p := x.(map[string]any)
fmt.Println(p["aim"], p["text"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "worth-a-reply:" + 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"));
System.out.println(reply.path("note").asText());
for (JsonNode p : reply.path("rewrites")) System.out.println(p.path("aim").asText() + " " + p.path("text").asText());
require "digest"
key = "worth-a-reply:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
puts reply["note"]
(reply["rewrites"] || []).each { |p| puts "#{p["aim"]} #{p["text"]}" }
$key = "worth-a-reply:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", ($reply["verdict"] ?? ""), "\n";
echo $reply["note"] ?? "", "\n";
foreach ($reply["rewrites"] ?? [] as $p) echo $p["aim"], " ", $p["text"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"worth-a-reply:{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"]}");
Console.WriteLine($"{reply["note"]}");
foreach (var p in reply["rewrites"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{p!["aim"]} {p["text"]}");
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: worth-a-reply:tune:$(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": "tune", "goal": "Hear which screen people want next", "audience": "People who use our small analytics tool", "post": "Dark mode is live on the dashboard today: https://example.com/changelog #buildinpublic\nWhich screen should get it next?", "thread": "none", "facts": "The post: 113 of 280 characters as X counts them, 1 link, hashtags #buildinpublic, ends with a question.\nThe post has 1 link (23 characters each). None of the ten published terms scores a click away from X; the conversation clicks do."}
# - see the input 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": "worth-a-reply:tune:" + 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": `worth-a-reply:tune:${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", "worth-a-reply:tune:"+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", "worth-a-reply:tune:" + 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" => "worth-a-reply:tune:#{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: worth-a-reply:tune:" . 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", $"worth-a-reply:tune:{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);