AgentDBDocs

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.

JavaScript: wrong, do not copy
// 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" }),
});
JavaScript: secure, do this
// 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.

Shell
# .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-key

The server reads it at start:

JavaScriptexamples/starters/html-node-backend/server.mjs
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:

JavaScriptexamples/starters/html-node-backend/server.mjs
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.

JavaScriptexamples/starters/html-node-backend/server.mjs
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.

JavaScriptexamples/starters/html-node-backend/server.mjs
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:

HTMLexamples/starters/html-node-backend/public/index.html
<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.

JavaScriptexamples/starters/html-node-backend/public/app.js
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.

JavaScriptexamples/starters/html-node-backend/public/app.js
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/contacts every 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

Shell
cd examples/starters/html-node-backend
cp .env.example .env    # then fill in your workspace and agent key
node --env-file=.env server.mjs

Open http://localhost:3000. Node 22 or later, no dependencies. To put it online, see Deployment.