Dock dock.build · v0.3
Artifact Publish Protocol · draft 0

A standard small enough to implement in a weekend.

This page is the executable spec for the four things every implementation must agree on: identity & URLs, revisions, comments, and the agent surface. Where a real standard already exists we adopt it instead of inventing — W3C Web Annotation for anchors, OAuth device flow for CLI auth, SSE for realtime. Boring on purpose.

Conformance

Four levels, so anyone can join the protocol

A tool doesn't need the whole loop to be a citizen. Levels let a static host, a CLI, or a full server all interoperate — and give us a story where other vendors implement Level 0 long before they implement Level 3.

LEVEL 0PublishPOST /v1/artifacts returns a stable URL + short_id. Content served faithfully.≈ Netlify Drop could do this
LEVEL 1+ RevisionsImmutable version chain, @vN deep links, restore, machine-readable version list.≈ Gist-grade history
LEVEL 2+ CommentsAnchored threads readable & writable via API, with base_version semantics.the loop becomes possible
LEVEL 3+ Agent surfaceMCP tools, content read-back as Markdown, SSE events, machine principals.Dock reference impl

Identity & URLs

One artifact, one short_id, forever

Identityshort_id

An artifact's permanent identity is its short_id: 6–10 chars, base36, generated server-side, never reused. Slugs are cosmetic and mutable; the short_id is what agents store and what comments reference. URL grammar: https://{org}.{host}/{short_id}[-{slug}][@v{n}]. Anything after short_id- is ignored for routing, so renames never break links.

Content addressingsha-256

Every uploaded file is stored under its SHA-256. Two identical publishes share one blob; integrity checks and dedup come free. A version is a pointer (artifact, n) → blob_key, never a copy.

Artifact kindsfile · bundle

Two kinds, one rule: if it's static, it just works. A file artifact is a single text/html or text/markdown document. A bundle artifact is any folder/zip of static assets — an Astro or Vite build, a Next.js export, Docusaurus, mdBook, an Observable export. index.html is the entry; nested paths serve with correct MIME types; a 200.html (or spa: true on publish) enables client-side routing fallback. Markdown is always also served raw at {url}.md — humans get the rendered page, machines get the source.

Bundle versioning is whole-bundle: a version snapshots the entire tree (content-addressed per file, so unchanged assets dedupe across versions). Per-version asset URLs are immutable — which is also what makes cross-artifact imports safe.

Wire format

The five calls that matter

OpEndpointNotes
POST/v1/artifactsMultipart: a file or a zipped bundle + JSON meta (title, visibility, slug?, spa?). Returns artifact object below.
POST/v1/artifacts/:short_id/versionsNew version. Body includes message and optional resolves: [comment_ids] — republish and resolve in one call.
GET/v1/artifacts/:short_idAccept: application/json → metadata + version list. Accept: text/markdown → content read-back (HTML is converted; MD is raw).
GET/v1/artifacts/:short_id/comments?state=openThreads with anchors, quotes, and base_version.
POST/v1/artifacts/:short_id/commentsCreate comment or reply (thread_id present = reply). Agents and humans use the same endpoint.

The artifact object every call returns or accepts — this is the whole shared vocabulary:

// 201 from POST /v1/artifacts { "short_id": "8f3kx9", "url": "https://acme.dock.build/8f3kx9-q1-review", "title": "Q1 Voice of Customer", "visibility": "org", // public | link | org | password "current_version": 1, "versions": [{ "n": 1, "sha256": "ab12…", "author": "agent:claude-code", "message": "first publish", "created_at": "2026-06-11T…" }], "open_comment_count": 0 }

Comments

Anchoring: adopt W3C Web Annotation, don't invent

