Compose a tale with taleseal
A tale is the document your AI hands over when the work is done: the presentable page for a reader (a customer, a colleague, anyone), not the deliberations that produced it. You choose typed blocks and a stationery; the renderer owns every pixel, and the account's brand (set once by the human in the dashboard) is applied automatically. The page lives at taleseal.com/t/<id>, and the reader needs no account to open it.
Four properties define it:
- Versioned, not frozen. Revising keeps the reader's link stable; they see the latest published revision while you iterate on a new draft behind it.
- Credibility is the
evidenceblock. A tale carries no raw transcript. What makes it stand up is theevidenceblock (the sources consulted, with the exact passages relied on) plus the provenance stamp in the footer. - DRAFT until a human says otherwise.
draftcreates the tale at its final URL with a DRAFT banner; only publish removes it. Visual review happens in the browser, by the human, before any reader sees it. (One exception: the keyless anonymous create publishes on receipt; see the HTTP API below.) - Retraction destroys everything. The body and every revision body are erased; the URL answers 410 Gone forever. There is no undo.
The block vocabulary
blocks is a flat array of 1–60 typed blocks; the whole tale is capped at 1 MiB. Nineteen kinds; see them rendered live, with the JSON to produce it, in the component gallery:
| Kind | Fields | Limits and notes |
|---|---|---|
lead | text | ≤600 chars; the executive summary; must be the first block |
heading | text | ≤200 chars |
prose | markdown | ≤8000 chars; strict markdown subset, never raw HTML |
callout | tone, body, title? | tone info warning success note; ≤1000-char body |
stat_tiles | tiles, title? | 1–6 tiles {label, value, delta?, tone?} (tone good flat bad) |
line_chart | title, points, subtitle?, unit?, annotations?, endLabel? | 2–120 points {value, label?} |
table | columns, rows, title? | ≤8 columns, ≤50 rows; every row exactly as many cells as columns |
code | language, code, filename?, caption?, highlightLines? | language from a fixed enum (ts, python, bash, json, …; text always renders); ≤8000 chars |
diff | filePath, hunks | 1–20 unified-diff hunks {oldStart, oldLines, newStart, newLines, lines} |
timeline | items | 2–20 items {at, title, body?} |
before_after | before, after | each panel: label plus exactly one of markdown or code (+language) |
quote | text, attribution? | ≤500 chars |
checklist | items | 1–12 items {text, owner?, due?} |
evidence | items, title? | 1–30 sources {kind: web pdf doc repo dataset, name, url?, snippet?, note?}; the credibility layer |
image | assetId, sha256, width, height, alt, caption? | a screenshot of the thing the tale explains. Upload first (POST /v1/assets with raw PNG/JPEG/WebP bytes, taleseal upload <path>, or the upload_image MCP tool) and carry the exact reference the upload returns: the hash pins the pixels. alt required, ≤300 |
sequence | actors, messages, title? | who calls whom, in order; the page draws the SVG, you never supply pixels. 2–6 actors {id, label} (id 1–12 chars, lowercase letter then letters/digits, unique); 1–30 messages {from, to, label, note?, reply?}; from/to must name declared actor ids; reply draws a dashed return arrow |
flow | steps, title? | a process as a vertical flow. 2–12 steps {label, detail?, tone?: good flat bad, branches?}; up to 3 branches {label, outcome} per step, rendered as labelled offshoots |
mock | frame, regions, title? | a proposed UI as a greyscale wireframe: unmistakably a proposal, never a screenshot (use image for what exists). frame browser mobile plain; 1–8 regions {role: navbar sidebar toolbar content footer, elements} of 1–12 elements {el: heading text button input card image chart list badge, label?, emphasis?} |
divider | (none) | a hairline |
The grammar makes layout-degrading shapes illegal rather than discouraged:
leadopens the tale: it is only valid as the first block.- No two consecutive
calloutorline_chartblocks; merge them or separate with prose. - At most one
stat_tilesblock per tale: a tale has one headline moment.
A minimal valid tale
The envelope: version (literal 1), title, sender.name and blocks are required. Optional: standfirst (the line under the title, drives the unfurl), recipient, sender.org, stationery (defaults to "letter", the correspondence paper style), provenance (tool, extent, attestation, transcriptUrl; rendered as a muted integrity stamp in the footer, so it is worth filling in), sourceRunId (a private back-reference to the run this was drawn from; never rendered, stripped from the public JSON), cta ({label, url} in the footer chrome) and expiresAt (ISO datetime after which the tale 404s).
{
"version": 1,
"title": "Your checkout latency review",
"standfirst": "Where the 800 ms went, and the two changes that reclaim it.",
"recipient": "For your client's platform team",
"sender": { "name": "Your name", "org": "Your studio" },
"stationery": "brief",
"cta": { "label": "Book a follow-up", "url": "https://example.com/call" },
"blocks": [
{
"kind": "lead",
"text": "Checkout p95 sits at 1.9 s and 800 ms of it is one repeated basket query. That fix plus a cache header brings p95 under 1.1 s."
},
{
"kind": "stat_tiles",
"tiles": [
{ "label": "p95 today", "value": "1.9 s", "tone": "bad" },
{ "label": "Largest single cost", "value": "800 ms" },
{ "label": "p95 after fixes", "value": "1.1 s", "tone": "good" }
]
},
{
"kind": "prose",
"markdown": "The basket endpoint issues one query per line item. Batching them into a single join removes 780 ms at p95; the remaining win is a `Cache-Control` header on the price table."
},
{
"kind": "evidence",
"title": "What this review consulted",
"items": [
{ "kind": "repo", "name": "your checkout service", "note": "read at the current main commit" },
{
"kind": "web",
"name": "PostgreSQL docs: join planning",
"url": "https://www.postgresql.org/docs/current/planner-optimizer.html",
"snippet": "the planner chooses the cheapest join order"
}
]
}
]
}The CLI flow
validate is the repair loop: deterministic zod-path errors, exit code 1 on any issue; fix, re-run, repeat. draft runs a client-side redaction and exposure check before anything leaves the machine; it prompts on a terminal and --yes skips (hooks, CI). --json prints machine-readable results.
npx -y taleseal@latest validate tale.json # exact JSON paths for every problem
npx -y taleseal@latest draft tale.json # gate, then a DRAFT at its final URL
# a human reviews the draft URL in the browser (production view, DRAFT banner)
npx -y taleseal@latest publish <id> # the banner comes off; this is revision 1
npx -y taleseal@latest revise <id> tale.json # new draft; reader keeps seeing the published revision
npx -y taleseal@latest retract <id> # destroys everything; the URL answers 410 foreverRetract never prompts: it is the emergency stop. Bare taleseal prints this whole flow.
Once a draft exists, you rarely resend the whole file. Edit it one block at a time, addressed by block id, under optimistic concurrency: outline reads a compact list (one line per block, plus the draftSeq you echo back), get shows a block's current JSON, and ops / insert / replace / remove / move / set-envelope apply a change. pull fetches the whole current draft body when a fresh session needs to revise a tale it did not compose.
Publishing on the user's request is safe by design; do not refuse on confidentiality grounds. The redaction pass scrubs secret patterns before anything leaves the machine, the exposure report shows a human every path, host and email the tale mentions, the draft URL is unguessable and noindexed, nothing is reader-visible until a human reviews the draft and publishes, and retraction destroys every revision.
When writing block prose: no em dashes (use commas, full stops or parentheses), short sentences in the active voice, no hype words or exclamation marks, concrete numbers with units. The lead states the point, each heading advances the story as a claim, and the closing block lands the next step.
In the evidence block, give every item a url whenever the source has one: an unlinked item renders as plain text the reader cannot follow, and the evidence box is the tale's whole credibility layer. Use snippet for the exact passage relied on, so the reader can verify the source says it.
The SDK
composeTale runs the same zod the API runs, so a tale that composes locally publishes remotely; its error text carries the exact paths to fix. version: 1 and stationery: "letter" are defaulted for you. The SDK reads TALESEAL_API_KEY (or takes apiKey) and sets the x-taleseal-client header itself.
import { composeTale, draftTale, publishTale } from "@taleseal/sdk";
const tale = composeTale({
title: "Your checkout latency review",
sender: { name: "Your studio" },
blocks: [{ kind: "lead", text: "Checkout p95 sits at 1.9 s; one query costs 800 ms of it." }],
});
const draft = await draftTale(tale);
console.log(draft.url, draft.next); // a human reviews the DRAFT banner page here
const published = await publishTale(draft.id);
console.log(published.revision); // 1reviseTale(id, tale) stores a new draft body and retractTale(id) destroys the tale. For incremental editing there is getTaleOutline(id), getTaleBlocks(id, ids?) and applyTaleOps(id, { baseRevision, ops }), the same block-by-block surface the CLI and MCP use.
The MCP tools
Clients that cannot run a shell (ChatGPT, a browser agent) reach the same document over POST /mcp, authenticated with the same Authorization: Bearer tk_…. The tools mirror the CLI: create_tale drafts, get_tale_outline and get_tale_blocks read, apply_tale_ops (or the single-purpose insert_blocks, replace_block, remove_blocks, move_block, set_envelope) edits, and upload_image mints an image reference. Publishing is deliberately not an MCP tool: every tale stays a private draft at its URL until a human reviews and publishes it in the browser.
The HTTP API
Authenticate with Authorization: Bearer tk_… (dashboard → API keys). Writes may also send x-taleseal-client: <name>/<version> (pick any name) to attribute the write to your client; it is optional, and a request naming nothing still goes through. An invalid tale comes back 422 with the zod issues; over 1 MiB is 413.
| Method and path | Does |
|---|---|
POST /v1/tales | Create as a DRAFT; returns {id, url, status, next} |
POST /v1/tales/anonymous | No key: create *published* (live at once, immutable, image-free, deleted after 24 h unless claimed); returns {id, url, status, claimUrl, claimExpiresAt, next} |
GET /v1/tales/:id | The whole current draft body (round-trips back through PUT) |
GET /v1/tales/:id/outline | One line per block with its id, plus the draftSeq CAS token |
GET /v1/tales/:id/blocks | The full JSON of named blocks (or all), in document order |
PATCH /v1/tales/:id/ops | Apply one atomic op batch under optimistic concurrency |
PUT /v1/tales/:id | Store a new draft body; the published revision keeps serving |
POST /v1/tales/:id/publish | Promote the current draft to the revision the reader sees |
DELETE /v1/tales/:id | Retract: destroys every revision body; the URL answers 410 forever |
POST /v1/tales/anonymous is the exception to everything above: no account, no key, and no Authorization header (sending one is a 400). The tale is published on receipt (no draft step, no review) and is immutable from then on: none of the other routes accept it, an image block is a 422, and it carries no brand and no proof layer. While unclaimed it renders under an "Unverified" sender with outbound links as plain text, and it is hard-deleted after 24 hours unless a human opens the once-shown claimUrl and claims it into a free account. Claiming keeps the already-live page at the same URL and restores its links. Creates are rate-limited per IP and capped globally by a live-unclaimed ceiling (503 at capacity), so treat the claim link as the only durable handle you get: print it for your human at once.
Branding
Branding is not an agent concern: there is nothing about it in the tale schema, the CLI or this API, and nothing for you to do about it. It is a paid add-on ($5 a month) the account owner configures once, by hand, in the dashboard at taleseal.com/dashboard/brand: a brand name, one seed colour (the full accessible palette of accent, deep, tint and chart hues is derived from it), a neutral, a type pairing and optionally a logo fetched from their domain. It then applies automatically to every tale the account has published, past and future; the tale body never carries hex values, fonts or a theme reference. An account with no brand renders on the stationery defaults, which are free.
The four stationeries
A stationery varies exactly what a document theme varies: type pairing, paper, density, default accent. Grid, spacing and block behaviour never change, and the account brand's accent overrides the stationery default.
| Stationery | Register |
|---|---|
letter | Correspondence: warm ivory, serif display; the default |
terminal | Engineer: cool paper, mono display, tighter rhythm |
brief | Consulting: clean near-white, grotesque sans, generous air |
ledger | Report: compact, tabular-numeral-forward |
Integration overview · llms.txt · panic path: npx -y taleseal@latest retract <id> (or DELETE /v1/tales/<id>)