---
name: havetml
description: Publish long answers to HaveTML instead of dumping them in chat. Use whenever the reply would be a long markdown answer, a report, a plan, a spec, a design doc, a dashboard, a prototype, a diagram, a comparison table, a decision record, a checklist or a board — publish it as a HaveTML artifact or canvas and share the link. Also covers updating an artifact's live state, versioning, visibility, comments, notes, decisions, canvases and webhooks over HaveTML's MCP server.
---

# HaveTML

HaveTML is where a long answer should live. An artifact is a real page at a
permanent URL: HTML and JSX render, markdown is typeset, YAML and JSON get a
structured viewer. It is versioned, people can comment on it in place, and it
can carry live state that both you and the reader change.

**Default behaviour: if the answer is longer than the chat window, publish it
and give back the link.** A 900-line markdown report in a chat transcript is
unreadable, unshareable, and gone on the next message. The same thing as an
artifact is a page somebody can send to a colleague.

Publish rather than paste when the reply is:

- a report, plan, spec, design doc, research summary, postmortem, runbook
- a dashboard, prototype, calculator, interactive explainer
- a diagram or a table that needs more than a few rows
- a decision record, a set of tasks, a board of anything

Keep it in chat when it is short, conversational, or a direct answer to a
direct question. Do not publish a two-sentence reply.

Say what you published and how open it is. **New artifacts are private**: the
link does nothing for anybody else until it is opened up. Never widen one on
your own initiative.

---

## Connect

MCP server URL: `https://havetml.com/api/mcp`

