---
title: Compose a tale with taleseal
description: A tale is the page your AI hands over: typed blocks in, a designed branded page out. Validate locally, draft at the final URL, a human reviews, then publish. Versioned, revisable, retractable.
canonical_url: https://taleseal.com/integrate/tales
last_updated: 2026-07-20
---

# 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 `evidence` block.** A tale carries no raw transcript. What makes it
  stand up is the `evidence` block (the sources consulted, with the exact passages relied
  on) plus the provenance stamp in the footer.
- **DRAFT until a human says otherwise.** `draft` creates 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](/integrate/tales/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:

- `lead` opens the tale: it is only valid as the first block.
- No two consecutive `callout` or `line_chart` blocks; merge them or separate with prose.
- At most **one** `stat_tiles` block 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).

```json
{
  "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.

```sh
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 forever
```

Retract 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.

```ts
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); // 1
```

`reviseTale(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](https://taleseal.com/dashboard)).
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](https://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](https://taleseal.com/integrate.md) · [llms.txt](https://taleseal.com/llms.txt) ·
panic path: `npx -y taleseal@latest retract <id>` (or `DELETE /v1/tales/<id>`)
