← House Style / API
Your token

Driving House Style from your own code

Everything the web page does is one HTTP API. The base URL is https://api.skillsafe.ai/v1/app-api, every call carries Authorization: Bearer <token>, and every response is the same envelope. The app is identified by the token, not by a path segment — the app slug never appears in a request path, and a route built by inserting it returns 404.

The task field comes first

House Style is one app with three lanes over the same prose. Every request must carry a task field; it is what routes the run. The three values are:

taskWhat you getDerived from
discoverThe voice profile: dimensions with verbatim evidence, the vocabulary the writing reaches for and avoids, and what it never does.@anthropics/discover-brand
codifyThe house style guide: principles, rules with worked do/not pairs, a terminology table, mechanics and a reviewer checklist.@anthropics/guideline-generation
enforceOne draft against the guide: every offending span quoted with its rule id, why it matters and a rewrite, plus the whole draft rewritten and a 0-100 score.@anthropics/brand-voice-enforcement

If task is missing or unrecognised the model picks the closest lane and names its choice in lane and in the first sentence of summary — it never blends two lanes into one answer. Read lane off the reply rather than assuming it echoes what you sent.

The input fields

FieldTypeRequiredNotes
taskstringyesdiscover, codify or enforce.
samplesstringyesThe work object. For discover and codify, the copy already published; for enforce, the one draft under review. The web app separates pieces with --- Label --- lines and you should too.
guidestringfor enforceThe house style guide, as Markdown or as the style_rules JSON the codify lane returns. Ignored by the other lanes.
audiencestringnoWho the writing is for. Under 200 characters.
channelstringnogeneral, web, product, docs, email, social, support.
registerstringnoauto, formal, neutral, conversational, playful.
notesstringnoWhat the samples cannot say — a spelling decision, a word legal has ruled out. Under 1200 characters.
prescanobjectno, but doYour own measurements. Anything in prescan.flags[] must carry an id, and the model is required to answer every id in coverage_check. Send an empty flags array if you have none.
upstreamstringnoThe previous lane's digest, when you are chaining lanes.

Mask before you send. The web app replaces addresses, phone numbers, key-shaped strings and signed links with [EMAIL-1]-style placeholders before the text leaves the browser, and the prompt treats those tokens as opaque. If you are posting raw text, do the same.

The envelope

Every reply is {"data": …} on success and {"error": …} on failure.

Statuserror.codeWhat it means
400VALIDATION_ERRORThe body is not the shape the app expects. error.details names the field.
401UNAUTHORIZEDMissing, expired or wrong-app token. Mint a new one.
402INSUFFICIENT_CREDITSThe balance cannot cover the hold. Call /estimate first — it is free.
404NOT_FOUNDUsually a job id that does not belong to this app, or a path segment that should not be there.
429RATE_LIMITEDBack off. Do not tight-loop the poll.
500INTERNALRetry once with the same Idempotency-Key.

The client used below

# A token first (see step 1). Everything below reuses it.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

Step 1 — get a token

Open /tokens.html in the browser that uses the app: it shows the token this origin already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing there needs the developer console.

From code, mint a guest token. A guest can call /me and /estimate; running a lane is metered and needs a personal token unless the publisher has enabled sponsorship, which this app has not.

curl -s -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"house-style"}'
# -> {"data":{"token":"aut_...","guest_id":"gst_..."}}

Step 2 — check the token with /me

GET /me returns subject_type (user or guest), credits, and the app the token is scoped to. Compare credits against the hold_credits from step 3 before you run anything.

curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"

Step 3 — price the run with /estimate

POST /estimate is free, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. The hold differs per lane, because the lanes have different prompts and output caps — re-estimate when you change task rather than reusing the previous number.

curl -s -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"enforce","samples":"## Introducing Tidewell Forecasting...","guide":"{}","audience":"customers","channel":"web","register":"auto","notes":"","prescan":{"words":120,"flags":[]}}'
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":4210,"min_credits":420,"sponsor_enabled":false}}

Step 4 — run a lane and poll the job

