AgentDBDocs

Live data

This page shows how your app learns that data changed. You can build:

  • a CRM screen that shows a new contact within a second or two;
  • a wall display with totals that update by themselves;
  • a service that sends an e-mail when a deal is won.

#Choose a method

MethodGood forKey neededSupported by AgentDB
Polling a querySimple screens, a few usersYes, on your serverYes
Change feed (GET /changes)Your backend follows every change, in orderYes, on your serverYes, with long-polling
Dashboard data link (?since&wait=1)Read-only pages without a backendNoYes, with long-polling
Custom dashboard (/_data/<source>)Read-only sites hosted by AgentDBNoYes, with long-polling (experimental)
WebhooksYour service reacts to events, without pollingYour server verifies a signatureYes, signed and retried
Pushing to many browsersLive screens for many usersYour backendYour own infrastructure: Server-Sent Events or WebSockets from your backend

AgentDB does not push to browsers directly with a key. Your backend follows the change feed or receives webhooks, then tells the open pages. The complete example does exactly that.

#Polling a query

The simplest way: run the same query every few seconds on your server. Keep the interval at 5 seconds or more, and stop when nobody looks. A 429 RATE_LIMITED answer means you poll too often; wait for the Retry-After seconds.

Polling is fine for a few users. For more, follow the change feed once on the server and share the result.

#The change feed

The change feed lists every change in a workspace, in commit order. Each change has a seq number. Your backend remembers the last seq it handled and asks for what came after.

RESTChecked
curl -s "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/changes?after=0&limit=10" \
  -H "Authorization: Bearer $AGENTDB_API_KEY"

The answer:

  • changes: each with seq, action (such as record.create, record.update, record.delete), subject_id (the record id) and at;
  • next_after: send it as after on your next call;
  • marker: the newest seq in the workspace. Start from it to skip the history.

Add wait=1 to long-poll: when nothing changed yet, AgentDB holds the request until a change arrives, or about 25 seconds. Then call again. This gives updates within about a second, without busy polling.

The feed carries ids, not values. Read the record with GET /records/{id} when you need its values. Hidden fields stay hidden: a key never sees changes to fields its permission rules hide. AI agents use the same feed as the MCP tool records_changes.

A dashboard data link needs no key, so a static page can use it. Create the dashboard once (see Dashboards and sharing), then poll its data_url:

  • the first call returns the panels and a marker;
  • call data_url?since=<marker>&wait=1 in a loop;
  • AgentDB answers about a second after the data changes, or with {"unchanged": true} after about 25 seconds.
JavaScript
async function live(dataUrl, onData) {
  let marker;
  for (;;) {
    const r = await fetch(dataUrl + (marker === undefined ? "" : "?since=" + marker + "&wait=1"));
    const j = await r.json();
    if (!j.data) throw new Error(j.error.message); // revoked, expired or refused
    marker = j.data.marker;
    if (!j.data.unchanged) onData(j.data);
  }
}

The link shows only the panels you defined. Read Pattern C for its security boundary.

#Custom dashboard data

A custom dashboard is a site AgentDB hosts (experimental). Its page reads the sources in its manifest.json from its own address, with the same long-poll:

JavaScript
async function live(source, onData) {
  let marker;
  for (;;) {
    const r = await fetch(`/_data/${source}` + (marker === undefined ? "" : `?since=${marker}&wait=1`));
    if (r.status === 401 || r.status === 404) return location.reload(); // access ended: sign in again
    const j = await r.json();
    marker = j.marker;
    if (!j.unchanged) onData(j);
  }
}

#Webhooks

A webhook sends an HTTPS POST to your service when something happens. Owners and admins create them with webhooks_create (MCP) or POST /v1/workspaces/{ws}/webhooks. See Automation for events and settings.

What AgentDB guarantees:

  • Signed: every request carries X-AgentDB-Signature: t=<unix time>,v1=<hex>. The signature is an HMAC-SHA256 of <t>.<raw body>, keyed with your whsec_ secret.
  • At least once: the same event can arrive twice. De-duplicate on the X-AgentDB-Event-Id header.
  • In order per webhook, and retried 10 times over about 2.4 hours when your answer is not 2xx.
  • Ids, not values: fetch the record with your own key.
  • Public HTTPS only: AgentDB refuses private and local addresses. To test on your laptop, use a tunnel, or follow the change feed instead.

Verify the signature on the raw body, before you parse it:

JavaScriptexamples/languages/javascript/webhooks/receiver.mjs
/**
 * True when the X-AgentDB-Signature header ("t=<unix time>,v1=<hex>[,v1=<hex>]") matches an
 * HMAC-SHA256 of "<t>.<raw body>" with your secret, and t is at most 5 minutes old.
 * During a secret rotation the header carries two v1 values: either one may match.
 */
export function verifySignature(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = String(header ?? "").split(",").map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return parts
    .filter((p) => p.startsWith("v1="))
    .some((p) => {
      const got = Buffer.from(p.slice(3), "hex");
      return got.length === expected.length && timingSafeEqual(got, expected);
    });
}

Then handle the event and answer quickly:

JavaScriptexamples/languages/javascript/webhooks/receiver.mjs
const seen = new Set(); // event ids already handled; use your database in production

