# hart — agent guide

The agent-first artifact host. Publish self-contained **HTML or JSX** and get a live, versioned, sandboxed URL. One static binary is BOTH the CLI (`hart <cmd>`) and the daemon (`hart serve`).

This instance: `https://hart.intrane.fr` — **free/open** — no token needed to publish

## Use an existing instance (fastest)

```sh
export HART_URL=https://hart.intrane.fr
# publish a finished, self-contained page:
hart publish page.html --owner you --artifact my-page
# -> {"url":"https://hart.intrane.fr/a/you/my-page", ...}
```

No `hart` binary? Every write is just an HTTP POST — you can curl it:

```sh
curl -X POST 'https://hart.intrane.fr/v1/publish?owner=you&artifact=my-page' \
  -H 'content-type: text/html' --data-binary @page.html
```

## Self-host in 30s

```sh
git clone https://github.com/javimosch/machin-hart && cd machin-hart
./runtime/fetch.sh          # seed the JSX runtime (react/babel)
./build.sh                  # needs `machin` on PATH -> ./hart
./hart serve 8799 &         # free/open by default
```
Gate publishing with `HART_TOKEN=<secret>`; expose publicly behind any reverse proxy and set `HART_PUBLIC=https://your.domain`.

## The publish contract (important)

hart hosts **self-contained** pages — inline ALL CSS/JS, embed images/fonts as `data:` URIs. The daemon wraps your body in a doctype skeleton and serves it under a strict CSP: **no external scripts/styles/fonts, no network** (`fetch`/XHR/WebSocket are blocked). A publish-time linter rejects external refs + network calls (HTTP 422) unless you pass `--force`. Use `--dry-run` to lint without storing.

**Landing pages / sales funnels** that legitimately need Stripe, analytics, web fonts, or YouTube/Loom embeds: publish with `--csp-mode landing` (relaxes the CSP to allow those sources and skips the self-contained linter for that artifact), or `--csp-mode custom --csp-policy '<CSP>'` to serve an exact policy you supply. Default stays `strict` (the full lockdown).

## Versioning

Re-publishing the same `--owner/--artifact` appends a version; `latest` tracks newest, old versions are immutably pinned.
- `https://hart.intrane.fr/a/<owner>/<artifact>` or `/latest` -> newest
- `.../v<n>` -> a pinned version
- `hart rollback <id> <v>` re-points latest (non-destructive)

## Template + data (update without re-uploading)

Publish a template once, then push just the data — it re-renders. Two mechanisms:
- `{{key}}` placeholders in the markup
- `window.HART_DATA` — a JS global your script/JSX reads

```sh
hart publish chart.html --owner you --artifact sales
hart data you/sales '{"points":[3,1,4,1,5]}'   # re-renders, same URL
```

## Visibility

- **unlisted** (default) — public read, not listed
- **public** — public read + listed at `/explore` and `/o/<owner>` (`hart explore [q]`)
- **private** — gated read: browsers get an unlock page (password -> signed cookie); agents send `X-Hart-Read-Key` (`HART_READ_KEY`).
- **team** (hart Pro) — gated read by **team membership**, not a password: any member of the owner reads it with their `X-Hart-Member-Key` (from `hart team invite` / `hart join <owner>`). No shared secret. Owner-key/admin always read (also the failsafe if the license lapses). Add members with `hart team invite <owner> <email> --role reader|writer` — a `reader` can read team artifacts but never write. Group harts within a team with `--tag`, and list them with `hart list --owner <owner> --visibility team`. In a browser, a member visits the artifact and clicks **Sign in with your team** (OIDC via `/team/signin`) to get a read-scoped session cookie — no header needed.

Set with `--visibility` / `--read-key` at publish, or `hart visibility <id> <mode>` later.

## Ownership (write protection)

On a free instance, the first write to a new `--owner` claims it. Pass `--owner-key <secret>` (or `HART_OWNER_KEY`) to claim a namespace; then all writes to that owner require the key (else 403). To retrofit a key on an existing open namespace (or rotate a keyed one), use `hart owner-key <owner> <new-key>` (admin or current owner key required). Anonymous (no-owner) artifacts get a random id.