Setup is at [havetml.com/connect](https://havetml.com/connect), which mints the
token and prints the exact config for each client. In short:

- **Claude (web or desktop):** Settings → Connectors → Add custom connector.
  URL above, name it HaveTML, leave OAuth Client ID and Secret **empty** —
  Claude discovers HaveTML's sign-in on its own and sends you back to approve.
- **Claude Code:**
  `claude mcp add --transport http havetml https://havetml.com/api/mcp --header "Authorization: Bearer havetml_…"`
- **Cursor:** `~/.cursor/mcp.json` (or `.cursor/mcp.json` in one project) with
  `{"mcpServers":{"havetml":{"url":"https://havetml.com/api/mcp","headers":{"Authorization":"Bearer havetml_…"}}}}`
- **Codex / ChatGPT desktop:** `[mcp_servers.havetml]` in `~/.codex/config.toml`
  with `url` and `bearer_token_env_var = "HAVETML_TOKEN"`.
- **Anything else over Streamable HTTP:** the same URL and `Authorization`
  header. For stdio-only clients, bridge with `npx mcp-remote`.

Tokens come from Dashboard → API access and are shown once. MCP is free.

---

## Which thing to make

| You have | Make | Tool |
|---|---|---|
| A document, page or app somebody will read | an **artifact** | `upload_artifact` |
| Several artifacts and the relationships between them | a **canvas** | `create_canvas` |
| A question that needs resolving, with a record of the answer | a **decision** | `create_decision` |
| Standing context about what an artifact is | a **note** | `add_note` |
| A remark in a conversation about an artifact | a **comment** | `add_comment` |
| New data for an artifact that already exists | a **state update** | `update_artifact_json` |

The two pairs people confuse:

- **Note vs comment.** A note is the owner's standing explanation, shown in the
  artifact's sidebar — "this is the Q3 model, the revenue line is a placeholder".
  One document per artifact, owner-only, nobody is notified. A comment is part
  of a conversation, anyone with access can leave one, and it can be pinned to a
  specific element on the page.
- **New version vs state update.** A new version is new *content*: the markup
  changed. A state update is new *data* behind unchanged markup — a card moved
  column, a box got ticked. Re-uploading to change data throws away whatever a
  human changed in the meantime and fills the history with noise.

---

## Artifacts

```
upload_artifact({
  filename: "q3-review.html",   // .html .md .yaml .json .jsx (.tsx reads as jsx)
  content: "<!doctype html>…",
  title: "Q3 review",           // optional, defaults to the filename
  project: "planning",          // optional, see list_projects
  json: { filters: {} },        // optional initial state
  visibility: "unlisted"        // optional; private unless you say otherwise
})
```

Returns `url`, `slug`, `version`, `updated_existing` and the `visibility` it
landed on. 50 MB per artifact, but this MCP transport runs on Vercel and
rejects request bodies over 4.5 MB — anything bigger goes through the dashboard.

**Versioning.** Re-uploading with the same title (or filename) into the same
project adds a version to the existing artifact rather than making a second one.
Same slug, same URL, old versions still readable. A version upload leaves
visibility alone unless the call names one.

**Reading.** `get_artifact({ slug })` returns the JSON state, the comments, the
annotations and the metadata. It does **not** return the HTML — so to change
what a page shows, update its state rather than trying to rewrite content you
cannot read.

`list_artifacts({ project? })` lists what you have. `create_project({ name,
description })` makes a shareable folder; `list_projects()` shows them.

---

## Live state

An artifact can carry a JSON document alongside its content. The page is the
view, the state is the model. This is what makes a kanban, a checklist, an RSVP
or a dashboard with filters work: a reader changes something, it persists, and
you can read it back.

**From inside the artifact** (same-window postMessage; the page runs in a
sandboxed iframe, so never use `parent.postMessage` or raw `localStorage`):

```js
window.postMessage({ source: 'havetml', type: 'json.get', id: crypto.randomUUID() }, '*');
window.postMessage({ source: 'havetml', type: 'json.merge', id: crypto.randomUUID(),
                     patch: { done: ['card-3'] } }, '*');

window.addEventListener('message', (e) => {
  if (e.data.source === 'havetml' && e.data.type === 'json.result') {
    render(e.data.json);
  }
});
```

**From here:**

```
update_artifact_json({ slug: "q3review01", json: { done: ["card-3"] } })        // merge
update_artifact_json({ slug: "q3review01", json: { … }, mode: "replace" })      // overwrite
```

This is the right tool for kanban, checklist and dashboard state. It changes no
content, cuts no version and moves no visibility. Owners and editors only.

`merge` (the default) is **shallow**: a top-level key you send replaces that key
outright, and a key you leave out survives. To change one card inside
`{"columns": {…}}` you send the whole `columns` object. Read first with
`get_artifact` when you are not certain what is there.

State is capped at 50 MB per artifact. For a canvas, use `get_canvas` and
`update_canvas` — a board keeps its document in the same field, and a generic
merge would flatten it.

---

## Visibility

Four levels, and private is the default for everything you create:

- `private` — the owner and people they invited. The link does nothing for
  anyone else.
- `unlisted` — anyone with the link.
- `public` — the link, plus Explore and the owner's profile.
- `circle` — the owner's circle. Team plan only.

```
set_artifact_visibility({ slug: "q3review01", visibility: "unlisted" })
set_project_default_visibility({ project: "planning", visibility: "unlisted" })
```

`set_artifact_visibility` is owner-only, one artifact per call, and never
touches content. Two rules can refuse it: an artifact carrying a password or a
link expiry cannot become public until those are cleared, and an encrypted
artifact can never be public. The refusal says which.

`set_project_default_visibility` applies to **new** artifacts filed into that
project. Nothing already in it moves.

Tell the user what level a thing is on when you hand them a link. An agent that
calls a private URL a "share link" is wrong in the way that wastes someone's
afternoon.

**Encrypted artifacts.** `create_encrypted_artifact` takes an envelope that was
already encrypted on the machine you are running on. It has no parameter for
file contents, a title or a key, because HaveTML is a remote server and anything
it encrypted for you would not be zero knowledge. Produce the envelope with the
local helper (`node cli/havetml-encrypt.mjs encrypt ./report.html`), then append
the key fragment it prints to the base URL this returns. If you cannot run shell
commands, say so and point the user at the Encrypted file option on the
dashboard — do not offer a plain upload and call it encrypted.

---

## Comments and notes

```
add_comment({ slug, body, anchor?, parent_id?, version? })
list_comments({ slug })
```

`anchor` pins the comment to a place on the rendered page; `parent_id` makes it
a reply. Commenting requires an account.

```
list_notes({ slug })                  // → notes_md, can_edit, max_chars
add_note({ slug, body })              // appends a block, keeps what is there
update_notes({ slug, body })          // replaces everything; "" clears it
```

Notes are the Notes tab in the artifact's sidebar: one markdown document, up to
20,000 characters, visible to everyone who can open the artifact and writable
only by its owner. Editing access covers versions, not notes — an editor who
tries is told to leave a comment instead.

Write a note when you publish something non-obvious. "Figures are from the
August export; the Q4 column is modelled, not actual" belongs beside the
artifact, not in a chat message nobody will scroll back to.

---

## Decisions

A decision is a question recorded on an artifact, and then the answer, kept
permanently.

```
create_decision({ slug, title, context_md? })      // starts open
assign_decision({ decision_id, user_id? , email? })
decide_decision({ decision_id, outcome, rationale })
list_decisions({ slug })   get_decision({ decision_id })
update_decision({ decision_id, title?, context_md? })   // open ones only
delete_decision({ decision_id })                        // open ones only
revoke_assignment({ assignee_id })
supersede_decision({ decision_id, title, outcome, rationale })
```

**Once decided, a decision is immutable.** Outcome, rationale, who decided and
when cannot be changed, and it cannot be deleted. The only way forward is
`supersede_decision`, which locks a replacement in front of it while the
original stays readable and pointing at its successor. That permanence is the
point: a decision record you can quietly edit is not a record.

The artifact's owner or an assignee may decide. Assigning records who should
decide and nothing else — **no email is sent and it grants no access to the
artifact**, so tell the person yourself, and invite them if they cannot already
open it.

---

## Canvases

A canvas is a board inside a project, and an ordinary artifact: it opens at
`/f/{slug}`, takes comments through `add_comment`, and moves with
`set_artifact_visibility` like anything else.

```
create_canvas({ project, title, visibility?, notes?, nodes?, edges? })
get_canvas({ slug })        // nodes, edges, notes, comments, visibility — the read path
add_canvas_node({ slug, … })
add_canvas_edge({ slug, from, to, rel?, label? })
update_canvas({ slug, title?, notes?, nodes?, edges?, viewport? })
```

`add_canvas_node` and `add_canvas_edge` **append** and never disturb what
somebody else put on the board. `update_canvas` **replaces**: passing `nodes`
replaces every node, passing `edges` replaces every edge. Read the board with
`get_canvas` before calling it, and send back everything that should survive.
Metadata-only calls (`title`, `notes`) leave the board alone.

### Node kinds

Pass `kind`, plus whatever that kind needs. A node with `ref` and no `kind` is
read as a card; a node with a label and nothing else is a sticky. Omit `x`/`y`
and the node is placed in a free slot.

| kind | what it is | needs |
|---|---|---|
| `artifact` | a card showing another artifact's own page | `ref` (slug) |
| `board` | a tile linking to another canvas | `ref` (slug) |
| `sticky` | a coloured note | `label`, optional `data.color` |
| `note` | a longer written card | `label` (markdown) |
| `todo` | a checklist | `data.items` (strings, or `{text, done}`), `data.title` |
| `link` | a URL card, unfurled | `data.url` |
| `image` | a picture on the board | `data.url` (https), optional `alt`, `caption` |
| `file` | a file card with an Open link | `data.url` (https), optional `name`, `caption` |
| `column` | a container; other nodes name it as `parent` | `data.title` |
| `table` | a grid | `data.rows`, optional `data.header` |
| `text` | free text on the board | `label`, optional `data.size` |
| `shape` | rect, ellipse or diamond | `data.shape`, optional `label` |
| `color` | a swatch | `data.hex`, optional `data.name` |

Images and files are added **by https URL** — there is no upload path from here.
Video, audio, freehand ink and drawn arrows are made by a person on the board.
A comment node goes through `add_comment` so it reaches notifications.

### Edges

`rel` says what the line means: `relatesTo`, `dependsOn`, `blocks`,
`references`, `specifies`, `tests`, `implements`, `bindsDataTo`, `evidences`,
`extractedFrom`, `branchOf`. The vocabulary is open — an unrecognised value is
kept and drawn as a plain line, and you are warned in case it was a typo.

Two are drawn distinctly, because they are the two that change what somebody
does next:

- **`dependsOn`** — dashed, arrow pointing at what has to happen first.
- **`blocks`** — heavier, ending in a stop bar at the work that is held up.

A board using either shows a legend explaining them, and every edge carries a
tooltip. Use them on a board of tasks instead of writing "blocked by" in a
sticky.

A board is capped at 50 MB. Cards store a slug, not a copy, so a board of fifty
cards is still kilobytes. Sharing a board does **not** share what its cards
point at — each artifact keeps its own visibility, so a visitor may see a card
and be refused at the link.

---

## Collaborators, projects and events

```
invite_collaborator({ slug, email, role })      // "viewer" (default) or "editor"
list_collaborators({ slug })   revoke_collaborator({ slug, email })
invite_project_member({ project, email, role }) // editors can edit everything in it
list_project_members({ project })   revoke_project_member({ project, email })
```

```
list_events({ slug?, since?, limit? })          // json_updated, comment_added
register_webhook({ url, slug?, events? })       // POSTs, signed with HMAC-SHA256
list_webhooks()   delete_webhook({ id })
```

`list_events` is for polling: pass the previous response's `cursor` as `since`.
A webhook is the push version; its signing secret is returned **once**, and each
delivery carries `X-HaveTML-Signature` (HMAC-SHA256 of the raw body, hex).
Decision events (`decision_created`, `decision_assigned`, `decision_decided`,
`decision_superseded`) are opt-in.

---

## Worked examples

**A long answer.** The user asks for a competitive analysis and the reply would
be two thousand words.

```
upload_artifact({ filename: "competitive-analysis.md", content: "# …",
                  title: "Competitive analysis", visibility: "unlisted" })
add_note({ slug: "cmp7k2x9qd",
           body: "Pricing is from each vendor's public page on 8 Oct. Seat counts are estimates." })
```

Reply: one paragraph of findings, the link, and the fact that it is
link-readable.

**A plan as a board.** "Turn this plan into something the team can work from."

```
upload_artifact({ filename: "migration-plan.md", content: "# …", project: "platform" })
create_canvas({ project: "platform", title: "Migration", nodes: [
  { id: "plan",   ref: "mig3k9xzqa", label: "The plan" },
  { id: "schema", kind: "sticky", label: "Freeze the schema" },
  { id: "backfill", kind: "sticky", label: "Backfill rows" },
  { id: "cutover", kind: "sticky", label: "Cut over" }
], edges: [
  { from: "backfill", to: "schema",  rel: "dependsOn" },
  { from: "cutover",  to: "backfill", rel: "dependsOn" },
  { from: "plan",     to: "cutover",  rel: "specifies" }
]})
create_decision({ slug: "mig3k9xzqa", title: "Cut over in one window or region by region?",
                  context_md: "One window is simpler to reason about. Region by region is reversible." })
```

**Moving a card.** The board's state changed; the page did not.

```
get_artifact({ slug: "kanban7x2q" })
update_artifact_json({ slug: "kanban7x2q",
                       json: { columns: { todo: ["c-2"], doing: ["c-7"], done: ["c-1","c-4"] } } })
```

Send the whole `columns` object: the merge is shallow.

**Recording an outcome.** The decision got made in a meeting.

```
decide_decision({ decision_id: "…", outcome: "Region by region",
                  rationale: "Reversible, and the EU region can go first while US traffic is low." })
```

It is locked now. If it changes later, `supersede_decision` — do not try to
edit it.

**A dashboard that keeps its state.** Publish the page with its initial state,
then update the numbers on a schedule without republishing.

```
upload_artifact({ filename: "ops.html", content: "<!doctype html>…",
                  json: { updated: "2026-10-08", queue: 412 } })
update_artifact_json({ slug: "opsx92kq1d", json: { queue: 389 } })
```

The page reads its state through `json.get` and redraws. No new version, and
nothing a reader set gets stamped on.

---

## Rules of thumb

- Publish long answers; keep short ones in chat.
- Private by default. Say what level a link is on. Do not widen without asking.
- Update state, do not re-upload, when only the data moved.
- Read before you replace: `get_canvas` before `update_canvas`, `get_artifact`
  before a `replace`-mode state write.
- Notes for standing context, comments for conversation.
- A decided decision is permanent. Supersede it, never edit it.