POST /run returns {"job_id": "job_…"} immediately. Poll GET /jobs/{id} until status leaves running; the reply then carries output (the model's text), charged_credits and truncated.

Send an Idempotency-Key on every run, derived from the lane plus the input plus an attempt counter. A retry after a network blip must reuse the same key or you pay twice. The web app uses house-style:<lane>:<hash>:<attempt>.

KEY="house-style:enforce:$(printf '%%s' "$SAMPLES$GUIDE" | shasum | cut -c1-16):1"

JOB=$(curl -s -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

until [ "$(curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
        | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')" \
      != "running" ]; do sleep 1; done

curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"

Step 5 — stream it instead

POST /run-stream is the same call over SSE. Events are delta with {"text": "…"} while the reply generates and done with the whole output at the end. On an idempotent replay the server may answer with plain JSON instead of an event stream — check the content-type before you parse.

Because the reply is one JSON object, a stream that stops early leaves you with a truncated object. The web app walks the bracket stack, cuts back to the last complete value, drops a dangling key and closes the open containers, then reports how many sections survived. Do the same rather than discarding the partial reply.

curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json

# event: delta   data: {"text":"{\"lane\":\"enfor"}
# event: delta   data: {"text":"ce\",\"title\":\"For"}
# event: done    data: {"job_id":"job_...","charged_credits":1980,"output":"{...}"}

The output contract

One JSON object, the same envelope in every lane.

FieldTypeNotes
lanestringThe lane actually answered. Read it; do not assume.
titlestringUnder 80 characters.
verdictstringdiscover: distinct | emerging | generic | inconsistent. codify: ready | provisional | needs-input. enforce: on-voice | drifting | off-voice.
headlinestringOne sentence under 160 characters.
summarystringTwo to four sentences.
traits[]array{name, value, evidence, confidence}. evidence is a verbatim span of the input.
rules[]array{id, category, rule, do, dont}. category is voice, structure, mechanics, terminology, formatting or inclusion.
terms[]array{term, use, avoid, note}.
findings[]array{id, severity, rule_id, quote, why, rewrite}. severity is critical, high, medium or low; rule_id is unwritten when no rule covers it; rewrite is "" when the span should be deleted.
artifactstringMarkdown. The profile, the guide, or the draft rewritten in full.
artifact_jsonobjectvoice_profile, style_rules or voice_report, keyed by kind. Validate it before you feed it back in.
coverage_check[]arrayOne entry per prescan.flags[].id: confirmed, cleared or not-applicable.
questions[]arrayWhat would raise confidence.
confidencestringhigh, medium or low.

Every key is always present. An empty section is [], "" or {} — never absent, never null, never the string "none".

Worked example: task: "discover"

Four published pieces in, a voice profile out. rules is empty by contract in this lane.

Request body

{
  "task": "discover",
  "samples": "--- Landing page ---\nBookkeeping that stops at the awkward part.\n...\n--- Release note ---\nMulti-currency refunds now reconcile on their own.\n...",
  "guide": "",
  "audience": "small-business owners who do their own books",
  "channel": "general",
  "register": "auto",
  "notes": "",
  "prescan": {
    "samples": 4,
    "words": 398,
    "reading_grade": 5.5,
    "flags": [
      {
        "id": "long-sentences",
        "severity": "medium"
      },
      {
        "id": "oxford-comma",
        "severity": "low"
      }
    ]
  }
}

Reply (trimmed)

{
  "lane": "discover",
  "title": "Tidewell: plain, specific, and willing to name the limit",
  "verdict": "distinct",
  "headline": "This voice sells by naming what the product will not do.",
  "summary": "Across four samples Tidewell writes the same way...",
  "traits": [
    {
      "name": "Rhythm",
      "value": "Long sentence, then a short one that lands it.",
      "evidence": "It no longer gives up.",
      "confidence": "high"
    }
  ],
  "rules": [],
  "terms": [
    {
      "term": "ledger",
      "use": "ledger",
      "avoid": "books",
      "note": "landing page hero"
    }
  ],
  "findings": [],
  "artifact": "# Tidewell voice profile\n...",
  "artifact_json": {
    "kind": "voice_profile",
    "dimensions": [],
    "vocabulary": {
      "prefer": [],
      "avoid": []
    },
    "never": []
  },
  "coverage_check": [
    {
      "flag_id": "long-sentences",
      "status": "confirmed",
      "note": "One sentence at 32 words."
    },
    {
      "flag_id": "oxford-comma",
      "status": "confirmed",
      "note": "Two lists take it, two do not."
    }
  ],
  "questions": [
    "Is there a piece written by someone outside the founding team?"
  ],
  "confidence": "high"
}

Worked example: task: "codify"

The same copy plus the previous lane's digest in upstream, a guide out. Every artifact_json.rules[].id matches a rules[].id in the envelope.

Request body

{
  "task": "codify",
  "samples": "--- Product page ---\n## Northgate Analytics: Cutting-Edge Insights...\n...",
  "guide": "",
  "audience": "data teams at mid-sized companies",
  "channel": "general",
  "register": "neutral",
  "notes": "We are standardising on US spelling. Legal will not allow the word guarantee.",
  "prescan": {
    "samples": 5,
    "words": 236,
    "flags": [
      {
        "id": "heading-case",
        "severity": "medium"
      },
      {
        "id": "buzzwords",
        "severity": "high"
      }
    ]
  },
  "upstream": "# Handed over from the voice profile lane\n..."
}

Reply (trimmed)

{
  "lane": "codify",
  "verdict": "provisional",
  "rules": [
    {
      "id": "plain-verbs",
      "category": "terminology",
      "rule": "Use the plain verb. Never leverage, utilise, empower or unlock.",
      "do": "Northgate reads your data.",
      "dont": "Northgate empowers organisations to leverage their data."
    }
  ],
  "artifact_json": {
    "kind": "style_rules",
    "version": "1",
    "principles": [
      {
        "id": "plain-over-impressive",
        "title": "Plain over impressive",
        "statement": "Say the specific thing."
      }
    ],
    "rules": [
      {
        "id": "plain-verbs",
        "category": "terminology",
        "rule": "Use the plain verb.",
        "do": "reads",
        "dont": "leverages"
      }
    ],
    "terminology": [
      {
        "term": "use",
        "use": "use",
        "avoid": "leverage, utilise"
      }
    ],
    "mechanics": {
      "contractions": "Yes.",
      "oxford_comma": "Use it.",
      "headings": "Sentence case.",
      "numbers": "One to nine spelled out.",
      "dates": "4 March.",
      "person": "Second person.",
      "exclamations": "At most one per piece."
    }
  },
  "coverage_check": [
    {
      "flag_id": "heading-case",
      "status": "confirmed",
      "note": "sentence-case-headings"
    },
    {
      "flag_id": "buzzwords",
      "status": "confirmed",
      "note": "plain-verbs, no-superlatives"
    }
  ],
  "confidence": "medium"
}

Worked example: task: "enforce"

One draft plus the guide in, findings and a rewritten draft out. artifact_json.violations mirrors findings one for one.

Request body

{
  "task": "enforce",
  "samples": "## Introducing Tidewell Forecasting: Next-Generation Cash Visibility\n...",
  "guide": "{\"kind\":\"style_rules\",\"rules\":[{\"id\":\"plain-verbs\",\"rule\":\"Use the plain verb.\"}]}",
  "audience": "existing customers on the Growth plan",
  "channel": "web",
  "register": "auto",
  "notes": "",
  "prescan": {
    "samples": 1,
    "words": 120,
    "masked_values": 2,
    "flags": [
      {
        "id": "passive-heavy",
        "severity": "medium"
      },
      {
        "id": "buzzwords",
        "severity": "high"
      }
    ]
  }
}

Reply (trimmed)

{
  "lane": "enforce",
  "verdict": "off-voice",
  "headline": "Nine of the guide's rules are broken in 120 words.",
  "findings": [
    {
      "id": "effortless-1",
      "severity": "critical",
      "rule_id": "no-effortless",
      "quote": "It's completely effortless. Simply connect, and watch the magic happen.",
      "why": "The guide's no-effortless rule exists because the landing page refuses this word by name.",
      "rewrite": "Connecting your ledger takes about a minute."
    }
  ],
  "artifact": "## Tidewell now forecasts your cash\n...",
  "artifact_json": {
    "kind": "voice_report",
    "score": 22,
    "on_voice": false,
    "violations": [
      {
        "rule_id": "no-effortless",
        "severity": "critical",
        "quote": "It's completely effortless.",
        "rewrite": "Connecting your ledger takes about a minute."
      }
    ]
  },
  "coverage_check": [
    {
      "flag_id": "passive-heavy",
      "status": "confirmed",
      "note": "both rewritten"
    },
    {
      "flag_id": "buzzwords",
      "status": "confirmed",
      "note": "each term is a finding"
    }
  ],
  "confidence": "high"
}

Chaining the lanes

The lanes are meant to run in order over one sitting. The web app does this with buttons; from code it is two field copies:

  1. Run discover over the published copy. Keep artifact_json and artifact.
  2. Run codify over the same samples, with the discover reply's profile summarised into upstream. Keep artifact_json — it is the style_rules object.
  3. Run enforce with the new draft in samples and JSON.stringify(style_rules) in guide.

Validate the style_rules object before step 3: it must be an object with a non-empty rules array whose entries each carry a unique string id and a rule string. A guide that fails that check will still be accepted by the model as prose, which is worse than being rejected, because the reply's rule_id values will then be invented rather than yours.

Rate limits and cost

Reading the app's own contract

/llms.txt carries the same input and output contract in a form an agent can read, plus the full list of what the in-browser prescan measures. The prescan itself is /stylescan.js — it is a plain function library with no network and no DOM, so you can vendor it and produce a prescan object identical to the one the web app sends.