## Memory layer (provenance, search, lineage)

Artifacts can carry provenance so agents can trace, find, and relate their work. At publish: `--agent <name> --run <id> --parent <id> --tags a,b --meta '{...}'`. `hart get` and `hart versions` return this metadata.

- `hart search [query] [--owner --tag --agent --run --limit]` searches title, name, data, tags, meta, agent, run_id, owner, and parent_id. Visibility is respected.
- `hart meta <id> [json]` reads or overwrites an artifact's structured meta JSON.
- `hart lineage <id>` walks the `parent_id` chain up to 20 hops.
- `hart diff <id> <v1> <v2>` reports changed/lines_added/lines_removed/size_delta.
- `hart checkpoint <id>[@v] --name <label>` labels a version; `hart labels <id>` lists them.
- `hart link <from> <to> --rel derived|depends|related|supersedes` relates two artifacts; `hart related <id>` follows those links.

## JSX

`hart publish app.jsx --format jsx` — author React/JSX; the daemon serves a same-origin React+Babel runtime and transpiles in-browser. No build step, no CDN. `React`/`ReactDOM` are globals; render into `#root`.

## Deliverables: chrome off, per artifact

The hart chrome (the badge, `More from <owner>`, `Explore public`, `Copy curl`) is right for a page that IS a hart page. It is wrong for a **deliverable** — a report you publish on a client's behalf, carrying their name, which they may forward. The chrome invites its reader off that document and back to your other work, and if an artifact is ever published `public` by mistake, the chrome is what turns that one-word slip into a browsable index of everyone else's reports. `unlisted` alone is one word from being wrong; removing the invitation is the durable fix.

```sh
hart publish report.html --owner you --artifact acme-q3 --no-chrome   # publish as a deliverable
hart chrome you/acme-q3 off                                          # or flip an existing one
hart chrome you/acme-q3 on                                           # restore
hart get you/acme-q3            # -> \"no_chrome\":1
```
Opt-in: the default is chrome ON, so nothing already published changes. A **plain republish keeps the setting** — you cannot silently re-attach the chrome to a deliverable by pushing a new version. Pass `--chrome` to deliberately put it back. Custom-domain mappings are chromeless already; this covers the `/a/<id>` URL, which is the one you actually hand to a client.

## Custom domains (multi-tenant / self-service)

Serve an artifact on a creator's own domain. hart maps `Host -> artifact` and serves it at `/`; **provisioning stays in your reverse proxy** (Traefik) — hart only records the mapping and serves it by the `Host` header. On a shared instance, `*.hart.intrane.fr`-style subdomains can be self-service: any owner can claim `<label>.<owner>.hart.intrane.fr` and the daemon enforces limits, an allow/deny list, and anti-hijack rules.

```sh
hart domain you/landing shop.example.com                 # map (served chromeless at /)
hart domain you/landing shop.example.com --emit-traefik  # + print a ready Traefik router block
hart domain you/landing you.shop.example.com --read-key <pw>  # private artifact needs its read key
hart domains                                             # list mappings (JSON)
hart domains --prune                                    # admin: remove orphan/stale mappings
hart domain-rm shop.example.com                          # unmap
curl -s -X POST '## Skill catalog

Any artifact can be published and discovered as an agent **skill**. Include `<meta>` tags in the `<head>`:

```html
<meta name="title" content="My skill">
<meta name="description" content="One-line description">
<meta name="keywords" content="agent, skill, ops">
```

- `--title` (or API `title=`) wins over `<meta name="title">`.
- `--meta '{"description":"..."}'` wins over the HTML `<meta name="description">`; if `--meta` is omitted, the HTML-derived value is stored and overwrites any previous HTML-derived value on re-publish.
- `<meta name="keywords">` is normalized to a JSON array.

List skills with `hart list --owner <who> --format skills` or `GET https://hart.intrane.fr/v1/skills/<owner>`. The response includes `id`, `title`, `description`, `keywords`, `visibility`, and `url` for each artifact. Private artifacts are only described for callers with the read key, owner key, or admin token; `hart get <id>` returns `description` and `keywords` when present. `hart search --tag <kw>` matches both `tags` and extracted `keywords`.