/** Handles one delivery. Answer 2xx fast; AgentDB retries anything else (10 times over ~2.4 h). */
export async function handleDelivery({ headers, rawBody, secret, fetchRecord }) {
  if (!verifySignature(secret, headers["x-agentdb-signature"], rawBody)) return { status: 401 };
  const eventId = headers["x-agentdb-event-id"];
  if (seen.has(eventId)) return { status: 200 }; // at-least-once delivery: same event, second time
  seen.add(eventId);
  const event = JSON.parse(rawBody); // ids only: {type, data: {object, record_id, version, fields}}
  if (event.type.startsWith("record.") && event.type !== "record.deleted") {
    const record = await fetchRecord(event.data.record_id); // read the values with your own key
    console.log(event.type, record.object, record.id, "version", record.version);
  }
  return { status: 204 };
}

Our tests sign deliveries with AgentDB's own signing code and check this receiver: valid, tampered, too old, rotated secrets and duplicates.

#Complete example: a page that updates when a record changes

This small app shows a list of contacts. When an AI agent or a person adds or changes a contact, every open page updates within a second or two. The browser never sees the key.

How it works:

  1. The backend long-polls the change feed with its key.
  2. On a record change, it sends a tiny event to every open page over Server-Sent Events.
  3. Each page then asks the backend for the fresh list.

The backend's AgentDB access (Node.js 22, no packages):

JavaScriptexamples/languages/javascript/live/server.mjs
const BASE_URL = (process.env.AGENTDB_URL ?? "https://app.agentdb.link").replace(/\/+$/, "");
const API = `${BASE_URL}/v1/workspaces/${process.env.AGENTDB_WORKSPACE}`;
const HEADERS = { Authorization: `Bearer ${process.env.AGENTDB_API_KEY}` }; // stays on the server

async function agentdb(path, init = {}) {
  const res = await fetch(API + path, { ...init, headers: { ...HEADERS, ...init.headers } });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
  return body.data;
}

Following the change feed and telling the pages:

JavaScriptexamples/languages/javascript/live/server.mjs
const clients = new Set(); // open browser connections (Server-Sent Events)

/** Follows the change feed forever. Each answer comes when data changes, or after about 25 s. */
async function followChanges(signal) {
  let after = (await agentdb("/changes?after=0&limit=1")).marker; // start at "now"
  while (!signal.aborted) {
    try {
      const page = await agentdb(`/changes?after=${after}&limit=100&wait=1`, { signal });
      for (const change of page.changes) {
        if (!change.action.startsWith("record.")) continue; // record.create, record.update, record.delete
        const event = JSON.stringify({ action: change.action, record_id: change.subject_id, seq: change.seq });
        for (const res of clients) res.write(`event: change\ndata: ${event}\n\n`);
      }
      after = page.next_after ?? after;
    } catch (err) {
      if (signal.aborted) return;
      console.error("change feed:", err.message); // network trouble or 429: wait, then go on
      await new Promise((r) => setTimeout(r, 5000));
    }
  }
}

The routes the page uses, including your sign-in check:

JavaScriptexamples/languages/javascript/live/server.mjs
/** Your own sign-in goes here (check a session cookie, SSO, ...). This demo lets everyone read. */
function authorize(req) {
  return req.method === "GET";
}

const PUBLIC = fileURLToPath(new URL("./public/", import.meta.url));

async function handle(req, res) {
  if (!authorize(req)) return res.writeHead(401).end();
  if (req.url === "/events") {
    res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
    res.write(": connected\n\n");
    clients.add(res);
    req.on("close", () => clients.delete(res));
    return;
  }
  if (req.url === "/api/contacts") {
    const page = await agentdb("/query", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ object: "contact", sort: [{ field: "updated_at", order: "desc" }], limit: 20, fields: ["name", "score"] }),
    });
    const rows = page.records.map((r) => ({ id: r.id, name: r.data.name, score: r.data.score ?? null }));
    res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
    return res.end(JSON.stringify(rows)); // only the fields the page needs
  }
  const file = { "/": "index.html", "/app.js": "app.js" }[req.url];
  if (!file) return res.writeHead(404).end();
  res.writeHead(200, { "Content-Type": file.endsWith(".js") ? "text/javascript" : "text/html; charset=utf-8" });
  res.end(await readFile(PUBLIC + file));
}

The page's script:

JavaScriptexamples/languages/javascript/live/public/app.js
const list = document.getElementById("list");
const status = document.getElementById("status");

async function refresh() {
  const res = await fetch("/api/contacts", { credentials: "same-origin" });
  if (!res.ok) return void (status.textContent = "Could not load contacts.");
  const rows = await res.json();
  list.replaceChildren(
    ...rows.map((r) => {
      const li = document.createElement("li");
      li.textContent = `${r.name}${r.score === null ? "" : ` (${r.score})`}`; // text, never HTML
      return li;
    }),
  );
  status.textContent = `Updated ${new Date().toLocaleTimeString()}`;
}

const events = new EventSource("/events"); // reconnects by itself
events.addEventListener("change", () => void refresh());
events.addEventListener("open", () => void refresh());
events.addEventListener("error", () => (status.textContent = "Reconnecting…"));

Run it:

Shell
cd examples/languages/javascript/live
AGENTDB_API_KEY=... AGENTDB_WORKSPACE=ws_... node server.mjs
# open http://localhost:8080 and change a contact in AgentDB

Every line above comes from the example files in examples/languages/javascript/live/. Our tests start this server, open the event stream, create a contact and expect the event and the new list.

This example keeps one change-feed loop for all pages. That scales to many viewers on one server. With several servers, run one loop per server, or put the events on your own message bus.