AgentDBDocs

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 the html-node-backend starter. 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.

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);
}

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

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 backend routes with node:http

The ALLOWED table lists every operation the browser may trigger. Nothing else 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 } }];
  },
};

#The same routes with Express

JavaScript
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.

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."];
}

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

JavaScriptexamples/starters/live-dashboard/server.mjs
/** 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
    }
  }
}