Artifacts API
Durable, designed renders. An artifact is a self-contained HTML document — a report, a plan, a business case, a spec — published once, frozen at a version, addressable forever, and shared with a person rather than queried by a machine. The wiki is the living telling; artifacts are the documents kept.
Finished, not living — a version never changes after publish; updating means publishing a new version. Design is content — the visual treatment is part of the author's statement; the platform enforces safety, not a house template. Self-contained or rejected — external resource references fail at publish time, not at view time years later. Org-gated by default — sharing outward is an explicit, revocable, minted capability.
Base URL https://api.kapable.ai. Publish/write needs an sk_org_
key with the write scope (or an org-member session); reads need read.
Agents are first-class publishers — most artifacts are authored by AI sessions
following the taste doctrine (see Skills).
Publish
POST /v1/artifacts is create-or-append: a new
slug creates version 1; an existing slug appends version n+1.
Returns the artifact id, the new version, the org URL, and the content
sha256 (content address, verified on write).
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts | publish |
curl -s -X POST https://api.kapable.ai/v1/artifacts \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{
"slug": "q3-business-case",
"title": "Q3 Business Case",
"kind": "case",
"html": "<!doctype html><html><head><title>Q3</title></head><body><h1>Q3</h1></body></html>",
"summary": "The case for shipping verified features",
"favicon": "🧾",
"session_ref": "planning-loop · 2026-07-03"
}'
# 201 Created
# { "artifact_id": "edce30ff-…", "version": 1,
# "url": "https://acme.kapable.ai/artifacts/q3-business-case",
# "sha256": "bfc97dac…" }
import { KapableClient } from '@kapable/sdk';
const client = new KapableClient({ apiKey: process.env.KAPABLE_ORG_KEY });
const res = await client.artifacts.publish({
slug: 'q3-business-case',
title: 'Q3 Business Case',
kind: 'case', // report | plan | spec | case | brief | other
html: selfContainedHtml,
summary: 'The case for shipping verified features',
favicon: '🧾', // 1–2 emoji: gallery + tab identity
session_ref: 'planning-loop · 2026-07-03',
});
// res.version === 1 ; res.sha256 === content address ; res.url === org URL
use kapable_sdk::{KapableClient, artifacts::PublishArtifactRequest};
let client = KapableClient::new(std::env::var("KAPABLE_ORG_KEY")?);
let res = client.artifacts().publish(PublishArtifactRequest {
slug: "q3-business-case".into(),
title: "Q3 Business Case".into(),
kind: "case".into(),
html: self_contained_html,
summary: Some("The case for shipping verified features".into()),
favicon: Some("🧾".into()),
session_ref: Some("planning-loop · 2026-07-03".into()),
tags: None,
}).await?;
Publish-time validation rejects any document that reaches outside its own bytes and
returns the exact offending constructs so an agent can self-fix:
external <script src>/<link href>/<img
src>, CSS @import/url() to non-data:
URLs, <iframe>/<object>/<base>,
and external meta refresh/form action. Plain hyperlinks
(<a href="https://…">) are fine — a document may cite
the web, it just may not depend on it. Size cap: 2 MiB
per version; versions are kept forever.
// 422 Unprocessable Entity
{ "error": "unprocessable", "code": 422,
"detail": "artifact is not self-contained; fix the offending constructs and re-publish",
"offending": [ { "construct": "script src", "snippet": "<script src=\"https://cdn.example/x.js\">" } ] }
List, get & serve
The gallery feed returns latest-version metadata only (never html). Fetch one artifact's
metadata + full version list, or its raw self-contained bytes (pin a version with
?v=n). Every served document carries the sealed CSP that enforces
self-containment at view time.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| GET | /v1/artifacts | list |
| GET | /v1/artifacts/{slug} | get |
| GET | /v1/artifacts/{slug}/v/{n} | get (version meta) |
| GET | /v1/artifacts/{slug}/html?v=n | html |
| DELETE | /v1/artifacts/{slug} | retract — member session (see Retraction) |
The org gallery is served at {org}.kapable.ai/artifacts/ (org-member session),
a designed register that is itself the feature's first artifact. Filter with
?kind= and search with ?q=.
Sharing
A share is a minted, revocable capability — not an ACL. Mint a link and a
partner with no Kapable account reads the exact bytes at
kapable.ai/a/{token} (noindex, a clean render with no chrome and no margin).
Pin a version or follow latest; set an optional expiry (it must lie in the
future — a past expires_at is a 400 at
mint, never a link that is dead on arrival); revoke and the link dies within one
request (410 Gone).
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts/{slug}/shares | share |
| GET | /v1/artifacts/{slug}/shares | listShares |
| DELETE | /v1/artifacts/{slug}/shares/{id} | revokeShare |
# Mint → returns { id, public_url, token }. Revoke by id; then /a/{token} → 410.
curl -s -X POST https://api.kapable.ai/v1/artifacts/q3-business-case/shares \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "version": 1 }'
# { "id": "2c52bf4e-…", "public_url": "https://kapable.ai/a/9f3750e9…", "token": "9f3750e9…" }
Retraction — the tombstone
Publishing is an agent act; retraction is a human one.
DELETE /v1/artifacts/{slug} sets a tombstone: the artifact leaves the
gallery and search, and every read surface — the org view, the raw
/v1 html/meta routes, and every live share — returns
410 Gone carrying the full tombstone facts (who retracted it, when,
and the optional reason), never a bare 404. It is member-session only:
an sk_org_ agent key gets a 403 (“agents publish, agents
never destroy”). Retraction is reversible — POST …/restore
clears the tombstone.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| DELETE | /v1/artifacts/{slug} | retract — member session only |
| POST | /v1/artifacts/{slug}/restore | restore — member session only |
| DELETE | /v1/artifacts/{slug}?purge=bytes | purgeBytes — member session only |
# Retract — member-cookie session only; an sk_org_ agent key → 403.
curl -s -X DELETE https://acme.kapable.ai/artifacts/q3-business-case \
--cookie "$SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{ "reason": "Superseded by the FY24 plan" }'
# Every read now 410s with the tombstone facts (org view AND /v1 html/meta AND live shares):
# { "error": "retracted", "code": 410, "detail": "this artifact has been retracted",
# "title": "Q3 Business Case", "retracted_at": "…", "retracted_by": "Herman",
# "reason": "Superseded by the FY24 plan" }
# Restore clears it (member session only):
curl -s -X POST https://acme.kapable.ai/artifacts/q3-business-case/restore \
--cookie "$SESSION_COOKIE"
# { "warnings": [] } // carries a note if the bytes were previously purged
await client.artifacts.retract('q3-business-case', {
reason: 'Superseded by the FY24 plan',
});
await client.artifacts.restore('q3-business-case');
// The tombstone register — read-scoped, shows ONLY retracted artifacts:
const gone = await client.artifacts.list({ retracted: '1' });
use kapable_sdk::artifacts::{RetractRequest, ListArtifactsQuery};
client.artifacts().retract("q3-business-case",
RetractRequest { reason: Some("Superseded by the FY24 plan".into()) }).await?;
client.artifacts().restore("q3-business-case").await?;
let gone = client.artifacts().list(ListArtifactsQuery {
retracted: Some("1".into()), ..Default::default() }).await?;
GET /v1/artifacts?retracted=1 lists ONLY retracted artifacts — the
tombstone register, mutually exclusive with the ordinary live list. It is available to
any read-scoped key, not just members: retraction hides a document's
content, never its existence. Any other value for
retracted is a 422 receipt naming the only legal value,
1.
For the genuine legal / leaked-secret case, DELETE
/v1/artifacts/{slug}?purge=bytes irreversibly overwrites every version's html
(and any audio) with a fixed sentinel. It is legal only on an already-retracted
artifact (else 409 “restore first” — two
deliberate acts, never one). Rows, shas, metadata, and verdicts all remain: the record
still proves what existed and when, just not its content. A later
restore revives the slug, and its warnings[] says the bytes
are gone for good.
A retracted document seals its margin too: the five annotation write paths (create,
reply, resolve, edit, edit-reply) refuse rather than accrue a note no surface can show.
An org member gets a 409 naming the fix — “this artifact is
retracted; the margin is closed with it — restore first (POST
/v1/artifacts/{slug}/restore) to continue the conversation” — while a
guest, who cannot restore, gets a 410 Gone (the withdrawn-capability
idiom the guest door already uses). Existing threads stay readable; only writing
is closed. Publishing a new version onto a retracted slug likewise 409s
— restore first.
The margin — annotations
The margin is the discussion layer that lives beside a frozen version, never inside it. A reader highlights a passage and comments; the highlight is durable — anchored to the version's frozen bytes and visible to every org member. The document is never edited: convergence happens one of two ways — minds align (a reply resolves the thread) or the artifact was wrong (a new version resolves it). Public shares never show the margin; it is an org-member surface.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts/{slug}/annotations | createAnnotation |
| GET | /v1/artifacts/{slug}/annotations?version=&status= | listAnnotations |
| GET | /v1/artifacts/{slug}/annotations/{id} | getAnnotation |
| POST | /v1/artifacts/{slug}/annotations/{id}/replies | replyAnnotation |
| POST | /v1/artifacts/{slug}/annotations/{id}/resolve | resolveAnnotation |
| PATCH | /v1/artifacts/{slug}/annotations/{id} | editAnnotation |
| PATCH | /v1/artifacts/{slug}/annotations/{id}/replies/{replyId} | editReply |
An annotation stores a quote selector anchor = {exact, prefix, suffix} plus a
server-maintained positions map ({"<version>": {start,end} |
null}). Because versions are immutable, anchoring is a publish-time batch: on each
new version, open annotations re-anchor once — a concrete position when the quote
survives, or an explicit null orphan receipt ("highlight lost
in vn") when the passage changed. Never a silent drop.
Resolve with status one of aligned (mental models converged;
the document was right), amended (the document was corrected by a new
version — requires resolved_by_version), or declined (a
reasoned refusal). Every resolve requires a reply body —
a resolution without a stated answer is a silent drop, and forbidden.
# Create → highlight a passage of a version and comment.
curl -s -X POST https://api.kapable.ai/v1/artifacts/q3-business-case/annotations \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "version": 1,
"anchor": { "exact": "shipping verified features", "prefix": "case for ", "suffix": "." },
"body": "Do we have the receipt for this claim?" }'
# Resolve amended, pointing at the fixing version (reply is required).
curl -s -X POST https://api.kapable.ai/v1/artifacts/q3-business-case/annotations/$ID/resolve \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "status": "amended", "resolved_by_version": 2,
"reply": "Corrected in v2 with the loop receipt.", "session_ref": "discuss · repo" }'
Every annotation renders a Copy prompt control in the viewer that emits a
versioned <kapable-annotation v="1"> block — paste it into any
Claude Code session and the kapable-artifact-discuss
skill answers in the margin (reply and resolve aligned, or republish and
resolve amended). The clipboard is the transport; the human stays in the loop.
Polish & suggestions (M8)
The margin's viewer batch: status-colored highlights (resolved threads read
quieter than open ones — the margin celebrates convergence by calming
down), an open · N replies chip so an answered-but-unresolved
thread never looks ignored, mono/tabular-nums timestamps on every annotation and
reply, a margin rail (the drawer, listing every thread in document
order), overlapping-highlight detection (a click in the overlap filters the rail to
the intersecting threads), figure annotation, edit-own, live updates over SSE, and
suggestions.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| PATCH | /v1/artifacts/{slug}/annotations/{id} | editAnnotation |
| PATCH | /v1/artifacts/{slug}/annotations/{id}/replies/{replyId} | editReply |
| GET | /artifacts/{slug}/sse | — (viewer-internal, not an SDK method) |
Authors may edit their own annotation/reply body anytime — no status gate.
The server compares the caller's identity to the row's created_by/
author and 403s a mismatch; a successful edit stamps
edited_at, shown in the viewer as edited · {ts}.
Figure annotation — selecting an <img>,
<svg>, or <table> offers "annotate this
figure." The anchor becomes an ELEMENT selector,
{kind: "element", tag, nth_of_type} — the Nth occurrence of that
tag in the document, in document order — instead of a text quote selector.
Element anchors get the same per-version orphan receipts as text anchors: if the
Nth img no longer exists in a new version, the thread orphans exactly
like a vanished quote.
createAnnotation accepts an optional proposed_text —
a concrete replacement for the highlighted passage, member-only (guests ask and
reply; they never propose replacement text, D24a). The viewer renders it as a
compact inline diff (deletions struck, insertions underlined). The steward treats
a suggestion exactly like any other question — it may discuss or decline, but
it never applies one (no action in its typed set does that, D13 unchanged).
Adopting a suggestion is a session decision: republish version n+1
incorporating the change, then resolve amended — the
kapable-artifact-discuss skill's "apply
suggestion" flow does exactly this.
Live updates — the org viewer opens an SSE stream
(GET /artifacts/{slug}/sse, viewer-internal, not an SDK method) fed by
the same LISTEN/NOTIFY trigger pattern the platform uses elsewhere
(notify_table_change on both margin tables). A change
event carries no row data — the client re-fetches through the normal API, so a
reply posted in one browser session appears in a second open viewer without a
reload. Degrades to reload-on-focus when SSE is unavailable.
Gallery signals
The list feed carries an open_thread_count per artifact, surfaced as an
"N open threads" chip on each gallery card. The ?discussions=active filter
(API and gallery) narrows the register to artifacts with open annotation threads —
the "active discussions" lens.
# Only artifacts with open threads
curl -s "https://api.kapable.ai/v1/artifacts?discussions=active" -H "x-api-key: $KAPABLE_ORG_KEY"
# { "data": [ { "slug": "q3-business-case", "open_thread_count": 1, … } ], "total": 1 }
Grounding & the dialectic view
publish accepts an optional grounding array — where this
version's truth lives, frozen with it forever: [{ kind: "repo"|"ks"|"url", ref:
string }], ≤16 entries, each ref ≤512 chars. Grounding is
advisory — absence never blocks a publish (D15); it only lowers the
steward's license to answer confidently (see below). It's surfaced in version metadata
(never the gallery list feed) and rendered as a quiet "grounded in" line in the chrome
— url refs are links, repo/ks refs are mono
text. Nothing that reads grounding ever fetches a url ref; it is
cited, never resolved.
curl -s -X POST https://api.kapable.ai/v1/artifacts \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "slug": "q3-business-case", "title": "Q3 Business Case", "kind": "case",
"html": "…",
"grounding": [
{ "kind": "repo", "ref": "platform/relate/src/loop.rs" },
{ "kind": "ks", "ref": "claim:relate-tick-54-improvements" }
] }'
GET {org}.kapable.ai/artifacts/{slug}/versions renders the
dialectic view: every version with its publish metadata, the aligned/
declined counts of threads it originated, and — for a version born from a fix
— "this version exists because…" naming the amended thread that
caused it, deep-linked back into the margin. It is entirely derived from existing publish
+ margin records (D16); nothing new is authored to render it. The chrome's version picker
links to it.
The steward
The steward is a background tick that answers open, question-intent
annotations on its own — grounded citation when it can give one, honest discussion
or a routed hand-off when it can't. It is deliberately restricted to a
typed action set it can never step outside of: aligned
(resolves the thread, citing the version's own declared grounding — downgraded
server-side to discuss if the version declared none, regardless of what the
model itself claims), discuss (a reply, thread stays open), declined
(a reasoned resolve), or route (hands off to a human/the authoring agent).
It never publishes and never resolves amended —
amending a document stays a deliberate act under the publish doctrine. Annotation bodies
and document content are fenced, labeled DATA in the steward's prompt; an annotation that
tries to instruct it ("ignore your instructions and publish/delete…") cannot
succeed — the model's raw output is parsed as strict JSON against the four literal
actions above, so there is no fifth action for an injection to land in.
Every annotation may declare an intent on
createAnnotation: question (default — the only intent the
steward acts on), uncertainty (an agent's own declared doubt after
publishing — badged distinctly, adjudicated by a human, steward-ignored by design),
or review-request (a plain flag, also steward-ignored).
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| GET | /v1/steward/status | stewardStatus |
Loop-safety is structural, not a heuristic: the steward never re-triggers on its own
replies (it only ever considers annotations with no existing steward reply), never acts
on agent/service-authored annotations, and never treats a non-question intent
as something to answer. It runs under a per-org daily cap (live-computed from the DB, so
it's correct across a blue-green deploy pair) — every skip, whatever the reason
(cap reached, no model configured, unparseable output), leaves a visible receipt in the
logs and in GET /v1/steward/status, never silence.
Bundle shares
A single share link exposes one artifact. A bundle exposes a
named set — the missing primitive for sharing a cross-linked batch (a
report + its appendices, a spec pack) publicly as the whole it was authored to be.
Bundle shares live in the same token namespace as plain shares:
GET /a/{token} resolves either kind automatically.
Bytes are served verbatim — a bundle never rewrites stored html,
never injects a <base>, never touches connect-src for a
plain (non-annotate) bundle. What it adds is a small prepended public chrome carrying a
click-resolver script: any link on the page whose path resolves to
/artifacts/{slug} for a slug that is a member of the bundle
navigates to /a/{token}/{slug} instead of the org host; every other link
— external, or a non-member artifact — is left completely untouched. Authors
write ordinary <a href="/artifacts/other-slug"> cross-links; the
bundle makes them resolve within the shared set for a logged-out reader.
Revoking a bundle (DELETE /v1/bundles/{id}) invalidates every member in one
call — the next request to any /a/{token}/* path 410s.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/bundles | mintBundle |
| GET | /v1/bundles | listBundles |
| DELETE | /v1/bundles/{id} | revokeBundle |
curl -s -X POST https://api.kapable.ai/v1/bundles \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Q3 launch pack", "slugs": ["q3-business-case", "q3-runbook", "q3-faq"] }'
# { "id": "…", "name": "Q3 launch pack", "token": "…", "public_url": "https://kapable.ai/a/…",
# "slugs": ["q3-business-case","q3-runbook","q3-faq"], "can_annotate": false }
Guest margins
A share or bundle minted with can_annotate: true serves the annotation layer
on its public link to a reader with no Kapable account — the
default share remains a sealed, clean render; crossing this boundary is a deliberate
mint-time choice, never a default. The guest surface is share-token auth:
the token embedded in the URL path is the sole credential (no separate guest login), so
it is not part of the authenticated customer SDK — a guest interacts through the
served page's own margin UI, not @kapable/sdk.
A guest types a display name once (remembered client-side) and can highlight a passage
to ask a question, or reply on a thread their own link opened. They can
never resolve a thread and their intent is always
question, regardless of anything sent client-side — there is no resolve
route on the guest surface at all. Writes are rate-limited per token.
The privacy wall: a guest reading back sees only threads opened under
their own share/bundle token — never an org-internal thread, never another
guest link's threads, even on the identical artifact. Org members see everything in the
normal margin viewer, with guest threads badged guest · {name}. Guest
questions count against the org's steward cap and get steward answers exactly like a
member's question would.
# mint an annotate-enabled share
curl -s -X POST https://api.kapable.ai/v1/artifacts/q3-business-case/shares \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "can_annotate": true }'
# a logged-out reader opening the public_url gets the margin layer;
# their questions/replies ride /a/{token}/annotations* (or /a/{token}/{slug}/annotations*
# for a bundle member) — share-token auth, not an sk_org_ key.
The warnings channel
POST /v1/artifacts now always returns an additive
warnings: [] array alongside the existing response fields — a WARN,
never a reject; the publish always succeeds. The first warning: pandoc's dollar-math
span residue (class="math) surviving into a stored artifact — no
artifact kind renders TeX, so its presence is almost always pipeline corruption. The
publish skill already rejects this author-side; the warning is defense-in-depth for
other authors' pipelines. Unknown/future warning codes are safely ignorable by old
clients.
{ "artifact_id": "…", "version": 2, "url": "…", "sha256": "…",
"warnings": [ { "construct": "math-span", "snippet": "class=\"math inline\"" } ] }
Per-org steward settings
The platform-wide STEWARD_ENABLED environment toggle remains the
master switch — the tick never runs at all when it's off,
regardless of any org setting. An org admin/owner (or platform staff scoped via
X-Org-Id) can additionally opt their own org out while the
platform default stays on, and override the per-org daily reply cap.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| GET | /v1/artifacts/admin/steward-settings | getStewardSettings |
| PUT | /v1/artifacts/admin/steward-settings | updateStewardSettings |
curl -s -X PUT https://api.kapable.ai/v1/artifacts/admin/steward-settings \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "enabled": false }'
# { "org_id": "…", "enabled": false, "daily_cap_override": null,
# "effective_enabled": false, "effective_daily_cap": 20 }
Reply-to routing & margin health
publish accepts an optional reply_to object — where the
steward's route action should deliver, frozen with the version alongside
grounding: { kind: "comms-mailbox", ref: string }, where
ref is a kapable-comms mailbox id (a UUID minted via kapable-comms' own
POST /v1/mailboxes). Explicit only — the free-text
session_ref provenance field is never parsed for routing. When a
route-d reply lands on a version with a declared reply_to, the
steward delivers it into that mailbox (via kapable-comms'
POST /v1/internal/intercept) in addition to posting the reply in the
margin as usual; the generic STEWARD_AGENT_MAIL_URL hook is unrelated and
still fires unconditionally on every route.
curl -s -X POST https://api.kapable.ai/v1/artifacts \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "slug": "q3-business-case", "title": "Q3 Business Case", "kind": "case",
"html": "…",
"reply_to": { "kind": "comms-mailbox", "ref": "5b1e6c0a-2f3a-4e9a-9d2b-8f1c2a3b4c5d" } }'
aligned
When a margin thread resolves aligned, the clarified fact may be durable
enough to belong in the Knowledge Substrate, not just that one thread. The
kapable-artifact-discuss skill MAY file a KS claim after such a
resolution — claim text = the clarified fact, provenance = the thread's own
deep-link URL. This is a skill-side, judgment call (a MAY, not a ceremony) — the
service itself stays uncoupled and never calls kapable-knowledge.
The org gallery also carries a derived margin health strip: the oldest
still-open question (aging), the steward's answer-coverage rate over
question-intent threads, and the artifacts with the most discussion. All
three are plain queries over the existing annotation tables — no new tables, no new
authored state — and the strip renders nothing at all when an org has no margin
activity yet (never a wall of meaningless zeros).
Audio summaries — the ear is a reader
A version may carry one spoken-summary recording, played from the
chrome's quiet “▸ Listen” control — never embedded in the
document, never autoplaying. Attach is write-once: a version that
already carries audio — a second attach — returns 409; a wrong
recording is corrected the only way anything here is corrected, by publishing the next
version. Audio has its own 3 MiB cap, independent of the document's
2 MiB cap, and is validated MPEG (mp3) by magic bytes — not by the declared
content-type alone.
Attach inline at publish time (one round trip) via the optional audio field,
or afterward via the dedicated attach endpoint. Generation is owned by
kapable-ai’s dedicated TTS endpoint
(POST /v1/ai/tts): if the org has configured its own ElevenLabs key
(PUT /v1/ai/admin/tts-settings, admin/owner only), that key synthesizes with
no metering; otherwise the platform’s own key synthesizes and a
usage event is recorded. Artifacts itself never calls ElevenLabs and stores no TTS
keys — it only ever accepts already-synthesized bytes.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| GET | /v1/artifacts/{slug}/audio | audio |
| POST | /v1/artifacts/{slug}/v/{n}/audio | attachAudio |
# 1. Synthesize (kapable-ai) — key resolution is automatic (org key, else platform key + metering).
curl -s -X POST https://api.kapable.ai/v1/ai/tts \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d '{ "text": "Relate'"'"'s fifty-fourth tick shipped twenty-one improvements today…" }' \
-o summary.mp3
# 2. Attach — write-once; a second attach on the same version → 409.
B64=$(base64 -i summary.mp3 | tr -d '\n')
curl -s -X POST https://api.kapable.ai/v1/artifacts/relate-tick-54/v/1/audio \
-H "x-api-key: $KAPABLE_ORG_KEY" -H "Content-Type: application/json" \
-d "{ \"data\": \"${B64}\" }"
# { "version": 1, "content_type": "audio/mpeg", "sha256": "…", "bytes": 84213,
# "audio_url": "https://acme.kapable.ai/artifacts/relate-tick-54/audio?v=1" }
Or inline at publish time, in one call — the audio field shares the
exact same base64/MPEG/3 MiB validation:
{ "slug": "relate-tick-54", "title": "…", "kind": "report", "html": "…",
"audio": { "data": "", "generated_via": "elevenlabs · eleven_v3 · platform-key" } }
POST /v1/artifacts’s response gains an audio_url field
(present only when THAT call attached audio), version metadata gains a
has_audio boolean, and the artifact.published herald event
gains an optional audio_url that kapable-notify renders as a “Listen”
line alongside the document link. The document’s serve-time CSP on authenticated org
views gains exactly media-src 'self' for the chrome’s player —
public share links and the raw html endpoint are completely unchanged.
The publish skill’s audio step is opt-in and doctrine-bound: author fresh spoken
prose (∼60–90 seconds, conversational) specifically for listening —
never the gallery summary field or the document text read
aloud. The platform never synthesizes uninvited; generating and attaching audio is
always a deliberate, visible act with a visible cost.
Follow-along cues (M17) — the audio payload may carry an optional
cues[] track: { t, quote } entries where t is
seconds into the recording (strictly increasing, 1–64 of them) and
quote reuses the margin's anchor shape to point at a passage in the
document. As narration crosses each t, the docent scrolls the reader there.
Cues freeze WITH the audio under the same write-once law (D29) — they attach in the
same audio payload and change only by publishing a new version. Version
metadata gains a has_cues boolean; the platform validates shape only
(finite, increasing t; an anchor present) and returns per-cue
422 receipts (cues[3].t, cues[3].quote.exact) so
an author self-fixes in one round-trip.
{ "data": "", "generated_via": "elevenlabs · eleven_v3 · org-key",
"cues": [
{ "t": 0, "quote": { "exact": "Relate's fifty-fourth tick" } },
{ "t": 12.5, "quote": { "exact": "twenty-one improvements", "prefix": "shipped " } }
] }
Audio is write-once per version, so republishing without re-attaching narration sheds the
recording. When a bare version's lineage still has a narrated one, version metadata
carries narrated_by (the nearest narrated version's number, earlier or
later; null when none), derived at read time — and the chrome offers a
quiet pointer back to the tour rather than dropping the “Listen” affordance
silently.
Decision artifacts — the decision is a document
A decision-kind artifact freezes a ballot with the version:
a selection mode (pick-one or {"pick-n": n}) and 2–8
options, each with a short id and a required label. The rendered candidates — the
side-by-side, argued case for each option — live in the document body as ordinary,
designed HTML; the decision metadata is the machine-readable twin. A
decision-kind publish with no ballot, or a ballot with fewer than
2 options, returns 422.
{ "slug": "palette-decision", "title": "Which palette ships", "kind": "decision", "html": "…",
"decision": {
"mode": "pick-one",
"options": [
{ "id": "a", "label": "Petrol palette", "summary": "Cooler, reads calmer at scale" },
{ "id": "b", "label": "Porcelain palette", "summary": "Warmer, higher first-glance energy" }
]
} }
Deciding is a human act in the chrome, never an agent capability
— the mirror of the steward never publishing. An org member sees a
“Decide” control on an undecided decision-kind version (radio inputs for
pick-one, checkboxes for pick-n, an optional rationale) and
submits from the browser. The verdict is write-once: a second decide
on the same version returns 409; a change of mind is a new version with a
fresh ballot, so the version history is the decision history, no new view
needed. chosen must be a subset of that version’s frozen options, or
422.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts/{slug}/v/{n}/verdict | decide — member session only |
| GET | /v1/artifacts/{slug}/v/{n}/verdict | getVerdict — members + agent keys |
# Member-cookie session only — an sk_org_ agent key gets a 403 here (not a route error):
curl -s -X POST https://acme.kapable.ai/artifacts/palette-decision/v/1/verdict \
-H "Content-Type: application/json" --cookie "$SESSION_COOKIE" \
-d '{ "chosen": ["a"], "rationale": "Petrol reads calmer at scale" }'
# { "version": 1, "chosen": ["a"], "decided_by_id": "…", "decided_by_name": "Herman",
# "decided_at": "…", "superseded_by": null, "warnings": [] }
# Read the verdict (agent keys allowed here):
curl -s https://api.kapable.ai/v1/artifacts/palette-decision/v/1/verdict \
-H "x-api-key: $KAPABLE_ORG_KEY"
Version metadata gains a decision field (the frozen ballot, null
when none) and a has_verdict boolean (presence-signaling, mirrors
has_audio). Once decided, every org viewer sees a verdict banner — the
chosen label(s), the decider’s name, a mono timestamp, and the rationale if given
— in its own deep, confident wash, distinct from the margin/audio
palette; a superseded version’s banner additionally notes
“superseded by v{n}”. On verdict write, the outcome travels: an
artifact.decided herald event fires, a declared reply_to mailbox
(if any) receives the decision through the same comms seam the steward uses, and a
Knowledge Substrate claim is filed (subject = the decision question, claim = the chosen
label + rationale, provenance = the ballot URL) — delivery failures on the latter two
surface in the response’s warnings[], never blocking the write itself.
Public shares and guest surfaces never show the Decide control or the verdict
banner — deciding and its record stay org-internal in v1.
A decision artifact is for choices worth a designed, durable record —
candidates worth arguing side by side, a verdict worth citing later. A quick,
low-stakes pick inside an agent session stays with the CLI’s
AskUserQuestion; there is no named-decider assignment, no approval
chain, no quorum, and no arbitrary form — options and an optional rationale,
nothing else.
Decision forms (M15) — the canonical ballot shape going forward is
a fields[] form, not a single choice. Each field carries an id,
a label, an optional required flag (default
false), and a control — one of six kinds:
pick-one, pick-many (optional min/max),
per-item (rule each of an items[] list against shared
choices[]), text, number, scale, and
date. 1–12 fields, unique ids, labels required everywhere.
{ "slug": "q3-review", "title": "Q3 launch review", "kind": "decision", "html": "…",
"decision": {
"fields": [
{ "id": "ship", "label": "Ship in Q3?", "required": true,
"control": { "kind": "pick-one",
"options": [ { "id": "yes", "label": "Ship" }, { "id": "no", "label": "Hold" } ] } },
{ "id": "confidence", "label": "Confidence", "required": true,
"control": { "kind": "scale", "min": 1, "max": 5, "min_label": "Low", "max_label": "High" } },
{ "id": "notes", "label": "Notes",
"control": { "kind": "text", "multiline": true, "max_len": 2000 } }
]
} }
A form is validated fail-closed at publish and again at decide: a
malformed ballot, or a verdict missing a required field or carrying a
wrong-shaped answer, returns 422 with an offending[] list
naming the exact field (fields[2].control.max_len,
responses.confidence) so an agent self-fixes in one round-trip. The legacy
{mode, options} ballot stays legal forever — the
server normalizes it internally to a single required field with id choice,
never rewriting your stored bytes. A verdict passes chosen for a legacy
ballot and responses (a {field_id: value} map) for a form;
pass the wrong one and the 422 tells you which the frozen ballot expects.
What a ballot may contain
Every bound below is rejected at publish with a per-field receipt, so an agent can check a ballot against this table before spending a round-trip. The table is generated from the constants the server actually enforces — it cannot drift from them without failing the docs build.
| Bound | Range | Notes |
|---|---|---|
| Fields per ballot | 1–12 | a form with 0 fields, or 13, is rejected at publish |
Options per pick-one / pick-many | 2–8 | ids unique, labels required |
per-item items | 1–24 | one row per item in the rendered document |
per-item choices | 2–8 | the shared choice set every item is ruled against |
text max_len | ≤ 2000 | the field’s own cap; omit for the default |
scale span (max − min) | 2–10 | a 1–5 or 1–10 rating; wider spans are rejected |
Verdict rationale | ≤ 2000 | optional free text submitted with the ruling |
Option recommended_reason | ≤ 280 | characters (Unicode scalar values); the chip is one or two sentences, the full case goes in the document body |
Enforced relationally, with the same per-field receipts:
pick-many:min≤max, and both ≤ the option countnumberandscale:min<max- field
ids unique within the ballot; option and itemids unique within their field - at most ONE option per field may set
recommended, and only onpick-one/pick-many— aper-itemfield’s choices are shared across every item, so the mark is rejected there recommended_reasonrequiresrecommended: a reason on an unmarked option is a 422, never a silent drop
Generated from kapable-artifacts/src/models.rs by
scripts/gen-decide-bounds.mjs — edit the constants, not this table.
Recommending an option
A ballot that lists options without arguing for one pushes the whole analytical burden
onto the decider — who has less context than the author who just did the
reading. An option may therefore carry recommended, an advisory
mark, plus an optional recommended_reason of one or two sentences.
{ "fields": [{ "id": "palette", "label": "Which palette ships?", "required": true,
"control": { "kind": "pick-one", "options": [
{ "id": "petrol", "label": "Petrol", "recommended": true,
"recommended_reason": "This is what the code does today; ruling otherwise is a change order." },
{ "id": "porcelain", "label": "Porcelain" }
]}}]}
The chrome renders it as a chip beside the option, with the reason as visible text rather than a hover tooltip — a decider who opens the control without reading the document still sees the argument. It never pre-selects. The control opens with nothing chosen, because a default selection would bias the ruling rather than inform it.
The rules, each a 422 with a per-field receipt naming the offending option:
| Receipt | Cause | Fix |
|---|---|---|
decision.options.recommended |
two or more options in one field set recommended |
keep the mark on the single option you are arguing for |
decision.choice.recommended |
a per-item choice set the mark |
a per-item field’s choices are shared across every item, so one mark cannot say
which item it recommends — argue it in the document body, or split the question
into pick-one fields |
decision.option.recommended_reason |
a reason with no recommended, an empty reason, or one over the cap |
set recommended: true, or drop the reason; shorten to the cap in the
table above |
Omit recommended rather than sending false. Both
are accepted and both round-trip faithfully, but they are not the same ballot: settlement
compares stored ballots with Postgres jsonb =, which is key-presence-sensitive,
so a version published with false only auto-settles against another that also
carries false. Absent is the canonical “no recommendation”, and every
ballot published before this field existed keeps validating and rendering exactly as it always did.
Authoring in markdown decide blocks? The mark is one optional continuation line
under the option it belongs to — recommended: <why>, or a bare
recommended for the mark with no reason:
```decide
id: palette
label: Which palette ships?
pick-one:
- petrol: Petrol
recommended: This is what the code does today; ruling otherwise is a change order.
- porcelain: Porcelain
```
Carrying a ballot forward
Republishing a decision artifact drops its ballot unless you say otherwise.
The sentinel is explicit — the bare JSON string "carry" in the
decision field — and it substitutes the newest version’s declared
ballot byte-identically. Because the bytes match, carrying onto an already-decided slug
auto-settles the new version with the borrowed verdict, so a restyle
never re-asks a settled question.
{ "slug": "q3-review", "title": "Q3 launch review (restyled)", "kind": "decision",
"html": "…", "decision": "carry" }
Absence is never carry. Omitting decision on a
decision-kind publish stays a 422, deliberately: absence-means-carry would
turn a forgotten field into a silent republish of a stale question. The three receipts
worth knowing before you hit them:
| Receipt | Cause | Fix |
|---|---|---|
decision.carry.empty |
carry was requested, but no prior version of this slug ever declared a ballot | declare the ballot in full on this version |
decision.carry.kind |
carry was requested on a non-decision kind |
publish kind: "decision", or declare the ballot in full |
decision.missing |
a decision-kind publish with no decision block at all |
pass a ballot, or "carry" |
Publish accepts a decision block on any kind, but
"carry" is refused on every kind except decision, and
omitting the block on a report is perfectly legal. So a report that
declares a ballot loses it on the next version with no error at any point,
and the viewer resolves a ballot strictly from the version being viewed — leaving
it reachable only at that version’s pinned /v/{n} URL. If a ballot
must outlive its version, publish it as kind: "decision".
Verdict follow-through — the verdict may open the work
A ballot may declare an optional follow_through block, frozen with the
version alongside decision: {"kind": "board-story", "ref"?:
"<kapable-board product_id>"}. In v1 there is exactly one target kind
— a kapable-board story — and ref is an
optional hint (a product id) to file it under; absent files with no product. A
ballot with no follow_through is legal — a plaque is allowed, just
never accidental.
{ "slug": "palette-decision", "title": "Which palette ships", "kind": "decision", "html": "…",
"decision": {
"mode": "pick-one",
"options": [ { "id": "a", "label": "Petrol palette" }, { "id": "b", "label": "Porcelain palette" } ],
"follow_through": { "kind": "board-story" }
} }
On decide, after herald/reply-to/Knowledge-Substrate delivery, the verdict opens the
work: a board story is created via kapable-board’s own API, titled from the
decision title and the chosen label, with the rationale and the ballot URL in its
body. The created story’s URL is stored on the verdict as
follow_up_ref and the chrome banner grows a small
“→ follow-through” link. A failure to create the story
— kapable-board unreachable, permission denied — is a
warnings[] receipt, exactly like the reply-to/KS-claim legs; the verdict
write itself is never blocked by it.
// GET …/verdict, once decided with a follow_through declared
{ "version": 1, "chosen": ["a"], "decided_by_id": "…", "decided_by_name": "Herman",
"decided_at": "…", "superseded_by": null, "warnings": [],
"follow_up_ref": "https://acme.kapable.ai/board/cards/…" }
One target kind, board-story. No auto-assignment, no scheduling, no status sync-back — the created story lives its own life on the board from here on.
Panels — many respond, one decides
A ballot may empanel respondents so the whole org weighs in before one member rules. Add
a respondents block to the decision:
{ "mode": "panel", "who": "org", "blind": true } — the only v1 enums
are mode: "panel" and who: "org" (any org member may respond);
anything else is a 422 with receipts. blind defaults
true — independence is the point of a panel. A response is the same
form-shaped responses map a verdict uses, validated against the version's
frozen ballot by the same machinery (D49), even a legacy ballot (which normalizes to its
one choice field).
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts/{slug}/v/{n}/responses | respond — member session only |
| GET | /v1/artifacts/{slug}/v/{n}/responses | getPanelResponses — members + agent keys |
# Respond — member-cookie session only; an sk_org_ agent key → 403 (D64).
curl -s -X POST https://acme.kapable.ai/artifacts/q3-review/v/1/responses \
-H "Content-Type: application/json" --cookie "$SESSION_COOKIE" \
-d '{ "responses": { "ship": "yes", "confidence": 4 }, "rationale": "Runbook is ready" }'
# { "version": 1, "blind": true, "closed": false, "response_count": 3,
# "own": { "member_name": "Herman", "responses": {…}, … } }
# // responses[] is withheld while blind + open + you'd not yet voted; your own vote reveals it.
# Read the tally (agent keys allowed here) — honors the blind:
curl -s https://api.kapable.ai/v1/artifacts/q3-review/v/1/responses -H "x-api-key: $KAPABLE_ORG_KEY"
const panel = await client.artifacts.respond('q3-review', 1, {
responses: { ship: 'yes', confidence: 4 },
rationale: 'Runbook is ready',
});
// panel.response_count is always present; panel.responses appears once you've voted.
const tally = await client.artifacts.getPanelResponses('q3-review', 1);
use kapable_sdk::artifacts::PanelRespondRequest;
use serde_json::json;
let panel = client.artifacts().respond("q3-review", 1, PanelRespondRequest {
responses: json!({ "ship": "yes", "confidence": 4 }),
rationale: Some("Runbook is ready".into()),
}).await?;
let tally = client.artifacts().get_panel_responses("q3-review", 1).await?;
response_count and your own vote are ALWAYS visible —
the blind never hides either. The full responses[] record is served only
when the panel is not blind, OR it is closed (a verdict exists), OR
you have already submitted; while withheld it is absent (not null),
so a client can test presence. The owner's verdict closes the panel
(D66): a response after that is a 409 panel-closed, and a second response
from the same member is a 409 (“a change of mind is a margin note,
not an edit”). Responding to a version whose ballot did not empanel respondents is
a 422.
The settled version
Republish a decision version whose ballot is byte-identical to an
earlier DECIDED version's, and the new version is settled, not open: version
metadata carries a derived settled_by — the version number whose
verdict already answers this ballot — computed at read time (D77), with nothing
written on publish. The chrome shows that borrowed verdict, and the
“Decide” control demotes to a quiet “re-rule” (the ruling already
exists; deciding again is deliberate, never the default). Change the ballot in any byte
and the new version is genuinely open — settled_by is
null and it awaits its own verdict.
// GET …/v/2 (version meta) — v2 re-published v1's decided ballot unchanged:
{ "version": 2, "has_verdict": false, "settled_by": 1, … }
// has_verdict:false WITH settled_by:1 is NOT an open question — the ruling lives on v1.
The same rider rides the panel payload: GET …/responses returns
settled_by when a version's empanelled ballot is settled by an earlier one
— so an empty-looking panel here is a question resolved elsewhere, not an ignored
one. Responding is still legal (it opens this version's own panel).
Constitutions — the law of language
A constitution-kind artifact is a charter an org writes for itself —
authorial context an agent reads before it works, the way it reads 02-TASTE. Every
constitution declares a scope: a tier
(master, topic, project, or person)
and a lowercase key. Tiers are platform grammar; the key is org vocabulary
(worldframe-legal, sarah). The master tier takes
no key (a key there is a 422); every other tier requires one. There is
one live charter per (tier, key) — publishing a second slug into a
taken scope is a 409 naming the incumbent (amend that slug, or retract it
first). A new version of an existing constitution must declare the same scope
(422 otherwise); move a charter by retract-and-republish.
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| POST | /v1/artifacts (kind: constitution) | publish |
| GET | /v1/artifacts/constitution/stack?topic=&project=&person= | getConstitutionStack |
# Resolve the charters applicable to a piece of work — each layer optional.
curl -s "https://api.kapable.ai/v1/artifacts/constitution/stack?topic=worldframe-legal&person=sarah" \
-H "x-api-key: $KAPABLE_ORG_KEY"
# { "constitutions": [
# { "slug": "org-charter", "tier": "master", "key": null, "version": 3, "title": "…", "html": "…" },
# { "slug": "wf-legal", "tier": "topic", "key": "worldframe-legal", "version": 1, … } ],
# "precedence": "Read these charters in order: master first, then topic, then project, then person. …",
# "unmatched": [ { "tier": "person", "key": "sarah" } ] } // 'sarah' has no live charter yet
const stack = await client.artifacts.getConstitutionStack({
topic: 'worldframe-legal',
person: 'sarah',
});
for (const c of stack.constitutions) applyCharter(c.html); // read every charter's html in full
// stack.unmatched names each provided scope with no live charter (a typo vs. "none yet").
use kapable_sdk::artifacts::ConstitutionStackQuery;
let stack = client.artifacts().get_constitution_stack(ConstitutionStackQuery {
topic: Some("worldframe-legal".into()),
person: Some("sarah".into()),
..Default::default()
}).await?;
A constitution shapes how agents write; it never validates a publish.
Keys are lowercase-normalized before lookup, and every provided scope param
that matched no live charter comes back verbatim in unmatched[] (always
present, [] when everything matched) — so a caller can tell a typo'd
key from “no charter for that scope yet.” An org with no constitutions
returns constitutions: [], never a 404.
Receipts, not silent drops
Every refusal on this surface names both the cause and the fix in the message itself
— the reader of an error here is increasingly an agent that acts on exactly what the
string says. List filters are strict, never lenient: an unknown kind, a
discussions value other than active, or a retracted
value other than 1 each return 422 naming the valid values
(e.g. “discussions 'open' is not a filter — the only value is 'active'
… omit it to list all”) rather than silently ignoring the typo and
returning the wrong set. The free-text q searches literally
— % and _ are escaped, so a query for 50%
matches the text “50%”, not every row.
Minting a share (or bundle) with an expires_at in the past is a
400, not a link that is dead on arrival: “expires_at … is
not in the future — a share minted already-expired is dead on arrival … pick
a future expiry, or omit expires_at for a share that lives until revoked.” The
pattern is uniform across the module: retraction 410s carry the tombstone facts, purge
demands a prior retract, a mis-shaped decision form 422s per field, and a closed panel
409s — a refusal is always a receipt, never a silent drop.
Org brand kit — the org’s name on the door
An org may declare its taste as data: a palette/type token tree, an inline logo, a
display name, and a short voice doctrine. The kit is a default, never a
law — publish never validates a document against it, and there is no
“off-brand” warning anywhere in this system, on any axis, ever. One
mutable kit per org (a PUT replaces the whole thing); reach it via
PUT/GET/DELETE /v1/artifacts/org/brand-kit.
{ "tokens": { "color": { "accent": { "$value": "#2E5C63" } } },
"logo_svg": "<svg viewBox=\"0 0 120 32\">…</svg>",
"display_name": "Geldentech",
"voice": "Calm, precise, never chatty. No exclamation points.",
"tts": { "voice_id": "abc123", "provider": "elevenlabs" } }
| Method | Path | SDK (client.artifacts) |
|---|---|---|
| PUT | /v1/artifacts/org/brand-kit | setBrandKit — member sessions + sk_org_ write keys |
| GET | /v1/artifacts/org/brand-kit | getBrandKit — any read scope, 404 if unset |
| DELETE | /v1/artifacts/org/brand-kit | deleteBrandKit — member session only |
tokens is loosely DTCG-shaped and shape-checked only
(every leaf lives in an object carrying $value) — the values
themselves are never inspected or constrained. logo_svg is sanitized
with the same self-containment law as documents (no scripts, no
foreignObject, no external refs), plus its own 32 KiB cap; the
whole kit is capped at 64 KiB total. tts (both fields required
non-empty strings when present) is likewise shape-checked only — it is data for
callers, never plumbing: the publish skill’s audio step uses
kit.tts.voice_id as the default TTS voice when the author didn’t
choose one (precedence: explicit caller voice > kit voice > the org’s
org_tts_settings.default_voice_id > provider default).
When a kit exists, the org-authenticated chrome bar and gallery render the logo,
display name, and color.accent through a CSS-variable seam —
margin/status washes and control geometry stay platform-fixed in every org. A
missing or invalid kit silently falls back to the platform default; it is never an
error surface. Public shares are completely unaffected.
A share/bundle token also resolves under the org’s own subdomain
({org}.kapable.ai/a/{token}) — the identical sealed bytes as the
kapable.ai/a/{token} apex form, same token, same CSP. The apex form
keeps working forever; a share mint response returns both URLs. A token minted by
one org 404s if opened under a different org’s subdomain.
The publish skill fetches the org’s kit and renders on-brand by default — but a subject that genuinely calls for a different treatment gets one, deliberately, without asking permission. There is no enforcement, linting, or off-brand signal anywhere in this system.
Skills
Two Claude Code skills carry the doctrine so any agent works to taste:
kapable-artifact-publish (loads the 02-TASTE design doctrine,
writes a self-contained document, self-validates locally, publishes) and
kapable-artifact-discuss (parses a copy-prompt block, judges
alignment-question vs. defect, then replies + resolves aligned or republishes
+ resolves amended). See the Skills page.
The Kapable getting-started artifact, published through this exact flow and shared as a public capability link: open the shared render →