Build a live, shared HTML artifact
Use this recipe when an html artifact should remember things or be shared: a to-do list, a poll,
a sign-up sheet, a scoreboard, a small game, or a dashboard that people annotate. Every HTML
artifact has its own key-value store. The daemon serves the store to the page at /api/* of the
page's own origin, and it pushes every change to every open copy of the page as it happens.
The exact HTTP contract is the "Artifact data" section of the installed release's API.md (in the
source checkout, packages/happy-agent/API.md). This recipe teaches the browser SDK, which wraps
that contract and is the right choice for almost every page.
What you get
- One store per artifact. Every version of the artifact uses the same store, so publishing a
new version with
update_artifactkeeps the data. Everyone using this Happy Agent shares it: each of the user's devices, and in team mode every member. - Who is looking.
happy.useris the viewer: a stableid, plusname,firstName,lastName, andphotoUrl, each of which may benull. Every write records its author inupdatedBy, andhappy.users.load(ids)turns those IDs into names and photos. Identity always comes from the signed-in person; a page cannot claim to be someone else. - You can read and write it too.
read_artifact_datashows an artifact's data with who wrote each key, andwrite_artifact_datasets or deletes a key as you. Use them to report results ("who voted for what"), seed starting data, or fix a bad value. Your writes reach every open page at once. Their author haskind: "agent"and your bot's name, or"Agent". - Versions you can read time from. Every write gets a UUIDv7
version, greater than every earlier one in the store.happy.timeOf(version)returns itsDate. - Realtime changes. Each change from anyone reaches every tracked value within moments.
- Network failures are handled for you. Writes apply on the page at once and queue while the daemon is unreachable. They retry with backoff, and each carries an idempotency ID, so a retry after a lost response never applies twice. The change stream reconnects by itself and resumes from the last version it saw.
- Problems are reported to you. The SDK sends uncaught errors, unhandled rejections,
console.error,console.warn, and its own sync trouble to the artifact's debug log, whichread_artifact_logsshows you with who hit them and on which version.
Rules that are easy to get wrong
- Load the SDK with exactly
<script src="/api/v1/client.js"></script>. The daemon serves it, so it upgrades with Happy Agent; never copy it into the artifact. - Always use absolute
/api/...URLs. A relativeapi/...resolves against the page's own path and does not reach the API. - Put no files under
api/in the artifact. The client showing the page sends/api/*to the daemon, so such files are never served. - Keep everything else in the artifact: no CDNs or other network resources. The page may be opened where the internet is unreachable.
- Treat the store as shared and untrusted. Any copy of the page, including older versions, can
write any key. Validate what you read, and render text with
textContent, neverinnerHTML. - Design keys so that people rarely write the same key. Give each item its own key
(
todos/<id>) rather than one big array, and give each person their own key where you can (votes/<userId>). Usehappy.kv.updatefor anything genuinely shared, such as a counter. - Unconfirmed writes live only in the page's memory and are dropped if the page closes; the daemon's store is the source of truth.
The SDK
window.happy exists as soon as the script runs.
| Member | What it does |
|---|---|
happy.ready | A promise that resolves once the viewer is known and the change stream has connected for the first time. It rejects when this Happy Agent has no data API, which older releases do not. |
happy.user | The viewer, or null before ready. |
happy.status, happy.onStatus(fn) | "connecting", "online", "syncing" (connected, with writes or reads in flight), "offline" (reconnecting), or "unavailable" (the artifact was deleted, or the API is missing). onStatus returns an unsubscribe function. |
happy.pending, happy.flush() | How many writes are not yet confirmed, and a promise that resolves when none are. |
happy.timeOf(version) | The Date a version was written. |
happy.users.load(ids), .get(id), .cached(id) | People by user ID, cached. Each has id, kind ("user", or "agent" for an agent's writes), name, firstName, lastName, and photoUrl (use it directly as an <img src>). Unknown IDs are left out. |
happy.kv.trackPrefix(prefix) | A live view of every key under a prefix: entries (in key order, live keys only), get(key), loaded, pending, synced, subscribe(fn) (called at once and on every change; returns an unsubscribe function), and stop(). |
happy.kv.track(key) | A live view of one key: exists, value, version, updatedBy, updatedAt, pending, loaded, synced, subscribe(fn), set(value, options), delete(options), and stop(). |
happy.kv.set(key, value, options) | Writes a JSON value. Trackers show it at once with pending: true. Resolves with the confirmed entry. Options: { ifVersion } writes only while the key is still at that version, and { ifAbsent: true } only while it holds no value. |
happy.kv.delete(key, options) | Deletes a key, with the same options. |
happy.kv.update(key, change) | Read, change, and compare-and-set, retried on conflict up to 8 times. change(current) receives the current value, or undefined, and returns the next one; returning undefined deletes the key. |
happy.kv.get(key), .entry(key), .list(prefix) | One-off reads: a value as this page sees it, a confirmed entry or null, and every confirmed entry under a prefix. |
happy.kv.onChange(fn) | Every confirmed change this page hears, from anyone, as an entry. A deletion has deleted: true. |
happy.debug.log(message, data, level) | Adds an entry to the artifact's debug log with source app. data is optional JSON; level is "info" (the default), "warn", or "error". Use it for the events you will want when someone reports a problem. |
happy.debug.disable(), happy.debug.flush() | Stop all reporting from this page, or send what is queued now instead of within two seconds. |
An entry is { key, value, deleted, version, updatedBy, updatedAt, writeId }. A tracked view adds
exists and pending. In a view, updatedBy is the viewer's ID while a write is pending.
A refused write rejects its promise with an Error carrying status and code, and the trackers
drop the optimistic value. code is "conflict" when an ifVersion or ifAbsent condition
failed, and error.current then holds the key as it is now. "too_large" means a value is over
256 KiB, and "storage_full" that the store is over 100,000 keys or 64 MiB. A write that fails
because of the network is never refused: it waits and retries.
Keys are 1–512 characters without control characters. Values are any JSON. A deleted key is remembered for 30 days so that pages coming back online learn of it; a page offline for longer reads the store again, which the SDK does by itself.
A complete example: a shared to-do list
This page lists to-dos from everyone, with each author's photo and name, shows the sync state,
and works offline. Publish it with create_artifact as an html artifact whose index.html
holds exactly this:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shared to-dos</title>
<script src="/api/v1/client.js"></script>
<style>
body {
font:
15px/1.4 system-ui,
sans-serif;
max-width: 34rem;
margin: 2rem auto;
padding: 0 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
#status {
font-size: 12px;
padding: 2px 8px;
border-radius: 99px;
background: #eee;
}
#status[data-status="online"] {
background: #d7f5dd;
}
#status[data-status="offline"],
#status[data-status="unavailable"] {
background: #fde2e2;
}
form {
display: flex;
gap: 8px;
margin: 1rem 0;
}
input[type="text"] {
flex: 1;
padding: 6px 8px;
}
li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
list-style: none;
}
li.pending {
opacity: 0.6;
}
li.done .title {
text-decoration: line-through;
color: #888;
}
.title {
flex: 1;
}
.avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
background: #ccc;
}
.meta {
font-size: 12px;
color: #888;
}
ul {
padding: 0;
}
</style>
</head>
<body>
<header>
<h1>Shared to-dos</h1>
<span id="status">Connecting…</span>
</header>
<form id="add">
<input id="text" type="text" placeholder="Add a to-do" maxlength="200" required />
<button>Add</button>
</form>
<ul id="list"></ul>
<script>
const statusLabels = {
connecting: "Connecting…",
online: "Synced",
syncing: "Syncing…",
offline: "Offline, changes are saved here",
unavailable: "Unavailable",
};
const status = document.getElementById("status");
function showStatus(value) {
status.dataset.status = value;
status.textContent = statusLabels[value] || value;
}
showStatus(happy.status);
happy.onStatus(showStatus);
const todos = happy.kv.trackPrefix("todos/");
document.getElementById("add").addEventListener("submit", (event) => {
event.preventDefault();
const input = document.getElementById("text");
const title = input.value.trim();
if (title === "") return;
// One key per item, so two people adding at once never conflict.
happy.kv.set("todos/" + crypto.randomUUID(), {
title,
done: false,
by: happy.user && happy.user.id,
at: Date.now(),
});
input.value = "";
});
// Load each author once, and draw again when their names and photos arrive.
const requested = new Set();
function loadAuthors(ids) {
const missing = ids.filter((id) => typeof id === "string" && !requested.has(id));
if (missing.length === 0) return;
missing.forEach((id) => requested.add(id));
happy.users
.load(missing)
.then(render, () => missing.forEach((id) => requested.delete(id)));
}
function render() {
const list = document.getElementById("list");
list.replaceChildren();
const entries = todos.entries
.filter((entry) => entry.value && typeof entry.value.title === "string")
.sort((a, b) => (a.value.at || 0) - (b.value.at || 0));
loadAuthors(entries.map((entry) => entry.value.by));
for (const entry of entries) {
const item = document.createElement("li");
item.className =
(entry.value.done ? "done " : "") + (entry.pending ? "pending" : "");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = entry.value.done === true;
box.addEventListener("change", () => {
happy.kv.update(entry.key, (current) =>
current === undefined ? undefined : { ...current, done: box.checked },
);
});
const author = happy.users.cached(entry.value.by);
const avatar = document.createElement("img");
avatar.className = "avatar";
avatar.alt = "";
if (author && author.photoUrl) avatar.src = author.photoUrl;
const title = document.createElement("span");
title.className = "title";
title.textContent = entry.value.title;
const meta = document.createElement("span");
meta.className = "meta";
const when = entry.version ? happy.timeOf(entry.version) : null;
meta.textContent =
(author && author.firstName ? author.firstName : "Someone") +
(when
? " · " +
when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: "");
const remove = document.createElement("button");
remove.textContent = "✕";
remove.title = "Delete";
remove.addEventListener("click", () => happy.kv.delete(entry.key));
item.append(box, avatar, title, meta, remove);
list.append(item);
}
}
todos.subscribe(render);
happy.ready.then(render, () => showStatus("unavailable"));
</script>
</body>
</html>
Open it in two windows, or on two devices, and add a to-do in one: it appears in the other at once. Turn the network off, add and tick items, and turn it back on: they sync without duplicates.
Other patterns
- Poll or reactions: one key per voter,
votes/<happy.user.id>, with the choice as the value. Track thevotes/prefix and count. Nobody can overwrite anyone else's vote by accident. - Counter or shared total:
happy.kv.update("score", (n) => (n || 0) + 1). - Single shared document, such as a board's layout: track the key and write with
{ ifVersion: tracker.version }. On a"conflict"rejection, showerror.currentand let the person merge or retry. - Presence ("who is here"): write
presence/<userId>with{ at: Date.now() }every 30 seconds while the page is visible, and show entries whoseupdatedAtis recent. Presence keys accumulate, so delete your own onpagehide. - Recent activity: subscribe with
happy.kv.onChangeand showupdatedByandhappy.timeOf(entry.version). - Ordering by creation time: put a UUIDv7-style or timestamp prefix in the key, or sort by
happy.timeOf(entry.version), which reflects the latest write.
Without the SDK
Every SDK call is a plain HTTP request to /api/v1/... on the page's origin, as API.md describes:
GET /api/v1/kv?prefix=, GET, PUT, and DELETE /api/v1/kv/entry?key=,
GET /api/v1/kv/changes?since=, and Server-Sent Events from GET /api/v1/kv/events?since=. Give a
write a writeId to make retries safe, use If-Match: "<version>" or If-None-Match: * for
compare-and-set, and after a reset event read the store again before streaming from its new
cursor.
Working with the data yourself
- Seed an app after publishing it:
write_artifact_datawith the keys and value shapes the page's script reads, such astodos/<id>with{"title":"…","done":false,"at":<ms>}. Passvalueas JSON text. UseifAbsent: trueso you never replace something a person already made. - Report on it:
read_artifact_datawith aprefixlists keys with their values, versions, authors, and times; page withpageCursor. - Change a value people may also be changing: read it, then write with
ifVersionset to the version you read. If the write is refused, read again and decide; never retry blindly. - Prefer changing the page when the data's shape must change. Old and new versions share one store, so a new version must still understand the data the old one wrote.
Debugging your app
Reporting is automatic. Each page batches what goes wrong and sends it to
/api/v1/debug/log every two seconds, at most 60 entries a minute; while the browser is offline,
and when a send fails, the batch is dropped rather than retried. The SDK's own sync entries say
when the change stream ended and reconnected, when a write conflicted or was refused, when the
page had to read its data again, and when the data became unavailable. The daemon keeps the newest
1,000 entries of the last 7 days per artifact, beside its data.
Develop with happy-agent artifact dev
Build and test the app before publishing it, against a local copy of the data API:
happy-agent artifact dev ./my-app --scratch .context/my-app-data
It serves the folder at http://127.0.0.1:4173/, answers /api/* with the same code, limits, and
SDK as the daemon, and keeps data and the debug log in the --scratch folder (without it, in a
temporary folder removed on exit). It never touches the daemon's data. Run it as a workspace
service, with the folder as an input and the scratch folder as scratch, so the person can open the
preview beside you:
service_start name: "my-app" port: 4173
cmd: "happy-agent artifact dev my-app --scratch .context/my-app-data"
sandbox: { inputs: ["my-app"], scratch: [".context/my-app-data"] }
Everything the server can do is under /__happy/: an HTML panel at /__happy/, and JSON routes
for scripts and for you:
| Route | What it does |
|---|---|
/?as=Alice | Opens the app as a made-up person, with a name and a generated photo. Each tab can be someone else; without as, the page is "You". |
GET /__happy/state | The folder, the faults in force, open change streams, and everyone known. |
POST /__happy/faults | Any of { "offline": true }, { "latencyMs": 800 }, { "failNext": 3 } (answer 503 without applying), and { "dropNext": 1 } (apply, then lose the response). |
POST /__happy/streams/cut | Cuts every open change stream, as a dropped connection does. |
POST /__happy/data/write | { "as": "Bob", "key": "todos/1", "value": {…} }, or "delete": true: a write by someone else, live to every page. |
POST /__happy/data/seed | { "values": { "key": value, … }, "as": "Bob" } writes several keys. |
GET / PUT /__happy/data | Dumps every entry, tombstones and authors included, or replaces the data with such a dump. |
POST /__happy/data/reset | Forgets every entry; open pages read their data again. |
GET /__happy/logs, DELETE /__happy/logs | The debug log, newest first, with level, user (a name), limit, and pageCursor; or clears it. |
GET /__happy/requests, DELETE | The last 500 data requests, newest first: who made them, status, time taken, and any fault applied. |
The debug log route itself is never faulted, so the log shows what pages reported during an outage. The loop that finds most bugs:
- Start
artifact devas a service and open the app; open it again with?as=Bob. - Make changes as both people, then make the network bad: go offline, add latency, fail or drop a
few writes, cut the streams. Check that both pages end up showing the same thing and that
GET /__happy/dataholds what you expect, with no doubled items. - Read
GET /__happy/logs?level=warnandGET /__happy/requestsfor anything surprising. - Publish with
create_artifactorupdate_artifact. After people use it, readread_artifact_logs, filtered bylevel,userId, orsince, to see what broke for whom.
Reproduce a bug with real data
Start the server from a copy of a published artifact's data:
happy-agent artifact dev ./my-app --from <artifactId>
It reads the artifact's data through the daemon, once, with its versions and authors' names, and never writes back. A workspace service cannot reach the daemon, so for a service save the copy first and start from the file:
happy-agent artifact snapshot <artifactId> --out .context/my-app-data.json
happy-agent artifact dev my-app --seed .context/my-app-data.json --scratch .context/my-app-data
--seed also takes a plain JSON object of keys and values.
Drive it with a browser
For a repeatable check, script real browsers against the dev server with Playwright and headless
Chromium. Nothing is bundled: install playwright-core (or playwright) in a scratch folder with
the project's package manager, and use a Chromium already on the machine
(its playwright install chromium command otherwise). A useful script:
- opens one page per person,
page.goto(base + "/?as=Alice"), and waits forwindow.happy.status === "online"; - records
page.on("console")errors and warnings,page.on("pageerror"), andpage.on("requestfailed"); - plays a scenario, calling
POST /__happy/faultsbetween steps, and waits for each page's text to converge withpage.waitForFunction; - ends with screenshots, each page's visible text,
GET /__happy/data, andGET /__happy/logs, so you can tell what each person saw and what the store holds.
Failed requests and console errors during injected faults are expected; anything else is a bug.
Checks before you publish
- The page loads
/api/v1/client.jswith an absolute path, and the artifact holds no files underapi/and loads nothing from the internet. - Item keys are per item or per person, and shared values use
updateorifVersion. - The page shows
happy.statusand pending items. - Everything read from the store is validated, and text is rendered with
textContent. - You ran it in
happy-agent artifact devas two people, offline and with lost responses, and both pages converged with no doubled items and nothing unexpected in the debug log.