Run Tone Book from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page counts the discovery intake against the skill's twelve minimum answers, flags a category that needs research first, reads the brand's own samples (sentence length, contractions, person, exclamation marks, emoji, the Oxford comma, passive voice, banned terms and machine-writing tells), carries the NN/g leans the organizer chose, and lists the hard platform constraints of every channel in scope - all free, in tonekit.js. The run defines the voice or ports it to channels; the page then holds the reply to the skill's validation and its own facts (tonereply.js) and assembles TONE.md. The run never invents a fact about the brand and cites nothing.
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":"tone-book"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. 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, brief, facts) is missing. The page always sends all five of task, brief, samples, guide, facts; samples and guide may be empty strings. |
| 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: both lanes are metered and need 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 comes first
Two lanes, chosen by task, in the order the skill works:
voice- the fixed personality that never changes across channels: three to five attributes in the pattern "X, but never Y", each with a definition, three do's, three don'ts, a sentence in the voice, an off-brand sentence and an anti-example copied from the brand's own samples; the archetype; the four NN/g positions (at least three off neutral); the lexicon with its banned list; eleven mechanics decisions; inclusive language; global do's and don'ts; before/after examples; and, for a category that needs it, the norms to verify.channel- the voice ported to up to four channels: the tone shifts, the voice does not. Per channel, its hard constraints copied exactly, the tonal shift, three do's, three don'ts, an example opening within the channel's limit, and the tone modulation matrix - a dominant tone and two or three prohibited tones for each of the ten situations. The guide it ports arrives inguide: the voice reply as the page assembles it, or a TONE.md pasted by the organizer.
A missing or unknown task is answered as voice. Both lanes send the same five fields: samples is filled only for a voice run and guide only for a channel run; the other is an empty string. The facts are the page's worked-out lines - different per lane - and a line starting "must:" is a rule the reply has to hold.
The input
| Field | Required | What |
|---|---|---|
task | yes | voice or channel. If it is missing or neither, do voice and set "task": "voice". |
brief | yes | the discovery answers as the organizer entered them, one labelled line per item; "not given" means not given. |
samples | send it (empty unless task is voice) | voice task only: the brand's own past content, as pasted; may be empty. |
guide | send it (empty unless task is channel) | channel task only: the brand's existing tone of voice guide (written by the voice task, or pasted). |
facts | yes | the page's worked-out lines for this task: for voice, the intake count, the channels in scope, the NN/g leans, the sentence-length target, what the samples show and the banned terms; for channel, the channels to write with their hard constraints and the situations for the matrix. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @samber/copywriting-tone-of-voice-creator - the only standard the run applies:
- Voice is the fixed personality of the brand and does not change; tone is the contextual modulation across channel, audience and situation. Confusing the two is the most common failure.
- Three to five voice attributes, each in the pattern "X, but never Y", each with a one-line definition, three concrete do's, three concrete don'ts, one example sentence in the voice and one anti-example. Fewer than three is unhelpful; more than five is unmemorable.
- The NN/g four dimensions - funny or serious, formal or casual, respectful or irreverent, enthusiastic or matter-of-fact - each on a five-point scale. Lean to one side on at least three of the four: a voice clustered at neutral is the category default.
- The archetype (one of the twelve Jung archetypes) is a positioning shortcut, not a voice solution - brands that lean too hard on it end up in cosplay.
- The tone modulation matrix is the operational core: per situation and channel, the dominant tone and two or three prohibited tones.
- The banned-words list does more work than the power-words list: prevention is cheaper than prescription. Ban machine-writing tells too.
- Do's and don'ts are concrete: a new writer or bot must know exactly what to do tomorrow. "Be concise" is not a do; "lead with the verb in the first 90 characters" is.
- Don't borrow voices wholesale: references triangulate, they are not imitated.
- Categories shaped by regulation, audience and platform (political, religious, defense, gaming, healthcare professional comms, sports, fintech and crypto, regulated substances, insurance and legal, other) need research before a voice is defined; never apply consumer-brand defaults to them.
- For a multi-locale brand, voice is documented per locale and transcreated, never surface-translated.
House rules
- Use only the brief, the samples, the guide and the facts. Never invent a fact about the brand - a product, a customer, a number, a past campaign, a competitor or a market.
- Never cite a source, a study, a statistic or a URL. Never name a real brand as the voice to copy; a reference the organizer gave may be named only to say what to take from it.
- Every example sentence is written for this brand, about what the brief says it does.
- Write in the language of the brief's first market; keep the JSON keys in English.
- Refer to people by name or role; never assume a gender.
- Never use a banned term (the organizer's, the starter list or the guide's own) anywhere except in the lexicon's banned list, an anti-example, a "before" example, or inside quotation marks in a don't or a why that names it.
- Never use backticks, never write the input's raw key names (task, facts, retry_note) in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
voice
Define the fixed voice - the part of the guide that never changes across channels.
summary- one sentence, at most 40 words: the voice in a phrase and the most important open item.attributes- three to five objects:name- "X, but never Y" (for example "Direct, but never curt");definition- one line;sounds_like- one sentence in the voice;not_like- one off-brand sentence;do- exactly three concrete behaviours;dont- exactly three concrete behaviours;anti_example- as the facts say: one sentence copied verbatim from the samples that breaks this attribute, or "".archetype-primary(one of Innocent, Sage, Explorer, Outlaw, Magician, Hero, Lover, Jester, Everyman, Caregiver, Ruler, Creator),secondary(one of them, or ""),rationale(one or two sentences).nng- exactly four objects in this order - funny-serious, formal-casual, respectful-irreverent, enthusiastic-matter-of-fact:dimension- that pair as written;position- one of the pair's five points (for funny-serious: funny, leaning-funny, neutral, leaning-serious, serious; the others the same way);rationale- one sentence. At least three positions are not neutral.lexicon-preferred(two to eight objects:term,use_for,not),banned(at least six objects:term,instead- a replacement or "omit",reason),power(ten to thirty words),jargon(allowed,prohibited),naming(brand,customers- the chosen noun for the people the brand serves).mechanics- exactly eleven objects in this order, eachdecisionandrule: person, contractions, oxford comma, sentence length, active vs passive, capitalisation, emoji, punctuation, numerals, dates, currencies. Where the samples show a habit, keep it or say why the guide changes it.inclusive- three to six objects, eachtopicandrule(gendered language, pronouns, ability, age, and others that matter here).dosanddonts- five to ten concrete items each, the most decision-affecting first.examples- two to five objects:context,before(off-brand - a sentence from the samples when one fits),after(on-brand),why(which attribute and rule).research- for a category the facts say needs research, one string per norm to verify before the guide is used;[]otherwise.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "voice",
"summary": "one sentence",
"attributes": [ { "name": "X, but never Y", "definition": "one line", "sounds_like": "a sentence", "not_like": "a sentence", "do": [ "a", "b", "c" ], "dont": [ "a", "b", "c" ], "anti_example": "" } ],
"archetype": { "primary": "Sage", "secondary": "", "rationale": "one or two sentences" },
"nng": [ { "dimension": "funny-serious", "position": "leaning-serious", "rationale": "one sentence" } ],
"lexicon": { "preferred": [ { "term": "a term", "use_for": "meaning", "not": "adjacent term" } ], "banned": [ { "term": "a term", "instead": "a replacement", "reason": "why" } ], "power": [ "a word" ], "jargon": { "allowed": "where", "prohibited": "where" }, "naming": { "brand": "how to write it", "customers": "the noun" } },
"mechanics": [ { "decision": "person", "rule": "the rule" } ],
"inclusive": [ { "topic": "gendered language", "rule": "the rule" } ],
"dos": [ "a concrete behaviour" ],
"donts": [ "a concrete behaviour" ],
"examples": [ { "context": "where", "before": "off-brand", "after": "on-brand", "why": "which rule" } ],
"research": [ ],
"caveats": [ ]
}
The page holds it to the skill's validation and its own facts (check() in tonereply.js; any warning makes the reply flagged): three to five attributes, each named with "never", each with exactly three do's and three don'ts, a sentence in the voice and an off-brand one; every anti-example found verbatim in the samples (and none when no samples were given), and at least one when samples were given; a primary archetype from the twelve; the four NN/g dimensions in order, each position one of its five points, at least three off neutral; at least six banned terms, including every term the organizer gave and every banned term the samples use; 10-30 power words and at least one preferred term; the eleven mechanics decisions in order; at least three inclusive-language topics; five to ten do's and don'ts; at least two before/after examples; research items when the category needs them; no source, link or study cited; and no banned term - the organizer's, the starter list's or the guide's own - anywhere but the banned list, the anti-examples and the "before" examples (a don't or a why may name one inside quotation marks). Notes that do not flag: an attribute without an anti-example, a lean moved from the organizer's without pointing at the samples, a contraction ban against samples that use them, a sentence-length rule that does not state the reading age's target.
channel
Port the guide to the channels the facts list: the voice stays, the tone modulates.
summary- one sentence, at most 40 words.channels- one object per channel in the facts, in that order, every one:channel- the channel's key exactly as the facts give it in parentheses (for example "linkedin");constraints- the facts' hard constraint lines for that channel, each copied exactly as one string;shift- one or two sentences: how tone shifts from the voice baseline here;do- exactly three concrete, channel-specific behaviours;dont- exactly three;opening- the first line a reader sees in this channel, within its hard limits: the subject line for email (at most 50 characters), the headline for a press release (about 70), the title for YouTube (at most 100), the first line of the post for LinkedIn (at most 210), Twitter / X (at most 280), Instagram (at most 125) and TikTok (at most 80), and the first sentence elsewhere;matrix- exactly one object per situation in the facts, in that order:situation(copied exactly),dominant(the dominant tone, a few words),prohibited(two or three tones, a few words each).caveats- at most 4 short strings; when the guide's attributes do not fit a channel, say so here instead of changing them.
{
"task": "channel",
"summary": "one sentence",
"channels": [ { "channel": "linkedin", "constraints": [ "copied exactly" ], "shift": "one or two sentences", "do": [ "a", "b", "c" ], "dont": [ "a", "b", "c" ], "opening": "a sentence", "matrix": [ { "situation": "Launch / win", "dominant": "a tone", "prohibited": [ "a tone", "a tone" ] } ] } ],
"caveats": [ ]
}
The page requires the channels the facts list, in that order, with their keys; per channel, every hard constraint copied exactly and no extra one, exactly three do's and three don'ts, a tonal shift, an example opening within the channel's limit in characters (X post 280, LinkedIn feed fold 210, Instagram caption fold 125, TikTok caption fold 80, email subject line 50, press-channel title 70, YouTube title 100), and a matrix of the ten situations in order and wording, each with a dominant tone and two or three prohibited tones; no banned term in the opening, the shift, the do's or the don'ts (outside quotation marks); and no source, link or study cited. The page then assembles TONE.md from the voice and the channels.
Worked examples
Three runs from the page's four free samples (fictional brands, people and figures): the voice of the cloud-cost tool, that voice ported to its four channels, and a coffee roaster's pasted guide ported to TikTok and Instagram. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but long arrays and a long guide are shortened in this display only, and every cut is marked where it happens and listed under the example. The last line is the page's own check of the full reply.
voice - A cloud-cost tool for engineers
The intake: ready to define (12 of 12 minimum answers given); category: B2B SaaS, tech or developer tools; channels in scope: Blog / long-form, LinkedIn, In-product UI, Email; the samples: 133 words, read by the page.
Input:
{
"task": "voice",
"brief": "Brand: Ledgerline\nMission: Show platform teams what their cloud spend is doing before the invoice does.\nCategory: B2B SaaS, tech or developer tools\nMarkets and languages: en-US, en-GB\nPrimary content goal: Demand gen\nPrimary audience: Platform and infrastructure engineers at 200-2,000 person software companies who own the cloud bill but not the budget\nSecondary audiences: Finance partners who read the monthly cost report\nChannels in scope: Blog / long-form, LinkedIn, In-product UI, Email\nReading-age target: Expert and technical\nRisk tolerance: Moderately distinctive\nArchetype guess: Sage\nVoice references: Stripe docs for precision, Linear changelogs for brevity, GOV.UK for plain error messages\nAnti-references: Generic enterprise cloud vendors, crypto-style hype accounts, growth-hacker LinkedIn\nFounder voice contribution: Moderate\nRegulatory regime: GDPR\nTaboos and topics to avoid: Blaming engineers for a cost spike; fear-based selling\nExisting banned words: solution, empower, AI-driven\nLocalisation: Multi-locale with a shared voice",
"samples": "Ledgerline: The Robust Solution For Cloud Cost Visibility\n\nIn today's fast-paced world, engineering teams can't afford surprises on their cloud bill. That's why we built Ledgerline - an innovative platform that empowers platform teams to leverage real-time cost data across every account, every cluster, and every team!\n\nCosts are tagged automatically and anomalies are flagged by our AI-driven engine. You'll never be caught off guard again.\n\nRelease notes, March: the anomaly view now groups spikes by service. Alerts fire within 15 minutes of a spike instead of the next morning. We also fixed the bug that double-counted reserved instances in the forecast.\n\nError: Something went wrong. Please try again later.\n\nHey there! We noticed you haven't connected an account yet. Reach out to our team and we'll get you set up in no time!",
"guide": "",
"facts": "Intake: 12 of 12 minimum answers given.\nChannels in scope: Blog / long-form, LinkedIn, In-product UI, Email.\nNN/g leans the organizer chose: funny-serious: leaning-serious; formal-casual: leaning-casual; respectful-irreverent: respectful; enthusiastic-matter-of-fact: matter-of-fact.\nmust: position at least three of the four NN/g dimensions off neutral; keep a lean the organizer chose unless the samples contradict it, and say so in that dimension's rationale.\nSentence length target for this reading age: an average of 20-30 words, lengths varied.\nWhat the samples show (133 words):\n- sentences: 12, average 11.1 words, longest 25\n- contractions: 2 negative (don't, can't), 3 positive (we're, you're)\n- person: 6 we/our/us, 3 you/your\n- exclamation marks: 3; emoji: 0; dashes: 0; ellipses: 0\n- lists: 1 with an Oxford comma, 0 without\n- passive: 1 of 12 sentences\n- headings: 1, 1 in Title Case\n- banned terms used: leverage, solution, robust, innovative, reach out, AI-driven; machine-writing tells: in today's fast-paced world\nmust: each anti_example is one sentence copied verbatim from the samples that breaks that attribute, or \"\" when no sentence does.\nBanned terms the organizer gave: solution, empower, AI-driven. The skill's starter list: leverage, empower, empowerment, solution, robust, synergy, innovative, cutting-edge, next-generation, disruptive, ecosystem, touch base, at the end of the day, going forward, best-of-breed, game-changer, game-changing, thought leader, thought leadership, reach out, bandwidth. Machine-writing tells: it's worth noting that; in today's fast-paced world; in conclusion; navigate the complex landscape; unleash the power of; delve into; embark on a journey.\nmust: lexicon.banned includes every banned term the organizer gave and every banned term the samples use (leverage, solution, robust, innovative, reach out, AI-driven)."
}
Reply (shortened for display - see the cuts below):
{
"task": "voice",
"summary": "Ledgerline sounds like a senior platform engineer reading the bill with you: exact numbers, calm verbs, no hype. Open item: how the shared voice is transcreated for en-GB readers and finance partners.",
"attributes": [
{
"name": "Precise, but never pedantic",
"definition": "Name the service, the number and the time window that changed, then stop explaining.",
"sounds_like": "Spend on the payments cluster rose 18% between Tuesday and Thursday, and two services account for almost all of it.",
"not_like": "Get unparalleled visibility into every dimension of your cloud spend across your entire organization.",
"do": [
"Put the number, the unit and the time window in the same sentence, for example 'up 18% week over week'.",
"Name the exact service, account or cluster a change belongs to instead of saying 'your infrastructure'.",
"State what Ledgerline measured and what it inferred as two separate sentences."
],
"dont": [
"Use 'every', 'all' or 'across your entire' unless the claim is literally true for the reader's setup.",
"Explain a basic cloud billing term to an engineer; link to the definition only in finance-facing copy.",
"Stack three adjectives in front of a noun, as in 'real-time, granular, actionable data'."
],
"anti_example": "That's why we built Ledgerline - an innovative platform that empowers platform teams to leverage real-time cost data across every account, every cluster, and every team!"
},
{
"name": "Calm, but never alarmist",
"definition": "Treat a cost spike as a fact to investigate, not a threat to sell against.",
"sounds_like": "A spike is not a failure; it is a question, and the anomaly view shows you which service to ask first.",
"not_like": "Your cloud bill is out of control and you might not even know it yet.",
"do": [
"Describe a spike with its size and its likely cause before any call to action.",
"Give the next step as a verb the reader can do now, for example 'Open the anomaly view'.",
"Say how soon an alert fires, as the release notes do, instead of promising that nothing will be missed."
],
"dont": [
"Open with a threat, a worst case or a rhetorical question about losing money.",
"Blame an engineer or a team for a spike; describe the resource, not the person.",
"Use urgency words such as 'before it's too late' or 'don't get caught'."
],
"anti_example": "In today's fast-paced world, engineering teams can't afford surprises on their cloud bill."
},
{
"name": "Plain, but never vague",
"definition": "Use the shortest ordinary word that is still exact, especially in errors and empty states.",
"sounds_like": "We couldn't read the billing export for account prod-eu because the role is missing the read permission; add it, then run the import again.",
"not_like": "An unexpected issue occurred while processing your request.",
"do": [
"Write every error as what happened, why, and the one thing to do next.",
"Use the name the reader sees in their cloud console for a resource, not an internal label.",
"Write empty states as an instruction that starts with a verb, for example 'Connect an account to see spend by service'."
],
"dont": [
"Ship 'Something went wrong' or 'Please try again later' without a cause and a next step.",
"Use marketing words such as 'effortless', 'unparalleled' or 'world-class'.",
"Hide the next step behind 'contact us' when the reader can fix it themselves."
],
"anti_example": "Error: Something went wrong."
},
{
"name": "Candid, but never salesy",
"definition": "Say what Ledgerline does and does not do, including the bugs it fixed.",
"sounds_like": "We fixed a bug that double-counted reserved instances in the forecast, so forecasts that included them read too high.",
"not_like": "Sign up today and never worry about your cloud bill again!",
"do": [
"Name a fixed bug and its effect on numbers the reader has already seen.",
"Promise a measured behavior, such as an alert window, instead of an outcome such as 'no more surprises'.",
"Write calls to action as a plain next step, for example 'Connect an account' or 'Read the release notes'."
],
"dont": [
"Promise that a reader will 'never' be surprised, caught off guard or overspend.",
"Use exclamation marks or 'Hey there' to sound friendly.",
"Describe the product as a platform, engine or suite when the feature has a plain name."
],
"anti_example": "You'll never be caught off guard again."
}
],
"archetype": {
"primary": "Sage",
"secondary": "Everyman",
"rationale": "Sage fits a brand whose job is to explain what the spend is doing before the invoice arrives. Everyman keeps the Sage from lecturing engineers who already know their systems."
},
"nng": [
{
"dimension": "funny-serious",
"position": "leaning-serious",
"rationale": "Money and on-call stress are serious, but a dry aside in a changelog is allowed."
},
{
"dimension": "formal-casual",
"position": "leaning-casual",
"rationale": "Contractions and 'we' and 'you' stay, as in the samples, but greetings such as 'Hey there' go."
},
{
"dimension": "respectful-irreverent",
"position": "respectful",
"rationale": "The reader owns the bill but not the budget, so the voice never mocks them, their team or their finance partners."
},
{
"dimension": "enthusiastic-matter-of-fact",
"position": "matter-of-fact",
"rationale": "The release-notes sample is the model; the exclamation marks in the other samples are what the guide removes."
}
],
"lexicon": {
"preferred": [
{
"term": "spend",
"use_for": "money already used in a period",
"not": "costs, burn"
},
{
"term": "spike",
"use_for": "a sudden rise in spend for one service or account",
"not": "surprise, explosion"
},
{
"term": "anomaly",
"use_for": "a change Ledgerline flags as outside the expected range",
"not": "alert, incident"
},
"[... 3 more preferred terms cut from this display]"
],
"banned": [
{
"term": "solution",
"instead": "the feature's name, or 'Ledgerline'",
"reason": "Organizer ban; it says nothing about what the product does."
},
{
"term": "empower",
"instead": "omit, or name what the reader can now do",
"reason": "Organizer ban; vague and self-congratulating."
},
{
"term": "AI-driven",
"instead": "describe the method in one plain clause",
"reason": "Organizer ban; it asks for trust instead of explaining."
},
{
"term": "leverage",
"instead": "use",
"reason": "Used in the samples; corporate filler."
},
{
"term": "robust",
"instead": "state the measured guarantee",
"reason": "Used in the samples; an unfalsifiable claim."
},
{
"term": "innovative",
"instead": "omit",
"reason": "Used in the samples; the reader decides that, not the brand."
},
"[... 6 more banned terms cut from this display]"
],
"power": [
"spend",
"spike",
"forecast",
"anomaly",
"service",
"account",
"cluster",
"tag",
"[... 6 more power words cut from this display]"
],
"jargon": {
"allowed": "Cloud and infrastructure terms engineers use daily (reserved instances, tags, clusters, egress) in product UI, blog posts and release notes.",
"prohibited": "Unexplained engineering terms in the monthly cost report and any email written for finance partners; FinOps buzzwords anywhere."
},
"naming": {
"brand": "Ledgerline, one word, capital L only; never LedgerLine or LEDGERLINE.",
"customers": "platform teams"
}
},
"mechanics": [
{
"decision": "person",
"rule": "Ledgerline is 'we' and the reader is 'you'; in UI, address the reader directly and refer to the product as Ledgerline only when 'we' is unclear."
},
{
"decision": "contractions",
"rule": "Use contractions (we'll, can't, you're) everywhere, as the samples do; spell out 'do not' only when a warning needs the stress."
},
{
"decision": "oxford comma",
"rule": "Use the Oxford comma, as the samples do: 'by service, account, and team'."
},
{
"decision": "sentence length",
"rule": "Aim for an average of 20-30 words in blog posts and emails, varied; keep UI strings and error messages under 15 words."
},
"[... 7 more mechanics rows cut from this display]"
],
"inclusive": [
{
"topic": "gendered language",
"rule": "Use 'they' for an unnamed engineer or finance partner and role nouns such as 'on-call engineer', never 'guys'."
},
{
"topic": "blame",
"rule": "Describe the resource that changed, not the person who deployed it: 'the batch job in account staging doubled', not 'someone left a job running'."
},
"[... 3 more inclusive-language rules cut from this display]"
],
"dos": [
"Put the number, the service and the time window in the first sentence of every alert, email and release note.",
"Write every error as cause plus next step, starting the next step with a verb.",
"Name the fixed bug and what it changed in numbers the reader has already seen.",
"[... 4 more do's cut from this display]"
],
"donts": [
"Do not open with fear, a worst case or a rhetorical question about wasted money.",
"Do not blame an engineer or team for a cost spike.",
"Do not use exclamation marks, 'Hey there' or 'in no time'.",
"[... 3 more don'ts cut from this display]"
],
"examples": [
{
"context": "Product page headline",
"before": "Ledgerline: The Robust Solution For Cloud Cost Visibility",
"after": "See which service moved your cloud spend before the invoice does",
"why": "Precise, but never pedantic: names what the reader sees; sentence case; banned terms removed."
},
"[... 3 more examples cut from this display]"
],
"research": [],
"caveats": [
"The brief does not say how en-US and en-GB spelling are split across channels; this guide writes en-US and gives en-GB dates.",
"Founder voice is 'moderate' but no founder samples were given; add founder-bylined posts before setting their tone.",
"Finance partners are a secondary audience with no samples; check the jargon rule against a real monthly cost report."
]
}
Cut from this display only (the saved reply and the check use the full arrays): lexicon.preferred (3 of 6 cut); lexicon.banned (6 of 12 cut); lexicon.power (6 of 14 cut); mechanics (7 of 11 cut); inclusive (3 of 5 cut); dos (4 of 7 cut); donts (3 of 6 cut); examples (3 of 4 cut).
What step 5 prints for it: Precise, but never pedantic | Calm, but never alarmist | Plain, but never vague | Candid, but never salesy
The page's check: kept - nothing to flag
channel - A cloud-cost tool for engineers
The guide is the voice reply above, as the page assembles it into TONE.md and hands to the channel lane (R.laneForm); channels to write: blog, linkedin, ui, email.
Input (the guide is 13706 characters; the first 1518 are shown):
{
"task": "channel",
"brief": "Brand: Ledgerline\nMission: Show platform teams what their cloud spend is doing before the invoice does.\nCategory: B2B SaaS, tech or developer tools\nMarkets and languages: en-US, en-GB\nPrimary content goal: Demand gen\nPrimary audience: Platform and infrastructure engineers at 200-2,000 person software companies who own the cloud bill but not the budget\nSecondary audiences: Finance partners who read the monthly cost report\nChannels in scope: Blog / long-form, LinkedIn, In-product UI, Email\nReading-age target: Expert and technical\nRisk tolerance: Moderately distinctive\nArchetype guess: Sage\nVoice references: Stripe docs for precision, Linear changelogs for brevity, GOV.UK for plain error messages\nAnti-references: Generic enterprise cloud vendors, crypto-style hype accounts, growth-hacker LinkedIn\nFounder voice contribution: Moderate\nRegulatory regime: GDPR\nTaboos and topics to avoid: Blaming engineers for a cost spike; fear-based selling\nExisting banned words: solution, empower, AI-driven\nLocalisation: Multi-locale with a shared voice",
"samples": "",
"guide": "# TONE.md - Ledgerline\n\n> Generated with Tone Book from @samber/copywriting-tone-of-voice-creator. A machine-readable brand voice contract: do not rename or reorder its sections.\n\n---\n\n## Context\n\n- **Brand:** Ledgerline\n- **Category:** B2B SaaS, tech or developer tools\n- **Mission (1 line):** Show platform teams what their cloud spend is doing before the invoice does.\n- **Primary audience:** Platform and infrastructure engineers at 200-2,000 person software companies who own the cloud bill but not the budget\n- **Secondary audiences:** Finance partners who read the monthly cost report\n- **Market(s) and language(s):** en-US, en-GB\n- **Reading-age target:** Expert and technical\n- **Channels in scope:** Blog / long-form, LinkedIn, In-product UI, Email\n- **Primary content goal:** Demand gen\n- **Risk tolerance:** Moderately distinctive\n- **Regulatory constraints:** GDPR\n\n---\n\n## Voice Attributes\n\nThe brand's fixed personality. These do **not** change across channels - only tone modulates.\n\n### 1. Precise, but never pedantic\n\n**Definition:** Name the service, the number and the time window that changed, then stop explaining.\n\n**Sounds like:** \"Spend on the payments cluster rose 18% between Tuesday and Thursday, and two services account for almost all of it.\"\n\n**Does not sound like:** \"Get unparalleled visibility into every dimension of your cloud spend across your entire organization.\"\n\n**Do:**\n\n- Put the number, the unit and the time window in the same sentence, for example 'up 18% week over week'.\n\n[... 12188 more characters of the guide cut from this display ...]",
"facts": "Channels to write, in this order, each with its hard constraints - copy each constraint line exactly, one string per line:\n- Blog / long-form (blog):\n - No platform-imposed length cap; reader attention is the cap.\n - Markdown supported in most CMSs; H2/H3 structure affects SEO and scanning.\n - Above-the-fold copy carries most of the engagement load.\n- LinkedIn (linkedin):\n - Post body: about 3,000 characters; truncates at about 210 characters in the feed.\n - The first 1-3 lines must hook, above the fold on mobile.\n - No markdown rendering; line breaks shape readability.\n- In-product UI (ui):\n - Button labels: about 24 characters.\n - Empty states, errors and confirmations each have their own micro-format.\n - WCAG 2.1 AA plain language; every string goes to translation.\n- Email (email):\n - Subject line: 30-50 characters before truncation on mobile.\n - Preheader: 40-100 characters.\n - From-name matters more than from-address for opens.\nSituations for the matrix column, in this order: Launch / win; Crisis / outage; Complaint (justified); Complaint (unjustified or viral); Sensitive societal topic; Bad news (price increase, sunset, layoffs); Routine / informational; Sales objection; Apology; Recruitment.\nmust: the voice attributes in the guide do not change - only tone modulates per channel. Never rename, add or drop an attribute."
}
Reply (shortened for display - see the cuts below):
{
"task": "channel",
"summary": "Ledgerline keeps one voice - precise, calm, plain and candid - and modulates only depth and pace: longest on the blog, a hook on LinkedIn, fewest words in the product, one clear next step in email.",
"channels": [
{
"channel": "blog",
"constraints": [
"No platform-imposed length cap; reader attention is the cap.",
"Markdown supported in most CMSs; H2/H3 structure affects SEO and scanning.",
"Above-the-fold copy carries most of the engagement load."
],
"shift": "The blog carries the most depth: full reasoning, worked numbers and the release-notes habit of saying what changed and what was fixed. The tone stays matter-of-fact, with room for one dry aside per post.",
"do": [
"Put the finding, with its number and time window, in the first two sentences above the fold.",
"Use H2 headings that state a claim in sentence case, for example 'Spikes cluster around batch jobs', not a label such as 'Overview'.",
"Close every post with one next step the reader can take in their own cloud account today."
],
"dont": [
"Open with a scene-setting paragraph about how hard cloud costs are.",
"Use a fear headline about wasted money or surprise invoices.",
"Stack more than one call to action at the end of a post."
],
"opening": "The anomaly view now groups cost spikes by service, so the first thing you see is which service moved, by how much and since when.",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Measured pride in the detail",
"prohibited": [
"boastful",
"hyped"
]
},
{
"situation": "Crisis / outage",
"dominant": "Factual and accountable",
"prohibited": [
"defensive",
"breezy"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Candid ownership with the fix",
"prohibited": [
"evasive",
"self-pitying"
]
},
"[... 7 more matrix rows cut from this display]"
]
},
{
"channel": "linkedin",
"constraints": [
"Post body: about 3,000 characters; truncates at about 210 characters in the feed.",
"The first 1-3 lines must hook, above the fold on mobile.",
"No markdown rendering; line breaks shape readability."
],
"shift": "LinkedIn is where Ledgerline is most likely to slide into growth-hacker posts, so the tone tightens: the first lines carry one finding in plain words, and warmth comes from crediting the platform teams who run the systems.",
"do": [
"Fit the whole hook - a number, a service or a time window - into the first 210 characters.",
"Use short paragraphs separated by line breaks, one idea each, with no formatting symbols.",
"End with a concrete link or question about the reader's own cloud bill, not a request for likes."
],
"dont": [
"Write one-line-per-sentence bait posts or 'agree?' endings.",
"Use hashtag strings or emoji bullets.",
"Post personal-brand lessons that are not about cloud spend."
],
"opening": "Our alerts used to fire the morning after a cost spike. They now fire within 15 minutes, grouped by the service that caused it.",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Specific and quietly confident",
"prohibited": [
"humblebrag",
"hyped"
]
},
{
"situation": "Crisis / outage",
"dominant": "Short, factual status",
"prohibited": [
"defensive",
"spin"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Public ownership",
"prohibited": [
"corporate",
"deflecting"
]
},
"[... 7 more matrix rows cut from this display]"
]
},
{
"channel": "ui",
"constraints": [
"Button labels: about 24 characters.",
"Empty states, errors and confirmations each have their own micro-format.",
"WCAG 2.1 AA plain language; every string goes to translation."
],
"shift": "In the product the voice is at its most compressed: no greetings, no personality flourishes, just cause and next step. Calm matters most here because the reader is often looking at a spike while on call.",
"do": [
"Keep button labels under 24 characters and start them with a verb, for example 'Connect account'.",
"Write every error as cause plus next step in at most two sentences.",
"Write strings without idioms or wordplay so they translate cleanly."
],
"dont": [
"Ship 'Something went wrong' or 'Oops' as an error.",
"Put jokes, greetings or exclamation marks in any string.",
"Use color alone, or the word 'red', to signal a spike."
],
"opening": "No cloud account connected yet. Connect one to see spend by service.",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Neutral and informative",
"prohibited": [
"celebratory",
"salesy"
]
},
{
"situation": "Crisis / outage",
"dominant": "Calm and exact",
"prohibited": [
"alarming",
"vague"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Clear about the cause",
"prohibited": [
"blaming",
"flippant"
]
},
"[... 7 more matrix rows cut from this display]"
]
},
{
"channel": "email",
"constraints": [
"Subject line: 30-50 characters before truncation on mobile.",
"Preheader: 40-100 characters.",
"From-name matters more than from-address for opens."
],
"shift": "Email sounds the most personal: one person at Ledgerline writing to one platform engineer, with the finding in the subject line and one next step. Emails for finance partners add one plain sentence that translates the numbers.",
"do": [
"Keep subject lines at 30-50 characters and put the number or the change in them.",
"Send from a named person at Ledgerline, as in 'Name from Ledgerline', not from a no-reply address.",
"Put a single next step in the first paragraph and repeat it once as the only button."
],
"dont": [
"Open with 'Hey there' or 'Hope this email finds you well'.",
"Use countdowns or 'last chance' in a subject line.",
"Send a finance partner engineering shorthand without a plain translation."
],
"opening": "Alerts now fire within 15 minutes of a spike",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Specific and useful",
"prohibited": [
"hyped",
"boastful"
]
},
{
"situation": "Crisis / outage",
"dominant": "Prompt and factual",
"prohibited": [
"alarming",
"defensive"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Personal ownership",
"prohibited": [
"templated",
"deflecting"
]
},
"[... 7 more matrix rows cut from this display]"
]
}
],
"caveats": [
"The 15-minute alert window comes from the March release notes in the samples; check it is still current before reusing the openings.",
"LinkedIn under a founder's name is not covered; the brief says moderate founder voice but gives no founder samples."
]
}
Cut from this display only (the saved reply and the check use the full arrays): blog.matrix (7 of 10 cut); linkedin.matrix (7 of 10 cut); ui.matrix (7 of 10 cut); email.matrix (7 of 10 cut).
What step 5 prints for it: blog: Measured pride in the detail | linkedin: Specific and quietly confident | ui: Neutral and informative | email: Specific and useful
The page's check: kept - nothing to flag
channel - A coffee roaster's guide, ported to TikTok
The guide is the organizer's own TONE.md, pasted into the form - no voice run first; channels to write: tiktok, instagram.
Input:
{
"task": "channel",
"brief": "Brand: Bramble & Bean\nMission: not given\nCategory: B2C or D2C consumer brand\nMarkets and languages: en-GB\nPrimary content goal: not chosen\nPrimary audience: not given\nSecondary audiences: not given\nChannels in scope: Web, Email, Instagram\nReading-age target: not chosen\nRisk tolerance: not chosen\nArchetype guess: not chosen\nVoice references: not given\nAnti-references: not given\nFounder voice contribution: not chosen\nRegulatory regime: not chosen\nTaboos and topics to avoid: not given\nExisting banned words: none given\nLocalisation: not chosen",
"samples": "",
"guide": "# TONE.md - Bramble & Bean\n\n## Context\n- Brand: Bramble & Bean, a small-batch coffee roaster selling beans by subscription.\n- Primary audience: home brewers who care where their coffee comes from but do not want a lecture.\n- Channels in scope: web, email, Instagram.\n\n## Voice Attributes\n\n### 1. Warm, but never twee\nDefinition: we talk like the person behind the roaster on a Saturday morning.\nDo: name the farm and the roast date; use \"you\" and \"we\"; keep jokes small and dry.\nDon't: use cute puns on every line; call customers \"coffee lovers\"; use exclamation marks in a row.\n\n### 2. Precise, but never snobbish\nDefinition: we give the detail that changes the cup, and stop there.\nDo: give brew ratios as numbers; name one tasting note, not seven; explain a term the first time.\nDon't: use cupping jargon without a translation; rank customers by their gear; say \"real coffee\".\n\n### 3. Honest, but never preachy\nDefinition: we say what we pay farmers and what went wrong with a roast.\nDo: publish the price we paid per kilo; own a late delivery in the first line; say \"we got this wrong\".\nDon't: lecture about ethics; use \"sustainable\" without a number; hide a delay in a footer.\n\n## NN/g positions\n- Funny-serious: leaning-funny. Formal-casual: casual. Respectful-irreverent: leaning-respectful. Enthusiastic-matter-of-fact: leaning-matter-of-fact.\n\n## Lexicon\n- Preferred: \"subscribers\" (not \"members\"), \"roast date\", \"farm\".\n- Banned: coffee lovers, artisanal, curated, journey, elevate, game-changer.",
"facts": "Channels to write, in this order, each with its hard constraints - copy each constraint line exactly, one string per line:\n- TikTok (tiktok):\n - Video up to 10 minutes; sweet spot 15-60 seconds.\n - Caption about 2,200 characters; first about 80 visible.\n - Native feel beats produced feel.\n- Instagram (instagram):\n - Feed caption 2,200 characters; first about 125 visible.\n - Stories last 24 hours.\n - Hashtags: 30 maximum, 3-5 typically optimal.\nSituations for the matrix column, in this order: Launch / win; Crisis / outage; Complaint (justified); Complaint (unjustified or viral); Sensitive societal topic; Bad news (price increase, sunset, layoffs); Routine / informational; Sales objection; Apology; Recruitment.\nmust: the voice attributes in the guide do not change - only tone modulates per channel. Never rename, add or drop an attribute."
}
Reply (shortened for display - see the cuts below):
{
"task": "channel",
"summary": "Bramble & Bean keeps its three attributes - warm, precise, honest - and modulates only pace: TikTok is the roaster talking to camera, Instagram is the farm, the roast date and the price paid, told in the caption's first line.",
"channels": [
{
"channel": "tiktok",
"constraints": [
"Video up to 10 minutes; sweet spot 15-60 seconds.",
"Caption about 2,200 characters; first about 80 visible.",
"Native feel beats produced feel."
],
"shift": "TikTok is the loosest the voice gets: spoken, unpolished, filmed at the roaster, with small dry jokes. Precision survives as one number on screen, and honesty as showing a roast that went wrong.",
"do": [
"Say the one fact - a brew ratio, a roast date or the price paid per kilo - in the first 3 seconds and in the first 80 characters of the caption.",
"Film handheld at the roaster or the kitchen counter, in one take where possible, with the person who roasted the batch talking.",
"Show a brew ratio as numbers on screen, for example grams of coffee to grams of water."
],
"dont": [
"Chase a trending sound or pun that has nothing to do with the coffee.",
"Use cupping jargon on screen without saying what it means.",
"Mock someone's kit or brewing method, even in a duet."
],
"opening": "Roast date is on the front of every bag. Here's why it changes your cup.",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Warm and dry, shown not shouted",
"prohibited": [
"hyped",
"smug"
]
},
{
"situation": "Crisis / outage",
"dominant": "Straight to camera, plain facts",
"prohibited": [
"jokey",
"defensive"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Owned in the first line",
"prohibited": [
"evasive",
"flippant"
]
},
"[... 7 more matrix rows cut from this display]"
]
},
{
"channel": "instagram",
"constraints": [
"Feed caption 2,200 characters; first about 125 visible.",
"Stories last 24 hours.",
"Hashtags: 30 maximum, 3-5 typically optimal."
],
"shift": "Instagram is a little more composed than TikTok: the picture is the bag, the farm or the cup, and the first 125 characters carry the farm, the roast date or the price paid. Jokes stay small; the caption below the fold can go into detail.",
"do": [
"Name the farm and the roast date in the first 125 characters of every coffee post.",
"Give the price paid per kilo as a number in any post about sourcing.",
"Use 3-5 hashtags about brewing and origin, at the end of the caption."
],
"dont": [
"Write a pun in every caption or stack exclamation marks.",
"Say a coffee is ethical or responsible without a number behind it.",
"Hide a delay or a roast problem in Stories only, where it disappears after 24 hours."
],
"opening": "Every bag names the farm and the roast date. This month's caption also gives the price we paid per kilo.",
"matrix": [
{
"situation": "Launch / win",
"dominant": "Warm, with the farm and the number",
"prohibited": [
"boastful",
"twee"
]
},
{
"situation": "Crisis / outage",
"dominant": "Plain update in the feed, not just Stories",
"prohibited": [
"breezy",
"defensive"
]
},
{
"situation": "Complaint (justified)",
"dominant": "Owned in the first line",
"prohibited": [
"evasive",
"corporate"
]
},
"[... 7 more matrix rows cut from this display]"
]
}
],
"caveats": [
"TikTok is not in the guide's channels (web, email, Instagram); this section is a port, and the guide's context line should be updated if TikTok is adopted.",
"The guide gives no farm names, roast dates or prices; so the openings name none; add real figures before posting.",
"Recruitment and sensitive topics are not covered by the guide; the matrix applies the three attributes, not a stated policy."
]
}
Cut from this display only (the saved reply and the check use the full arrays): tiktok.matrix (7 of 10 cut); instagram.matrix (7 of 10 cut).
What step 5 prints for it: tiktok: Warm and dry, shown not shouted | instagram: Warm, with the farm and the number
The page's check: kept - nothing to flag
The free TONE.md skeleton for the cloud-cost sample
What the page writes before any run (R.assemble(K.form(form), null, null)): the Context section filled from the intake, the sections the runs fill marked as not written yet.
# TONE.md - Ledgerline
> Generated with Tone Book from @samber/copywriting-tone-of-voice-creator. A machine-readable brand voice contract: do not rename or reorder its sections.
---
## Context
- **Brand:** Ledgerline
- **Category:** B2B SaaS, tech or developer tools
- **Mission (1 line):** Show platform teams what their cloud spend is doing before the invoice does.
- **Primary audience:** Platform and infrastructure engineers at 200-2,000 person software companies who own the cloud bill but not the budget
- **Secondary audiences:** Finance partners who read the monthly cost report
- **Market(s) and language(s):** en-US, en-GB
- **Reading-age target:** Expert and technical
- **Channels in scope:** Blog / long-form, LinkedIn, In-product UI, Email
- **Primary content goal:** Demand gen
- **Risk tolerance:** Moderately distinctive
- **Regulatory constraints:** GDPR
## Tone Modulation Matrix
Not written yet - run the channel lane for the channels in scope.
---
## Channel-Specific Guidance
Not written yet - run the channel lane.
---
## Provenance
- **Generated from:** discovery interview only - nothing run yet
- **Sources cited:** none
- **Change cadence:** quarterly audit
Build the input yourself
Load the engine in node (global.window = global; require("./tonekit.js"); require("./tonereply.js")), then:
const K = ToneKit, R = ToneReply;
const f = K.form(Object.assign(K.blank(), { brand: "Acme Robotics", mission: "Make warehouse picking boring.", category: "industrial", markets: "en-US",
goal: "demand", audience: "Operations managers at mid-size warehouses who buy automation", channels: "web, linkedin, email",
reading: "adult", risk: "moderate", nng0: "leaning-serious", nng1: "leaning-casual", nng2: "respectful", nng3: "matter-of-fact",
banned: "solution\nseamless", samples: "" }));
// f.rows (the twelve intake answers), f.verdict, f.research, f.obs (what the samples show), f.findings
K.board(f); K.channelsCsv(f); R.assemble(f, null, null) // the free exports
const b = K.buildInput(f, "voice"); // or "channel" (set f.guide, or R.laneForm(form, "channel", voiceReply))
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "voice");
// R.check(reply, b) // {status: kept or flagged, notes}
// R.assemble(f, voiceReply, channelReply) // TONE.md
Get a token for the steps below from /tokens.html.
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="YOUR_TOKEN" # from https://tone-book.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 = "YOUR_TOKEN" # from https://tone-book.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"]
// tone-book.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://tone-book.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 = "YOUR_TOKEN" // from https://tone-book.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
}
// ToneBook.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 ToneBook {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://tone-book.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 = "YOUR_TOKEN" # from https://tone-book.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 = "YOUR_TOKEN"; // from https://tone-book.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 = "YOUR_TOKEN"; // from https://tone-book.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":"tone-book"}' | 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": "tone-book"})["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: "tone-book" })).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": "tone-book"}, 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", "tone-book"), 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: "tone-book" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "tone-book"])["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 = "tone-book" }))!["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": "voice", "brief": "Brand: ...\nMission: ...", "samples": "The brand's own past copy ...", "guide": "", "facts": "Intake: 12 of 12 minimum answers given. ..."}
# - 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": "voice", "brief": "Brand: ...\nMission: ...", "samples": "The brand's own past copy ...", "guide": "", "facts": "Intake: 12 of 12 minimum answers given. ..."}
# - 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: "voice" or "channel", brief, samples, guide, 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 tone-book:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A voice reply carries summary, attributes, archetype, nng, lexicon, mechanics, inclusive, dos, donts, examples, research and caveats; a channel reply carries summary, channels (each with its constraints, shift, do's, don'ts, opening and matrix) and caveats. The code below prints the gist of either: the voice attribute names, or each channel key with the dominant tone of its first matrix row.
KEY="tone-book:$(jq -r .task input.json):$(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 'if .task == "channel" then (.channels[] | "\(.channel): \(.matrix[0].dominant)")
else (.attributes[] | "- \(.name)") end' reply.json
import hashlib, time
key = "tone-book:" + 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["task"])
if reply["task"] == "channel":
for c in reply["channels"]:
print(c["channel"] + ":", c["matrix"][0]["dominant"] if c["matrix"] else "")
else:
for a in reply["attributes"]:
print("-", a["name"])
import { createHash } from "node:crypto";
const key = `tone-book:${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.task);
if (reply.task === "channel") for (const c of reply.channels) console.log(c.channel + ":", c.matrix[0] ? c.matrix[0].dominant : "");
else for (const a of reply.attributes) console.log("-", a.name);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "tone-book:" + 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)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
if reply["task"] == "channel" {
chans, _ := reply["channels"].([]any)
for _, c := range chans {
cm := c.(map[string]any)
rows, _ := cm["matrix"].([]any)
var dominant any = ""
if len(rows) > 0 { dominant = rows[0].(map[string]any)["dominant"] }
fmt.Println(cm["channel"], "-", dominant)
}
} else {
attrs, _ := reply["attributes"].([]any)
for _, a := range attrs { fmt.Println("-", a.(map[string]any)["name"]) }
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "tone-book:" + 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.path("task").asText());
if (reply.path("task").asText().equals("channel")) {
for (JsonNode c : reply.path("channels")) System.out.println(c.path("channel").asText() + ": " + c.path("matrix").path(0).path("dominant").asText());
} else {
for (JsonNode a : reply.path("attributes")) System.out.println("- " + a.path("name").asText());
}
require "digest"
key = "tone-book:#{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["task"]].inspect
if reply["task"] == "channel"
reply["channels"].each { |c| puts "#{c["channel"]}: #{c.dig("matrix", 0, "dominant")}" }
else
reply["attributes"].each { |a| puts "- #{a["name"]}" }
end
$key = "tone-book:" . $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["task"], "\n";
if ($reply["task"] === "channel") { foreach ($reply["channels"] as $c) echo $c["channel"], ": ", $c["matrix"][0]["dominant"] ?? "", "\n"; }
else { foreach ($reply["attributes"] as $a) echo "- ", $a["name"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"tone-book:{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>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "channel") { foreach (var c in reply["channels"]!.AsArray()) { var rows = c!["matrix"]?.AsArray(); Console.WriteLine($"{c["channel"]}: {(rows is { Count: > 0 } ? rows[0]!["dominant"] : "")}"); } }
else { foreach (var a in reply["attributes"]!.AsArray()) Console.WriteLine("- " + a!["name"]); }
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: tone-book:$(jq -r .task input.json):$(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": "voice", "brief": "Brand: ...\nMission: ...", "samples": "The brand's own past copy ...", "guide": "", "facts": "Intake: 12 of 12 minimum answers given. ..."}
# - 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": "tone-book:" + rev["task"] + ":" + 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": `tone-book:${rev.task}:${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", "tone-book:"+input["task"].(string)+":"+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", "tone-book:" + input.get("task") + ":" + 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" => "tone-book:#{JSON.parse(rev)["task"]}:#{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: tone-book:" . json_decode($rev, true)["task"] . ":" . 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", $"tone-book:{input["task"]}:{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);