The hard problem in commenting on documents that get republished is where the comment sticks. The W3C Web Annotation Data Model solved this a decade ago (it's what Hypothes.is runs on). We use its selector vocabulary verbatim, so existing annotation tooling understands our comments for free.

// a comment, on the wire { "id": "c_01h…", "thread_id": "t_01h…", "base_version": 1, // what the author saw "author": "user:jess@acme.com", "body_md": "Use median, not mean.", "state": "open", // open | resolved | orphaned "target": { "selector": [ { "type": "TextQuoteSelector", // primary: survives edits "exact": "mean sentiment 3.1", "prefix": "Q1 trended down to ", "suffix": " across channels" }, { "type": "CssSelector", // fast path for HTML "value": "#sentiment-chart" } ] } }
Re-anchoring on republishdeterministic, no ML

When v2 lands: (1) try CssSelector exact match; (2) try TextQuoteSelector exact text; (3) fuzzy-match quote with context (normalized Levenshtein ≥ 0.8 within prefix/suffix window); (4) else mark orphaned — still visible in the sidebar, pinned to its base_version, one click to view v1 where it anchored. Comments are never silently dropped.

Threads, not docsappend-only

A thread is an append-only list of comments. No edits-in-place across users, no merge conflicts, no CRDT needed. Author can edit/delete own comments (tombstoned, audit-logged). Resolve/reopen are state flips on the thread, by humans or agents. In bundle artifacts every target also carries its path, so feedback works on any page of a multi-page site.

Markdown targetssame model

For MD artifacts the same TextQuoteSelector works against the raw source; a line_range hint is stored alongside for editor integrations (jump-to-line in the agent's working file).

Multiplayer

Realtime that survives every corporate proxy

Multiplayer here means: comments and versions appear live, and you can see who's looking. It does not mean co-editing the artifact body — agents republish, humans annotate. That asymmetry is what lets us skip CRDTs/OT and WebSocket infrastructure entirely.

One SSE stream per artifact

# works through proxies, no upgrade dance, # reconnects free via Last-Event-ID GET /v1/artifacts/8f3kx9/events Accept: text/event-stream event: comment.created data: {"id":"c_01h…","thread_id":"t_01h…", …} event: version.published data: {"n":2,"message":"address review"} event: presence data: {"viewers":[{"name":"Jess L."},{"name":"agent:claude-code"}]}

Why this and not WebSockets/CRDTs

  • SSE is plain HTTP. No special proxy config for self-hosters, no sticky sessions, trivially fits the one-container story.
  • Append-only data needs no conflict resolution. Optimistic insert + server event = done. Last-write-wins on the two mutable bits (thread state, slug).
  • Presence is a heartbeat, POST /presence every 30s while the tab is open, fanned out on the same stream. Ephemeral, in-memory, lost on restart — by design.
  • Scale path exists when needed: single process fans out in-memory; multi-replica fans out via Postgres LISTEN/NOTIFY. Still no new infrastructure.

Revisions

Versions are facts, not branches

Linear chainno branching in v0

Versions are a strictly increasing integer per artifact. No forks, no merge — an artifact is a published surface, not a repo. "Restore v2" publishes a new v4 whose blob is v2's (history never rewrites). Branch-like workflows happen upstream in git where they belong.

Deep links@vN

…/8f3kx9@v1 always renders exactly v1, immutably — what reviews, audits, and comment base_version jumps rely on. The bare URL means "current". ?diff=v1..v2 renders a diff: line-diff for MD, rendered text-diff for HTML (structural HTML diff is explicitly out of scope for v0).

Retentionpolicy, not truncation

Self-host keeps everything by default (blobs are deduped; cost is trivial). Hosted free tier prunes to last 5 versions, except any version referenced by a comment's base_version — feedback context is never garbage-collected.

Authorshiphuman + agent principals

Every version records its principal: user:jess@acme.com or agent:claude-code (token-derived, not self-declared). The UI renders agent publishes distinctly — provenance is a feature, not metadata.

Agent surface

MCP tools + machine auth

MCP toolMaps to
publish_artifactPOST /v1/artifacts — returns URL + short_id for the agent to report to the user
publish_versionPOST …/versions with resolves — the loop-closer
get_artifactGET … as Markdown — read what's live, any version
list_commentsGET …/comments?state=open — structured feedback queue
reply_commentPOST …/comments — agents can discuss, not just resolve

Ships as a published MCP server + a Claude Code / Codex skill that wraps the CLI. Same five verbs everywhere.

CLI & agent loginOAuth device flow

dock login prints a code, opens the browser, polls for grant — works headless and inside containers. Result is a scoped token in ~/.config/dock. CI uses org-issued service tokens (DOCK_TOKEN env var). No passwords ever touch the CLI.

Token scopingleast privilege

Tokens carry org + optional artifact-prefix scope and a kind (user-delegated vs service). An agent token can publish and read comments; it cannot change visibility, delete artifacts, or read other artifacts unless granted. Revocation is instant (tokens are DB rows, checked per request — at this traffic, no JWT statelessness needed).

Webhookspush, for CI loops

comment.created / version.published POSTed with HMAC signature — so a GitHub Action can wake an agent when feedback arrives, instead of polling.

Spec governance

The spec lives in the open repo as spec/APP-0.md with a JSON Schema + OpenAPI file and a conformance test suite (npx dock-conformance <base-url>). Versioned independently of the product; changes by RFC. The test suite is the standard — if it passes, you're conformant, whoever you are.