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
| Method | Good for | Key needed | Supported by AgentDB |
|---|---|---|---|
| Polling a query | Simple screens, a few users | Yes, on your server | Yes |
Change feed (GET /changes) | Your backend follows every change, in order | Yes, on your server | Yes, with long-polling |
Dashboard data link (?since&wait=1) | Read-only pages without a backend | No | Yes, with long-polling |
Custom dashboard (/_data/<source>) | Read-only sites hosted by AgentDB | No | Yes, with long-polling (experimental) |
| Webhooks | Your service reacts to events, without polling | Your server verifies a signature | Yes, signed and retried |
| Pushing to many browsers | Live screens for many users | Your backend | Your 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.
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 withseq,action(such asrecord.create,record.update,record.delete),subject_id(the record id) andat;next_after: send it asafteron your next call;marker: the newestseqin 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.
#Dashboard data links
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=1in a loop; - AgentDB answers about a second after the data changes, or with
{"unchanged": true}after about 25 seconds.
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:
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 yourwhsec_secret. - At least once: the same event can arrive twice. De-duplicate on the
X-AgentDB-Event-Idheader. - 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:
/**
* 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:
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:
- The backend long-polls the change feed with its key.
- On a record change, it sends a tiny event to every open page over Server-Sent Events.
- Each page then asks the backend for the fresh list.
The backend's AgentDB access (Node.js 22, no packages):
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:
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:
/** 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:
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:
cd examples/languages/javascript/live
AGENTDB_API_KEY=... AGENTDB_WORKSPACE=ws_... node server.mjs
# open http://localhost:8080 and change a contact in AgentDBEvery 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.