## Commands

| cmd | does |
|---|---|
| `publish <file> [--owner --artifact --title --format html\|jsx --visibility --read-key --csp-mode strict\|landing\|custom --csp-policy --unguessable --dry-run --force]` | upload -> {id,url,version} |
| `data <id> '<json>'` | update the live data (re-renders) |
| `visibility <id> <unlisted\|public\|private> [--read-key --clear-read-key]` | change visibility |
| `chrome <id> <on\|off>` | per-artifact hart chrome — off for client-facing deliverables |
| `versions <id>` / `rollback <id> <v>` | history / revert |
| `list [--owner <who>] [--format skills]` / `get <id> [--html --read-key]` / `rm <id>` | manage (`--format skills` = compact skill catalog; get --html = raw stored body) |
| `owner-key <owner> <new-key>` | set/rotate the owner key for a namespace (admin or current key required) |
| `domain <id> <domain> [--chrome --emit-traefik --read-key <pw>]` / `domain-rm <domain>` / `domains [--prune]` | map a custom domain to an artifact; `--prune` removes orphan/stale mappings (admin) |
| `stats <id>` | living-deliverable analytics: views, last view, freshness, top referrers |
| `feedback <msg> [--kind bug\|idea\|praise] [--context <c>]` | send feedback (dual-writes to this instance + a central relay) |
| `fresh <id> <30s\|15m\|2h\|1d\|off>` | set/clear a freshness SLA |
| `refresh <id> [--url <URL>\|--cmd '<sh>'] --every <dur> [--header 'H: v']\|--off\|--now` | daemon self-refreshes the artifact's data on a schedule (living loop) |
| `live <id> <on\|off>` | allow the page to poll its own data.json and repaint live (opt-in CSP relax) |
| `upgrade [--plan pro]` | mint a checkout link to buy hart Pro (give the URL to the human to pay) |
| `license <key>` / `license status` | set / inspect the hart Pro license (unlocks pro features) |
| `audit [--owner --action --since --limit]` | who changed what — the audit log (**hart Pro**) |
| `team add\|invite\|list\|rm <owner> [<email>]` | manage team members / per-member keys (**hart Pro**) |
| `join <owner>` | SSO self-onboarding — sign in once, receive your member key (**hart Pro**) |
| `stale [--owner <who>] [--older-than <dur>]` | living deliverables that went quiet (JSON signal) |
| `explore [query]` | public discovery feed (JSON) |
| `meta <id> [json]` | read or overwrite artifact meta JSON |
| `search [query] [--owner --tag --agent --run --limit]` | memory-layer artifact search |
| `diff <id> <v1> <v2>` | line/byte delta between two versions |
| `lineage <id>` | walk the parent chain (provenance) |
| `checkpoint <id>[@v] --name <label>` | label a version |
| `labels <id>` | list checkpoint labels |
| `link <from> <to> --rel <derived|depends|related|supersedes>` | relate two artifacts |
| `related <id>` | artifacts linked to/from this id |
| `admin owners` / `admin list [--owner <who>]` | operator cross-owner visibility (needs `HART_ADMIN_TOKEN`) |
| `serve [port]` | run the daemon |
| `mcp` | run as a stdio MCP server (native tools for MCP agents) |
| `guide` / `skill` | this manual / a drop-in agent SKILL.md |

## MCP (native tools for MCP-capable agents)

Besides the CLI, hart can run as a **stdio MCP server** so MCP-native agents (Claude Desktop, Cursor, …) get hart as first-class tools. One binary, no extra runtime — it wraps hart's own HTTP API and inherits auth from env. Configure your MCP client:

```json
{"mcpServers":{"hart":{"command":"hart","args":["mcp"],"env":{"HART_URL":"https://hart.intrane.fr"}}}}
```
Tools exposed: `hart_publish`, `hart_data`, `hart_list`, `hart_get`, `hart_stats`, `hart_stale`, `hart_explore`. (The CLI remains the primary interface; MCP is an additional surface.)

## Stats (living deliverables)

View counts are **server-side** (the CSP blocks any client-side beacon), so they're privacy-respecting — no cookies, no JS, no third-party trackers. `hart stats <id>` returns total views, last-view time, freshness (last publish `updated` + last `hart data` push `data_updated`), and the top inbound referrer hosts.

```sh
hart stats you/sales
# -> {"views":128,"last_view":..,"updated":..,"data_updated":..,"referrers":[{"host":"news.ycombinator.com","count":41},{"host":"direct","count":52}]}
```
Gated to the owner (owner-key) or the admin token; open for an unclaimed owner. `hart admin list` also carries a `views` count per artifact.

## Staleness (living deliverables that went quiet)

Some artifacts are kept fresh by an agent/cron pushing `hart data` on a cadence. Give one a **freshness SLA** and hart will flag it once it hasn't been refreshed in time — so a silently-dead dashboard doesn't go unnoticed.

```sh
hart fresh you/board 15m        # SLA: expect a refresh at least every 15 min (off = clear)
hart stale                      # artifacts past their own SLA (admin: all owners)
hart stale --owner you          # scoped to one owner
hart stale --older-than 2h      # ad-hoc: anything not touched in 2h (ignores SLAs)
# -> {"mode":"sla","count":1,"stale":[{"id":"you/board","age":"22m","fresh_ttl":900, ...}]}
```
hart emits the **signal** (JSON); your agent/cron does the alerting (Slack, etc.). `fresh_ttl` + a computed `stale` also appear in `hart stats` and `hart admin list`. Freshness = time since the last publish or `hart data` push (`updated`).

## Living loop (self-refreshing deliverables)

`hart data` and `hart fresh`/`hart stale` assume **something external** keeps pushing data. `hart refresh` closes the loop: the daemon runs a **source** on a schedule and pushes the result as the artifact's live data itself — no external cron. Pair it with `hart fresh` and the SLA stays green because hart is the one keeping it green.

```sh
# GET a JSON URL every 15 min and make it the artifact's data (url sources need the owner key)
hart refresh you/board --url https://api.example.com/metrics --every 15m --header 'Authorization: Bearer XYZ'

# or run a shell command (pipes/curl|jq ok) — arbitrary shell on the server, so ADMIN ONLY
hart refresh you/board --cmd 'curl -s https://api.example.com/m | jq .summary' --every 15m

hart refresh you/board            # show config + last run status
hart refresh you/board --now      # run once, right now, inline
hart refresh you/board --off      # stop refreshing
```
The source's output must be a JSON object or array (it becomes `window.HART_DATA` + `{{key}}`); non-JSON or a failed fetch is recorded in `last_status` and the existing data is left untouched. Minimum interval 30s. `--url` sources need the owner key; `--cmd` sources require `HART_ADMIN_TOKEN` (they execute shell on your box). The daemon runs due sources every few seconds in-process — one binary, no scheduler to deploy.

**Live repaint (no reload).** By default a refreshed page shows new data on the *next load*. Mark an artifact **live** and it repaints while someone's watching it: hart injects a small poller that fetches the artifact's own `https://hart.intrane.fr/a/<id>/data.json` and updates `window.HART_DATA`, then fires a `hart:data` event. Your template paints from `window.HART_DATA` on load and repaints on the event:

