taleseal
v0.9.0
Published
Draft, edit and publish a tale — a page your AI writes at a shareable link. The taleseal CLI.
Downloads
9,460
Readme
taleseal
Your AI writes the page. You send the link.
A tale is a clean page of real blocks (words, tables, charts, diagrams, code, images) that your AI drafts at a private, unguessable link. Not a transcript, not a wall of chat: a page a human wants to read. You review it, ask for changes in plain words, and the same link updates. Publish, and the reader needs no account.
This CLI is how an AI (or you) drafts, edits and publishes one.
One command to a live page
npx -y taleseal@latest init claude # or: init codex · init cursor · bare initinit sets up your tool's taleseal plugin (marketplace install where the tool has a CLI,
printed steps where it does not) and publishes a live welcome page, no account needed. It
prints the page's link and a once-shown claim link that keeps the page beyond 24 hours. With
a stored key the page publishes straight to your account instead. Whatever happens to the
tool setup, the page still publishes: a failed plugin install prints the manual steps and
carries on.
The rest of the surface
npx taleseal draft tale.json # works with no account: publishes LIVE, anonymously,
# and prints a claim link (deleted in 24 h unclaimed)
npx taleseal login # sign in, once, to draft privately and keep pages
npx taleseal draft tale.json # with a key: create a DRAFT; get a private link to review
npx taleseal publish <tale-id> # publish it; send the same link ondraft scrubs the content and shows you an exposure report before anything leaves the machine.
With an API key the draft URL carries a DRAFT banner and renders exactly as the published page
will, so review happens in the browser, and publishing keeps the link stable: the recipient
always sees the latest published revision.
With no key at all (or --anon) there is no draft step: the page is live the moment it is
created, immutable, rendered with an unclaimed treatment (no branding, an "Unverified" sender,
outbound links as plain text), and hard-deleted after 24 hours unless the printed claim link is
used to keep it. Claiming is free, transfers the page to the claiming account, and restores
links and branding. A key that is set but empty is refused as a misconfiguration; it never
silently goes anonymous.
The flow
compose tale.json → validate → draft (gate + link) → review in the browser
→ publish → send → (revise → publish)* → retract
no account: compose → validate → draft (gate) → LIVE at once → claim within 24 h to keepEditing an existing draft is id-addressed and atomic: read the outline for the block ids and the
draftSeq, then send ops echoing that draftSeq as --base. A stale base is a conflict: nothing
applies and you re-base on the outline it hands back; a batch applies whole or not at all.
Login
Bare taleseal login opens the browser: approve there (signing up on the way if needed) and an
API key is created and stored in ~/.config/taleseal/config.json ($XDG_CONFIG_HOME respected,
file mode 0600). It works from SSH boxes and containers too: open the printed URL on any device
and match the confirmation code. Codes last ten minutes and work once.
For CI, scripts and browserless machines, taleseal login --key tk_… stores a key minted by hand
in the dashboard instead. It is validated against the server
first: a key rejected with 401 is never stored; if the server is unreachable the key is stored
anyway, with a warning. taleseal logout removes the stored key.
Usage
Compose and publish:
validate <tale.json> Check against the schema: each problem with its exact JSON path.
draft <tale.json> Redact, show the exposure gate, then create the tale as a DRAFT
and print its URL. With no key at all (or --anon): publish
LIVE anonymously: no draft step; deleted in 24 h unless claimed.
publish <tale-id> Promote the current draft to the revision the recipient sees.
revise <tale-id> <tale.json> Store a new whole-tale draft body; the recipient keeps seeing
the published revision until the next publish.
retract <tale-id> Destroy the tale and every revision. The URL answers 410 forever.
Read a draft:
pull <tale-id> [file] Write the current draft body to <file> (or stdout).
outline <tale-id> The draftSeq you echo as --base, plus one line per block.
get <tale-id> [blockId...] Full JSON of the named blocks (or all).
Edit a draft (id-addressed, atomic, optimistic concurrency):
ops <tale-id> <ops.json> Apply a JSON array of ops in one atomic batch.
insert <tale-id> <blocks.json> --after <id> | --before <id> | --start | --end
replace <tale-id> <blockId> <block.json>
remove <tale-id> <blockId...>
move <tale-id> <blockId> --after <id> | --before <id> | --start | --end
set-envelope <tale-id> --title … | --standfirst … | --recipient … | --stationery … |
--sender-name … | --sender-org … | --cta-label … | --cta-url … |
--expires-at … | --clear <fields>
Other:
upload <path> Upload an image and print the reference an "image" block carries.
login [--key tk_…] Sign in and store an API key. logout Remove it.
Options:
--base <n> The draftSeq from `outline`, echoed as an edit's concurrency token. Omit it and the
CLI reads the current draftSeq itself: convenient, but a concurrent edit would be
clobbered; pass --base to be safe.
--idem <key> Idempotency key: re-sending a batch with the same key never re-applies.
--anon Draft only: publish anonymously even when a key is available; live at once,
immutable, deleted after 24 hours unless claimed.
--yes Skip the pre-draft / pre-publish prompt (hooks, CI).
--json Print machine-readable results.
-h, --help Show this help.On success the tale URL is printed to stdout. Errors go to stderr with exit code 1.
The exposure gate
Secrets travel nowhere without a human seeing what would ship. When draft (or revise) is about
to send a body, it first scrubs it client-side (AWS keys, GitHub/Slack tokens, sk- API keys,
JWTs, Bearer headers, PEM private-key blocks and password=/secret:-style assignments become
[REDACTED:<kind>] markers) and prints an exposure report counting what it found. Then:
- Interactive terminal: the report is shown and you are asked to confirm. Anything but yes cancels; nothing leaves the machine.
- Not a TTY (git hooks, CI, piped output): sending is refused with exit code 1 unless
--yesis passed. Automation must opt in explicitly.
Configuration
Both values resolve in the same order everywhere: env var first, then the config file written by
taleseal login, then the default:
| Value | Resolution order |
| --- | --- |
| API key | TALESEAL_API_KEY env var → config "apiKey" |
| Base URL | TALESEAL_URL env var → config "url" → https://taleseal.com |
The config file is ~/.config/taleseal/config.json ($XDG_CONFIG_HOME/taleseal/config.json when
set), shape {"apiKey": "tk_…", "url"?: "https://…"}, mode 0600. Publish, revise and retract
require a key. draft with no key at all publishes anonymously (--anon forces it even with
one); a key that is set but empty is refused as a misconfiguration, never treated as anonymous.
validate needs none.
Branding
Colours and a logo are not a tale or CLI concern. Branding is a paid add-on the account owner configures once at taleseal.com/dashboard/brand, and every tale the account publishes picks it up automatically. Anonymous pages carry no branding while unclaimed; claiming one applies the claiming account's brand from then on.
Programmatic API
The same surface as the CLI, from the SDK:
import { applyTaleOps, composeTale, draftTale, getTaleOutline, publishTale } from "@taleseal/sdk";
// Compose a whole tale and create it as a draft (reads TALESEAL_API_KEY;
// TALESEAL_URL defaults to https://taleseal.com).
const tale = composeTale({ title: "Brightloop upgraded to Growth", blocks: [/* … */] });
const { id } = await draftTale(tale);
// Or edit an existing draft, block by block, atomically.
const { draftSeq } = await getTaleOutline(id);
await applyTaleOps(id, { baseRevision: draftSeq, ops: [/* … */] });
await publishTale(id); // promote the draft to the revision the recipient sees
// Or with no account at all: published live at once, deleted in 24 h unless claimed.
const anon = await draftTale(tale, { anonymous: true }); // anon.claimUrl, anon.claimExpiresAtRequires Node ≥ 22.
