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:
| task | What you get | Derived from |
|---|---|---|
discover | The voice profile: dimensions with verbatim evidence, the vocabulary the writing reaches for and avoids, and what it never does. | @anthropics/discover-brand |
codify | The house style guide: principles, rules with worked do/not pairs, a terminology table, mechanics and a reviewer checklist. | @anthropics/guideline-generation |
enforce | One 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
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | discover, codify or enforce. |
samples | string | yes | The 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. |
guide | string | for enforce | The house style guide, as Markdown or as the style_rules JSON the codify lane returns. Ignored by the other lanes. |
audience | string | no | Who the writing is for. Under 200 characters. |
channel | string | no | general, web, product, docs, email, social, support. |
register | string | no | auto, formal, neutral, conversational, playful. |
notes | string | no | What the samples cannot say — a spelling decision, a word legal has ruled out. Under 1200 characters. |
prescan | object | no, but do | Your 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. |
upstream | string | no | The 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.
| Status | error.code | What it means |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not the shape the app expects. error.details names the field. |
| 401 | UNAUTHORIZED | Missing, expired or wrong-app token. Mint a new one. |
| 402 | INSUFFICIENT_CREDITS | The balance cannot cover the hold. Call /estimate first — it is free. |
| 404 | NOT_FOUND | Usually a job id that does not belong to this app, or a path segment that should not be there. |
| 429 | RATE_LIMITED | Back off. Do not tight-loop the poll. |
| 500 | INTERNAL | Retry 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"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from tokens.html, or POST /guest below
def call(method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html, or POST /guest below
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ? json.error.code : res.status);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func call(method, path string, body any) (map[string]any, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data map[string]any `json:"data"`
}
err = json.NewDecoder(res.Body).Decode(&env)
return env.Data, err
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.BodyPublisher body = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, body)
.build();
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil)
uri = URI(BASE.to_s + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $method, string $path, ?array $body = null) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out["data"] ?? null;
}
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Token);
async Task<JsonElement> Call(HttpMethod method, string path, object? body = null) {
var req = new HttpRequestMessage(method, Base + path);
if (body is not null) req.Content = JsonContent.Create(body);
var res = await http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("data");
}
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_..."}}
req = urllib.request.Request(BASE + "/guest",
data=json.dumps({"slug": "house-style"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "house-style" })
});
const TOKEN = (await res.json()).data.token;
b, _ := json.Marshal(map[string]string{"slug": "house-style"})
res, _ := http.Post(base+"/guest", "application/json", bytes.NewReader(b))
// read res.Body -> {"data":{"token":"aut_..."}}
String body = "{\"slug\":\"house-style\"}";
// POST BASE + "/guest" with Content-Type: application/json and no Authorization
// header; read data.token out of the reply.
uri = URI(BASE.to_s + "/guest")
res = Net::HTTP.post(uri, JSON.dump({ slug: "house-style" }),
"Content-Type" => "application/json")
TOKEN = JSON.parse(res.body)["data"]["token"]
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "house-style"]),
]);
$token = json_decode(curl_exec($ch), true)["data"]["token"];
var guest = await http.PostAsJsonAsync(Base + "/guest", new { slug = "house-style" });
var token = (await guest.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("data").GetProperty("token").GetString();
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"
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
me, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["credits"])
String me = call("GET", "/me", null);
System.out.println(me);
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"];
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("credits"));
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}}
payload = {
"task": "enforce",
"samples": "## Introducing Tidewell Forecasting...",
"guide": "{\"kind\":\"style_rules\",\"rules\":[...]}",
"audience": "existing customers on the Growth plan",
"channel": "web",
"register": "auto",
"notes": "",
"prescan": {"words": 120, "flags": [{"id": "buzzwords", "severity": "high"}]}
}
est = call("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const payload = {
task: "enforce",
samples: "## Introducing Tidewell Forecasting...",
guide: JSON.stringify(styleRules),
audience: "existing customers on the Growth plan",
channel: "web",
register: "auto",
notes: "",
prescan: { words: 120, flags: [{ id: "buzzwords", severity: "high" }] }
};
const est = await call("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
payload := map[string]any{
"task": "enforce",
"samples": "## Introducing Tidewell Forecasting...",
"guide": styleRulesJSON,
"audience": "existing customers on the Growth plan",
"channel": "web",
"register": "auto",
"prescan": map[string]any{"words": 120},
}
est, _ := call("POST", "/estimate", payload)
fmt.Println(est["model"], est["hold_credits"])
String payload = """
{"task":"enforce","samples":"## Introducing Tidewell Forecasting...",
"guide":"{}","audience":"customers","channel":"web","register":"auto",
"notes":"","prescan":{"words":120,"flags":[]}}
""";
System.out.println(call("POST", "/estimate", payload));
payload = {
task: "enforce",
samples: "## Introducing Tidewell Forecasting...",
guide: style_rules.to_json,
audience: "existing customers on the Growth plan",
channel: "web",
register: "auto",
notes: "",
prescan: { words: 120, flags: [] }
}
est = call("POST", "/estimate", payload)
puts est["model"], est["hold_credits"]
$payload = [
"task" => "enforce",
"samples" => "## Introducing Tidewell Forecasting...",
"guide" => json_encode($styleRules),
"audience" => "existing customers on the Growth plan",
"channel" => "web",
"register" => "auto",
"notes" => "",
"prescan" => ["words" => 120, "flags" => []],
];
$est = call("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"];
var payload = new {
task = "enforce",
samples = "## Introducing Tidewell Forecasting...",
guide = styleRulesJson,
audience = "existing customers on the Growth plan",
channel = "web",
register = "auto",
notes = "",
prescan = new { words = 120 }
};
var est = await Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine(est.GetProperty("hold_credits"));
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"
import time, hashlib
seed = payload["task"] + payload["samples"] + payload["guide"]
key = "house-style:%s:%s:1" % (payload["task"], hashlib.sha256(seed.encode()).hexdigest()[:16])
req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key) # a retry must reuse it
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] != "running":
break
time.sleep(1)
result = json.loads(job["output"])
print(result["verdict"], len(result["findings"]))
const key = `house-style:${payload.task}:${hash(payload)}:1`; // reuse on retry
const started = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const { data } = await started.json();
let job;
do {
await new Promise(r => setTimeout(r, 1000));
job = await call("GET", "/jobs/" + data.job_id);
} while (job.status === "running");
const result = JSON.parse(job.output);
console.log(result.verdict, result.findings.length);
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // same key on every retry
res, _ := http.DefaultClient.Do(req)
// read data.job_id, then GET /jobs/{id} until status != "running"
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
// read data.job_id, then poll GET /jobs/{id} until status != "running"
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("GET", "/jobs/#{job_id}")
break job if job["status"] != "running"
sleep 1
end
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
do {
sleep(1);
$job = call("GET", "/jobs/" . $jobId);
} while ($job["status"] === "running");
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = JsonContent.Create(payload)
};
req.Headers.Add("Idempotency-Key", key);
var started = await http.SendAsync(req);
var jobId = (await started.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
do {
await Task.Delay(1000);
job = await Call(HttpMethod.Get, "/jobs/" + jobId);
} while (job.GetProperty("status").GetString() == "running");
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":"{...}"}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf = ""
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data: "):
evt = json.loads(line[6:])
if "text" in evt:
buf += evt["text"]
elif "output" in evt:
buf = evt["output"]
result = json.loads(buf)
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
for (const line of buf.split("\n")) {
if (!line.startsWith("data: ")) continue;
const evt = JSON.parse(line.slice(6));
if (evt.text) out += evt.text;
if (evt.output) out = evt.output;
}
buf = buf.slice(buf.lastIndexOf("\n") + 1);
}
const result = JSON.parse(out);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data: ") {
// {"text": "..."} while generating, {"output": "..."} at the end
}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data: "))
.forEach(l -> System.out.println(l.substring(6)));
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
evt = JSON.parse(line[6..])
# evt["text"] while generating, evt["output"] at the end
end
end
end
end
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$evt = json_decode(substr($line, 6), true);
// $evt["text"] while generating, $evt["output"] at the end
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(payload)
};
req.Headers.Add("Idempotency-Key", key);
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is string line) {
if (line.StartsWith("data: ")) {
// {"text": "..."} while generating, {"output": "..."} at the end
}
}
The output contract
One JSON object, the same envelope in every lane.
| Field | Type | Notes |
|---|---|---|
lane | string | The lane actually answered. Read it; do not assume. |
title | string | Under 80 characters. |
verdict | string | discover: distinct | emerging | generic | inconsistent. codify: ready | provisional | needs-input. enforce: on-voice | drifting | off-voice. |
headline | string | One sentence under 160 characters. |
summary | string | Two 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. |
artifact | string | Markdown. The profile, the guide, or the draft rewritten in full. |
artifact_json | object | voice_profile, style_rules or voice_report, keyed by kind. Validate it before you feed it back in. |
coverage_check[] | array | One entry per prescan.flags[].id: confirmed, cleared or not-applicable. |
questions[] | array | What would raise confidence. |
confidence | string | high, 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:
- Run
discoverover the published copy. Keepartifact_jsonandartifact. - Run
codifyover the samesamples, with the discover reply's profile summarised intoupstream. Keepartifact_json— it is thestyle_rulesobject. - Run
enforcewith the new draft insamplesandJSON.stringify(style_rules)inguide.
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
/estimate,/meand/guestare free./runand/run-streamare metered at thegpt-terratier with a 1000 bps publisher markup. You are chargedcharged_credits, which is normally far belowhold_credits.- If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced output cap and the reply carries"truncated": true. Treat that as a partial answer, not a complete one. - On a 429, back off; the poll loop above sleeps a second between attempts for that reason.
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.