```html
<script>
  function paint(d){ /* render d */ }
  paint(window.HART_DATA);                               // initial
  addEventListener('hart:data', e => paint(e.detail));   // live updates
</script>
```
```sh
hart publish board.html --owner you --artifact board --live   # publish as live
hart live you/board on|off                                    # toggle anytime (setting a refresh source turns it on)
```
Live is **opt-in per artifact**: only a live page's CSP is relaxed to `connect-src 'self'` (so it can fetch *its own* data.json and nothing else) — every other artifact keeps the full `default-src 'none'` lockdown. The poll interval tracks the refresh cadence (override with `window.HART_LIVE_MS`). `data.json` uses the same read-gating as the page (a private artifact's poll rides its unlock cookie).

## Admin (operator god-mode)

On an instance **you host**, set `HART_ADMIN_TOKEN` (separate from `HART_TOKEN`) on the daemon to unlock cross-owner discovery — for auditing all artifacts your agents/operators produced on your own box.

```sh
export HART_ADMIN_TOKEN=<secret>   # or: hart admin login <secret>
hart admin owners                  # every owner: {owner, artifacts, bytes, has_owner_key, updated}
hart admin list [--owner <who>]    # every artifact: {id, owner, url, visibility, has_read_key, version, updated}
hart admin digest [--days N]       # adoption: new owners/artifacts (default 7d), totals, top by views
hart admin mv <old-id> <new-owner/new-name>   # move/rename, keeps version history + read-key
```

**Visual operator dashboard:** point a browser at `https://hart.intrane.fr/_fleet` and sign in with the admin token — every owner + artifact (incl. unlisted/private), view counts, and stale living-deliverables flagged, server-rendered. The JSON API above is for agents; `/_fleet` is for you.

**`admin mv` examples** — reorganize owners without losing anything (history, visibility, live data, and read-key all move with the artifact; the old URL 404s):

```sh
# fold a per-agent owner into a shared namespace, prefixing to keep provenance
hart admin mv am/fleet-monitor   intrane/am-fleet-monitor      # v36 stays v36
hart admin mv crmd/product       intrane/crmd-product
# regroup several owners under a new project namespace
hart admin mv geored/e2e-retroplanning     simpliciti/geored-e2e-retroplanning
hart admin mv sso-server/changelog-7d      simpliciti/sso-server-changelog-7d
# a plain rename (same owner)
hart admin mv acme/q3-draft      acme/q3-final
```

Guards: `404` if the source is missing, `409` if the target id already exists, `400` on a same-id or malformed `to` (must be `<owner>/<name>`).

Keys/read-keys are stored **hashed**, so admin surfaces `has_owner_key`/`has_read_key` booleans, never the secret. `admin mv` renames an artifact and all its versions to a new owner/name in place — history, visibility, live data, and read-key are preserved (nothing is re-uploaded); the old URL 404s. Unset `HART_ADMIN_TOKEN` = the admin API is **off** (403), and cross-owner `hart list` is then admin-only on multi-tenant instances (owner-scoped `list --owner X` is unaffected).

## Env

