Node.js and Express
Build a small Node backend that holds the AgentDB key and offers your frontend a few safe routes. Node 22 has fetch built in, so you need no HTTP library.
There are two versions on this page:
node:http, with no dependencies. This is thehtml-node-backendstarter. The smoke test runs it (npm run docs:starters).- Express, for projects that already use it. Not run in CI. It makes the same AgentDB calls.
#Where the key lives
In the server's environment. Node 22 reads a .env file with node --env-file=.env server.mjs. Do not commit .env.
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);
}Use a separate agent key per app. A viewer key can only read. An editor key can write records but cannot change the schema or other keys.
#The AgentDB call
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 backend routes with node:http
The ALLOWED table lists every operation the browser may trigger. Nothing else 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 } }];
},
};#The same routes with Express
import express from "express";
const app = express();
app.use(express.json({ limit: "10kb" })); // JSON only: blocks plain cross-site form posts
app.get("/api/contacts", async (req, res, next) => {
try {
const q = String(req.query.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,
});
res.set("cache-control", "no-store").json({ contacts: data.records.map((r) => ({ id: r.id, name: r.data.name, email: r.data.email ?? null })) });
} catch (e) {
next(e);
}
});
app.post("/api/contacts", async (req, res, next) => {
try {
if (!req.is("application/json")) return res.status(415).json({ error: "Send JSON." });
const name = String(req.body.name ?? "").trim().slice(0, 200);
if (!name) return res.status(400).json({ error: "A contact needs a name." });
const submit = String(req.get("x-submit-id") ?? crypto.randomUUID()).replace(/[^A-Za-z0-9-]/g, "").slice(0, 64);
const rec = await agentdb("POST", "/records", { object: "contact", data: { name } }, `express.${submit}`);
res.status(201).json({ contact: { id: rec.id, name: rec.data.name } });
} catch (e) {
next(e);
}
});
app.use(express.static("public")); // the frontend: same origin, no CORS needed
// Errors: show validation messages, hide everything else.
app.use((err, _req, res, _next) => {
if (err.code === "VERSION_CONFLICT") return res.status(409).json({ error: "Someone changed it first." });
if (err.status === 400 || err.status === 422) return res.status(400).json({ error: err.message });
console.error(err);
res.status(502).json({ error: "The data service is not available." });
});
app.listen(process.env.PORT ?? 3000, "127.0.0.1");agentdb() is the same function as above. Add security headers with a middleware or a package such as helmet.
#The frontend
The page calls /api/contacts on the same origin. See Plain HTML and JavaScript, or React and Vue.
#Loading, empty and error states
The backend answers { error } with a short message and a fitting status code (400, 409, 415, 502). The page shows that message and never the raw AgentDB answer.
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."];
}#Live updates
A long-running Node process can follow the change feed for all viewers and push updates with server-sent events. The live dashboard starter does exactly that.
/** One loop for the whole server: wait for changes, then send a fresh snapshot to every viewer. */
async function follow() {
// Start at the newest change: the first snapshot already holds everything before it.
let after = (await agentdb("GET", "/changes?limit=1")).marker;
current = await snapshot();
for (;;) {
try {
// wait=1: AgentDB answers as soon as something changes, or after about 25 seconds.
const feed = await agentdb("GET", `/changes?after=${after}&limit=100&wait=1`, undefined, AbortSignal.timeout(40_000));
if (feed.changes.length === 0) continue;
after = feed.next_after ?? feed.changes.at(-1).seq;
if (feed.changes.some((c) => c.action.startsWith("record.") || c.action.startsWith("schema."))) {
current = await snapshot();
broadcast("contacts", current);
}
} catch (e) {
console.error("Change feed:", e.code ?? e.name, e.message);
broadcast("problem", { message: "Live updates paused. Retrying…" });
await new Promise((r) => setTimeout(r, 5_000)); // back off, then resume where we left off
}
}
}