Plain HTML and JavaScript
Build a contact list with search and an "add" form. The page is plain HTML and vanilla JavaScript. A small Node server holds the AgentDB key and calls AgentDB for the page.
This page uses the html-node-backend starter. The smoke test runs it against a local AgentDB (npm run docs:starters).
#The pattern
A page cannot keep a secret. Anyone can open the browser's developer tools and read every key the page uses. So the page calls your own server, and only your server calls AgentDB. See Pattern B.
// Wrong: the key is in the page. Anyone who opens it can read and change your data.
fetch("https://app.agentdb.link/v1/workspaces/ws_.../query", {
method: "POST",
headers: { authorization: "Bearer adbk.your-key" },
body: JSON.stringify({ object: "contact" }),
});// Secure: the page calls its own backend. The backend has the key.
const res = await fetch("/api/contacts?q=" + encodeURIComponent(q));#Where the key lives
The key lives in the server's environment. Create a separate agent key for the app with role editor. Keep your own owner key out of the app.
# .env, next to server.mjs. Never commit it.
AGENTDB_API_URL=https://app.agentdb.link
AGENTDB_WORKSPACE=ws_your_workspace_id
AGENTDB_TOKEN=replace-with-your-agent-keyThe server reads it at start:
const API = (process.env.AGENTDB_API_URL ?? "https://app.agentdb.link").replace(/\/+$/, "");
const WORKSPACE = process.env.AGENTDB_WORKSPACE;
const TOKEN = process.env.AGENTDB_TOKEN; // an agent key (role editor), never an owner key
if (!WORKSPACE || !TOKEN) {
console.error("Set AGENTDB_WORKSPACE and AGENTDB_TOKEN (see .env.example).");
process.exit(1);
}#The backend route
One function calls AgentDB. It adds the key and turns an error answer into an exception:
async function agentdb(method, path, body, idempotencyKey) {
const res = await fetch(`${API}/v1/workspaces/${WORKSPACE}${path}`, {
method,
headers: {
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
...(idempotencyKey && { "idempotency-key": idempotencyKey }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
const err = new Error(json.error?.message ?? `AgentDB answered ${res.status}`);
err.status = res.status;
err.code = json.error?.code;
throw err;
}
return json.data;
}The ALLOWED table is the whole API of the server. The browser can list and search contacts, and add one. Every other path answers 404. Input is trimmed and limited before it reaches AgentDB.
const ALLOWED = {
"GET /api/contacts": async (url) => {
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
const data = await agentdb("POST", "/query", {
object: "contact",
where: { all: q ? [{ field: "name", op: "contains", value: q }] : [] },
sort: [{ field: "name", order: "asc" }],
limit: 50,
});
return [200, { contacts: data.records.map((r) => ({ id: r.id, name: r.data.name, email: r.data.email ?? null })) }];
},
"POST /api/contacts": async (_url, body, req) => {
const name = String(body.name ?? "").trim().slice(0, 200);
const email = String(body.email ?? "").trim().slice(0, 254);
if (!name) return [400, { error: "A contact needs a name." }];
// One id per submit from the page: a retried request is not written twice.
const submit = String(req.headers["x-submit-id"] ?? randomUUID()).replace(/[^A-Za-z0-9-]/g, "").slice(0, 64);
const rec = await agentdb("POST", "/records", { object: "contact", data: { name, ...(email && { email }) } }, `html-node.${submit}`);
return [201, { contact: { id: rec.id, name: rec.data.name, email: rec.data.email ?? null } }];
},
};Errors from AgentDB become short messages. Validation messages are safe to show. Anything else is logged on the server, and the page gets a general message.
function toHttpError(e) {
if (!e.code && (e.status === 413 || e.status === 400)) return [e.status, e.message]; // our own checks
if (e.code === "VERSION_CONFLICT") return [409, "Someone changed it first. Reload and try again."];
if (e.status === 400 || e.status === 422) return [400, e.message]; // validation: safe to show
console.error("AgentDB call failed:", e.code ?? e.status, e.message);
return [502, "The data service is not available. Try again in a moment."];
}#The frontend
The page has a search box, a status line and a form:
<form id="search" role="search">
<label for="q">Search by name</label>
<input id="q" name="q" type="search" maxlength="100">
</form>
<p id="status" role="status" aria-live="polite">Loading…</p>
<ul id="list"></ul>
<h2>Add a contact</h2>
<form id="add">
<label for="name">Name</label>
<input id="name" name="name" required maxlength="200">
<label for="email">Email</label>
<input id="email" name="email" type="email" maxlength="254">
<button>Add</button>
<p id="add-msg" role="status" aria-live="polite"></p>
</form>#Loading, empty and error states
The status line always says what is going on: loading, no results, or an error. Names are set with textContent, never as HTML.
async function load() {
status.textContent = "Loading…";
try {
const res = await fetch("/api/contacts?q=" + encodeURIComponent(q.value.trim()));
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Could not load contacts.");
// Set text, never HTML: names come from your data and may contain anything.
list.replaceChildren(
...body.contacts.map((c) => {
const li = document.createElement("li");
li.textContent = c.email ? `${c.name} (${c.email})` : c.name;
return li;
}),
);
status.textContent = body.contacts.length ? "" : q.value ? "No contacts match your search." : "No contacts yet. Add the first one below.";
} catch (e) {
list.replaceChildren();
status.textContent = `Error: ${e.message}`;
}
}Adding a contact sends JSON with a new submit id. The server passes it on as an Idempotency-Key, so a retried request is written once.
document.getElementById("add").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.currentTarget;
const msg = document.getElementById("add-msg");
const button = form.querySelector("button");
button.disabled = true;
msg.textContent = "Saving…";
try {
const res = await fetch("/api/contacts", {
method: "POST",
headers: { "content-type": "application/json", "x-submit-id": crypto.randomUUID() },
body: JSON.stringify({ name: form.name.value, email: form.email.value }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Could not save.");
msg.textContent = `Added ${body.contact.name}.`;
form.reset();
load();
} catch (err) {
msg.textContent = `Error: ${err.message}`;
} finally {
button.disabled = false;
}
});#Live updates
This page loads data when it opens and after each change it makes. To show changes made by others:
- Poll
/api/contactsevery 10 to 30 seconds, or - add a server-sent events route that follows the change feed, as in the live dashboard starter. See Live data for every option.
#Run it
cd examples/starters/html-node-backend
cp .env.example .env # then fill in your workspace and agent key
node --env-file=.env server.mjsOpen http://localhost:3000. Node 22 or later, no dependencies. To put it online, see Deployment.