All client env vars can also be set in `~/.hart/config` or a per-project `.hart.env` (loaded for all CLI commands except `serve`; precedence: flag > env > `.hart.env` > `~/.hart/config`). `HART_URL` (daemon) · `HART_TOKEN` (publish token, if the instance requires one) · `HART_OWNER_KEY` (namespace write key; also `HART_OWNER_KEY_<owner>` per namespace) · `HART_READ_KEY` (read key for a private artifact; also `HART_READ_KEY_<owner>_<artifact>` per artifact) · `HART_ADMIN_TOKEN` (operator god-token for the admin API — cross-owner list; separate from `HART_TOKEN`, unset = admin off). Server: `HART_DB`, `HART_RUNTIME_DIR`, `HART_PUBLIC`, `HART_LANDING`, `HART_MAX_SUBMITS_PER_MIN` (10), `HART_MAX_READ_ATTEMPTS_PER_MIN` (30 — per-IP failed private-read ceiling), `HART_MAX_READ_ATTEMPTS_PER_ID_PER_MIN` (10 — per-IP per-artifact failed-read ceiling), `HART_MAX_OWNER_MB` (30 — the per-owner storage cap; raising it above `HART_FREE_MAX_OWNER_MB` requires a **hart Pro** license), `HART_FREE_MAX_OWNER_MB` (30 — the free-tier ceiling), `HART_EXPLORE=0`, `HART_COOKIE_SECRET`, `HART_LICENSE_KEY` (Pro key), `HART_LICENSE_PUBKEY` (run your own issuer). **Custom domains:** `HART_DOMAIN_ALLOW` (allow patterns), `HART_DOMAIN_DENY` (deny patterns), `HART_DOMAIN_PRIVATE_PATTERNS` (force private), `HART_DOMAIN_SUBDOMAIN_OWNER_MATCH=1` (`*.hart.intrane.fr` label must match owner), `HART_DOMAIN_MAX_PER_OWNER` (limit per owner), `HART_DOMAIN_GC_INTERVAL` (duration — background orphan cleanup), `HART_DOMAIN_HOOK` (executable for `add|remove` events). **Production hardening** (auto on when `HART_PUBLIC` is set): `HART_HARDEN=1` · `HART_TRUST_PROXY=1` (only behind a proxy you control) · `HART_MAX_BODY_BYTES` (default 10 MiB) · `HART_READ_TIMEOUT_MS` (default 30s) · `HART_ACCESS_LOG=1`; opt out locally with `HART_HARDEN=0`. **BYOK key map:** `https://hart.intrane.fr/byok.md` (also `docs/BYOK.md` in the repo).

## hart Pro (self-host)

hart is open-core: the CLI is fully functional and free, and a few features unlock with a **license key** (`hart license <key>`, or `HART_LICENSE_KEY` on the daemon; `hart license status` shows your tier). Keys are Ed25519-signed and verified offline (no phone-home). Pro today: **limits** (raise the per-owner storage quota), **audit-log** (`hart audit` — who changed what), **teams** (`hart team` — per-member keys). Enforcement is honest, not DRM — pay for support, updates, and to stay compliant.

**Teams SSO (optional, your IdP).** `hart join <owner>` lets a member self-onboard via **any OpenID Connect provider you configure** — you bring the IdP, hart is a standard OIDC client. Set `HART_OIDC_ISSUER` (e.g. your Auth0/Okta/Keycloak/Google/machin-idp), `HART_OIDC_CLIENT_ID`, `HART_OIDC_CLIENT_SECRET` (register `<your-hart>/team/callback` as the redirect URI at your IdP); optional `HART_OIDC_SCOPES` (default `openid email`). hart discovers the endpoints, runs the code flow, and reads the member's email from `userinfo`. Unset = SSO off (teams still work via `hart team add` member keys).

**Buying is agent-first.** Run `hart upgrade` — it returns a checkout URL; give it to the human to pay. They're emailed a license key; then run `hart license <key>` (or set `HART_LICENSE_KEY`). Lost the key? `https://hart.intrane.fr/manage` re-emails it. Contact: javi@intrane.fr.

## Workflow caveats from usage

- No `hart` binary? Publish with curl: `curl -s -X POST 'https://hart.intrane.fr/v1/publish?owner=<owner>&artifact=<slug>&visibility=unlisted' -H 'content-type: text/html' --data-binary @file.html`
- Convert Markdown to self-contained HTML with pandoc: `pandoc --embed-resources --standalone --css style.css -f markdown-yaml_metadata_block --metadata title='...' -o out.html in.md`. Use `-f markdown-yaml_metadata_block` when the Markdown frontmatter contains unquoted colons (e.g. `Asunto: foo: bar`).
- Re-publishing the same `owner/artifact` updates `latest`; old versions stay at `/v<n>`.
- Move/delete an artifact: `curl -s -X DELETE 'https://hart.intrane.fr/v1/artifacts/<owner>/<artifact>'`.

> **Agent self-improvement note:** After each hart session, if you discover a new caveat, faster command, or API detail, append a concise bullet to this 'Workflow caveats from usage' section and to any in-repo `.devin/skills/hart/SKILL.md` if it exists. Keep it factual and non-duplicative.

Source + releases: https://github.com/javimosch/machin-hart
