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.

Four properties define it

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

MethodPathSDK (client.artifacts)
POST/v1/artifactspublish
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?;
Self-contained or rejected (422)

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.

MethodPathSDK (client.artifacts)
GET/v1/artifactslist
GET/v1/artifacts/{slug}get
GET/v1/artifacts/{slug}/v/{n}get (version meta)
GET/v1/artifacts/{slug}/html?v=nhtml
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).

MethodPathSDK (client.artifacts)
POST/v1/artifacts/{slug}/sharesshare
GET/v1/artifacts/{slug}/shareslistShares
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.

MethodPathSDK (client.artifacts)
DELETE/v1/artifacts/{slug}retract — member session only
POST/v1/artifacts/{slug}/restorerestore — member session only
DELETE/v1/artifacts/{slug}?purge=bytespurgeBytes — 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?;
The retracted register (D55)

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.

Purge is the escalation, not the default (D57)

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.

MethodPathSDK (client.artifacts)
POST/v1/artifacts/{slug}/annotationscreateAnnotation
GET/v1/artifacts/{slug}/annotations?version=&status=listAnnotations
GET/v1/artifacts/{slug}/annotations/{id}getAnnotation
POST/v1/artifacts/{slug}/annotations/{id}/repliesreplyAnnotation
POST/v1/artifacts/{slug}/annotations/{id}/resolveresolveAnnotation
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.

Resolution is a taxonomy (D12)

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.

MethodPathSDK (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)
Edit-own (D23)

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.

Suggestions (D24)

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.

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

MethodPathSDK (client.artifacts)
GET/v1/steward/statusstewardStatus

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.

MethodPathSDK (client.artifacts)
POST/v1/bundlesmintBundle
GET/v1/bundleslistBundles
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.

MethodPathSDK (client.artifacts)
GET/v1/artifacts/admin/steward-settingsgetStewardSettings
PUT/v1/artifacts/admin/steward-settingsupdateStewardSettings
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" } }'
Knowledge capture on 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.

MethodPathSDK (client.artifacts)
GET/v1/artifacts/{slug}/audioaudio
POST/v1/artifacts/{slug}/v/{n}/audioattachAudio
# 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.

Written for the ear, not read from the page

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.

MethodPathSDK (client.artifacts)
POST/v1/artifacts/{slug}/v/{n}/verdictdecide — member session only
GET/v1/artifacts/{slug}/v/{n}/verdictgetVerdict — 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.

Not every choice needs a document

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 } }
    ]
  } }
Whole-form, fail-closed, per-field receipts (D47–D52)

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.

BoundRangeNotes
Fields per ballot1–12a form with 0 fields, or 13, is rejected at publish
Options per pick-one / pick-many2–8ids unique, labels required
per-item items1–24one row per item in the rendered document
per-item choices2–8the shared choice set every item is ruled against
text max_len≤ 2000the field’s own cap; omit for the default
scale span (max − min)2–10a 1–5 or 1–10 rating; wider spans are rejected
Verdict rationale≤ 2000optional free text submitted with the ruling
Option recommended_reason≤ 280characters (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:

Generated from kapable-artifacts/src/models.rs by scripts/gen-decide-bounds.mjs — edit the constants, not this table.

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:

ReceiptCauseFix
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:

ReceiptCauseFix
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"
A ballot on a non-decision kind cannot survive a republish

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/…" }
Boundaries (v1)

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

MethodPathSDK (client.artifacts)
POST/v1/artifacts/{slug}/v/{n}/responsesrespond — member session only
GET/v1/artifacts/{slug}/v/{n}/responsesgetPanelResponses — 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?;
The blind, and closing the panel (D65/D66)

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

MethodPathSDK (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?;
Advisory, never enforcement (D69)

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" } }
MethodPathSDK (client.artifacts)
PUT/v1/artifacts/org/brand-kitsetBrandKit — member sessions + sk_org_ write keys
GET/v1/artifacts/org/brand-kitgetBrandKit — any read scope, 404 if unset
DELETE/v1/artifacts/org/brand-kitdeleteBrandKit — 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 kit shapes taste, it never polices it

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.

Worked example

The Kapable getting-started artifact, published through this exact flow and shared as a public capability link: open the shared render →