# AgentDB documentation (1.0.0-rc.13, released 2026-11-03) MCP: https://app.agentdb.link/mcp · REST: https://app.agentdb.link/v1 · Docs: https://docs.agentdb.link/1.0.0-rc.13/ ## Build an app Guides for building websites, CRMs, dashboards and custom apps on AgentDB follow under "build/". Security rule: never put an AgentDB API key (account, workspace or agent key) in browser code, public HTML, a mobile app or a client bundle. Call AgentDB from your own server, which holds the key in an environment variable and checks your own users. For read-only pages without a backend, use a dashboard data link or a custom dashboard instead of a key. # AgentDB documentation AgentDB gives your AI agent its own database. Your agent defines tables, stores records and files, and links them. It then finds them again with exact queries. Nobody writes SQL. You can use AgentDB in three ways: - Your AI client talks to AgentDB over MCP. Examples are Claude, ChatGPT, Cursor and Claude Code. - You or a script call the REST API at `https://app.agentdb.link`. - You run the `agentdb` command-line tool. All three reach the same data with the same rules. > AgentDB is in beta. Store only test data or data that is not sensitive. ## New here? 1. [Get started](getting-started.md) in about five minutes. You sign up, get a key and store your first records. 2. [Connect your AI](connect-your-ai.md) so your agent can use AgentDB by itself. 3. Read the [concepts](concepts.md) to learn the words this documentation uses. ## What you can do - **Give your agent a memory that lasts.** Your agent creates objects such as `contact` and `company` with typed fields. See [Concepts](concepts.md#objects-and-fields). - **Find exactly the records you need.** Filter, sort, page, count and total your data. See [Query your data](querying.md). - **Search text across records.** Turn on full-text search for an object and search by words. See [Full-text search](querying.md#full-text-search). - **Store and import files.** Attach documents to records and import CSV or JSON files. See [Files and imports](files-and-imports.md). - **Share live dashboards.** Your agent designs a page and AgentDB feeds it live, read-only data. See [Dashboards and sharing](dashboards-and-sharing.md). - **Run work on a schedule or on a change.** Use webhooks, scheduled jobs and flows. See [Automation](automation.md). - **Keep a human in the loop.** Approve or reject sensitive changes in the AgentHub. See [Hub and approvals](hub-and-approvals.md). - **Control who can do what.** Invite people, give agents their own keys and set roles. See [Accounts and security](accounts-and-security.md). - **Start from a ready app.** Install a CRM, a to-do list or a Kanban board in one step. See [App packages](app-packages.md). - **Build your own app on it.** Use AgentDB as the database of a website, CRM or dashboard, in your own language. See [Build with AgentDB](build/index.md). - **Fix a problem.** Look up an error code and what to do next. See [Troubleshooting](troubleshooting.md). ## Reference - [CLI reference](reference/cli.html): every `agentdb` command and option. - [Field types](reference/field-types.html): every field type with its rules and examples. - [File types](reference/file-types.html): which files you can upload. - [Release notes](release-notes.html): what changed in each version. ## For AI agents Agents can read a compact, machine-readable version of this documentation. See [Documentation for agents](agents.html). An agent connected over MCP can also ask AgentDB itself. The `agentdb_capabilities` tool lists every operation with its input and an example. See [Discover operations](connect-your-ai.md#discover-operations). # Get started In about five minutes you will have your own AgentDB workspace. You will store contacts and companies, link them and query them. You do not write SQL. This guide uses the `agentdb` command-line tool. Your AI agent can do the same steps over MCP. See [Connect your AI](connect-your-ai.md). > AgentDB is in beta. Store only test data or data that is not sensitive. ## 1. Sign up {#sign-up} 1. Open `https://app.agentdb.link`. 2. Enter your email address. A project name is optional. 3. Accept the terms and choose **Create workspace**. AgentDB creates your account, your first workspace and an API key at once. There is no waiting list. ## 2. Save your API key {#save-your-key} The next page shows your API key. It starts with `adbk.`. > The page shows the key only once. Choose **Copy API key** or **Download .env** and keep it in a password manager. Never paste it into a chat or commit it to git. Lost your key? Request a recovery link at `https://app.agentdb.link/recover`. See [Accounts and security](accounts-and-security.md). ## 3. Install the CLI and log in {#install-the-cli} Install the CLI on macOS or Linux. It needs no Node.js. ```sh curl -fsSL https://app.agentdb.link/downloads/cli/install.sh | sh ``` Log in. The CLI opens your browser. You approve the login there with your API key. ```sh agentdb login --api-url https://app.agentdb.link ``` The CLI keeps the key in your operating system's credential store. It never writes the key into a project file. Scripts and CI can use two environment variables instead. `AGENTDB_TOKEN` holds the key. `AGENTDB_WORKSPACE` holds the workspace id. ## 4. Define a company and a contact {#define-objects} An **object** is a type of record, like a table. A **field** is a typed value on that object. Read more in [Concepts](concepts.md#objects-and-fields). Create the `company` object with a unique name and a country: ```cli agentdb schema object-create company ``` ```cli agentdb schema field-add company name --type text --unique ``` ```cli agentdb schema field-add company country --type text ``` Create the `contact` object with an email address, a name and a score: ```cli agentdb schema object-create contact ``` ```cli agentdb schema field-add contact email --type email --unique ``` ```cli agentdb schema field-add contact name --type text ``` ```cli agentdb schema field-add contact score --type integer ``` Define how they relate. A contact `works_at` one company: ```cli agentdb schema relationship-add contact works_at company --cardinality one ``` ## 5. Write records {#write-records} Create a company and a contact. Each command prints the new record with its `id`. ```cli agentdb records create company --data '{"name":"Example Ltd","country":"NL"}' ``` ```cli agentdb records create contact --data '{"email":"ada@example.com","name":"Ada Example","score":82}' ``` AgentDB checks every value against its field type. A wrong value is refused with a clear error, and nothing is written. ## 6. Link the records {#link-records} Link the contact to the company: ```cli agentdb records link $CONTACT works_at $COMPANY ``` Now ask who works at the company. `--incoming` follows links that point at the company. ```cli agentdb records related $COMPANY works_at --incoming ``` ## 7. Query your records {#query} Find contacts with a score of 70 or more at a company in NL: ```cli agentdb query --json '{"object":"contact","where":{"all":[{"field":"score","op":"gte","value":70},{"relationship":"works_at","where":{"all":[{"field":"country","op":"eq","value":"NL"}]}}]},"sort":[{"field":"score","order":"desc"}],"limit":10}' ``` The same query works over REST. Your agent sends the same JSON to the `records_query` tool. ```rest curl -X POST https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/query \ -H "Authorization: Bearer $AGENTDB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"object":"contact","where":{"all":[{"field":"score","op":"gte","value":70}]}}' ``` Learn every filter and option in [Query your data](querying.md). ## 8. Let your agent do it {#ask-your-agent} Connect your AI client. See [Connect your AI](connect-your-ai.md). Then ask in plain language, for example: > Using AgentDB, create a company object (name unique, country) and a contact object (email unique, name, score integer). A contact works_at one company. Add three fictional companies and eight fictional contacts and link them. > Using AgentDB, find contacts with score 70 or more at companies in NL, sorted by score. Your agent works out the tool calls itself. ## Next steps - [Connect your AI](connect-your-ai.md) to Claude, ChatGPT, Cursor or another client. - [Files and imports](files-and-imports.md) to attach documents and import a CSV file. - [Dashboards and sharing](dashboards-and-sharing.md) to show your data on a live page. # Connect your AI Connect your AI client once. After that, your agent can create objects, store records and query them by itself. You only ask in plain language. AgentDB is a remote MCP server. Every client connects to the same address: ```text https://app.agentdb.link/mcp ``` There are three ways to connect. Pick the first one your client supports. | Way | Clients | You need | |-----|---------|----------| | Sign in through the browser (OAuth) | claude.ai, Claude Desktop, ChatGPT | An AgentDB account | | `agentdb mcp install` | Claude Code, Codex, Cursor, VS Code, Claude Desktop, Windsurf, Gemini CLI | The `agentdb` CLI | | API key in a header | Any client that can send custom headers | Your API key | ## claude.ai and Claude Desktop {#claude} 1. In Claude, open **Customize**, then **Connectors**, then **Add custom connector**. 2. Enter the URL `https://app.agentdb.link/mcp`. Keep the authentication on **OAuth**. 3. Claude opens an AgentDB page. Approve the connection there. 4. Back in Claude, turn the connector on in a chat. The AgentDB tools are now available. On the approval page you have three choices: - **I'm new: create a workspace.** AgentDB creates an account and a workspace for you. - **Use my API key.** You paste your key once. Claude never sees it. - **Email me a sign-in link.** AgentDB emails a link to your account address. Claude Desktop uses the connectors of your Claude account. You can also use `agentdb mcp install claude-desktop`, described below. ## ChatGPT {#chatgpt} ChatGPT connects with OAuth, like claude.ai. Custom connectors need a paid ChatGPT plan. On a Business or Enterprise plan, an admin may have to allow them. 1. In ChatGPT, add a custom MCP server or connector. The menu name depends on your ChatGPT version. 2. Name it AgentDB and enter the URL `https://app.agentdb.link/mcp`. 3. Choose **OAuth** as the authentication. 4. Approve the connection on the AgentDB page that opens. 5. In a chat, enable the AgentDB connector and ask: "Using AgentDB, show my workspaces." ## Claude Code, Cursor and other local tools {#local-tools} The `agentdb` CLI sets up local AI tools for you. Your key stays in the operating system's credential store. No key is written into the tool's configuration file. 1. Install the CLI and log in. See [Get started](getting-started.md#install-the-cli). 2. Register AgentDB in your tool: ```sh agentdb mcp install claude ``` Replace `claude` with the name of your tool: | Tool | Name | |------|------| | Claude Code | `claude` | | OpenAI Codex | `codex` | | Cursor | `cursor` | | VS Code (Copilot agent mode) | `vscode` | | Claude Desktop | `claude-desktop` | | Windsurf | `windsurf` | | Gemini CLI | `gemini` | Then restart the tool or start a new chat. The command prints the exact next step for your tool. If the tool already has a server named `agentdb`, the command stops. Add `--force` to replace it. Run `agentdb mcp list` to see which tools have AgentDB. Run `agentdb mcp remove ` to take it out again. Claude Code can also use the official AgentDB skill. It teaches the agent good habits with AgentDB: ```sh agentdb skill install claude ``` ## Any other MCP client {#api-key-header} Any client that supports Streamable HTTP with custom headers works. Give it the URL and this header: ```text Authorization: Bearer ``` For example, Claude Code without the CLI: ```sh claude mcp add --transport http agentdb https://app.agentdb.link/mcp --header "Authorization: Bearer " ``` Write `Bearer`, one space, then the full key. Do not add quotes inside the value. ## Choose the workspace {#workspaces} An account can have several workspaces. Every data tool takes an optional `workspace` argument: an id, a slug or the exact name. Without it, the tool uses your default workspace. Tell your agent which workspace to use. For example: "In my Example CRM workspace, list the contacts." The agent resolves the name once with `workspace_select`. ## Use a short tool list {#compact-tools} AgentDB has more than 200 operations. Some clients work better with fewer tools. Add `?tools=compact` to the URL: ```text https://app.agentdb.link/mcp?tools=compact ``` Clients that send headers can send `x-agentdb-tools: compact` instead. The compact list holds 16 everyday tools, such as `schema_inspect`, `records_create`, `records_query` and `records_search`. It also holds `agentdb_capabilities` and `agentdb_execute`. Every other operation stays available through `agentdb_execute`. ## Discover operations {#discover-operations} Your agent does not need to scan the whole tool list. It asks `agentdb_capabilities` instead. A search in plain words returns the best matching operations: ```mcp {"tool": "agentdb_capabilities", "arguments": {"search": "attach a file to a record"}} ``` Name one operation to get its full input schema and an example: ```mcp {"tool": "agentdb_capabilities", "arguments": {"operation": "records_query"}} ``` Then run any operation with `agentdb_execute`. It has the same permissions, checks and results as the operation's own tool: ```mcp {"tool": "agentdb_execute", "arguments": {"operation": "workspace_info", "arguments": {}}} ``` Without arguments, `agentdb_capabilities` returns a short index of all categories. ## Connected apps {#connected-apps} Every AI client you connected by signing in appears under **Connected apps**. You can see what each one can reach and end its access. 1. Sign in to the app at `https://app.agentdb.link/app`. 2. Open **Settings**, then **Connected apps**. 3. Each row shows the client name, the workspaces it reaches, when you connected it and when it was last used. 4. Choose **Disconnect…** on a row and confirm. The client loses access at once. Its access tokens and refresh tokens stop working. To use it again, connect it again. > Removing a connector inside Claude or ChatGPT does not tell AgentDB. Its access keeps working until you disconnect it here too. Keys used by the CLI and by local tools are API keys, not connected apps. `agentdb logout` removes the saved key. It also revokes a key that `agentdb login` issued. ## Check the connection {#check} Ask your agent: "Using AgentDB, show the workspace info and the schema." The agent calls `workspace_info` and `schema_inspect`. Does your agent miss a tool that this documentation names? See [My AI client does not see a new tool](troubleshooting.md#missing-tool). # Build with AgentDB Build a website, CRM, dashboard or custom application using AgentDB as your persistent data layer. Use your preferred programming language and framework. You can build, for example: - a **customer portal** where clients see their own orders and files; - a **small CRM** with contacts, companies, deals and notes; - an **internal dashboard** that updates when the data changes; - a **booking or intake form** that writes records your AI agents then process; - a **mobile or desktop app** with its own backend. Your app and your AI agents share the same data. An agent can fill and clean the records over MCP, while your app shows them to people. > Read [Security for apps](security.md) before you write code. The most important rule: an AgentDB API key never goes into a browser, a mobile app or a public file. ## What AgentDB gives you | AgentDB provides | Your app builds | |------------------|-----------------| | Typed records, fields and relationships, with validation | The screens, forms and navigation people use | | Exact queries with filters, sorting and cursor pages | Which data each of your users may see | | Files: upload, download and signed links | Sign-in for your own users | | Version checks and idempotency keys for safe writes | Business rules and workflows | | A change feed, webhooks and live dashboard links | Pushing updates to many browsers | | API keys, agent keys with roles, permission rules | Hosting your app | AgentDB is the database behind your app. It does **not** provide a CRM, a portal or an admin screen for your customers. You build that interface. You can use the AgentDB app yourself to look at the data while you build. ## How an app talks to AgentDB Every app follows the same shape: 1. People use your app in a browser or on a phone. 2. Your app sends their requests to **your own backend** (server, serverless function or framework route). 3. Your backend checks who the person is and what they may do. 4. Your backend calls the AgentDB REST API with a key it keeps secret. 5. Your backend returns only the data that person needs. For read-only screens without a backend, use a [dashboard data link or a custom dashboard](security.md#pattern-c-read-only-dashboards). They need no key in the page. ## Choose your path **Start with security.** [Security for apps](security.md) shows the three safe patterns, with wrong and secure code side by side. **Pick your language.** Each guide shows connecting, reading, writing, queries, relationships, files, errors, rate limits and retries. Every code block on those pages runs in our tests. - [Languages overview and test setup](languages/index.md) - [JavaScript (Node.js)](languages/javascript.md) and [TypeScript](languages/typescript.md) - [PHP](languages/php.md), [Python](languages/python.md) and [Ruby](languages/ruby.md) - [Go](languages/go.md), [Java](languages/java.md), [Kotlin](languages/kotlin.md), [C#](languages/csharp.md), [Rust](languages/rust.md) and [Swift](languages/swift.md) **Make it live.** [Live data](live-data.md) explains polling, the change feed, dashboard long-polling and webhooks. It ends with a complete page that updates when a record changes. **Use a framework or deploy.** Framework starters and deployment guides are in the Frameworks and Deploy groups of this section. ## Working with an AI coding tool These pages are written for people and for AI coding tools. Give your tool this instruction: ```text Use AgentDB as the database. Read https://docs.agentdb.link/latest/build/security.md first. Call the AgentDB REST API only from server-side code, with the key in the AGENTDB_API_KEY environment variable. Never put the key in browser code, HTML or a client bundle. ``` AI tools can also read [llms.txt](llms.txt) and [examples.json](examples.json). The second file lists every tested example by language, with its files and topics. ## Before you start You need: - an AgentDB account and a workspace (see [Getting started](../getting-started.md)); - a key for your backend: an [agent key](security.md#least-privilege-keys) with the smallest role that works; - the workspace id (`ws_...`), shown in the app under **Settings**. Put both values in environment variables on your server: `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. The examples also read `AGENTDB_URL`, which defaults to `https://app.agentdb.link`. > AgentDB is in beta. Store only synthetic or non-sensitive data. # Security for apps This page shows how to build an app on AgentDB without leaking access to your data. Read it before you write code. It applies to every language and framework. ## The rule {#the-rule} **Never put an AgentDB API key in code that runs on someone else's device.** That means no key in: - HTML, JavaScript or CSS that a browser loads; - a client bundle built by React, Vue, Svelte, Angular, Next.js or Vite; - a mobile app, desktop app or browser extension; - a public Git repository, a screenshot or a support ticket. This applies to every kind of key: account keys, workspace keys and agent keys. All of them start with `adbk.`. ## Why a key in the browser is a problem Anyone can open the developer tools of their browser and read every request a page makes. A key in a page is therefore public. A key acts with the full rights of its owner: - An **account key** reaches every workspace of your account. - An **agent key** reaches one workspace, with the role of its agent. - Neither key knows who your end users are. **AgentDB has no tokens for your end users.** It cannot limit a key to "the orders of customer 42". So do not call the AgentDB REST API from a browser with a key. That check belongs in your own backend. The workspace REST API also sends no CORS headers. A browser on your website cannot read its answers. Do not work around this with a proxy that adds the key for anyone who asks: that proxy is the same leak. ## Wrong and secure, side by side {#wrong-vs-secure} The wrong way puts the key in the page. Anyone who opens the page can copy it and read, change or delete your data. ```js wrong // app.js, loaded by the browser. DO NOT DO THIS. const res = await fetch("https://app.agentdb.link/v1/workspaces/ws_example/query", { method: "POST", headers: { Authorization: "Bearer adbk.YOUR-KEY", // visible to every visitor "Content-Type": "application/json", }, body: JSON.stringify({ object: "contact", limit: 20 }), }); // The same leak, hidden by a build tool: NEXT_PUBLIC_ and VITE_ variables end up in the bundle. const key = process.env.NEXT_PUBLIC_AGENTDB_KEY; ``` The secure way splits the work in two. The browser calls **your** backend, without any key. Your backend checks the person and calls AgentDB with a key from its environment. Browser code (no key, no AgentDB address): ```javascript secure file=examples/languages/javascript/live/public/app.js#browser 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…")); ``` Backend code (holds the key, decides what to return): ```javascript secure file=examples/languages/javascript/live/server.mjs#setup 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; } ``` ```javascript secure file=examples/languages/javascript/live/server.mjs#routes /** 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)); } ``` Both files come from a complete example that runs in our tests. See [Live data](live-data.md#complete-example) for the whole app. ## Pattern A: server-side apps {#pattern-a-server-side-apps} Use this for websites rendered on a server, background jobs, scripts, APIs and framework server code (PHP, Django, Rails, Laravel, Express, ASP.NET, Spring, Next.js route handlers and server components). How it works: 1. The server reads the key from an environment variable, such as `AGENTDB_API_KEY`. 2. It calls AgentDB, then renders HTML or returns JSON. 3. The browser receives only the result. Rules: - Store the key in your host's secret settings or a `.env` file that is **not** in Git. Commit a `.env.example` with an empty value instead. - Load the key only in server code. In Next.js, keep AgentDB calls in files that `import "server-only"`. Never give the variable a `NEXT_PUBLIC_`, `VITE_`, `PUBLIC_` or `REACT_APP_` prefix. - Never log the key, and never return it in an error message. - Give each app its own key, so you can stop one app without stopping the rest. ## Least-privilege keys {#least-privilege-keys} Give your backend the smallest rights that work. Use an **agent key**: it belongs to one named agent in one workspace, with one role. | Role | Rights of the key | |------|-------------------| | `viewer` | Read records and the schema. Good for a public website or a read-only portal. | | `editor` | Also create, change and delete records. Good for a CRM or a form backend. | | `admin` | Also change the schema and manage agents and webhooks. Rarely needed by an app. | Create a read-only agent for a website backend: ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/agents" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "website-backend", "role": "viewer", "expires_in_hours": 2160}' ``` The answer holds the new key once. Put it in your server's secret settings right away. Narrow it further with **permission rules**. A rule can hide an object or a field from an agent, or make it read-only. With `owner_field`, an agent sees only records whose field names that agent. See [Roles and permissions](../accounts-and-security.md#roles-and-permissions). Keep account keys for your own tools and scripts. Do not deploy them with an app. ## Pattern B: browser app with its own backend {#pattern-b-browser-app-with-its-own-backend} Use this for single-page apps, mobile apps and desktop apps. 1. The app signs in **your** users with your own sign-in (sessions, OAuth, a hosted identity provider). 2. The app calls **your** backend: `/api/contacts`, `/api/orders`. 3. Your backend checks the session, then decides what this user may see or change. 4. Your backend calls AgentDB with its key and returns only the fields the screen needs. Rules for the backend: - **Check access on every request.** For example, filter a query by the signed-in customer's id on the server. Never trust an id or a filter sent by the browser. - **Expose actions, not the API.** Offer `POST /api/notes` with fixed fields, not a route that forwards any AgentDB call. - **Return only what is needed.** Use `fields` in a query to leave out internal fields. - **Show values as text.** Record values come from people and agents. Insert them with `textContent` or your framework's escaping, never as raw HTML. - **Protect your own forms.** Use CSRF protection and rate limits on your backend, as for any web app. - **Send files through signed links.** Create a signed link on the server with `POST /files/{id}/links`. It expires, needs no key and can be revoked. Put it in an `` or a download button. ## Pattern C: read-only dashboards {#pattern-c-read-only-dashboards} Some pages only show data and have no backend. AgentDB offers two ways that need no key in the page. **Dashboard data links** (`dashboards_create`). Security boundary: - The link shows only the panels you defined: totals, or at most 50 rows of the fields you chose. It never reaches other records or other fields. - `access: "link"`: anyone with the link can read it. Treat the link as a password. - `access: "password"`: the reader also needs a password of 10 to 200 characters. - `access: "key"`: only server-side apps with an AgentDB key can read it. There is no hosted page. - New link dashboards expire after 90 days by default (`expires_in_days`, 1 to 365). - `allowed_origins` refuses browsers on other websites. It is not a secret, so combine it with a password or an expiry. - `dashboards_rotate_token` replaces the link; the old one stops at once. `dashboards_revoke` ends the dashboard. **Custom dashboards** (`dashboard_sites_create`, experimental). AgentDB hosts your HTML, CSS and JavaScript at its own address. Security boundary: - The page reads only the data sources declared in its `manifest.json`, through `fetch("/_data/")`. A query source must list its `fields`; only those fields leave the server. - The page cannot call other servers or load outside scripts. - Sharing modes: `only_me` (the owner, with two-step sign-in), `link_password`, `people` (invited email addresses) and `link`. A `link` site that shows workspace data needs a person's approval in the AgentHub. See [Dashboards and sharing](../dashboards-and-sharing.md) for the details. Both are read-only: to let people change data, use pattern B. ## Rotate and revoke keys {#rotate-and-revoke} Plan for a key to leak one day. Then the fix takes minutes. **Agent key** (recommended for apps): 1. Create a new agent with the same role and rules. 2. Put its key in your server's secret settings and redeploy. 3. Revoke the old agent with `agents_revoke` or `DELETE /v1/workspaces/{ws}/agents/{agent}`. Its keys stop at once. **Account key:** 1. Create a new key in **Settings → Account API keys**. 2. Deploy it. 3. Revoke the old key in the same list. It stops working at once. **If a key leaked:** revoke it first, then rotate. Then review recent changes: a record's history shows who changed it, and agent actions show as `agent:`. Also rotate any webhook secret (`webhooks_rotate_secret`) and dashboard link (`dashboards_rotate_token`) that were stored next to it. ## Checklist {#checklist} - No `adbk.` key in any file a browser or phone downloads. - No `NEXT_PUBLIC_`, `VITE_`, `PUBLIC_` or `REACT_APP_` variable holds a key. - The backend uses an agent key with the smallest role. - The backend checks the signed-in user on every request. - Files reach browsers through signed links that expire. - Dashboards use a password or an expiry when the data is not public. - Webhook requests are verified with their signature ([Live data](live-data.md#webhooks)). - You know how to rotate each key in under 10 minutes. Our docs checks enforce part of this list: they fail when an example file or page puts a key or an `Authorization` header into browser code. # 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 {#choose} | 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/`) | 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](#complete-example) does exactly that. ## Polling a query {#polling} 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 {#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. ```rest 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`. ## Dashboard data links {#dashboard-links} A dashboard data link needs no key, so a static page can use it. Create the dashboard once (see [Dashboards and sharing](../dashboards-and-sharing.md)), then poll its `data_url`: - the first call returns the panels and a `marker`; - call `data_url?since=&wait=1` in a loop; - AgentDB answers about a second after the data changes, or with `{"unchanged": true}` after about 25 seconds. ```js browser 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](security.md#pattern-c-read-only-dashboards) for its security boundary. ## Custom dashboard data {#custom-dashboards} 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: ```js browser 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 {#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](../automation.md#webhooks) for events and settings. What AgentDB guarantees: - **Signed:** every request carries `X-AgentDB-Signature: t=,v1=`. The signature is an HMAC-SHA256 of `.`, 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: ```javascript file=examples/languages/javascript/webhooks/receiver.mjs#verify /** * True when the X-AgentDB-Signature header ("t=,v1=[,v1=]") matches an * HMAC-SHA256 of "." 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: ```javascript file=examples/languages/javascript/webhooks/receiver.mjs#receiver 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 {#complete-example} 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): ```javascript file=examples/languages/javascript/live/server.mjs#setup 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: ```javascript file=examples/languages/javascript/live/server.mjs#change-feed 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: ```javascript file=examples/languages/javascript/live/server.mjs#routes /** 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: ```javascript file=examples/languages/javascript/live/public/app.js#browser 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: ```sh 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. # Languages overview AgentDB has one REST API. Any language that can send HTTPS requests can use it. You need no SDK: each guide uses the language's own HTTP client. Every guide covers the same topics, in the same order: 1. Connect: base URL, environment variables and the `Authorization` header. 2. Read records. 3. Create and update records, with version checks. 4. Query with filters, sorting and cursor pages. 5. Link records and list the links. 6. Upload, download and share files. 7. Handle errors. 8. Rate limits and retries. 9. Idempotency keys for safe retries. > All code on these pages runs on a server. Never put the key in browser code. See [Security for apps](../security.md). ## Tested examples {#tested} The code blocks on each guide are not copies. The docs include them from one runnable program per language. `npm run docs:examples` starts a local AgentDB, creates the schema below and runs every program. Each program must print one `OK` line per topic. | Language | Guide | HTTP client | Tested | |----------|-------|-------------|--------| | JavaScript | [JavaScript](javascript.md) | `fetch` (Node.js 22) | Locally and in CI | | TypeScript | [TypeScript](typescript.md) | `fetch` (Node.js 22) | Locally and in CI | | PHP | [PHP](php.md) | curl extension | Locally and in CI | | Python | [Python](python.md) | `urllib` (standard library) | Locally and in CI | | Go | [Go](go.md) | `net/http` | In CI | | C# | [C#](csharp.md) | `HttpClient` | Locally and in CI | | Java | [Java](java.md) | `java.net.http.HttpClient` | In CI | | Ruby | [Ruby](ruby.md) | `Net::HTTP` | Locally and in CI | | Rust | [Rust](rust.md) | `reqwest` | In CI | | Swift | [Swift](swift.md) | `URLSession` | Locally (macOS); in CI when the runner has Swift | | Kotlin | [Kotlin](kotlin.md) | `java.net.http.HttpClient` | In CI when the runner has Kotlin | AI tools can read the same list as JSON: [examples.json](examples.json). ## Environment variables {#environment} Every example reads three variables: | Variable | Value | |----------|-------| | `AGENTDB_URL` | `https://app.agentdb.link` (the default) | | `AGENTDB_API_KEY` | A server-side key, best an [agent key](../security.md#least-privilege-keys) | | `AGENTDB_WORKSPACE` | The workspace id, `ws_...` | Set them in your server's secret settings, or in a `.env` file that is not in Git. ## The example schema {#schema} The examples use two objects and one relationship: a `contact` with a name, an e-mail address and a score, a `company` with a name, and `works_at` from a contact to its company. Ask your AI to create them, or run these calls once with an admin or owner key. ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "contact"}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types/contact/fields" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "name", "type": "text", "required": true}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types/contact/fields" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "email", "type": "text", "unique": true}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types/contact/fields" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "score", "type": "integer"}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "company"}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/object-types/company/fields" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "name", "type": "text", "required": true}' ``` ```rest curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/relationship-types" \ -H "Authorization: Bearer $AGENTDB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "works_at", "source_object": "contact", "target_object": "company", "cardinality": "one"}' ``` `cardinality: "one"` means a contact works at one company. A company can have many contacts. ## The API in one table {#api} All paths start with `https://app.agentdb.link/v1/workspaces/{ws}`. Every answer is JSON: `{"data": ...}` on success, `{"error": {...}}` on failure. | Task | Call | |------|------| | Inspect the schema | `GET /object-types` | | Create a record | `POST /records` with `{"object", "data"}` | | Read a record | `GET /records/{id}` | | Update a record | `PATCH /records/{id}` with `{"version", "data"}` | | Query | `POST /query` with `{"object", "where", "sort", "limit", "cursor"}` | | Link two records | `POST /links` with `{"relationship", "source_record_id", "target_record_id"}` | | List linked records | `GET /records/{id}/related?relationship=` | | Announce a file | `POST /files` with `{"filename", "content_type", "size"}` | | Upload its bytes | `PUT /files/{id}/content` with `Content-Type: application/octet-stream` | | Attach it to a record | `POST /attachments` with `{"file_id", "record_id"}` | | Download it | `GET /files/{id}/content` | | Signed link for a browser | `POST /files/{id}/links` with `{"expires_in_seconds"}` | | Changes since a point | `GET /changes?after=&wait=1` | The [reference](reference/index.html) lists every operation with its arguments. The [error codes](../../troubleshooting.md) page explains each error. ## Errors, rate limits and retries {#retries} - **Errors** come as `{"request_id", "error": {"code", "message", "retryable", "details"}}` with an HTTP status. Act on `code`, not on the message text. - **`VERSION_CONFLICT`** (409): someone changed the record after you read it. Read it again, apply your change, and send the new version. - **`RATE_LIMITED`** (429): wait for the seconds in the `Retry-After` header, then retry. Without the header, back off: 0.5, 1, 2, 4 seconds. - **Network errors and timeouts:** retry a read freely. Retry a write only with the same `Idempotency-Key`. AgentDB then returns the first result and writes nothing twice. Keys are kept for 7 days. # AgentDB with JavaScript (Node.js) Use AgentDB as the database of your JavaScript (Node.js) app. This page shows every call you need, with the built-in `fetch` of Node.js 22 or newer. No packages. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. This code runs on a server. In a framework such as Express, Fastify, Next.js or Nuxt, put it in server-only code: a route handler, an API route or a server action. ## Before you start {#before-you-start} - Node.js 22 or newer. Bun and Deno work the same way. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```javascript file=examples/languages/javascript/crm.mjs#connect const BASE_URL = (process.env.AGENTDB_URL ?? "https://app.agentdb.link").replace(/\/+$/, ""); const API_KEY = process.env.AGENTDB_API_KEY; // a server-side secret: never in browser code const WORKSPACE = process.env.AGENTDB_WORKSPACE; // ws_... if (!API_KEY || !WORKSPACE) throw new Error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE"); const API = `${BASE_URL}/v1/workspaces/${WORKSPACE}`; ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```javascript file=examples/languages/javascript/crm.mjs#request /** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */ class AgentDBError extends Error { constructor(status, error = {}) { super(`${status} ${error.code ?? "HTTP_ERROR"}: ${error.message ?? ""}`); this.status = status; this.code = error.code ?? "HTTP_ERROR"; this.retryable = error.retryable === true; this.details = error.details ?? {}; } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After * (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write * with an Idempotency-Key). Returns the "data" part of the answer, or the raw Response. */ async function api(method, path, { json, body, idempotencyKey, raw = false } = {}) { const headers = { Authorization: `Bearer ${API_KEY}` }; if (json !== undefined) headers["Content-Type"] = "application/json"; if (body !== undefined) headers["Content-Type"] = "application/octet-stream"; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const safeToRepeat = method === "GET" || !!idempotencyKey; for (let attempt = 0; ; attempt++) { let res; try { res = await fetch(API + path, { method, headers, body: json !== undefined ? JSON.stringify(json) : body }); } catch (err) { if (safeToRepeat && attempt < 4) { await sleep(500 * 2 ** attempt); continue; } throw err; } if ((res.status === 429 || res.status === 503) && attempt < 4) { const retryAfter = Number(res.headers.get("retry-after")); await sleep(retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt + Math.random() * 250); continue; } if (!res.ok) { const envelope = await res.json().catch(() => ({})); throw new AgentDBError(res.status, envelope.error); } return raw ? res : (await res.json()).data; } } ``` Check the connection by reading the schema: ```javascript file=examples/languages/javascript/crm.mjs#connect-check const schema = await api("GET", "/object-types"); console.log("Objects:", schema.objects.map((o) => o.name).join(", ")); ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```javascript file=examples/languages/javascript/crm.mjs#create const ada = await api("POST", "/records", { json: { object: "contact", data: { name: "Ada Lovelace", email: `ada-${run}@example.com`, score: 72 } }, idempotencyKey: randomUUID(), // safe to retry: the same key never writes twice }); console.log("Created", ada.id, "version", ada.version); ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```javascript file=examples/languages/javascript/crm.mjs#read const record = await api("GET", `/records/${ada.id}`); console.log(record.data.name, record.data.email, "version", record.version); ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```javascript file=examples/languages/javascript/crm.mjs#update /** Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. */ async function updateRecord(id, change, attempts = 3) { for (let i = 1; ; i++) { const current = await api("GET", `/records/${id}`); try { return await api("PATCH", `/records/${id}`, { json: { version: current.version, data: change(current.data) } }); } catch (err) { if (err instanceof AgentDBError && err.code === "VERSION_CONFLICT" && i < attempts) continue; throw err; } } } const updated = await updateRecord(ada.id, (data) => ({ score: data.score + 10 })); console.log("Score", updated.data.score, "version", updated.version); ``` This is what the conflict looks like: ```javascript file=examples/languages/javascript/crm.mjs#version-conflict try { await api("PATCH", `/records/${ada.id}`, { json: { version: 1, data: { score: 0 } } }); // stale version } catch (err) { if (!(err instanceof AgentDBError) || err.code !== "VERSION_CONFLICT") throw err; console.log("Conflict: the record is now at version", err.details.current_version); } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```javascript file=examples/languages/javascript/crm.mjs#query /** Every record that matches, page by page (limit up to 200; follow next_cursor). */ async function queryAll(object, where, sort, pageSize = 2) { const out = []; let cursor = null; do { const page = await api("POST", "/query", { json: { object, where, sort, limit: pageSize, ...(cursor && { cursor }) } }); out.push(...page.records); cursor = page.next_cursor; // null on the last page } while (cursor); return out; } const good = await queryAll( "contact", { all: [{ field: "score", op: "gte", value: 70 }, { field: "email", op: "neq", value: "nobody@example.com" }] }, [{ field: "score", order: "desc" }], ); for (const c of good) console.log(c.data.score, c.data.name); ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```javascript file=examples/languages/javascript/crm.mjs#relationships const company = await api("POST", "/records", { json: { object: "company", data: { name: `Example Ltd ${run}` } } }); await api("POST", "/links", { json: { relationship: "works_at", source_record_id: ada.id, target_record_id: company.id }, idempotencyKey: `link-${ada.id}-${company.id}`, }); const related = await api("GET", `/records/${ada.id}/related?relationship=works_at`); console.log("Ada works at", related.records.map((r) => r.data.name).join(", ")); ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```javascript file=examples/languages/javascript/crm.mjs#files const bytes = Buffer.from(`Meeting notes for Ada (${run})\n`, "utf8"); // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. const file = await api("POST", "/files", { json: { filename: "notes.txt", content_type: "text/plain", size: bytes.length } }); await api("PUT", `/files/${file.id}/content`, { body: bytes }); await api("POST", "/attachments", { json: { file_id: file.id, record_id: ada.id } }); // Download with the key (server side) ... const download = await api("GET", `/files/${file.id}/content`, { raw: true }); const back = Buffer.from(await download.arrayBuffer()); // ... or make a signed link that expires, for an or a download button in your page. const link = await api("POST", `/files/${file.id}/links`, { json: { expires_in_seconds: 600 } }); console.log("Signed link until", link.expires_at); ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```javascript file=examples/languages/javascript/crm.mjs#errors try { await api("GET", "/records/rec_doesnotexist"); } catch (err) { if (!(err instanceof AgentDBError)) throw err; // a network error, not an AgentDB answer console.log(err.status, err.code, err.message); // 404 NOT_FOUND ... if (err.retryable) console.log("Safe to retry later"); } ``` `fetch` throws a `TypeError` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```javascript file=examples/languages/javascript/crm.mjs#idempotency const key = randomUUID(); // one key per intention; reuse it only to retry the same request const request = { object: "contact", data: { name: "Mary Somerville", email: `mary-${run}@example.com` } }; const first = await api("POST", "/records", { json: request, idempotencyKey: key }); const retry = await api("POST", "/records", { json: request, idempotencyKey: key }); // e.g. after a timeout console.log(first.id === retry.id ? "Same record, written once" : "Duplicate!"); ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/javascript/crm.mjs`. Set the three environment variables and run: ```sh node crm.mjs ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with TypeScript Use AgentDB as the database of your TypeScript app. This page shows every call you need, with the built-in `fetch` of Node.js 22, with types. No packages. Run it with `tsx` or `node --experimental-strip-types`. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. Use this in server code only: a Next.js route handler or server component (with `import "server-only"`), a NestJS service, an Express or Fastify route. ## Before you start {#before-you-start} - Node.js 22 or newer and TypeScript 5. The example passes `strict` type checks. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```typescript file=examples/languages/typescript/crm.ts#connect const BASE_URL = (process.env.AGENTDB_URL ?? "https://app.agentdb.link").replace(/\/+$/, ""); const API_KEY = process.env.AGENTDB_API_KEY; // a server-side secret: never in browser code const WORKSPACE = process.env.AGENTDB_WORKSPACE; // ws_... if (!API_KEY || !WORKSPACE) throw new Error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE"); const API = `${BASE_URL}/v1/workspaces/${WORKSPACE}`; ``` The answers have a fixed shape. These types describe them: ```typescript file=examples/languages/typescript/crm.ts#types /** A record as AgentDB returns it. `data` holds your fields. */ interface AgentRecord> { id: string; object: string; version: number; created_at: string; updated_at: string; data: T; } interface Contact { name: string; email?: string; score?: number; } interface QueryPage { records: AgentRecord[]; next_cursor: string | null; } type Where = { all: Condition[] }; // every condition must match type Condition = { field: string; op: "eq" | "neq" | "in" | "lt" | "lte" | "gt" | "gte" | "is_null" | "is_not_null"; value?: unknown }; ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```typescript file=examples/languages/typescript/crm.ts#request /** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */ class AgentDBError extends Error { readonly code: string; readonly retryable: boolean; readonly details: Record; constructor( readonly status: number, error: { code?: string; message?: string; retryable?: boolean; details?: Record } = {}, ) { super(`${status} ${error.code ?? "HTTP_ERROR"}: ${error.message ?? ""}`); this.code = error.code ?? "HTTP_ERROR"; this.retryable = error.retryable === true; this.details = error.details ?? {}; } } interface CallOptions { json?: unknown; body?: Uint8Array; idempotencyKey?: string; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** One API call with the key in the Authorization header; retries 429/503 and safe network errors. */ async function send(method: string, path: string, opts: CallOptions = {}): Promise { const headers: Record = { Authorization: `Bearer ${API_KEY}` }; if (opts.json !== undefined) headers["Content-Type"] = "application/json"; if (opts.body !== undefined) headers["Content-Type"] = "application/octet-stream"; if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey; const safeToRepeat = method === "GET" || !!opts.idempotencyKey; for (let attempt = 0; ; attempt++) { let res: Response; try { res = await fetch(API + path, { method, headers, body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body }); } catch (err) { if (safeToRepeat && attempt < 4) { await sleep(500 * 2 ** attempt); continue; } throw err; } if ((res.status === 429 || res.status === 503) && attempt < 4) { const retryAfter = Number(res.headers.get("retry-after")); await sleep(retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt + Math.random() * 250); continue; } if (!res.ok) { const envelope = (await res.json().catch(() => ({}))) as { error?: ConstructorParameters[1] }; throw new AgentDBError(res.status, envelope.error); } return res; } } /** The "data" part of a JSON answer, typed by the caller. */ async function api(method: string, path: string, opts: CallOptions = {}): Promise { const res = await send(method, path, opts); return ((await res.json()) as { data: T }).data; } ``` Check the connection by reading the schema: ```typescript file=examples/languages/typescript/crm.ts#connect-check const schema = await api<{ objects: { name: string }[] }>("GET", "/object-types"); console.log("Objects:", schema.objects.map((o) => o.name).join(", ")); ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```typescript file=examples/languages/typescript/crm.ts#create const ada = await api>("POST", "/records", { json: { object: "contact", data: { name: "Ada Lovelace", email: `ada-${run}@example.com`, score: 72 } }, idempotencyKey: randomUUID(), // safe to retry: the same key never writes twice }); console.log("Created", ada.id, "version", ada.version); ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```typescript file=examples/languages/typescript/crm.ts#read const record = await api>("GET", `/records/${ada.id}`); console.log(record.data.name, record.data.email, "version", record.version); ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```typescript file=examples/languages/typescript/crm.ts#update /** Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. */ async function updateRecord(id: string, change: (data: T) => Partial, attempts = 3): Promise> { for (let i = 1; ; i++) { const current = await api>("GET", `/records/${id}`); try { return await api>("PATCH", `/records/${id}`, { json: { version: current.version, data: change(current.data) } }); } catch (err) { if (err instanceof AgentDBError && err.code === "VERSION_CONFLICT" && i < attempts) continue; throw err; } } } const updated = await updateRecord(ada.id, (data) => ({ score: (data.score ?? 0) + 10 })); console.log("Score", updated.data.score, "version", updated.version); ``` This is what the conflict looks like: ```typescript file=examples/languages/typescript/crm.ts#version-conflict try { await api("PATCH", `/records/${ada.id}`, { json: { version: 1, data: { score: 0 } } }); // stale version } catch (err) { if (!(err instanceof AgentDBError) || err.code !== "VERSION_CONFLICT") throw err; console.log("Conflict: the record is now at version", err.details.current_version); } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```typescript file=examples/languages/typescript/crm.ts#query /** Every record that matches, page by page (limit up to 200; follow next_cursor). */ async function queryAll(object: string, where: Where, sort: { field: string; order: "asc" | "desc" }[], pageSize = 2): Promise[]> { const out: AgentRecord[] = []; let cursor: string | null = null; do { const page: QueryPage = await api>("POST", "/query", { json: { object, where, sort, limit: pageSize, ...(cursor && { cursor }) } }); out.push(...page.records); cursor = page.next_cursor; // null on the last page } while (cursor); return out; } const good = await queryAll( "contact", { all: [{ field: "score", op: "gte", value: 70 }, { field: "email", op: "neq", value: "nobody@example.com" }] }, [{ field: "score", order: "desc" }], ); for (const c of good) console.log(c.data.score, c.data.name); ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```typescript file=examples/languages/typescript/crm.ts#relationships const company = await api>("POST", "/records", { json: { object: "company", data: { name: `Example Ltd ${run}` } } }); await api("POST", "/links", { json: { relationship: "works_at", source_record_id: ada.id, target_record_id: company.id }, idempotencyKey: `link-${ada.id}-${company.id}`, }); const related = await api<{ records: AgentRecord<{ name: string }>[] }>("GET", `/records/${ada.id}/related?relationship=works_at`); console.log("Ada works at", related.records.map((r) => r.data.name).join(", ")); ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```typescript file=examples/languages/typescript/crm.ts#files const bytes = new TextEncoder().encode(`Meeting notes for Ada (${run})\n`); // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. const file = await api<{ id: string }>("POST", "/files", { json: { filename: "notes.txt", content_type: "text/plain", size: bytes.length } }); await api("PUT", `/files/${file.id}/content`, { body: bytes }); await api("POST", "/attachments", { json: { file_id: file.id, record_id: ada.id } }); // Download with the key (server side) ... const back = new Uint8Array(await (await send("GET", `/files/${file.id}/content`)).arrayBuffer()); // ... or make a signed link that expires, for an or a download button in your page. const link = await api<{ url: string; expires_at: string }>("POST", `/files/${file.id}/links`, { json: { expires_in_seconds: 600 } }); console.log("Signed link until", link.expires_at); ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```typescript file=examples/languages/typescript/crm.ts#errors try { await api("GET", "/records/rec_doesnotexist"); } catch (err) { if (!(err instanceof AgentDBError)) throw err; // a network error, not an AgentDB answer console.log(err.status, err.code, err.message); // 404 NOT_FOUND ... if (err.retryable) console.log("Safe to retry later"); } ``` The types describe the answers AgentDB sends. Add your own field types for each object. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```typescript file=examples/languages/typescript/crm.ts#idempotency const key = randomUUID(); // one key per intention; reuse it only to retry the same request const request = { object: "contact", data: { name: "Mary Somerville", email: `mary-${run}@example.com` } }; const first = await api>("POST", "/records", { json: request, idempotencyKey: key }); const retry = await api>("POST", "/records", { json: request, idempotencyKey: key }); // e.g. after a timeout console.log(first.id === retry.id ? "Same record, written once" : "Duplicate!"); ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/typescript/crm.ts`. Set the three environment variables and run: ```sh npx tsx crm.ts ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with PHP Use AgentDB as the database of your PHP app. This page shows every call you need, with PHP's built-in curl extension. No Composer packages. With Guzzle, the calls look the same: one function, the same headers. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. This code runs on your server. In Laravel or Symfony, read the key with `env()` or the secrets vault, and call AgentDB from a controller or a service class. Never print the key into a Blade or Twig template. ## Before you start {#before-you-start} - PHP 8.1 or newer with the curl extension (enabled on almost every host). - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```php file=examples/languages/php/crm.php#connect $baseUrl = rtrim(getenv('AGENTDB_URL') ?: 'https://app.agentdb.link', '/'); $apiKey = getenv('AGENTDB_API_KEY'); // a server-side secret: never in browser code $workspace = getenv('AGENTDB_WORKSPACE'); // ws_... if (!$apiKey || !$workspace) { fwrite(STDERR, "Set AGENTDB_API_KEY and AGENTDB_WORKSPACE\n"); exit(1); } define('AGENTDB_API', "$baseUrl/v1/workspaces/$workspace"); define('AGENTDB_KEY', $apiKey); ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```php file=examples/languages/php/crm.php#request /** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */ final class AgentDBError extends RuntimeException { public function __construct( public readonly int $status, public readonly string $errorCode, string $message, public readonly bool $retryable, public readonly array $details, ) { parent::__construct("$status $errorCode: $message"); } } /** * One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After * (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write * with an Idempotency-Key). Returns the "data" part of the answer, or the raw body. */ function api(string $method, string $path, ?array $json = null, ?string $body = null, ?string $idempotencyKey = null, bool $raw = false): mixed { $headers = ['Authorization: Bearer ' . AGENTDB_KEY]; $payload = null; if ($json !== null) { $headers[] = 'Content-Type: application/json'; $payload = json_encode($json, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); } if ($body !== null) { $headers[] = 'Content-Type: application/octet-stream'; $payload = $body; } if ($idempotencyKey !== null) { $headers[] = "Idempotency-Key: $idempotencyKey"; } $safeToRepeat = $method === 'GET' || $idempotencyKey !== null; for ($attempt = 0; ; $attempt++) { $retryAfter = null; $ch = curl_init(AGENTDB_API . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int { if (stripos($line, 'retry-after:') === 0) { $retryAfter = (int) trim(substr($line, 12)); } return strlen($line); }, ]); if ($payload !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); } $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $networkError = curl_error($ch); unset($ch); if ($response === false) { if ($safeToRepeat && $attempt < 4) { usleep(500_000 * 2 ** $attempt); continue; } throw new RuntimeException("Network error: $networkError"); } if (($status === 429 || $status === 503) && $attempt < 4) { usleep($retryAfter ? $retryAfter * 1_000_000 : 500_000 * 2 ** $attempt + random_int(0, 250_000)); continue; } if ($status >= 400) { $error = json_decode((string) $response, true)['error'] ?? []; throw new AgentDBError($status, $error['code'] ?? 'HTTP_ERROR', $error['message'] ?? '', ($error['retryable'] ?? false) === true, $error['details'] ?? []); } return $raw ? $response : json_decode($response, true, 512, JSON_THROW_ON_ERROR)['data']; } } /** A random UUID (v4) for Idempotency-Key headers. */ function uuid(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } ``` Check the connection by reading the schema: ```php file=examples/languages/php/crm.php#connect-check $schema = api('GET', '/object-types'); echo 'Objects: ', implode(', ', array_column($schema['objects'], 'name')), "\n"; ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```php file=examples/languages/php/crm.php#create $ada = api('POST', '/records', json: ['object' => 'contact', 'data' => ['name' => 'Ada Lovelace', 'email' => "ada-$run@example.com", 'score' => 72]], idempotencyKey: uuid(), // safe to retry: the same key never writes twice ); echo "Created {$ada['id']} version {$ada['version']}\n"; ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```php file=examples/languages/php/crm.php#read $record = api('GET', "/records/{$ada['id']}"); echo "{$record['data']['name']} {$record['data']['email']} version {$record['version']}\n"; ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```php file=examples/languages/php/crm.php#update /** Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. */ function updateRecord(string $id, callable $change, int $attempts = 3): array { for ($i = 1; ; $i++) { $current = api('GET', "/records/$id"); try { return api('PATCH', "/records/$id", json: ['version' => $current['version'], 'data' => $change($current['data'])]); } catch (AgentDBError $e) { if ($e->errorCode === 'VERSION_CONFLICT' && $i < $attempts) { continue; } throw $e; } } } $updated = updateRecord($ada['id'], fn (array $data) => ['score' => $data['score'] + 10]); echo "Score {$updated['data']['score']} version {$updated['version']}\n"; ``` This is what the conflict looks like: ```php file=examples/languages/php/crm.php#version-conflict try { api('PATCH', "/records/{$ada['id']}", json: ['version' => 1, 'data' => ['score' => 0]]); // stale version } catch (AgentDBError $e) { if ($e->errorCode !== 'VERSION_CONFLICT') { throw $e; } echo "Conflict: the record is now at version {$e->details['current_version']}\n"; } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```php file=examples/languages/php/crm.php#query /** Every record that matches, page by page (limit up to 200; follow next_cursor). */ function queryAll(string $object, array $where, array $sort, int $pageSize = 2): array { $out = []; $cursor = null; do { $body = ['object' => $object, 'where' => $where, 'sort' => $sort, 'limit' => $pageSize]; if ($cursor !== null) { $body['cursor'] = $cursor; } $page = api('POST', '/query', json: $body); array_push($out, ...$page['records']); $cursor = $page['next_cursor']; // null on the last page } while ($cursor !== null); return $out; } $good = queryAll('contact', ['all' => [['field' => 'score', 'op' => 'gte', 'value' => 70], ['field' => 'email', 'op' => 'neq', 'value' => 'nobody@example.com']]], [['field' => 'score', 'order' => 'desc']], ); foreach ($good as $c) { echo "{$c['data']['score']} {$c['data']['name']}\n"; } ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```php file=examples/languages/php/crm.php#relationships $company = api('POST', '/records', json: ['object' => 'company', 'data' => ['name' => "Example Ltd $run"]]); api('POST', '/links', json: ['relationship' => 'works_at', 'source_record_id' => $ada['id'], 'target_record_id' => $company['id']], idempotencyKey: "link-{$ada['id']}-{$company['id']}", ); $related = api('GET', "/records/{$ada['id']}/related?relationship=works_at"); echo 'Ada works at ', implode(', ', array_map(fn ($r) => $r['data']['name'], $related['records'])), "\n"; ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```php file=examples/languages/php/crm.php#files $bytes = "Meeting notes for Ada ($run)\n"; // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. $file = api('POST', '/files', json: ['filename' => 'notes.txt', 'content_type' => 'text/plain', 'size' => strlen($bytes)]); api('PUT', "/files/{$file['id']}/content", body: $bytes); api('POST', '/attachments', json: ['file_id' => $file['id'], 'record_id' => $ada['id']]); // Download with the key (server side) ... $back = api('GET', "/files/{$file['id']}/content", raw: true); // ... or make a signed link that expires, for an or a download button in your page. $link = api('POST', "/files/{$file['id']}/links", json: ['expires_in_seconds' => 600]); echo "Signed link until {$link['expires_at']}\n"; ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```php file=examples/languages/php/crm.php#errors try { api('GET', '/records/rec_doesnotexist'); } catch (AgentDBError $e) { // a network error throws RuntimeException instead echo "{$e->status} {$e->errorCode} {$e->getMessage()}\n"; // 404 NOT_FOUND ... if ($e->retryable) { echo "Safe to retry later\n"; } } ``` `curl_exec` returns `false` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```php file=examples/languages/php/crm.php#idempotency $key = uuid(); // one key per intention; reuse it only to retry the same request $request = ['object' => 'contact', 'data' => ['name' => 'Mary Somerville', 'email' => "mary-$run@example.com"]]; $first = api('POST', '/records', json: $request, idempotencyKey: $key); $retry = api('POST', '/records', json: $request, idempotencyKey: $key); // e.g. after a timeout echo $first['id'] === $retry['id'] ? "Same record, written once\n" : "Duplicate!\n"; ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/php/crm.php`. Set the three environment variables and run: ```sh php crm.php ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Python Use AgentDB as the database of your Python app. This page shows every call you need, with `urllib` from the standard library. No packages. With `requests` or `httpx`, the calls look the same: one function, the same headers. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. This code runs on your server. In Django, Flask or FastAPI, read the key from the environment at startup and call AgentDB from a view or a service module. ## Before you start {#before-you-start} - Python 3.9 or newer. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```python file=examples/languages/python/crm.py#connect BASE_URL = os.environ.get("AGENTDB_URL", "https://app.agentdb.link").rstrip("/") API_KEY = os.environ.get("AGENTDB_API_KEY") # a server-side secret: never in browser code WORKSPACE = os.environ.get("AGENTDB_WORKSPACE") # ws_... if not API_KEY or not WORKSPACE: sys.exit("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") API = f"{BASE_URL}/v1/workspaces/{WORKSPACE}" ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```python file=examples/languages/python/crm.py#request class AgentDBError(Exception): """An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}.""" def __init__(self, status, error): error = error or {} super().__init__(f"{status} {error.get('code', 'HTTP_ERROR')}: {error.get('message', '')}") self.status = status self.code = error.get("code", "HTTP_ERROR") self.retryable = error.get("retryable") is True self.details = error.get("details") or {} def api(method, path, json_body=None, body=None, idempotency_key=None, raw=False): """One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write with an Idempotency-Key). Returns the "data" part, or the raw bytes.""" headers = {"Authorization": f"Bearer {API_KEY}"} data = None if json_body is not None: headers["Content-Type"] = "application/json" data = json.dumps(json_body).encode() if body is not None: headers["Content-Type"] = "application/octet-stream" data = body if idempotency_key: headers["Idempotency-Key"] = idempotency_key safe_to_repeat = method == "GET" or bool(idempotency_key) attempt = 0 while True: request = urllib.request.Request(API + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(request, timeout=60) as res: content = res.read() return content if raw else json.loads(content)["data"] except urllib.error.HTTPError as err: if err.code in (429, 503) and attempt < 4: retry_after = err.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 0.5 * 2**attempt + random.random() / 4) attempt += 1 continue try: envelope = json.loads(err.read()) except ValueError: envelope = {} raise AgentDBError(err.code, envelope.get("error")) from None except urllib.error.URLError: if safe_to_repeat and attempt < 4: time.sleep(0.5 * 2**attempt) attempt += 1 continue raise ``` Check the connection by reading the schema: ```python file=examples/languages/python/crm.py#connect-check schema = api("GET", "/object-types") print("Objects:", ", ".join(o["name"] for o in schema["objects"])) ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```python file=examples/languages/python/crm.py#create ada = api( "POST", "/records", json_body={"object": "contact", "data": {"name": "Ada Lovelace", "email": f"ada-{run}@example.com", "score": 72}}, idempotency_key=str(uuid.uuid4()), # safe to retry: the same key never writes twice ) print("Created", ada["id"], "version", ada["version"]) ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```python file=examples/languages/python/crm.py#read record = api("GET", f"/records/{ada['id']}") print(record["data"]["name"], record["data"]["email"], "version", record["version"]) ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```python file=examples/languages/python/crm.py#update def update_record(record_id, change, attempts=3): """Read, change, write with the current version. On VERSION_CONFLICT: read again and retry.""" for i in range(1, attempts + 1): current = api("GET", f"/records/{record_id}") try: return api("PATCH", f"/records/{record_id}", json_body={"version": current["version"], "data": change(current["data"])}) except AgentDBError as err: if err.code == "VERSION_CONFLICT" and i < attempts: continue raise updated = update_record(ada["id"], lambda data: {"score": data["score"] + 10}) print("Score", updated["data"]["score"], "version", updated["version"]) ``` This is what the conflict looks like: ```python file=examples/languages/python/crm.py#version-conflict try: api("PATCH", f"/records/{ada['id']}", json_body={"version": 1, "data": {"score": 0}}) # stale version except AgentDBError as err: if err.code != "VERSION_CONFLICT": raise print("Conflict: the record is now at version", err.details.get("current_version")) ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```python file=examples/languages/python/crm.py#query def query_all(object_name, where, sort, page_size=2): """Every record that matches, page by page (limit up to 200; follow next_cursor).""" out, cursor = [], None while True: body = {"object": object_name, "where": where, "sort": sort, "limit": page_size} if cursor: body["cursor"] = cursor page = api("POST", "/query", json_body=body) out.extend(page["records"]) cursor = page["next_cursor"] # None on the last page if not cursor: return out good = query_all( "contact", {"all": [{"field": "score", "op": "gte", "value": 70}, {"field": "email", "op": "neq", "value": "nobody@example.com"}]}, [{"field": "score", "order": "desc"}], ) for c in good: print(c["data"]["score"], c["data"]["name"]) ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```python file=examples/languages/python/crm.py#relationships company = api("POST", "/records", json_body={"object": "company", "data": {"name": f"Example Ltd {run}"}}) api( "POST", "/links", json_body={"relationship": "works_at", "source_record_id": ada["id"], "target_record_id": company["id"]}, idempotency_key=f"link-{ada['id']}-{company['id']}", ) related = api("GET", f"/records/{ada['id']}/related?relationship=works_at") print("Ada works at", ", ".join(r["data"]["name"] for r in related["records"])) ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```python file=examples/languages/python/crm.py#files content = f"Meeting notes for Ada ({run})\n".encode() # 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. file = api("POST", "/files", json_body={"filename": "notes.txt", "content_type": "text/plain", "size": len(content)}) api("PUT", f"/files/{file['id']}/content", body=content) api("POST", "/attachments", json_body={"file_id": file["id"], "record_id": ada["id"]}) # Download with the key (server side) ... back = api("GET", f"/files/{file['id']}/content", raw=True) # ... or make a signed link that expires, for an or a download button in your page. link = api("POST", f"/files/{file['id']}/links", json_body={"expires_in_seconds": 600}) print("Signed link until", link["expires_at"]) ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```python file=examples/languages/python/crm.py#errors try: api("GET", "/records/rec_doesnotexist") except AgentDBError as err: # a network error raises URLError instead print(err.status, err.code, err) # 404 NOT_FOUND ... if err.retryable: print("Safe to retry later") ``` Network errors raise `URLError`; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```python file=examples/languages/python/crm.py#idempotency key = str(uuid.uuid4()) # one key per intention; reuse it only to retry the same request request = {"object": "contact", "data": {"name": "Mary Somerville", "email": f"mary-{run}@example.com"}} first = api("POST", "/records", json_body=request, idempotency_key=key) retry = api("POST", "/records", json_body=request, idempotency_key=key) # e.g. after a timeout print("Same record, written once" if first["id"] == retry["id"] else "Duplicate!") ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/python/crm.py`. Set the three environment variables and run: ```sh python3 crm.py ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Go Use AgentDB as the database of your Go app. This page shows every call you need, with `net/http` and `encoding/json` from the standard library. No modules beyond the standard library. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested in CI: its code runs against a fresh AgentDB on every change. > Server-side only. Create one `http.Client` with a timeout and reuse it. Call AgentDB from your HTTP handlers; never send the key to a browser. ## Before you start {#before-you-start} - Go 1.22 or newer. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```go file=examples/languages/go/main.go#connect var ( baseURL = strings.TrimRight(cmp.Or(os.Getenv("AGENTDB_URL"), "https://app.agentdb.link"), "/") apiKey = os.Getenv("AGENTDB_API_KEY") // a server-side secret: never in browser code workspace = os.Getenv("AGENTDB_WORKSPACE") // ws_... apiURL = baseURL + "/v1/workspaces/" + workspace ) func init() { if apiKey == "" || workspace == "" { log.Fatal("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") } } ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```go file=examples/languages/go/main.go#request // M is a JSON object. type M = map[string]any // AgentDBError is an AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. type AgentDBError struct { Status int Code string `json:"code"` Message string `json:"message"` Retryable bool `json:"retryable"` Details M `json:"details"` } func (e *AgentDBError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) } // Request holds the optional parts of a call. type Request struct { JSON any // sent as application/json Body []byte // sent as application/octet-stream IdempotencyKey string } // send makes one API call. It sends the key in the Authorization header. It retries 429 and 503 // with Retry-After (or exponential backoff), and network errors when the call is safe to repeat // (GET, or a write with an Idempotency-Key). It returns the raw response; close its Body. func send(method, path string, req Request) (*http.Response, error) { safeToRepeat := method == "GET" || req.IdempotencyKey != "" for attempt := 0; ; attempt++ { backoff := time.Duration(500< 0 { wait = time.Duration(seconds) * time.Second } time.Sleep(wait) continue } if res.StatusCode < 200 || res.StatusCode > 299 { defer res.Body.Close() var envelope struct { Error AgentDBError `json:"error"` } _ = json.NewDecoder(res.Body).Decode(&envelope) // no envelope: keep the defaults apiErr := envelope.Error apiErr.Status = res.StatusCode if apiErr.Code == "" { apiErr.Code = "HTTP_ERROR" } return nil, &apiErr } return res, nil } } // call makes one API call and returns the "data" part of the answer. func call(method, path string, req Request) (M, error) { res, err := send(method, path, req) if err != nil { return nil, err } defer res.Body.Close() var envelope struct { Data M `json:"data"` } err = json.NewDecoder(res.Body).Decode(&envelope) return envelope.Data, err } ``` Check the connection by reading the schema: ```go file=examples/languages/go/main.go#connect-check schema, err := call("GET", "/object-types", Request{}) if err != nil { log.Fatal(err) } var names []string for _, o := range schema["objects"].([]any) { names = append(names, o.(M)["name"].(string)) } fmt.Println("Objects:", strings.Join(names, ", ")) ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```go file=examples/languages/go/main.go#create ada, err := call("POST", "/records", Request{ JSON: M{"object": "contact", "data": M{"name": "Ada Lovelace", "email": "ada-" + run + "@example.com", "score": 72}}, IdempotencyKey: randomUUID(), // safe to retry: the same key never writes twice }) if err != nil { log.Fatal(err) } adaID := ada["id"].(string) fmt.Println("Created", adaID, "version", ada["version"]) ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```go file=examples/languages/go/main.go#read record, err := call("GET", "/records/"+adaID, Request{}) if err != nil { log.Fatal(err) } data := record["data"].(M) fmt.Println(data["name"], data["email"], "version", record["version"]) ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```go file=examples/languages/go/main.go#update // updateRecord reads, changes and writes with the current version. // On VERSION_CONFLICT it reads again and retries. updateRecord := func(id string, change func(data M) M) (M, error) { for i := 1; ; i++ { current, err := call("GET", "/records/"+id, Request{}) if err != nil { return nil, err } updated, err := call("PATCH", "/records/"+id, Request{JSON: M{"version": current["version"], "data": change(current["data"].(M))}}) var apiErr *AgentDBError if errors.As(err, &apiErr) && apiErr.Code == "VERSION_CONFLICT" && i < 3 { continue } return updated, err } } updated, err := updateRecord(adaID, func(data M) M { return M{"score": data["score"].(float64) + 10} }) if err != nil { log.Fatal(err) } fmt.Println("Score", updated["data"].(M)["score"], "version", updated["version"]) ``` This is what the conflict looks like: ```go file=examples/languages/go/main.go#version-conflict _, err = call("PATCH", "/records/"+adaID, Request{JSON: M{"version": 1, "data": M{"score": 0}}}) // stale version var conflict *AgentDBError if errors.As(err, &conflict) && conflict.Code == "VERSION_CONFLICT" { fmt.Println("Conflict: the record is now at version", conflict.Details["current_version"]) } else if err != nil { log.Fatal(err) } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```go file=examples/languages/go/main.go#query // queryAll returns every record that matches, page by page (limit up to 200; follow next_cursor). queryAll := func(object string, where M, sort []M, pageSize int) ([]M, error) { var out []M cursor := "" for { query := M{"object": object, "where": where, "sort": sort, "limit": pageSize} if cursor != "" { query["cursor"] = cursor } page, err := call("POST", "/query", Request{JSON: query}) if err != nil { return nil, err } for _, r := range page["records"].([]any) { out = append(out, r.(M)) } cursor, _ = page["next_cursor"].(string) // null on the last page if cursor == "" { return out, nil } } } good, err := queryAll( "contact", M{"all": []M{{"field": "score", "op": "gte", "value": 70}, {"field": "email", "op": "neq", "value": "nobody@example.com"}}}, []M{{"field": "score", "order": "desc"}}, 2, ) if err != nil { log.Fatal(err) } for _, c := range good { fmt.Println(c["data"].(M)["score"], c["data"].(M)["name"]) } ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```go file=examples/languages/go/main.go#relationships company, err := call("POST", "/records", Request{JSON: M{"object": "company", "data": M{"name": "Example Ltd " + run}}}) if err != nil { log.Fatal(err) } _, err = call("POST", "/links", Request{ JSON: M{"relationship": "works_at", "source_record_id": adaID, "target_record_id": company["id"]}, IdempotencyKey: fmt.Sprintf("link-%s-%s", adaID, company["id"]), }) if err != nil { log.Fatal(err) } related, err := call("GET", "/records/"+adaID+"/related?relationship=works_at", Request{}) if err != nil { log.Fatal(err) } var employers []string for _, r := range related["records"].([]any) { employers = append(employers, r.(M)["data"].(M)["name"].(string)) } fmt.Println("Ada works at", strings.Join(employers, ", ")) ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```go file=examples/languages/go/main.go#files content := []byte("Meeting notes for Ada (" + run + ")\n") // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. file, err := call("POST", "/files", Request{JSON: M{"filename": "notes.txt", "content_type": "text/plain", "size": len(content)}}) if err != nil { log.Fatal(err) } fileID := file["id"].(string) if _, err := call("PUT", "/files/"+fileID+"/content", Request{Body: content}); err != nil { log.Fatal(err) } if _, err := call("POST", "/attachments", Request{JSON: M{"file_id": fileID, "record_id": adaID}}); err != nil { log.Fatal(err) } // Download with the key (server side) ... download, err := send("GET", "/files/"+fileID+"/content", Request{}) if err != nil { log.Fatal(err) } back, err := io.ReadAll(download.Body) download.Body.Close() if err != nil { log.Fatal(err) } // ... or make a signed link that expires, for an or a download button in your page. link, err := call("POST", "/files/"+fileID+"/links", Request{JSON: M{"expires_in_seconds": 600}}) if err != nil { log.Fatal(err) } fmt.Println("Signed link until", link["expires_at"]) ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```go file=examples/languages/go/main.go#errors _, err = call("GET", "/records/rec_doesnotexist", Request{}) var apiErr *AgentDBError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Message) // 404 NOT_FOUND ... if apiErr.Retryable { fmt.Println("Safe to retry later") } } else if err != nil { log.Fatal(err) // a network error, not an AgentDB answer } ``` Go returns errors as values; the helper returns an `*AgentDBError` for AgentDB answers, and other errors for network trouble. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```go file=examples/languages/go/main.go#idempotency key := randomUUID() // one key per intention; reuse it only to retry the same request request := M{"object": "contact", "data": M{"name": "Mary Somerville", "email": "mary-" + run + "@example.com"}} first, err := call("POST", "/records", Request{JSON: request, IdempotencyKey: key}) if err != nil { log.Fatal(err) } retry, err := call("POST", "/records", Request{JSON: request, IdempotencyKey: key}) // e.g. after a timeout if err != nil { log.Fatal(err) } if first["id"] == retry["id"] { fmt.Println("Same record, written once") } else { fmt.Println("Duplicate!") } ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/go/main.go`. Set the three environment variables and run: ```sh go run . ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with C# (.NET) Use AgentDB as the database of your C# (.NET) app. This page shows every call you need, with `HttpClient` and `System.Text.Json`. No NuGet packages. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. In ASP.NET Core, register one `HttpClient` (for example with `IHttpClientFactory`) and read the key from configuration or user secrets. Call AgentDB from controllers or minimal-API handlers. ## Before you start {#before-you-start} - .NET 8 or newer (the example project targets .NET 9). - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```csharp file=examples/languages/csharp/Program.cs#connect static readonly string BaseUrl = (Environment.GetEnvironmentVariable("AGENTDB_URL") ?? "https://app.agentdb.link").TrimEnd('/'); static readonly string ApiKey = Env("AGENTDB_API_KEY"); // a server-side secret: never in browser code static readonly string Workspace = Env("AGENTDB_WORKSPACE"); // ws_... static readonly string ApiUrl = $"{BaseUrl}/v1/workspaces/{Workspace}"; static readonly HttpClient Http = new(); static string Env(string name) { var value = Environment.GetEnvironmentVariable(name); if (string.IsNullOrEmpty(value)) throw new InvalidOperationException("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE"); return value; } ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```csharp file=examples/languages/csharp/Program.cs#request /// An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. class AgentDBError(int status, JsonNode? error) : Exception($"{status} {(string?)error?["code"] ?? "HTTP_ERROR"}: {(string?)error?["message"] ?? ""}") { public int Status { get; } = status; public string Code { get; } = (string?)error?["code"] ?? "HTTP_ERROR"; public bool Retryable { get; } = error?["retryable"]?.GetValueKind() == JsonValueKind.True; public JsonNode Details { get; } = error?["details"] ?? new JsonObject(); } /// /// One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After /// (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write /// with an Idempotency-Key). Returns the raw response; Api() below returns its "data" part. /// static async Task Send(string method, string path, object? json = null, byte[]? body = null, string? idempotencyKey = null) { var safeToRepeat = method == "GET" || idempotencyKey != null; for (var attempt = 0; ; attempt++) { // A request message can be sent only once, so build a new one for every attempt. var req = new HttpRequestMessage(new HttpMethod(method), ApiUrl + path); req.Headers.Add("Authorization", $"Bearer {ApiKey}"); if (json != null) req.Content = new StringContent(JsonSerializer.Serialize(json), Encoding.UTF8, "application/json"); if (body != null) { req.Content = new ByteArrayContent(body); req.Content.Headers.ContentType = new("application/octet-stream"); } if (idempotencyKey != null) req.Headers.Add("Idempotency-Key", idempotencyKey); HttpResponseMessage res; try { res = await Http.SendAsync(req); } catch (HttpRequestException) when (safeToRepeat && attempt < 4) { await Task.Delay(500 * (1 << attempt)); continue; } if ((res.StatusCode == HttpStatusCode.TooManyRequests || res.StatusCode == HttpStatusCode.ServiceUnavailable) && attempt < 4) { var retryAfter = res.Headers.RetryAfter?.Delta; await Task.Delay(retryAfter > TimeSpan.Zero ? retryAfter.Value : TimeSpan.FromMilliseconds(500 * (1 << attempt) + Random.Shared.Next(250))); continue; } if (!res.IsSuccessStatusCode) { JsonNode? envelope = null; try { envelope = JsonNode.Parse(await res.Content.ReadAsStringAsync()); } catch (JsonException) { } throw new AgentDBError((int)res.StatusCode, envelope?["error"]); } return res; } } /// One API call that answers with JSON. Returns the "data" part. static async Task Api(string method, string path, object? json = null, string? idempotencyKey = null) { var res = await Send(method, path, json: json, idempotencyKey: idempotencyKey); return JsonNode.Parse(await res.Content.ReadAsStringAsync())!["data"]!; } ``` Check the connection by reading the schema: ```csharp file=examples/languages/csharp/Program.cs#connect-check var schema = await Api("GET", "/object-types"); var objects = schema["objects"]!.AsArray(); Console.WriteLine("Objects: " + string.Join(", ", objects.Select(o => (string)o!["name"]!))); ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```csharp file=examples/languages/csharp/Program.cs#create var ada = await Api("POST", "/records", new { @object = "contact", data = new { name = "Ada Lovelace", email = $"ada-{run}@example.com", score = 72 } }, idempotencyKey: Guid.NewGuid().ToString()); // safe to retry: the same key never writes twice var adaId = (string)ada["id"]!; Console.WriteLine($"Created {adaId} version {ada["version"]}"); ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```csharp file=examples/languages/csharp/Program.cs#read var record = await Api("GET", $"/records/{adaId}"); Console.WriteLine($"{record["data"]!["name"]} {record["data"]!["email"]} version {record["version"]}"); ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```csharp file=examples/languages/csharp/Program.cs#update // Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. static async Task UpdateRecord(string id, Func change, int attempts = 3) { for (var i = 1; ; i++) { var current = await Api("GET", $"/records/{id}"); try { return await Api("PATCH", $"/records/{id}", new { version = (int)current["version"]!, data = change(current["data"]!) }); } catch (AgentDBError err) when (err.Code == "VERSION_CONFLICT" && i < attempts) { // Someone else changed the record: loop to read it again. } } } var updated = await UpdateRecord(adaId, data => new { score = (int)data["score"]! + 10 }); Console.WriteLine($"Score {updated["data"]!["score"]} version {updated["version"]}"); ``` This is what the conflict looks like: ```csharp file=examples/languages/csharp/Program.cs#version-conflict try { await Api("PATCH", $"/records/{adaId}", new { version = 1, data = new { score = 0 } }); // stale version } catch (AgentDBError err) when (err.Code == "VERSION_CONFLICT") { Console.WriteLine($"Conflict: the record is now at version {err.Details["current_version"]}"); } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```csharp file=examples/languages/csharp/Program.cs#query // Every record that matches, page by page (limit up to 200; follow next_cursor). static async Task> QueryAll(string obj, object where, object sort, int pageSize = 2) { var all = new List(); string? cursor = null; do { var body = new JsonObject { ["object"] = obj, ["where"] = JsonSerializer.SerializeToNode(where), ["sort"] = JsonSerializer.SerializeToNode(sort), ["limit"] = pageSize, }; if (cursor != null) body["cursor"] = cursor; var page = await Api("POST", "/query", body); all.AddRange(page["records"]!.AsArray().Select(r => r!)); cursor = (string?)page["next_cursor"]; // null on the last page } while (cursor != null); return all; } var good = await QueryAll( "contact", new { all = new object[] { new { field = "score", op = "gte", value = 70 }, new { field = "email", op = "neq", value = "nobody@example.com" } } }, new[] { new { field = "score", order = "desc" } }); foreach (var c in good) Console.WriteLine($"{c["data"]!["score"]} {c["data"]!["name"]}"); ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```csharp file=examples/languages/csharp/Program.cs#relationships var company = await Api("POST", "/records", new { @object = "company", data = new { name = $"Example Ltd {run}" } }); var companyId = (string)company["id"]!; await Api("POST", "/links", new { relationship = "works_at", source_record_id = adaId, target_record_id = companyId }, idempotencyKey: $"link-{adaId}-{companyId}"); var related = (await Api("GET", $"/records/{adaId}/related?relationship=works_at"))["records"]!.AsArray(); Console.WriteLine("Ada works at " + string.Join(", ", related.Select(r => (string)r!["data"]!["name"]!))); ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```csharp file=examples/languages/csharp/Program.cs#files var bytes = Encoding.UTF8.GetBytes($"Meeting notes for Ada ({run})\n"); // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. var file = await Api("POST", "/files", new { filename = "notes.txt", content_type = "text/plain", size = bytes.Length }); var fileId = (string)file["id"]!; await Send("PUT", $"/files/{fileId}/content", body: bytes); await Api("POST", "/attachments", new { file_id = fileId, record_id = adaId }); // Download with the key (server side) ... var download = await Send("GET", $"/files/{fileId}/content"); var back = await download.Content.ReadAsByteArrayAsync(); // ... or make a signed link that expires, for an or a download button in your page. var link = await Api("POST", $"/files/{fileId}/links", new { expires_in_seconds = 600 }); Console.WriteLine($"Signed link until {link["expires_at"]}"); ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```csharp file=examples/languages/csharp/Program.cs#errors try { await Api("GET", "/records/rec_doesnotexist"); } catch (AgentDBError err) // a network error is an HttpRequestException, not an AgentDB answer { Console.WriteLine($"{err.Status} {err.Code} {err.Message}"); // 404 NOT_FOUND ... if (err.Retryable) Console.WriteLine("Safe to retry later"); } ``` `HttpClient` throws `HttpRequestException` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```csharp file=examples/languages/csharp/Program.cs#idempotency var key = Guid.NewGuid().ToString(); // one key per intention; reuse it only to retry the same request var request = new { @object = "contact", data = new { name = "Mary Somerville", email = $"mary-{run}@example.com" } }; var first = await Api("POST", "/records", request, idempotencyKey: key); var retry = await Api("POST", "/records", request, idempotencyKey: key); // e.g. after a timeout Console.WriteLine((string?)first["id"] == (string?)retry["id"] ? "Same record, written once" : "Duplicate!"); ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/csharp/Program.cs`, `examples/languages/csharp/Crm.csproj`. Set the three environment variables and run: ```sh dotnet run ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Java Use AgentDB as the database of your Java app. This page shows every call you need, with `java.net.http.HttpClient`. No libraries. The JDK has no JSON parser, so the example carries a small one; in an application, use Jackson or Gson. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested in CI: its code runs against a fresh AgentDB on every change. > Server-side only. In Spring Boot, wrap these calls in a service bean and read the key from the environment or a secrets store. Call it from controllers; never from a browser. ## Before you start {#before-you-start} - Java 21 or newer. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```java file=examples/languages/java/Crm.java#connect static final String BASE_URL = Objects.requireNonNullElse(System.getenv("AGENTDB_URL"), "https://app.agentdb.link").replaceAll("/+$", ""); static final String API_KEY = System.getenv("AGENTDB_API_KEY"); // a server-side secret: never in browser code static final String WORKSPACE = System.getenv("AGENTDB_WORKSPACE"); // ws_... static final String API = BASE_URL + "/v1/workspaces/" + WORKSPACE; static final HttpClient HTTP = HttpClient.newHttpClient(); static { if (API_KEY == null || WORKSPACE == null) throw new IllegalStateException("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE"); } ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```java file=examples/languages/java/Crm.java#request /** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */ static class AgentDBError extends RuntimeException { final int status; final String code; final boolean retryable; final Map details; AgentDBError(int status, Map error) { super(status + " " + error.getOrDefault("code", "HTTP_ERROR") + ": " + error.getOrDefault("message", "")); this.status = status; this.code = (String) error.getOrDefault("code", "HTTP_ERROR"); this.retryable = Boolean.TRUE.equals(error.get("retryable")); this.details = error.get("details") instanceof Map d ? map(d) : Map.of(); } } /** * One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After * (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write * with an Idempotency-Key). Sends json as application/json, or body as raw bytes. Returns the raw response. */ static HttpResponse send(String method, String path, Object json, byte[] body, String idempotencyKey) throws IOException, InterruptedException { boolean safeToRepeat = method.equals("GET") || idempotencyKey != null; for (int attempt = 0; ; attempt++) { long backoff = 500L << attempt; var request = HttpRequest.newBuilder(URI.create(API + path)).header("Authorization", "Bearer " + API_KEY); if (json != null) { request.header("Content-Type", "application/json").method(method, BodyPublishers.ofString(Json.write(json))); } else if (body != null) { request.header("Content-Type", "application/octet-stream").method(method, BodyPublishers.ofByteArray(body)); } else { request.method(method, BodyPublishers.noBody()); } if (idempotencyKey != null) request.header("Idempotency-Key", idempotencyKey); HttpResponse res; try { res = HTTP.send(request.build(), BodyHandlers.ofByteArray()); } catch (IOException e) { if (safeToRepeat && attempt < 4) { Thread.sleep(backoff); continue; } throw e; } int status = res.statusCode(); if ((status == 429 || status == 503) && attempt < 4) { String retryAfter = res.headers().firstValue("Retry-After").orElse(""); long seconds = retryAfter.matches("\\d+") ? Long.parseLong(retryAfter) : 0; Thread.sleep(seconds > 0 ? seconds * 1000 : backoff + (long) (Math.random() * 250)); continue; } if (status < 200 || status > 299) { Map error = null; try { error = map(map(Json.parse(new String(res.body(), UTF_8))).get("error")); } catch (RuntimeException notJson) { // no error envelope: keep the defaults } throw new AgentDBError(status, error != null ? error : Map.of()); } return res; } } /** One API call that returns the "data" part of the answer. */ static Map api(String method, String path, Object json, String idempotencyKey) throws IOException, InterruptedException { var answer = Json.parse(new String(send(method, path, json, null, idempotencyKey).body(), UTF_8)); return map(map(answer).get("data")); } static Map api(String method, String path, Object json) throws IOException, InterruptedException { return api(method, path, json, null); } static Map api(String method, String path) throws IOException, InterruptedException { return api(method, path, null, null); } /** Casts for the parsed JSON: an object is a Map, an array is a List. */ @SuppressWarnings("unchecked") static Map map(Object value) { return (Map) value; } @SuppressWarnings("unchecked") static List> list(Object value) { return (List>) value; } ``` Check the connection by reading the schema: ```java file=examples/languages/java/Crm.java#connect-check var schema = api("GET", "/object-types"); var names = list(schema.get("objects")).stream().map(o -> (String) o.get("name")).toList(); System.out.println("Objects: " + String.join(", ", names)); ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```java file=examples/languages/java/Crm.java#create var ada = api("POST", "/records", Map.of("object", "contact", "data", Map.of("name", "Ada Lovelace", "email", "ada-" + run + "@example.com", "score", 72)), UUID.randomUUID().toString()); // safe to retry: the same key never writes twice var adaId = (String) ada.get("id"); System.out.println("Created " + adaId + " version " + ada.get("version")); ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```java file=examples/languages/java/Crm.java#read var record = api("GET", "/records/" + adaId); var data = map(record.get("data")); System.out.println(data.get("name") + " " + data.get("email") + " version " + record.get("version")); ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```java file=examples/languages/java/Crm.java#update // Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. Map updated = null; for (int attempt = 1; updated == null; attempt++) { var current = api("GET", "/records/" + adaId); long score = (Long) map(current.get("data")).get("score"); try { updated = api("PATCH", "/records/" + adaId, Map.of("version", current.get("version"), "data", Map.of("score", score + 10))); } catch (AgentDBError e) { if (!e.code.equals("VERSION_CONFLICT") || attempt == 3) throw e; } } System.out.println("Score " + map(updated.get("data")).get("score") + " version " + updated.get("version")); ``` This is what the conflict looks like: ```java file=examples/languages/java/Crm.java#version-conflict try { api("PATCH", "/records/" + adaId, Map.of("version", 1, "data", Map.of("score", 0))); // stale version } catch (AgentDBError e) { if (!e.code.equals("VERSION_CONFLICT")) throw e; System.out.println("Conflict: the record is now at version " + e.details.get("current_version")); } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```java file=examples/languages/java/Crm.java#query // Every record that matches, page by page (limit up to 200; follow next_cursor). var where = Map.of("all", List.of( Map.of("field", "score", "op", "gte", "value", 70), Map.of("field", "email", "op", "neq", "value", "nobody@example.com"))); var sort = List.of(Map.of("field", "score", "order", "desc")); var good = new ArrayList>(); String cursor = null; do { var query = new HashMap(Map.of("object", "contact", "where", where, "sort", sort, "limit", 2)); if (cursor != null) query.put("cursor", cursor); var page = api("POST", "/query", query); good.addAll(list(page.get("records"))); cursor = (String) page.get("next_cursor"); // null on the last page } while (cursor != null); for (var c : good) System.out.println(map(c.get("data")).get("score") + " " + map(c.get("data")).get("name")); ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```java file=examples/languages/java/Crm.java#relationships var company = api("POST", "/records", Map.of("object", "company", "data", Map.of("name", "Example Ltd " + run))); var companyId = (String) company.get("id"); api("POST", "/links", Map.of("relationship", "works_at", "source_record_id", adaId, "target_record_id", companyId), "link-" + adaId + "-" + companyId); var related = list(api("GET", "/records/" + adaId + "/related?relationship=works_at").get("records")); System.out.println("Ada works at " + String.join(", ", related.stream().map(r -> (String) map(r.get("data")).get("name")).toList())); ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```java file=examples/languages/java/Crm.java#files byte[] bytes = ("Meeting notes for Ada (" + run + ")\n").getBytes(UTF_8); // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. var file = api("POST", "/files", Map.of("filename", "notes.txt", "content_type", "text/plain", "size", bytes.length)); var fileId = (String) file.get("id"); send("PUT", "/files/" + fileId + "/content", null, bytes, null); api("POST", "/attachments", Map.of("file_id", fileId, "record_id", adaId)); // Download with the key (server side) ... byte[] back = send("GET", "/files/" + fileId + "/content", null, null, null).body(); // ... or make a signed link that expires, for an or a download button in your page. var link = api("POST", "/files/" + fileId + "/links", Map.of("expires_in_seconds", 600)); System.out.println("Signed link until " + link.get("expires_at")); ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```java file=examples/languages/java/Crm.java#errors try { api("GET", "/records/rec_doesnotexist"); } catch (AgentDBError e) { // a network error is an IOException, not an AgentDB answer System.out.println(e.getMessage()); // 404 NOT_FOUND: ... if (e.retryable) System.out.println("Safe to retry later"); } ``` `HttpClient` throws `IOException` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```java file=examples/languages/java/Crm.java#idempotency var key = UUID.randomUUID().toString(); // one key per intention; reuse it only to retry the same request var request = Map.of("object", "contact", "data", Map.of("name", "Mary Somerville", "email", "mary-" + run + "@example.com")); var first = api("POST", "/records", request, key); var retry = api("POST", "/records", request, key); // e.g. after a timeout System.out.println(first.get("id").equals(retry.get("id")) ? "Same record, written once" : "Duplicate!"); ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/java/Crm.java`. Set the three environment variables and run: ```sh java Crm.java ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Ruby Use AgentDB as the database of your Ruby app. This page shows every call you need, with `Net::HTTP` and `json` from the standard library. No gems. With Faraday or HTTParty, the calls look the same. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested: its code runs locally and in CI against a fresh AgentDB on every change. > Server-side only. In Rails, read the key from `ENV` or encrypted credentials, and call AgentDB from a model service or a job. Never render the key into a view. ## Before you start {#before-you-start} - Ruby 3.1 or newer. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```ruby file=examples/languages/ruby/crm.rb#connect BASE_URL = ENV.fetch("AGENTDB_URL", "https://app.agentdb.link").chomp("/") API_KEY = ENV["AGENTDB_API_KEY"] # a server-side secret: never in browser code WORKSPACE = ENV["AGENTDB_WORKSPACE"] # ws_... abort "Set AGENTDB_API_KEY and AGENTDB_WORKSPACE" if API_KEY.to_s.empty? || WORKSPACE.to_s.empty? API = "#{BASE_URL}/v1/workspaces/#{WORKSPACE}" ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```ruby file=examples/languages/ruby/crm.rb#request # An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. class AgentDBError < StandardError attr_reader :status, :code, :retryable, :details def initialize(status, error) error ||= {} @status = status @code = error["code"] || "HTTP_ERROR" @retryable = error["retryable"] == true @details = error["details"] || {} super("#{status} #{@code}: #{error["message"]}") end end # One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After # (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write # with an Idempotency-Key). Returns the "data" part of the answer, or the raw body. def api(method, path, json: nil, body: nil, idempotency_key: nil, raw: false) uri = URI(API + path) headers = { "Authorization" => "Bearer #{API_KEY}" } payload = nil if json headers["Content-Type"] = "application/json" payload = JSON.generate(json) end if body headers["Content-Type"] = "application/octet-stream" payload = body end headers["Idempotency-Key"] = idempotency_key if idempotency_key safe_to_repeat = method == "GET" || !idempotency_key.nil? attempt = 0 loop do begin res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 60) do |http| http.send_request(method, uri.request_uri, payload, headers) end rescue IOError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError raise unless safe_to_repeat && attempt < 4 sleep(0.5 * 2**attempt) attempt += 1 next end status = res.code.to_i if [429, 503].include?(status) && attempt < 4 retry_after = res["Retry-After"].to_i sleep(retry_after.positive? ? retry_after : 0.5 * 2**attempt + rand / 4) attempt += 1 next end if status >= 400 envelope = JSON.parse(res.body) rescue {} raise AgentDBError.new(status, envelope["error"]) end return raw ? res.body : JSON.parse(res.body)["data"] end end ``` Check the connection by reading the schema: ```ruby file=examples/languages/ruby/crm.rb#connect-check schema = api("GET", "/object-types") puts "Objects: #{schema["objects"].map { |o| o["name"] }.join(", ")}" ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```ruby file=examples/languages/ruby/crm.rb#create ada = api("POST", "/records", json: { object: "contact", data: { name: "Ada Lovelace", email: "ada-#{run}@example.com", score: 72 } }, idempotency_key: SecureRandom.uuid) # safe to retry: the same key never writes twice puts "Created #{ada["id"]} version #{ada["version"]}" ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```ruby file=examples/languages/ruby/crm.rb#read record = api("GET", "/records/#{ada["id"]}") puts "#{record["data"]["name"]} #{record["data"]["email"]} version #{record["version"]}" ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```ruby file=examples/languages/ruby/crm.rb#update # Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. def update_record(id, attempts: 3) (1..attempts).each do |i| current = api("GET", "/records/#{id}") begin return api("PATCH", "/records/#{id}", json: { version: current["version"], data: yield(current["data"]) }) rescue AgentDBError => e raise unless e.code == "VERSION_CONFLICT" && i < attempts end end end updated = update_record(ada["id"]) { |data| { score: data["score"] + 10 } } puts "Score #{updated["data"]["score"]} version #{updated["version"]}" ``` This is what the conflict looks like: ```ruby file=examples/languages/ruby/crm.rb#version-conflict begin api("PATCH", "/records/#{ada["id"]}", json: { version: 1, data: { score: 0 } }) # stale version rescue AgentDBError => e raise unless e.code == "VERSION_CONFLICT" puts "Conflict: the record is now at version #{e.details["current_version"]}" end ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```ruby file=examples/languages/ruby/crm.rb#query # Every record that matches, page by page (limit up to 200; follow next_cursor). def query_all(object, where, sort, page_size: 2) out = [] cursor = nil loop do body = { object: object, where: where, sort: sort, limit: page_size } body[:cursor] = cursor if cursor page = api("POST", "/query", json: body) out.concat(page["records"]) cursor = page["next_cursor"] # nil on the last page return out unless cursor end end good = query_all("contact", { all: [{ field: "score", op: "gte", value: 70 }, { field: "email", op: "neq", value: "nobody@example.com" }] }, [{ field: "score", order: "desc" }]) good.each { |c| puts "#{c["data"]["score"]} #{c["data"]["name"]}" } ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```ruby file=examples/languages/ruby/crm.rb#relationships company = api("POST", "/records", json: { object: "company", data: { name: "Example Ltd #{run}" } }) api("POST", "/links", json: { relationship: "works_at", source_record_id: ada["id"], target_record_id: company["id"] }, idempotency_key: "link-#{ada["id"]}-#{company["id"]}") related = api("GET", "/records/#{ada["id"]}/related?relationship=works_at") puts "Ada works at #{related["records"].map { |r| r["data"]["name"] }.join(", ")}" ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```ruby file=examples/languages/ruby/crm.rb#files bytes = "Meeting notes for Ada (#{run})\n".b # 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. file = api("POST", "/files", json: { filename: "notes.txt", content_type: "text/plain", size: bytes.bytesize }) api("PUT", "/files/#{file["id"]}/content", body: bytes) api("POST", "/attachments", json: { file_id: file["id"], record_id: ada["id"] }) # Download with the key (server side) ... back = api("GET", "/files/#{file["id"]}/content", raw: true) # ... or make a signed link that expires, for an or a download button in your page. link = api("POST", "/files/#{file["id"]}/links", json: { expires_in_seconds: 600 }) puts "Signed link until #{link["expires_at"]}" ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```ruby file=examples/languages/ruby/crm.rb#errors begin api("GET", "/records/rec_doesnotexist") rescue AgentDBError => e # a network error raises IOError or SystemCallError instead puts "#{e.status} #{e.code} #{e.message}" # 404 NOT_FOUND ... puts "Safe to retry later" if e.retryable end ``` Network errors raise `IOError` or `SystemCallError`; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```ruby file=examples/languages/ruby/crm.rb#idempotency key = SecureRandom.uuid # one key per intention; reuse it only to retry the same request request = { object: "contact", data: { name: "Mary Somerville", email: "mary-#{run}@example.com" } } first = api("POST", "/records", json: request, idempotency_key: key) retry_result = api("POST", "/records", json: request, idempotency_key: key) # e.g. after a timeout puts first["id"] == retry_result["id"] ? "Same record, written once" : "Duplicate!" ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/ruby/crm.rb`. Set the three environment variables and run: ```sh ruby crm.rb ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Rust Use AgentDB as the database of your Rust app. This page shows every call you need, with `reqwest` (blocking) and `serde_json`. Two crates: `reqwest` and `serde_json`. An async app uses the async `reqwest::Client` the same way. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested in CI: its code runs against a fresh AgentDB on every change. > Server-side only. Create one `reqwest` client and reuse it. In Axum or Actix, keep it in your application state. ## Before you start {#before-you-start} - Rust 1.88 or newer (the version current reqwest needs). - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```rust file=examples/languages/rust/src/main.rs#connect let base_url = std::env::var("AGENTDB_URL").unwrap_or_else(|_| "https://app.agentdb.link".into()); let api_key = std::env::var("AGENTDB_API_KEY"); // a server-side secret: never in browser code let workspace = std::env::var("AGENTDB_WORKSPACE"); // ws_... let (Ok(api_key), Ok(workspace)) = (api_key, workspace) else { return Err("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE".into()); }; let api = Api { url: format!( "{}/v1/workspaces/{}", base_url.trim_end_matches('/'), workspace ), key: api_key, http: Client::new(), }; ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```rust file=examples/languages/rust/src/main.rs#request /// An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. #[derive(Debug)] struct AgentDBError { status: u16, code: String, message: String, retryable: bool, details: Value, } impl fmt::Display for AgentDBError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} {}: {}", self.status, self.code, self.message) } } impl Error for AgentDBError {} /// The optional parts of a call. #[derive(Default)] struct Request { json: Option, // sent as application/json body: Option>, // sent as application/octet-stream idempotency_key: Option, // makes a write safe to retry } impl Request { fn json(value: Value) -> Self { Request { json: Some(value), ..Default::default() } } fn idempotency_key(mut self, key: impl Into) -> Self { self.idempotency_key = Some(key.into()); self } } /// The workspace API: its URL, the API key and one HTTP client. struct Api { url: String, key: String, http: Client, } impl Api { /// One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After /// (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write /// with an Idempotency-Key). Returns the raw response. fn send(&self, method: Method, path: &str, req: &Request) -> Result> { let safe_to_repeat = method == Method::GET || req.idempotency_key.is_some(); for attempt in 0..=4u32 { let backoff = Duration::from_millis(500 << attempt); let mut builder = self .http .request(method.clone(), format!("{}{}", self.url, path)) .bearer_auth(&self.key); if let Some(json) = &req.json { builder = builder.json(json); } if let Some(body) = &req.body { builder = builder .header(CONTENT_TYPE, "application/octet-stream") .body(body.clone()); } if let Some(key) = &req.idempotency_key { builder = builder.header("Idempotency-Key", key); } let res = match builder.send() { Ok(res) => res, Err(_) if safe_to_repeat && attempt < 4 => { sleep(backoff); continue; } Err(err) => return Err(err.into()), }; let status = res.status().as_u16(); if (status == 429 || status == 503) && attempt < 4 { let retry_after = res .headers() .get(RETRY_AFTER) .and_then(|v| v.to_str().ok()?.parse::().ok()); let jitter = Duration::from_millis(u64::from( SystemTime::now() .duration_since(UNIX_EPOCH)? .subsec_millis() % 250, )); sleep(match retry_after { Some(seconds) if seconds > 0 => Duration::from_secs(seconds), _ => backoff + jitter, }); continue; } if !res.status().is_success() { let envelope: Value = res.json().unwrap_or(Value::Null); let error = &envelope["error"]; return Err(Box::new(AgentDBError { status, code: error["code"].as_str().unwrap_or("HTTP_ERROR").to_string(), message: error["message"].as_str().unwrap_or("").to_string(), retryable: error["retryable"] == true, details: error["details"].clone(), })); } return Ok(res); } unreachable!("the last attempt always returns") } /// One API call that returns the "data" part of the answer. fn call(&self, method: Method, path: &str, req: Request) -> Result> { let mut answer: Value = self.send(method, path, &req)?.json()?; Ok(answer["data"].take()) } } ``` Check the connection by reading the schema: ```rust file=examples/languages/rust/src/main.rs#connect-check let schema = api.call(Method::GET, "/object-types", Request::default())?; let names: Vec<&str> = schema["objects"] .as_array() .into_iter() .flatten() .filter_map(|o| o["name"].as_str()) .collect(); println!("Objects: {}", names.join(", ")); ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```rust file=examples/languages/rust/src/main.rs#create let ada = api.call( Method::POST, "/records", Request::json(json!({ "object": "contact", "data": { "name": "Ada Lovelace", "email": format!("ada-{run}@example.com"), "score": 72 } })) .idempotency_key(random_uuid()), // safe to retry: the same key never writes twice )?; let ada_id = ada["id"].as_str().unwrap_or_default().to_string(); println!("Created {} version {}", ada_id, ada["version"]); ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```rust file=examples/languages/rust/src/main.rs#read let record = api.call( Method::GET, &format!("/records/{ada_id}"), Request::default(), )?; let data = &record["data"]; println!( "{} {} version {}", data["name"].as_str().unwrap_or(""), data["email"].as_str().unwrap_or(""), record["version"] ); ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```rust file=examples/languages/rust/src/main.rs#update /// Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. fn update_record( api: &Api, id: &str, change: impl Fn(&Value) -> Value, ) -> Result> { let attempts = 3; for i in 1..=attempts { let current = api.call(Method::GET, &format!("/records/{id}"), Request::default())?; let body = json!({ "version": current["version"], "data": change(¤t["data"]) }); match api.call( Method::PATCH, &format!("/records/{id}"), Request::json(body), ) { Err(err) if i < attempts && err .downcast_ref::() .is_some_and(|e| e.code == "VERSION_CONFLICT") => { continue } result => return result, } } unreachable!("the last attempt always returns") } let updated = update_record( &api, &ada_id, |data| json!({ "score": data["score"].as_i64().unwrap_or(0) + 10 }), )?; println!( "Score {} version {}", updated["data"]["score"], updated["version"] ); ``` This is what the conflict looks like: ```rust file=examples/languages/rust/src/main.rs#version-conflict let stale = json!({ "version": 1, "data": { "score": 0 } }); // stale version if let Err(err) = api.call( Method::PATCH, &format!("/records/{ada_id}"), Request::json(stale), ) { match err.downcast_ref::() { Some(e) if e.code == "VERSION_CONFLICT" => println!( "Conflict: the record is now at version {}", e.details["current_version"] ), _ => return Err(err), } } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```rust file=examples/languages/rust/src/main.rs#query /// Every record that matches, page by page (limit up to 200; follow next_cursor). fn query_all( api: &Api, object: &str, filter: &Value, sort: &Value, page_size: u32, ) -> Result, Box> { let mut out = Vec::new(); let mut cursor: Option = None; loop { let mut query = json!({ "object": object, "where": filter, "sort": sort, "limit": page_size }); if let Some(cursor) = &cursor { query["cursor"] = json!(cursor); } let mut page = api.call(Method::POST, "/query", Request::json(query))?; if let Value::Array(records) = page["records"].take() { out.extend(records); } cursor = page["next_cursor"].as_str().map(String::from); // null on the last page if cursor.is_none() { return Ok(out); } } } let good = query_all( &api, "contact", &json!({ "all": [{ "field": "score", "op": "gte", "value": 70 }, { "field": "email", "op": "neq", "value": "nobody@example.com" }] }), &json!([{ "field": "score", "order": "desc" }]), 2, )?; for c in &good { println!( "{} {}", c["data"]["score"], c["data"]["name"].as_str().unwrap_or("") ); } ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```rust file=examples/languages/rust/src/main.rs#relationships let company = api.call( Method::POST, "/records", Request::json( json!({ "object": "company", "data": { "name": format!("Example Ltd {run}") } }), ), )?; let company_id = company["id"].as_str().unwrap_or_default(); api.call( Method::POST, "/links", Request::json(json!({ "relationship": "works_at", "source_record_id": ada_id, "target_record_id": company_id })) .idempotency_key(format!("link-{ada_id}-{company_id}")), )?; let related = api.call( Method::GET, &format!("/records/{ada_id}/related?relationship=works_at"), Request::default(), )?; let employers: Vec<&str> = related["records"] .as_array() .into_iter() .flatten() .filter_map(|r| r["data"]["name"].as_str()) .collect(); println!("Ada works at {}", employers.join(", ")); ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```rust file=examples/languages/rust/src/main.rs#files let bytes = format!("Meeting notes for Ada ({run})\n").into_bytes(); // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. let file = api.call( Method::POST, "/files", Request::json( json!({ "filename": "notes.txt", "content_type": "text/plain", "size": bytes.len() }), ), )?; let file_id = file["id"].as_str().unwrap_or_default(); api.call( Method::PUT, &format!("/files/{file_id}/content"), Request { body: Some(bytes.clone()), ..Default::default() }, )?; api.call( Method::POST, "/attachments", Request::json(json!({ "file_id": file_id, "record_id": ada_id })), )?; // Download with the key (server side) ... let back = api .send( Method::GET, &format!("/files/{file_id}/content"), &Request::default(), )? .bytes()?; // ... or make a signed link that expires, for an or a download button in your page. let link = api.call( Method::POST, &format!("/files/{file_id}/links"), Request::json(json!({ "expires_in_seconds": 600 })), )?; println!( "Signed link until {}", link["expires_at"].as_str().unwrap_or("") ); ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```rust file=examples/languages/rust/src/main.rs#errors if let Err(err) = api.call(Method::GET, "/records/rec_doesnotexist", Request::default()) { let Some(e) = err.downcast_ref::() else { return Err(err); // a network error, not an AgentDB answer }; println!("{} {} {}", e.status, e.code, e.message); // 404 NOT_FOUND ... if e.retryable { println!("Safe to retry later"); } } ``` The helper returns `Err` with an `AgentDBError` for AgentDB answers, and other errors for network trouble. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```rust file=examples/languages/rust/src/main.rs#idempotency let key = random_uuid(); // one key per intention; reuse it only to retry the same request let request = json!({ "object": "contact", "data": { "name": "Mary Somerville", "email": format!("mary-{run}@example.com") } }); let first = api.call( Method::POST, "/records", Request::json(request.clone()).idempotency_key(&key), )?; let retry = api.call( Method::POST, "/records", Request::json(request).idempotency_key(&key), )?; // e.g. after a timeout println!( "{}", if first["id"] == retry["id"] { "Same record, written once" } else { "Duplicate!" } ); ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/rust/src/main.rs`, `examples/languages/rust/Cargo.toml`. Set the three environment variables and run: ```sh cargo run ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Swift Use AgentDB as the database of your Swift app. This page shows every call you need, with `URLSession` with async/await. No packages. You can copy each block into your own code. Together they form one program that our tests run. > This page is tested locally on macOS with `npm run docs:examples`. It is not a required CI check: CI runs it only when the runner has Swift. > Server-side only. Use this on a server (Vapor, Hummingbird) or in your own tools. **Do not ship an AgentDB key inside an iOS or macOS app**: anyone can extract it. An app calls your backend instead; see [pattern B](../security.md#pattern-b-browser-app-with-its-own-backend). ## Before you start {#before-you-start} - Swift 5.9 or newer, on macOS or Linux. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```swift file=examples/languages/swift/main.swift#connect let env = ProcessInfo.processInfo.environment var baseUrl = env["AGENTDB_URL"] ?? "https://app.agentdb.link" while baseUrl.hasSuffix("/") { baseUrl.removeLast() } let apiKey = env["AGENTDB_API_KEY"] ?? "" // a server-side secret: never in app or browser code let workspace = env["AGENTDB_WORKSPACE"] ?? "" // ws_... if apiKey.isEmpty || workspace.isEmpty { fatalError("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") } let api = "\(baseUrl)/v1/workspaces/\(workspace)" ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```swift file=examples/languages/swift/main.swift#request /// A JSON object from the API, read with keys: record["data"]["name"]. typealias JSON = [String: Any] /// An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. struct AgentDBError: Error, CustomStringConvertible { let status: Int let code: String let message: String let retryable: Bool let details: JSON init(status: Int, error: JSON?) { self.status = status code = error?["code"] as? String ?? "HTTP_ERROR" message = error?["message"] as? String ?? "" retryable = error?["retryable"] as? Bool ?? false details = error?["details"] as? JSON ?? [:] } var description: String { "\(status) \(code): \(message)" } } /// One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After /// (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write /// with an Idempotency-Key). Returns the raw body; call() below returns its "data" part. func send(_ method: String, _ path: String, json: Any? = nil, body: Data? = nil, idempotencyKey: String? = nil) async throws -> Data { var req = URLRequest(url: URL(string: api + path)!) req.httpMethod = method req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") if let json { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: json) } if let body { req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") req.httpBody = body } if let idempotencyKey { req.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key") } let safeToRepeat = method == "GET" || idempotencyKey != nil var attempt = 0 while true { let data: Data, res: HTTPURLResponse do { let (d, r) = try await URLSession.shared.data(for: req) (data, res) = (d, r as! HTTPURLResponse) } catch where error is URLError && safeToRepeat && attempt < 4 { try await Task.sleep(nanoseconds: UInt64(500 << attempt) * 1_000_000) attempt += 1 continue } if (res.statusCode == 429 || res.statusCode == 503) && attempt < 4 { let retryAfter = Double(res.value(forHTTPHeaderField: "Retry-After") ?? "") ?? 0 let seconds = retryAfter > 0 ? retryAfter : Double(500 << attempt) / 1000 + Double.random(in: 0..<0.25) try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) attempt += 1 continue } if !(200..<300).contains(res.statusCode) { let envelope = (try? JSONSerialization.jsonObject(with: data)) as? JSON throw AgentDBError(status: res.statusCode, error: envelope?["error"] as? JSON) } return data } } /// One API call that answers with JSON. Returns the "data" part. func call(_ method: String, _ path: String, json: Any? = nil, idempotencyKey: String? = nil) async throws -> JSON { let data = try await send(method, path, json: json, idempotencyKey: idempotencyKey) let envelope = try JSONSerialization.jsonObject(with: data) as? JSON return envelope?["data"] as? JSON ?? [:] } ``` Check the connection by reading the schema: ```swift file=examples/languages/swift/main.swift#connect-check let schema = try await call("GET", "/object-types") let objects = schema["objects"] as? [JSON] ?? [] print("Objects:", objects.compactMap { $0["name"] as? String }.joined(separator: ", ")) ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```swift file=examples/languages/swift/main.swift#create let ada = try await call("POST", "/records", json: ["object": "contact", "data": ["name": "Ada Lovelace", "email": "ada-\(run)@example.com", "score": 72]], idempotencyKey: UUID().uuidString) // safe to retry: the same key never writes twice let adaId = ada["id"] as! String print("Created", adaId, "version", ada["version"] ?? "") ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```swift file=examples/languages/swift/main.swift#read let record = try await call("GET", "/records/\(adaId)") let fields = record["data"] as? JSON ?? [:] print(fields["name"] ?? "", fields["email"] ?? "", "version", record["version"] ?? "") ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```swift file=examples/languages/swift/main.swift#update /// Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. func updateRecord(_ id: String, attempts: Int = 3, _ change: (JSON) -> JSON) async throws -> JSON { var i = 1 while true { let current = try await call("GET", "/records/\(id)") do { let data = change(current["data"] as? JSON ?? [:]) return try await call("PATCH", "/records/\(id)", json: ["version": current["version"] ?? 0, "data": data]) } catch let err as AgentDBError where err.code == "VERSION_CONFLICT" && i < attempts { i += 1 // someone else changed the record: read it again } } } let updated = try await updateRecord(adaId) { data in ["score": (data["score"] as? Int ?? 0) + 10] } let updatedData = updated["data"] as? JSON ?? [:] print("Score", updatedData["score"] ?? "", "version", updated["version"] ?? "") ``` This is what the conflict looks like: ```swift file=examples/languages/swift/main.swift#version-conflict do { _ = try await call("PATCH", "/records/\(adaId)", json: ["version": 1, "data": ["score": 0]]) // stale version } catch let err as AgentDBError where err.code == "VERSION_CONFLICT" { print("Conflict: the record is now at version", err.details["current_version"] ?? "") } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```swift file=examples/languages/swift/main.swift#query /// Every record that matches, page by page (limit up to 200; follow next_cursor). func queryAll(_ object: String, where filter: JSON, sort: [JSON], pageSize: Int = 2) async throws -> [JSON] { var out: [JSON] = [] var cursor: String? = nil repeat { var body: JSON = ["object": object, "where": filter, "sort": sort, "limit": pageSize] if let cursor { body["cursor"] = cursor } let page = try await call("POST", "/query", json: body) out += page["records"] as? [JSON] ?? [] cursor = page["next_cursor"] as? String // null on the last page } while cursor != nil return out } let good = try await queryAll( "contact", where: ["all": [["field": "score", "op": "gte", "value": 70], ["field": "email", "op": "neq", "value": "nobody@example.com"]]], sort: [["field": "score", "order": "desc"]] ) for c in good { let data = c["data"] as? JSON ?? [:] print(data["score"] ?? "", data["name"] ?? "") } ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```swift file=examples/languages/swift/main.swift#relationships let company = try await call("POST", "/records", json: ["object": "company", "data": ["name": "Example Ltd \(run)"]]) let companyId = company["id"] as! String _ = try await call("POST", "/links", json: ["relationship": "works_at", "source_record_id": adaId, "target_record_id": companyId], idempotencyKey: "link-\(adaId)-\(companyId)") let related = try await call("GET", "/records/\(adaId)/related?relationship=works_at")["records"] as? [JSON] ?? [] print("Ada works at", related.compactMap { ($0["data"] as? JSON)?["name"] as? String }.joined(separator: ", ")) ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```swift file=examples/languages/swift/main.swift#files let bytes = Data("Meeting notes for Ada (\(run))\n".utf8) // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. let file = try await call("POST", "/files", json: ["filename": "notes.txt", "content_type": "text/plain", "size": bytes.count]) let fileId = file["id"] as! String _ = try await send("PUT", "/files/\(fileId)/content", body: bytes) _ = try await call("POST", "/attachments", json: ["file_id": fileId, "record_id": adaId]) // Download with the key (server side) ... let back = try await send("GET", "/files/\(fileId)/content") // ... or make a signed link that expires, for an or a download button in your page. let link = try await call("POST", "/files/\(fileId)/links", json: ["expires_in_seconds": 600]) print("Signed link until", link["expires_at"] ?? "") ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```swift file=examples/languages/swift/main.swift#errors do { _ = try await call("GET", "/records/rec_doesnotexist") } catch let err as AgentDBError { // a network error is a URLError, not an AgentDB answer print(err.status, err.code, err.message) // 404 NOT_FOUND ... if err.retryable { print("Safe to retry later") } } ``` `URLSession` throws `URLError` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```swift file=examples/languages/swift/main.swift#idempotency let key = UUID().uuidString // one key per intention; reuse it only to retry the same request let request: JSON = ["object": "contact", "data": ["name": "Mary Somerville", "email": "mary-\(run)@example.com"]] let first = try await call("POST", "/records", json: request, idempotencyKey: key) let retry = try await call("POST", "/records", json: request, idempotencyKey: key) // e.g. after a timeout print(first["id"] as? String == retry["id"] as? String ? "Same record, written once" : "Duplicate!") ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/swift/main.swift`. Set the three environment variables and run: ```sh swift main.swift ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # AgentDB with Kotlin Use AgentDB as the database of your Kotlin app. This page shows every call you need, with `java.net.http.HttpClient` from the JDK. No libraries. The example carries a small JSON helper; in an application, use kotlinx.serialization, Jackson or Moshi. Ktor and OkHttp clients work the same way. You can copy each block into your own code. Together they form one program that our tests run. > Not tested in CI as a required check: CI runs this code only when the runner has the Kotlin compiler. The same calls are tested in [Java](java.md), which uses the same HTTP client. > Server-side only. Use this on a server (Ktor, Spring Boot). **Do not ship an AgentDB key inside an Android app**: anyone can extract it. The app calls your backend instead; see [pattern B](../security.md#pattern-b-browser-app-with-its-own-backend). ## Before you start {#before-you-start} - Kotlin 1.9 or newer on Java 21. - A workspace with the [example schema](index.md#schema): `contact`, `company` and `works_at`. - Three environment variables on your server: `AGENTDB_URL`, `AGENTDB_API_KEY` and `AGENTDB_WORKSPACE`. See [Environment variables](index.md#environment). ## Connect {#connect} Read the settings from the environment. The key never appears in the code. ```kotlin file=examples/languages/kotlin/Crm.kt#connect val BASE_URL = (System.getenv("AGENTDB_URL") ?: "https://app.agentdb.link").trimEnd('/') val API_KEY: String = System.getenv("AGENTDB_API_KEY") ?: error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") // a server-side secret: never in browser code val WORKSPACE: String = System.getenv("AGENTDB_WORKSPACE") ?: error("Set AGENTDB_API_KEY and AGENTDB_WORKSPACE") // ws_... val API = "$BASE_URL/v1/workspaces/$WORKSPACE" val HTTP: HttpClient = HttpClient.newHttpClient() ``` Every call goes through one small helper. It sends the key in the `Authorization: Bearer` header, turns AgentDB errors into one error type, and retries when that is safe. ```kotlin file=examples/languages/kotlin/Crm.kt#request /** An AgentDB error: HTTP status plus the error envelope {code, message, retryable, details}. */ class AgentDBError(val status: Int, error: Map) : Exception("$status ${error["code"] ?: "HTTP_ERROR"}: ${error["message"] ?: ""}") { val code = error["code"] as? String ?: "HTTP_ERROR" val retryable = error["retryable"] == true val details = error["details"] as? Map<*, *> ?: emptyMap() } /** * One API call. Sends the key in the Authorization header. Retries 429 and 503 with Retry-After * (or exponential backoff), and network errors when the call is safe to repeat (GET, or a write * with an Idempotency-Key). Sends json as application/json, or body as raw bytes. Returns the raw response. */ fun send(method: String, path: String, json: Any? = null, body: ByteArray? = null, idempotencyKey: String? = null): HttpResponse { val safeToRepeat = method == "GET" || idempotencyKey != null for (attempt in 0..4) { val backoff = 500L shl attempt val request = HttpRequest.newBuilder(URI.create(API + path)).header("Authorization", "Bearer $API_KEY") when { json != null -> request.header("Content-Type", "application/json").method(method, BodyPublishers.ofString(Json.write(json))) body != null -> request.header("Content-Type", "application/octet-stream").method(method, BodyPublishers.ofByteArray(body)) else -> request.method(method, BodyPublishers.noBody()) } if (idempotencyKey != null) request.header("Idempotency-Key", idempotencyKey) val res = try { HTTP.send(request.build(), BodyHandlers.ofByteArray()) } catch (e: IOException) { if (safeToRepeat && attempt < 4) { Thread.sleep(backoff) continue } throw e } val status = res.statusCode() if ((status == 429 || status == 503) && attempt < 4) { val seconds = res.headers().firstValue("Retry-After").orElse("").toLongOrNull() ?: 0 Thread.sleep(if (seconds > 0) seconds * 1000 else backoff + (Math.random() * 250).toLong()) continue } if (status !in 200..299) { val error = runCatching { Json.parse(res.body().decodeToString()).obj()["error"].obj() }.getOrNull() throw AgentDBError(status, error ?: emptyMap()) // no error envelope: keep the defaults } return res } error("unreachable: the last attempt always returns or throws") } /** One API call that returns the "data" part of the answer. */ fun api(method: String, path: String, json: Any? = null, idempotencyKey: String? = null): Map = Json.parse(send(method, path, json, idempotencyKey = idempotencyKey).body().decodeToString()).obj()["data"].obj() /** Casts for the parsed JSON: an object is a Map, an array is a List. */ @Suppress("UNCHECKED_CAST") fun Any?.obj() = this as Map @Suppress("UNCHECKED_CAST") fun Any?.list() = this as List> ``` Check the connection by reading the schema: ```kotlin file=examples/languages/kotlin/Crm.kt#connect-check val schema = api("GET", "/object-types") val names = schema["objects"].list().map { it["name"] as String } println("Objects: ${names.joinToString(", ")}") ``` ## Create a record {#create} Send the object name and the field values. AgentDB checks each value against its field type. The answer holds the new record with its `id` and `version` 1. ```kotlin file=examples/languages/kotlin/Crm.kt#create val ada = api( "POST", "/records", mapOf("object" to "contact", "data" to mapOf("name" to "Ada Lovelace", "email" to "ada-$run@example.com", "score" to 72)), idempotencyKey = UUID.randomUUID().toString(), // safe to retry: the same key never writes twice ) val adaId = ada["id"] as String println("Created $adaId version ${ada["version"]}") ``` The `Idempotency-Key` header makes a retry safe. See [Idempotency keys](#idempotency). ## Read a record {#read} ```kotlin file=examples/languages/kotlin/Crm.kt#read val record = api("GET", "/records/$adaId") val data = record["data"].obj() println("${data["name"]} ${data["email"]} version ${record["version"]}") ``` ## Update a record {#update} An update names the `version` you read. If someone changed the record since then, AgentDB refuses with `VERSION_CONFLICT` (HTTP 409) and writes nothing. Read it again, apply your change, and try again: ```kotlin file=examples/languages/kotlin/Crm.kt#update /** Read, change, write with the current version. On VERSION_CONFLICT: read again and retry. */ fun updateRecord(id: String, attempts: Int = 3, change: (Map) -> Map): Map { for (i in 1..attempts) { val current = api("GET", "/records/$id") try { return api("PATCH", "/records/$id", mapOf("version" to current["version"], "data" to change(current["data"].obj()))) } catch (e: AgentDBError) { if (e.code != "VERSION_CONFLICT" || i == attempts) throw e } } error("unreachable: the last attempt always returns or throws") } val updated = updateRecord(adaId) { data -> mapOf("score" to (data["score"] as Long) + 10) } println("Score ${updated["data"].obj()["score"]} version ${updated["version"]}") ``` This is what the conflict looks like: ```kotlin file=examples/languages/kotlin/Crm.kt#version-conflict try { api("PATCH", "/records/$adaId", mapOf("version" to 1, "data" to mapOf("score" to 0))) // stale version } catch (e: AgentDBError) { if (e.code != "VERSION_CONFLICT") throw e println("Conflict: the record is now at version ${e.details["current_version"]}") } ``` ## Query with filters, sort and pages {#query} A query takes an object, a `where` filter, a `sort` and a `limit` (at most 200). Every condition in `all` must match. Use the `in` operator for "one of several values". Operators: `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null`, `is_not_null`. When more records match, the answer holds a `next_cursor`. Send it as `cursor` with the same query to get the next page. A `next_cursor` of `null` means you have them all. ```kotlin file=examples/languages/kotlin/Crm.kt#query /** Every record that matches, page by page (limit up to 200; follow next_cursor). */ fun queryAll(objectType: String, where: Any, sort: Any, pageSize: Int = 2): List> { val out = mutableListOf>() var cursor: String? = null do { val query = mutableMapOf("object" to objectType, "where" to where, "sort" to sort, "limit" to pageSize) if (cursor != null) query["cursor"] = cursor val page = api("POST", "/query", query) out += page["records"].list() cursor = page["next_cursor"] as String? // null on the last page } while (cursor != null) return out } val good = queryAll( "contact", mapOf("all" to listOf(mapOf("field" to "score", "op" to "gte", "value" to 70), mapOf("field" to "email", "op" to "neq", "value" to "nobody@example.com"))), listOf(mapOf("field" to "score", "order" to "desc")), ) for (c in good) println("${c["data"].obj()["score"]} ${c["data"].obj()["name"]}") ``` See [Querying](../../querying.md) for text search, totals and saved views. ## Link records {#relationships} A relationship type such as `works_at` links a contact to a company. Create a link, then list the records on the other side: ```kotlin file=examples/languages/kotlin/Crm.kt#relationships val company = api("POST", "/records", mapOf("object" to "company", "data" to mapOf("name" to "Example Ltd $run"))) val companyId = company["id"] as String api( "POST", "/links", mapOf("relationship" to "works_at", "source_record_id" to adaId, "target_record_id" to companyId), idempotencyKey = "link-$adaId-$companyId", ) val related = api("GET", "/records/$adaId/related?relationship=works_at")["records"].list() println("Ada works at ${related.joinToString(", ") { it["data"].obj()["name"] as String }}") ``` Add `direction=incoming` to list from the other side, for example all contacts of a company. ## Files {#files} A file upload has three steps: announce the file with its name, type and size; send the raw bytes; attach it to a record. AgentDB checks that the bytes match the type. ```kotlin file=examples/languages/kotlin/Crm.kt#files val bytes = "Meeting notes for Ada ($run)\n".toByteArray() // 1. Announce the file, 2. send the raw bytes, 3. attach it to a record. val file = api("POST", "/files", mapOf("filename" to "notes.txt", "content_type" to "text/plain", "size" to bytes.size)) val fileId = file["id"] as String send("PUT", "/files/$fileId/content", body = bytes) api("POST", "/attachments", mapOf("file_id" to fileId, "record_id" to adaId)) // Download with the key (server side) ... val back = send("GET", "/files/$fileId/content").body() // ... or make a signed link that expires, for an or a download button in your page. val link = api("POST", "/files/$fileId/links", mapOf("expires_in_seconds" to 600)) println("Signed link until ${link["expires_at"]}") ``` To show a file in a browser, give the page the **signed link**, never your key. The link expires and can be revoked. See [Files and imports](../../files-and-imports.md). ## Errors {#errors} Every error has a stable `code`, a `message`, a `retryable` flag and `details`, plus an HTTP status. Act on the `code`. ```kotlin file=examples/languages/kotlin/Crm.kt#errors try { api("GET", "/records/rec_doesnotexist") } catch (e: AgentDBError) { // a network error is an IOException, not an AgentDB answer println(e.message) // 404 NOT_FOUND: ... if (e.retryable) println("Safe to retry later") } ``` `HttpClient` throws `IOException` on network errors; the helper retries those only when the call is safe to repeat. Common codes: | Code | HTTP | What to do | |------|------|------------| | `VALIDATION_ERROR`, `FIELD_TYPE_MISMATCH` | 400 | Fix the request; `details` names the field. | | `UNAUTHENTICATED` | 401 | Check the key. | | `FORBIDDEN` | 403 | The key's role or a permission rule does not allow this. | | `NOT_FOUND` | 404 | Check the id and the workspace. | | `VERSION_CONFLICT` | 409 | Read again and retry the update. | | `RATE_LIMITED` | 429 | Wait for `Retry-After`, then retry. | The [error codes](../../troubleshooting.md) page lists them all. ## Rate limits and retries {#retries} AgentDB limits how many requests a key and a workspace may send. Over the limit, it answers `429 RATE_LIMITED` with a `Retry-After` header in seconds. The `request` helper above already handles this: - On 429 or 503, it waits for `Retry-After`, or backs off (0.5, 1, 2, 4 seconds), then tries again. - On a network error, it retries only reads and writes with an idempotency key. - It gives up after a few attempts and reports the error. To load many records, use one batch call instead of one call per record: `POST /records/upsert` takes up to 500 items. See [records_upsert_batch](reference/records.html#records_upsert_batch). ## Idempotency keys {#idempotency} A timeout can leave you unsure whether a write happened. Send an `Idempotency-Key` header with each write you may retry. If the same key comes again with the same request, AgentDB returns the first result and writes nothing twice. Keys are kept for 7 days. ```kotlin file=examples/languages/kotlin/Crm.kt#idempotency val key = UUID.randomUUID().toString() // one key per intention; reuse it only to retry the same request val request = mapOf("object" to "contact", "data" to mapOf("name" to "Mary Somerville", "email" to "mary-$run@example.com")) val first = api("POST", "/records", request, idempotencyKey = key) val retry = api("POST", "/records", request, idempotencyKey = key) // e.g. after a timeout println(if (first["id"] == retry["id"]) "Same record, written once" else "Duplicate!") ``` Use a new key for each new intention, such as each form submission. The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. ## Run the full example {#run} The complete program is `examples/languages/kotlin/Crm.kt`. Set the three environment variables and run: ```sh kotlinc Crm.kt -include-runtime -d crm.jar && java -jar crm.jar ``` It prints one `OK` line per topic: `OK connect`, `OK create`, `OK read`, `OK update`, `OK query`, `OK relationships`, `OK files`, `OK errors` and `OK idempotency`. # 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 {#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](../security.md#pattern-b-browser-app-with-its-own-backend). ```js wrong // 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" }), }); ``` ```js secure // 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 {#key} 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. ```sh # .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: ```javascript file=examples/starters/html-node-backend/server.mjs#config 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 {#backend} One function calls AgentDB. It adds the key and turns an error answer into an exception: ```javascript file=examples/starters/html-node-backend/server.mjs#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 `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. ```javascript file=examples/starters/html-node-backend/server.mjs#allow-list 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. ```javascript file=examples/starters/html-node-backend/server.mjs#error-mapping 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 {#frontend} The page has a search box, a status line and a form: ```html file=examples/starters/html-node-backend/public/index.html#markup

Loading…

    Add a contact

    ``` ## Loading, empty and error states {#states} The status line always says what is going on: loading, no results, or an error. Names are set with `textContent`, never as HTML. ```javascript file=examples/starters/html-node-backend/public/app.js#load 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. ```javascript file=examples/starters/html-node-backend/public/app.js#create 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 {#live} 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](../crm-example.md#live). See [Live data](../live-data.md) for every option. ## Run it {#run} ```sh 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](../deployment.md). # React Build a React contact list on AgentDB. React runs in the browser, so it never holds the AgentDB key. It calls your backend, and the backend calls AgentDB. **Not run in CI.** The React component below is not run in the test suite. The backend it calls is the `html-node-backend` starter, which the smoke test runs (`npm run docs:starters`). ## Where the key lives {#key} On the backend only, in its environment: `AGENTDB_WORKSPACE` and `AGENTDB_TOKEN` (an agent key with role `editor`). Build tools put some environment variables into the browser bundle. Never give the key a name that does that. ```sh wrong # Wrong: Vite copies every VITE_ variable into the JavaScript it sends to browsers. VITE_AGENTDB_TOKEN=adbk.your-key ``` ```sh secure # Secure: the backend reads this. The React build never sees it. AGENTDB_TOKEN=replace-with-your-agent-key ``` ## The backend route {#backend} Use any backend from this section. The smallest is the Node server of the [plain HTML guide](plain-html-js.md#backend): it serves `GET /api/contacts?q=` and `POST /api/contacts`. ```javascript file=examples/starters/html-node-backend/server.mjs#allow-list 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 } }]; }, }; ``` During development, let Vite forward `/api` to that server. The browser then calls the same origin, and you need no CORS settings. ```js // vite.config.js import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { proxy: { "/api": "http://localhost:3000" } }, }); ``` In production, serve the built files and the backend from one origin. See [Deployment](../deployment.md#reverse-proxy). ## The frontend {#frontend} ```jsx import { useEffect, useState } from "react"; export function Contacts() { const [q, setQ] = useState(""); const [state, setState] = useState({ status: "loading", contacts: [], error: null }); useEffect(() => { const ac = new AbortController(); setState((s) => ({ ...s, status: "loading" })); fetch(`/api/contacts?q=${encodeURIComponent(q)}`, { signal: ac.signal }) .then(async (res) => { const body = await res.json(); if (!res.ok) throw new Error(body.error ?? "Could not load contacts."); setState({ status: "ready", contacts: body.contacts, error: null }); }) .catch((e) => { if (e.name !== "AbortError") setState({ status: "error", contacts: [], error: e.message }); }); return () => ac.abort(); // a newer search cancels the older request }, [q]); return (
    setQ(e.target.value)} aria-label="Search by name" /> {state.status === "loading" &&

    Loading…

    } {state.status === "error" &&

    Error: {state.error}

    } {state.status === "ready" && state.contacts.length === 0 &&

    No contacts found.

    }
      {state.contacts.map((c) => (
    • {c.name}
    • ))}
    ); } ``` React escapes `{c.name}` as text. Do not use `dangerouslySetInnerHTML` with data from AgentDB. Adding a contact posts JSON to the backend: ```jsx async function addContact(name, email) { const res = await fetch("/api/contacts", { method: "POST", headers: { "content-type": "application/json", "x-submit-id": crypto.randomUUID() }, body: JSON.stringify({ name, email }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error ?? "Could not save."); return body.contact; } ``` ## Loading, empty and error states {#states} Keep one `status` value: `loading`, `ready` or `error`. Show "no contacts" only when the status is `ready` and the list is empty. Show the backend's error message; it never holds internal details. ## Live updates {#live} For changes made elsewhere, add an `/events` route to your backend that follows the change feed, then listen with `EventSource` in a `useEffect`. The [live dashboard starter](../crm-example.md#live) shows the backend. See [Live data](../live-data.md). ```jsx useEffect(() => { const source = new EventSource("/events"); source.addEventListener("contacts", (e) => setLive(JSON.parse(e.data))); return () => source.close(); }, []); ``` # Next.js Build a contact list with the Next.js App Router. Route handlers run on the server and hold the AgentDB key. Client components call those route handlers. **Not run in CI.** The code below is not run in the test suite. It makes the same AgentDB calls as the `crm-typescript` starter, which the smoke test runs (`npm run docs:starters`). ## Where the key lives {#key} In `.env.local` on your machine, and in the project's environment variables on your host. Next.js sends every `NEXT_PUBLIC_` variable to the browser. The key must never have that prefix. ```sh wrong # Wrong: NEXT_PUBLIC_ puts the key in the JavaScript bundle for every visitor. NEXT_PUBLIC_AGENTDB_TOKEN=adbk.your-key ``` ```sh secure # Secure: only server code (route handlers, server components) can read these. AGENTDB_API_URL=https://app.agentdb.link AGENTDB_WORKSPACE=ws_your_workspace_id AGENTDB_TOKEN=replace-with-your-agent-key ``` ## The AgentDB helper {#helper} Put the AgentDB call in one server-only module. The `server-only` package makes the build fail if a client component imports it. ```ts server // lib/agentdb.ts import "server-only"; const API = process.env.AGENTDB_API_URL ?? "https://app.agentdb.link"; export async function agentdb(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${API}/v1/workspaces/${process.env.AGENTDB_WORKSPACE}${path}`, { method, headers: { authorization: `Bearer ${process.env.AGENTDB_TOKEN}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store", }); const json = await res.json(); if (!res.ok) throw Object.assign(new Error(json.error?.message ?? `AgentDB answered ${res.status}`), { status: res.status, code: json.error?.code }); return json.data as T; } ``` ## The backend route {#backend} ```ts server // app/api/contacts/route.ts import "server-only"; import { NextResponse, type NextRequest } from "next/server"; import { agentdb } from "@/lib/agentdb"; type Contact = { id: string; data: { name: string; email?: string } }; export async function GET(req: NextRequest) { const q = (req.nextUrl.searchParams.get("q") ?? "").trim().slice(0, 100); try { const data = await agentdb<{ records: Contact[] }>("POST", "/query", { object: "contact", where: { all: q ? [{ field: "name", op: "contains", value: q }] : [] }, sort: [{ field: "name", order: "asc" }], limit: 50, }); return NextResponse.json({ contacts: data.records.map((r) => ({ id: r.id, name: r.data.name, email: r.data.email ?? null })) }); } catch { return NextResponse.json({ error: "The data service is not available." }, { status: 502 }); } } export async function POST(req: NextRequest) { const body = await req.json().catch(() => ({})); const name = String(body.name ?? "").trim().slice(0, 200); if (!name) return NextResponse.json({ error: "A contact needs a name." }, { status: 400 }); const rec = await agentdb("POST", "/records", { object: "contact", data: { name } }); return NextResponse.json({ contact: { id: rec.id, name: rec.data.name } }, { status: 201 }); } ``` A server component can also call `agentdb()` directly and render the list on the server. Never pass the key, or the raw AgentDB answer with fields you do not need, into a client component's props. ## The frontend {#frontend} ```tsx // app/contacts/contact-list.tsx "use client"; import { useEffect, useState } from "react"; type Row = { id: string; name: string }; export function ContactList() { const [q, setQ] = useState(""); const [rows, setRows] = useState(null); const [error, setError] = useState(null); useEffect(() => { const ac = new AbortController(); setRows(null); setError(null); fetch(`/api/contacts?q=${encodeURIComponent(q)}`, { signal: ac.signal }) .then(async (res) => { const body = await res.json(); if (!res.ok) throw new Error(body.error); setRows(body.contacts); }) .catch((e) => e.name !== "AbortError" && setError(e.message)); return () => ac.abort(); }, [q]); return ( <> setQ(e.target.value)} aria-label="Search" /> {error ?

    Error: {error}

    : rows === null ?

    Loading…

    : rows.length === 0 ?

    No contacts found.

    : null}
      {rows?.map((r) =>
    • {r.name}
    • )}
    ); } ``` ## Loading, empty and error states {#states} `rows === null` means loading. An empty array means "no results". An error replaces both. For server components, use `loading.tsx` for the loading state and `error.tsx` for errors. ## Live updates {#live} Serverless functions stop after each request, so they cannot keep a change-feed loop running. Choose one: - Poll your route handler every 10 to 30 seconds with `setInterval`. - Create a read-only [dashboard data link](../live-data.md#dashboard-links) and long-poll it from the browser. It holds no key. - Run a long-lived Node process (for example on a VPS) for server-sent events, as in the [live dashboard starter](../crm-example.md#live). ## Deploy {#deploy} On Vercel or Netlify, add the three variables in the project settings. See [Deployment](../deployment.md#netlify-and-vercel). # Vue Build a Vue 3 contact list on AgentDB. Vue runs in the browser, so it calls your backend. The backend holds the AgentDB key. **Not run in CI.** The Vue component is not run in the test suite. The backend it calls is the `html-node-backend` starter, which the smoke test runs. ## Where the key lives {#key} On the backend, in its environment. Vite puts every `VITE_` variable into the browser bundle, so the key never gets that prefix. Nuxt does the same with `NUXT_PUBLIC_` and `runtimeConfig.public`. ```sh wrong VITE_AGENTDB_TOKEN=adbk.your-key ``` ```sh secure # Read by the backend (or by Nuxt server routes through private runtimeConfig). AGENTDB_TOKEN=replace-with-your-agent-key ``` ## The backend route {#backend} Use the Node server of the [plain HTML guide](plain-html-js.md#backend), or any backend in this section. It serves `GET /api/contacts?q=` and `POST /api/contacts`. ```javascript file=examples/starters/html-node-backend/server.mjs#allow-list 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 } }]; }, }; ``` With Vite, forward `/api` to it during development: `server: { proxy: { "/api": "http://localhost:3000" } }` in `vite.config.js`. With Nuxt, put the AgentDB call in `server/api/contacts.get.ts` and read the key from private `useRuntimeConfig()` values. Do not use `runtimeConfig.public` for it. ## The frontend {#frontend} ```vue ``` `{{ c.name }}` is shown as text. Do not use `v-html` with data from AgentDB. ## Loading, empty and error states {#states} One `status` ref drives the page. "No contacts" shows only when the status is `ready` and the list is empty. ## Live updates {#live} Add an `/events` route to your backend (see the [live dashboard starter](../crm-example.md#live)). In the component, open `new EventSource("/events")` in `onMounted` and close it in `onUnmounted`. See [Live data](../live-data.md). # Svelte and SvelteKit Build a contact list with SvelteKit. A server route (`+server.ts`) holds the AgentDB key. The page calls that route. **Not run in CI.** The code below is not run in the test suite. It makes the same AgentDB calls as the starters that the smoke test runs. ## Where the key lives {#key} In `.env` locally and in your host's environment in production. SvelteKit has two kinds of variables: | Module | Who can read it | Use for the key? | |---|---|---| | `$env/static/private`, `$env/dynamic/private` | Server code only | Yes | | `$env/static/public` (names start with `PUBLIC_`) | Also the browser | Never | ```sh wrong PUBLIC_AGENTDB_TOKEN=adbk.your-key ``` ```sh secure AGENTDB_WORKSPACE=ws_your_workspace_id AGENTDB_TOKEN=replace-with-your-agent-key ``` SvelteKit refuses to build when a page imports `$env/static/private`. That protects you from a key in the browser. ## The backend route {#backend} ```ts server // src/routes/api/contacts/+server.ts import { json, error } from "@sveltejs/kit"; import { AGENTDB_TOKEN, AGENTDB_WORKSPACE } from "$env/static/private"; const API = "https://app.agentdb.link"; async function agentdb(method: string, path: string, body?: unknown) { const res = await fetch(`${API}/v1/workspaces/${AGENTDB_WORKSPACE}${path}`, { method, headers: { authorization: `Bearer ${AGENTDB_TOKEN}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); if (!res.ok) error(res.status === 400 ? 400 : 502, data.error?.message ?? "The data service is not available."); return data.data; } export async function GET({ 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 json({ contacts: data.records.map((r) => ({ id: r.id, name: r.data.name })) }); } export async function POST({ request }) { const body = await request.json(); const name = String(body.name ?? "").trim().slice(0, 200); if (!name) error(400, "A contact needs a name."); const rec = await agentdb("POST", "/records", { object: "contact", data: { name } }); return json({ contact: { id: rec.id, name: rec.data.name } }, { status: 201 }); } ``` You can also load data in `+page.server.ts`. That file runs on the server too, so it may import the same helper. ## The frontend {#frontend} ```svelte {#if result.status === "loading"}

    Loading…

    {:else if result.status === "error"}

    Error: {result.error}

    {:else if result.contacts.length === 0}

    No contacts found.

    {/if}
      {#each result.contacts as c (c.id)}
    • {c.name}
    • {/each}
    ``` Svelte shows `{c.name}` as text. Do not use `{@html}` with data from AgentDB. ## Loading, empty and error states {#states} One `status` field drives the page: `loading`, `ready` or `error`. SvelteKit's `error()` answers with `{ message }`; the page shows it. ## Live updates {#live} Poll the route every 10 to 30 seconds, or add a server-sent events route on a long-running Node server (`adapter-node`) that follows the change feed. The [live dashboard starter](../crm-example.md#live) shows that loop. See [Live data](../live-data.md). # 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 {#key} In the server's environment. Node 22 reads a `.env` file with `node --env-file=.env server.mjs`. Do not commit `.env`. ```javascript file=examples/starters/html-node-backend/server.mjs#config 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 {#call} ```javascript file=examples/starters/html-node-backend/server.mjs#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 {#node-http} The `ALLOWED` table lists every operation the browser may trigger. Nothing else reaches AgentDB. ```javascript file=examples/starters/html-node-backend/server.mjs#allow-list 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 {#express} ```js server 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 {#frontend} The page calls `/api/contacts` on the same origin. See [Plain HTML and JavaScript](plain-html-js.md#frontend), or [React](react.md) and [Vue](vue.md). ## Loading, empty and error states {#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. ```javascript file=examples/starters/html-node-backend/server.mjs#error-mapping 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 {#live} A long-running Node process can follow the change feed for all viewers and push updates with server-sent events. The [live dashboard starter](../crm-example.md#live) does exactly that. ```javascript file=examples/starters/live-dashboard/server.mjs#change-feed /** 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 } } } ``` # PHP Build server-rendered pages on AgentDB with plain PHP. PHP runs on the server, so the key stays there. The browser gets HTML only. This page uses the `crm-php` starter. The smoke test runs it with PHP's built-in server (`npm run docs:starters`). The full walkthrough is in [CRM example](../crm-example.md). ## Where the key lives {#key} In the server's environment, or in a `.env` file outside the web root. The starter's `public/` folder is the web root, and `.env` sits one level above it, so the web server never serves it. ```sh AGENTDB_API_URL=https://app.agentdb.link AGENTDB_WORKSPACE=ws_your_workspace_id AGENTDB_TOKEN=replace-with-your-agent-key ``` Never echo the key into a page, a JavaScript variable or a `data-` attribute. ## The AgentDB client {#client} PHP's built-in HTTP stream wrapper is enough. `ignore_errors` lets the client read AgentDB's JSON error answer. ```php file=examples/starters/crm-php/src/AgentDb.php#client final class AgentDb { public function __construct( private readonly string $apiUrl, private readonly string $workspace, private readonly string $token, ) { } public static function fromEnv(): self { $workspace = getenv('AGENTDB_WORKSPACE') ?: ''; $token = getenv('AGENTDB_TOKEN') ?: ''; if ($workspace === '' || $token === '') { throw new RuntimeException('Set AGENTDB_WORKSPACE and AGENTDB_TOKEN (see .env.example).'); } return new self(rtrim(getenv('AGENTDB_API_URL') ?: 'https://app.agentdb.link', '/'), $workspace, $token); } /** @return array the "data" part of the answer */ public function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array { $headers = ["Authorization: Bearer {$this->token}", 'Content-Type: application/json', 'Accept: application/json']; if ($idempotencyKey !== null) { $headers[] = "Idempotency-Key: {$idempotencyKey}"; } $context = stream_context_create(['http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR), 'ignore_errors' => true, // read the JSON error body on 4xx/5xx too 'timeout' => 15, ]]); $raw = @file_get_contents("{$this->apiUrl}/v1/workspaces/{$this->workspace}{$path}", false, $context); if ($raw === false) { throw new AgentDbException(0, null, 'Could not reach AgentDB.'); } $status = 0; // PHP 8.4+ has http_get_last_response_headers(); older versions fill $http_response_header. $responseHeaders = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : ($http_response_header ?? []); foreach ($responseHeaders ?? [] as $line) { if (preg_match('#^HTTP/\S+ (\d{3})#', $line, $m)) { $status = (int) $m[1]; // the last status line wins (after redirects) } } $json = json_decode($raw, true); if ($status < 200 || $status >= 300 || !is_array($json)) { throw new AgentDbException($status, $json['error']['code'] ?? null, $json['error']['message'] ?? "AgentDB answered {$status}"); } return $json['data']; } } ``` ## The backend route {#backend} Each page is a function. The contact list runs one query with an optional `contains` filter: ```php file=examples/starters/crm-php/public/index.php#list function contacts_page(AgentDb $db): void { $q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100); $result = $db->call('POST', '/query', [ 'object' => 'contact', 'where' => ['all' => $q === '' ? [] : [['field' => 'name', 'op' => 'contains', 'value' => $q]]], 'sort' => [['field' => 'name', 'order' => 'asc']], 'limit' => 100, ]); $rows = ''; foreach ($result['records'] as $r) { $rows .= '
  • ' . e($r['data']['name']) . ' ' . e($r['data']['email'] ?? '') . '
  • '; } $empty = $q === '' ? 'No contacts yet. Add the first one.' : 'No contacts found for “' . e($q) . '”.'; page('Contacts', '
    ' . ($rows === '' ? "

    {$empty}

    " : "
      {$rows}
    ") . '

    New contact

    '); } ``` Saving a contact creates the record and its company link in one call. Editing sends the version from the form, so a second editor cannot overwrite the first by accident: ```php file=examples/starters/crm-php/public/index.php#save function contact_form_page(AgentDb $db, ?string $id, string $method): void { $error = null; $status = 200; $current = $id === null ? null : $db->call('GET', "/records/{$id}"); $company = $id === null ? null : company_of($db, $id); $values = [ 'name' => $current['data']['name'] ?? '', 'email' => $current['data']['email'] ?? '', 'role' => $current['data']['role'] ?? '', 'company_id' => $company['id'] ?? '', 'version' => (string) ($current['version'] ?? ''), ]; if ($method === 'POST') { $values = ['name' => field('name', 200), 'email' => field('email', 254), 'role' => field('role', 200), 'company_id' => record_id($_POST['company_id'] ?? null) ?? '', 'version' => field('version', 20)]; $data = ['name' => $values['name'], 'email' => $values['email'] ?: null, 'role' => $values['role'] ?: null]; try { if ($values['name'] === '') { throw new AgentDbException(422, 'VALIDATION_ERROR', 'A contact needs a name.'); } if ($id === null) { $links = $values['company_id'] === '' ? [] : [['relationship' => 'works_at', 'target_record_id' => $values['company_id']]]; $rec = $db->call('POST', '/records', ['object' => 'contact', 'data' => array_filter($data), 'links' => $links], 'crm-php.' . hash('sha256', session_id() . $_SESSION['csrf'] . $values['name'] . $values['email'])); $id = $rec['id']; } else { // The version from the form: AgentDB refuses the write if someone saved in between. $db->call('PATCH', "/records/{$id}", ['version' => (int) $values['version'], 'data' => $data]); if (($company['id'] ?? '') !== $values['company_id']) { if ($company !== null) { $db->call('DELETE', "/links/{$company['link_id']}"); } if ($values['company_id'] !== '') { $db->call('POST', '/links', ['relationship' => 'works_at', 'source_record_id' => $id, 'target_record_id' => $values['company_id']]); } } } header('Location: /contact?id=' . rawurlencode($id) . '&saved=1', true, 303); return; } catch (AgentDbException $e) { [$status, $error] = match (true) { $e->errorCode === 'VERSION_CONFLICT' => [409, 'Someone changed it first. Reload the page to see the latest version, then edit again.'], in_array($e->status, [400, 422], true) => [422, $e->getMessage()], default => throw $e, }; } } http_response_code($status); page($id === null ? 'New contact' : 'Edit contact', contact_form($values, all_companies($db), $company, $error, isset($_GET['saved']))); } ``` ## The frontend {#frontend} The pages are plain HTML forms. Three rules keep them safe: - `e()` (`htmlspecialchars`) escapes every value before it goes into HTML. - Every form carries a CSRF token tied to the session. A POST without it answers 403. - The session cookie is `HttpOnly` and `SameSite=Lax`. ## Loading, empty and error states {#states} Server-rendered pages have no loading state: the page arrives complete. Empty lists say so ("No contacts found"). A validation error re-shows the form with the message and HTTP 422. A version conflict re-shows it with HTTP 409. Other AgentDB errors show a general message and are logged with `error_log`. ## Live updates {#live} A PHP request ends after the page is sent. For live pages, add a small script that polls `/api/...` every 10 to 30 seconds, or use a read-only [dashboard data link](../live-data.md#dashboard-links) from the browser. See [Live data](../live-data.md). ## Run it {#run} ```sh cd examples/starters/crm-php cp .env.example .env # then fill in your workspace and agent key php -S 127.0.0.1:8000 -t public public/index.php ``` On Apache or nginx with PHP-FPM, point the document root at `public/`. See [Deployment](../deployment.md). # Laravel Call AgentDB from Laravel controllers with the built-in `Http` client. The key stays in `.env` and `config/services.php`. Blade views and your JavaScript get only the data. **Not run in CI.** The Laravel code is not run in the test suite. It makes the same AgentDB calls as the `crm-php` starter, which the smoke test runs. ## Where the key lives {#key} ```sh # .env (never committed) AGENTDB_API_URL=https://app.agentdb.link AGENTDB_WORKSPACE=ws_your_workspace_id AGENTDB_TOKEN=replace-with-your-agent-key ``` ```php // config/services.php 'agentdb' => [ 'url' => env('AGENTDB_API_URL', 'https://app.agentdb.link'), 'workspace' => env('AGENTDB_WORKSPACE'), 'token' => env('AGENTDB_TOKEN'), ], ``` Read it with `config('services.agentdb.token')`. Vite exposes `VITE_` variables to the browser: never name the key that way. ## The AgentDB client {#client} ```php // app/Services/AgentDb.php namespace App\Services; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; class AgentDb { public function client(): PendingRequest { $c = config('services.agentdb'); return Http::baseUrl("{$c['url']}/v1/workspaces/{$c['workspace']}") ->withToken($c['token']) ->acceptJson() ->timeout(15); } public function query(array $query): array { return $this->client()->post('/query', $query)->throw()->json('data'); } } ``` ## The backend route {#backend} ```php // routes/web.php Route::get('/contacts', [ContactController::class, 'index']); Route::post('/contacts', [ContactController::class, 'store']); // app/Http/Controllers/ContactController.php public function index(Request $request, AgentDb $db) { $q = mb_substr(trim((string) $request->query('q', '')), 0, 100); $data = $db->query([ 'object' => 'contact', 'where' => ['all' => $q === '' ? [] : [['field' => 'name', 'op' => 'contains', 'value' => $q]]], 'sort' => [['field' => 'name', 'order' => 'asc']], 'limit' => 50, ]); return view('contacts.index', ['contacts' => $data['records'], 'q' => $q]); } public function store(Request $request, AgentDb $db) { $valid = $request->validate(['name' => 'required|string|max:200', 'email' => 'nullable|email|max:254']); $db->client() ->withHeaders(['Idempotency-Key' => 'laravel.' . $request->session()->token() . '.' . sha1($valid['name'])]) ->post('/records', ['object' => 'contact', 'data' => array_filter($valid)]) ->throw(); return redirect('/contacts')->with('status', 'Contact added.'); } ``` Laravel adds CSRF protection to `web` routes. Keep `@csrf` in every form. ## The frontend {#frontend} ```php {{-- resources/views/contacts/index.blade.php --}}
    @forelse ($contacts as $c)
  • {{ $c['data']['name'] }}
  • @empty

    No contacts found.

    @endforelse
    @csrf
    ``` `{{ }}` escapes values. Do not use `{!! !!}` with data from AgentDB. ## Loading, empty and error states {#states} Blade pages arrive complete. `@forelse … @empty` covers the empty state. For AgentDB errors, catch `Illuminate\Http\Client\RequestException`. Show `$e->response->json('error.message')` for status 400, and a general message otherwise. A `VERSION_CONFLICT` (409) means someone saved first. ## Live updates {#live} Poll a JSON route every 10 to 30 seconds from the page, or use a read-only [dashboard data link](../live-data.md#dashboard-links). For push, run a queue worker or a scheduled command that follows the change feed and broadcasts with Laravel's broadcasting. See [Live data](../live-data.md). # Python and FastAPI Build a small Python service that lists, searches and creates contacts. FastAPI serves the routes. `httpx` calls AgentDB. The key stays in the server process. This page uses the `python-fastapi` starter. The smoke test runs `app.py` when FastAPI, uvicorn and httpx are installed. Otherwise it runs `server_stdlib.py`, which has the same routes and uses only the standard library. ## Where the key lives {#key} In the environment of the server process. `uvicorn --env-file .env` loads it (with `python-dotenv` installed). ```python file=examples/starters/python-fastapi/app.py#config API = os.environ.get("AGENTDB_API_URL", "https://app.agentdb.link").rstrip("/") WORKSPACE = os.environ["AGENTDB_WORKSPACE"] TOKEN = os.environ["AGENTDB_TOKEN"] # an agent key with role editor, never an owner key ``` ## The AgentDB client {#client} One `httpx.AsyncClient` for the whole app. It carries the key and the workspace URL. ```python file=examples/starters/python-fastapi/app.py#client @asynccontextmanager async def lifespan(app: FastAPI): # One connection pool for the whole app. app.state.agentdb = httpx.AsyncClient( base_url=f"{API}/v1/workspaces/{WORKSPACE}", headers={"Authorization": f"Bearer {TOKEN}"}, timeout=15, ) yield await app.state.agentdb.aclose() async def agentdb(request: Request, method: str, path: str, body=None, idempotency_key: str | None = None): headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {} try: res = await request.app.state.agentdb.request(method, path, json=body, headers=headers) except httpx.HTTPError as e: raise AgentDBError(0, None, "Could not reach AgentDB.") from e payload = res.json() if res.is_error: err = payload.get("error", {}) raise AgentDBError(res.status_code, err.get("code"), err.get("message", f"AgentDB answered {res.status_code}")) return payload["data"] ``` Without FastAPI, `urllib.request` does the same job: ```python file=examples/starters/python-fastapi/server_stdlib.py#client def agentdb(method, path, body=None, idempotency_key=None): req = urllib.request.Request( f"{API}/v1/workspaces/{WORKSPACE}{path}", method=method, data=None if body is None else json.dumps(body).encode(), headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json", **({"Idempotency-Key": idempotency_key} if idempotency_key else {})}, ) try: with urllib.request.urlopen(req, timeout=15) as res: return json.load(res)["data"] except urllib.error.HTTPError as e: err = json.load(e).get("error", {}) raise AgentDBError(e.code, err.get("code"), err.get("message", f"AgentDB answered {e.code}")) from None except urllib.error.URLError: raise AgentDBError(0, None, "Could not reach AgentDB.") from None ``` ## The backend routes {#backend} ```python file=examples/starters/python-fastapi/app.py#routes @app.get("/api/contacts") async def list_contacts(request: Request, q: str = ""): q = q.strip()[:100] data = await agentdb(request, "POST", "/query", { "object": "contact", "where": {"all": [{"field": "name", "op": "contains", "value": q}] if q else []}, "sort": [{"field": "name", "order": "asc"}], "limit": 50, }) return {"contacts": [contact_out(r) for r in data["records"]]} @app.post("/api/contacts", status_code=201) async def create_contact(request: Request): if not request.headers.get("content-type", "").startswith("application/json"): raise HTTPException(415, "Send JSON.") body = await request.json() if not isinstance(body, dict): raise HTTPException(400, "Send a JSON object.") name = str(body.get("name") or "").strip()[:200] email = str(body.get("email") or "").strip()[:254] if not name: raise HTTPException(400, "A contact needs a name.") submit = "".join(ch for ch in request.headers.get("x-submit-id", str(uuid.uuid4())) if ch.isalnum() or ch == "-")[:64] rec = await agentdb(request, "POST", "/records", {"object": "contact", "data": {"name": name, **({"email": email} if email else {})}}, idempotency_key=f"fastapi.{submit}") return {"contact": contact_out(rec)} ``` The name check returns 400 with a clear message. The submit id becomes an `Idempotency-Key`, so a retried request is written once. ## The frontend {#frontend} The page in `static/` is the same plain HTML page as in [Plain HTML and JavaScript](plain-html-js.md#frontend). It calls `/api/contacts` on the same origin. ```javascript file=examples/starters/python-fastapi/static/app.js#load 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}`; } } ``` ## Loading, empty and error states {#states} The service turns AgentDB errors into short messages. Validation errors are shown. Anything else is logged, and the page gets a general message. ```python file=examples/starters/python-fastapi/app.py#errors @app.exception_handler(AgentDBError) async def agentdb_error(_request: Request, e: AgentDBError): if e.code == "VERSION_CONFLICT": return JSONResponse({"error": "Someone changed it first. Reload and try again."}, status_code=409) if e.status in (400, 422): return JSONResponse({"error": e.message}, status_code=400) # validation: safe to show print(f"AgentDB call failed: {e.status} {e.code} {e.message}", flush=True) return JSONResponse({"error": "The data service is not available. Try again in a moment."}, status_code=502) @app.exception_handler(HTTPException) async def http_error(_request: Request, e: HTTPException): return JSONResponse({"error": e.detail}, status_code=e.status_code) ``` ## Live updates {#live} FastAPI can stream server-sent events with a `StreamingResponse` and `media_type="text/event-stream"`. Run one background task that follows the change feed (`GET /changes?after=&wait=1`) and fans out to all open streams, like the [live dashboard starter](../crm-example.md#live). See [Live data](../live-data.md). ## Run it {#run} ```sh cd examples/starters/python-fastapi cp .env.example .env # then fill in your workspace and agent key python3 -m venv .venv && . .venv/bin/activate pip install -r requirements.txt python-dotenv uvicorn app:app --env-file .env --port 8000 ``` Or, with no packages: `set -a; . ./.env; set +a; python3 server_stdlib.py`. # ASP.NET Core Build an ASP.NET Core minimal API in front of AgentDB. A named `HttpClient` holds the key. A static page in `wwwroot/` calls the API on the same origin. This page uses the `aspnet-minimal` starter. The smoke test builds it with `dotnet build` and runs it (`npm run docs:starters`). It needs no NuGet packages. ## Where the key lives {#key} In configuration: environment variables in production, user secrets during development. Never in `wwwroot/` and never in `appsettings.json` in source control. ```sh dotnet user-secrets init dotnet user-secrets set AGENTDB_WORKSPACE ws_your_workspace_id dotnet user-secrets set AGENTDB_TOKEN replace-with-your-agent-key ``` ## The AgentDB client {#client} ```csharp file=examples/starters/aspnet-minimal/Program.cs#client var apiUrl = (builder.Configuration["AGENTDB_API_URL"] ?? "https://app.agentdb.link").TrimEnd('/'); var workspace = builder.Configuration["AGENTDB_WORKSPACE"] ?? throw new InvalidOperationException("Set AGENTDB_WORKSPACE."); var token = builder.Configuration["AGENTDB_TOKEN"] ?? throw new InvalidOperationException("Set AGENTDB_TOKEN (an agent key, role editor)."); builder.Services.AddHttpClient("agentdb", http => { http.BaseAddress = new Uri($"{apiUrl}/v1/workspaces/{workspace}/"); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); http.Timeout = TimeSpan.FromSeconds(15); }); ``` Every call goes through one helper. It returns the `data` part, or the status and error: ```csharp file=examples/starters/aspnet-minimal/Program.cs#call public static async Task<(int Status, JsonNode? Data, JsonNode? Error)> Call(IHttpClientFactory factory, HttpMethod method, string path, JsonNode? body = null, string? idempotencyKey = null) { using var req = new HttpRequestMessage(method, path); if (body is not null) req.Content = JsonContent.Create(body); if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey); try { using var res = await factory.CreateClient("agentdb").SendAsync(req); var json = JsonNode.Parse(await res.Content.ReadAsStringAsync()); return res.IsSuccessStatusCode ? ((int)res.StatusCode, json?["data"], null) : ((int)res.StatusCode, null, json?["error"]); } catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return (0, null, null); } } public static IResult ErrorResult(int status, JsonNode? error) { var code = error?["code"]?.GetValue(); if (code == "VERSION_CONFLICT") return Results.Json(new { error = "Someone changed it first. Reload and try again." }, statusCode: 409); if (status is 400 or 422) return Results.Json(new { error = error?["message"]?.GetValue() ?? "Invalid input." }, statusCode: 400); Console.Error.WriteLine($"AgentDB call failed: {status} {code}"); return Results.Json(new { error = "The data service is not available. Try again in a moment." }, statusCode: 502); } ``` ## The backend routes {#backend} ```csharp file=examples/starters/aspnet-minimal/Program.cs#routes app.MapGet("/api/contacts", async (string? q, IHttpClientFactory factory) => { q = (q ?? "").Trim(); if (q.Length > 100) q = q[..100]; var where = new JsonArray(); if (q != "") where.Add(new JsonObject { ["field"] = "name", ["op"] = "contains", ["value"] = q }); var query = new JsonObject { ["object"] = "contact", ["where"] = new JsonObject { ["all"] = where }, ["sort"] = new JsonArray(new JsonObject { ["field"] = "name", ["order"] = "asc" }), ["limit"] = 50, }; var (status, data, error) = await AgentDb.Call(factory, HttpMethod.Post, "query", query); if (data is null) return AgentDb.ErrorResult(status, error); var contacts = data["records"]!.AsArray().Select(r => AgentDb.ContactOut(r!)); return Results.Ok(new { contacts }); }); app.MapPost("/api/contacts", async (HttpRequest request, IHttpClientFactory factory) => { if (!request.HasJsonContentType()) return Results.Json(new { error = "Send JSON." }, statusCode: 415); var body = await request.ReadFromJsonAsync(); var name = (body?["name"]?.GetValue() ?? "").Trim(); var email = (body?["email"]?.GetValue() ?? "").Trim(); if (name.Length > 200) name = name[..200]; if (email.Length > 254) email = email[..254]; if (name == "") return Results.Json(new { error = "A contact needs a name." }, statusCode: 400); var data = new JsonObject { ["name"] = name }; if (email != "") data["email"] = email; var submit = new string((request.Headers["X-Submit-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString()).Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').Take(64).ToArray()); var (status, rec, error) = await AgentDb.Call(factory, HttpMethod.Post, "records", new JsonObject { ["object"] = "contact", ["data"] = data }, $"aspnet.{submit}"); if (rec is null) return AgentDb.ErrorResult(status, error); return Results.Json(new { contact = AgentDb.ContactOut(rec) }, statusCode: 201); }); ``` ## The frontend {#frontend} `wwwroot/index.html` and `wwwroot/app.js` are the page from [Plain HTML and JavaScript](plain-html-js.md#frontend). `UseDefaultFiles()` and `UseStaticFiles()` serve them. With Blazor WebAssembly, the same rule applies: the WebAssembly app runs in the browser, so it calls this API and never AgentDB. ## Loading, empty and error states {#states} `ErrorResult` maps AgentDB answers to short messages: 400 for validation, 409 for `VERSION_CONFLICT`, 502 for the rest. The page shows "Loading…", "No contacts match your search" or the error message. ## Live updates {#live} Run a `BackgroundService` that follows the change feed (`GET changes?after=&wait=1`) and pushes to clients with server-sent events or SignalR. See the [live dashboard starter](../crm-example.md#live) for the loop and [Live data](../live-data.md) for the options. ## Run it {#run} ```sh cd examples/starters/aspnet-minimal dotnet run ``` # Java and Spring Boot Call AgentDB from a Spring Boot controller with `RestClient` (Spring Framework 6.1 and later). The key stays in configuration. Your frontend calls the controller. **Not run in CI.** The Java code is not run in the test suite. It makes the same AgentDB calls as the starters that the smoke test runs. ## Where the key lives {#key} In environment variables, read through `application.properties`: ```properties # application.properties agentdb.url=${AGENTDB_API_URL:https://app.agentdb.link} agentdb.workspace=${AGENTDB_WORKSPACE} agentdb.token=${AGENTDB_TOKEN} ``` Do not put the real key in `application.properties` in source control. Do not copy it into a Thymeleaf template or a JavaScript bundle. ## The AgentDB client {#client} ```java @Configuration class AgentDbConfig { @Bean RestClient agentdb(@Value("${agentdb.url}") String url, @Value("${agentdb.workspace}") String workspace, @Value("${agentdb.token}") String token) { return RestClient.builder() .baseUrl(url + "/v1/workspaces/" + workspace) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } } ``` ## The backend route {#backend} ```java @RestController @RequestMapping("/api/contacts") class ContactController { private final RestClient agentdb; ContactController(RestClient agentdb) { this.agentdb = agentdb; } @GetMapping Map list(@RequestParam(defaultValue = "") String q) { String term = q.strip().length() > 100 ? q.strip().substring(0, 100) : q.strip(); List> where = term.isEmpty() ? List.of() : List.of(Map.of("field", "name", "op", "contains", "value", term)); Map query = Map.of( "object", "contact", "where", Map.of("all", where), "sort", List.of(Map.of("field", "name", "order", "asc")), "limit", 50); JsonNode records = agentdb.post().uri("/query").body(query).retrieve().body(JsonNode.class).path("data").path("records"); List> contacts = new ArrayList<>(); for (JsonNode r : records) contacts.add(Map.of("id", r.path("id").asText(), "name", r.path("data").path("name").asText())); return Map.of("contacts", contacts); } @PostMapping ResponseEntity> create(@RequestBody Map body, @RequestHeader(value = "X-Submit-Id", required = false) String submitId) { String name = body.getOrDefault("name", "").strip(); if (name.isEmpty() || name.length() > 200) return ResponseEntity.badRequest().body(Map.of("error", "A contact needs a name.")); String key = "spring." + (submitId != null ? submitId.replaceAll("[^A-Za-z0-9-]", "") : UUID.randomUUID().toString()); JsonNode rec = agentdb.post().uri("/records").header("Idempotency-Key", key) .body(Map.of("object", "contact", "data", Map.of("name", name))) .retrieve().body(JsonNode.class).path("data"); return ResponseEntity.status(201).body(Map.of("contact", Map.of("id", rec.path("id").asText(), "name", name))); } @ExceptionHandler(HttpClientErrorException.class) ResponseEntity> agentdbError(HttpClientErrorException e) { if (e.getStatusCode().value() == 409) return ResponseEntity.status(409).body(Map.of("error", "Someone changed it first.")); if (e.getStatusCode().value() == 400) return ResponseEntity.badRequest().body(Map.of("error", "Check the input.")); return ResponseEntity.status(502).body(Map.of("error", "The data service is not available.")); } } ``` ## The frontend {#frontend} Serve the page from `src/main/resources/static/`. It calls `/api/contacts` on the same origin, like the page in [Plain HTML and JavaScript](plain-html-js.md#frontend). If you use Spring Security with sessions, keep CSRF protection on for `POST` routes. ## Loading, empty and error states {#states} The controller answers `{ "error": "..." }` with 400, 409 or 502. The page shows "Loading…" while it waits, "No contacts found" for an empty list, and the error message otherwise. ## Live updates {#live} Return an `SseEmitter` from a controller, and run one scheduled task that follows the change feed (`GET /changes?after=&wait=1`) and sends to every emitter. See the [live dashboard starter](../crm-example.md#live) and [Live data](../live-data.md). # Deployment Put your app online so that the AgentDB key stays on the server. This page covers a traditional server (VPS) with nginx, and Netlify and Vercel. ## What nginx does, and what it does not {#nginx-role} nginx is a web server and a reverse proxy. It serves files, passes requests to your backend, and handles HTTPS. nginx is **not** authentication. It does not know your users, and it cannot decide who may read which contact. Your backend signs users in and checks what they may do. AgentDB checks the key your backend sends. ## The shape of a deployment {#shape} ```text browser ──HTTPS──> nginx ──> static files (HTML, JS, CSS) no key └──> your backend on 127.0.0.1:3000 holds the AgentDB key └──HTTPS──> https://app.agentdb.link ``` - The browser talks to one origin. It never calls AgentDB. - The backend listens on `127.0.0.1` only. nginx is the only way in. - The key is in the backend's environment, nowhere else. ## Credentials and environment variables {#credentials} - Make one agent key per app with the smallest role it needs: `viewer` to read, `editor` to write records. See [Least-privilege keys](security.md#least-privilege-keys). - Store it in the service's environment, for example a systemd `EnvironmentFile` that only the service user can read (`chmod 600`). - Never commit `.env`. Commit `.env.example` with placeholders only. - Never copy the key into the static files, the frontend build, a `` tag or a JavaScript variable. - To replace a key, create a new one, deploy it, then revoke the old one. ```ini # /etc/systemd/system/crm.service [Service] User=crm WorkingDirectory=/srv/crm EnvironmentFile=/srv/crm/.env ExecStart=/usr/bin/node src/server.ts Restart=on-failure ``` ## Static frontend hosting {#static} nginx serves the built frontend from a folder. Files with a content hash in the name can be cached for a long time. `index.html` must be checked on every visit, or users keep an old version. ```nginx root /srv/crm/public; location = /index.html { add_header Cache-Control "no-cache" always; } location ~* \.(?:js|css|woff2|png|svg)$ { # Use "immutable" only for file names that change with their content (app.3f9a1c.js). add_header Cache-Control "public, max-age=31536000, immutable" always; } location / { try_files $uri $uri/ /index.html; # single-page apps; use =404 for plain sites } ``` ## Reverse-proxying the backend {#reverse-proxy} ```nginx location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; add_header Cache-Control "no-store" always; # API answers are per user and change often client_max_body_size 1m; } ``` Cache-Control in short: static files with hashed names get a long cache, `index.html` gets `no-cache`, and API answers get `no-store`. ## HTTPS with certbot {#https} ```sh sudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d crm.example.com sudo certbot renew --dry-run # checks that automatic renewal works ``` certbot adds the certificate to the nginx site and a redirect from HTTP to HTTPS. Renewal runs on a timer. ## Secure headers {#headers} ```nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; ``` - **CSP** lets the page load scripts and send requests to its own origin only. `connect-src 'self'` also means the page cannot call AgentDB directly, which is what you want. - **nosniff** stops browsers from guessing file types. - **HSTS** tells browsers to use HTTPS only. Add it after HTTPS works. Start with a short `max-age` if you are unsure. - nginx drops `add_header` lines from the `server` block when a `location` has its own `add_header`. Repeat the security headers there, or put them in a shared include file. ## CORS {#cors} Prefer no CORS at all. Serve the frontend and the backend from one origin, as above, and the browser needs no CORS headers. If the frontend must live on another origin, allow exactly that origin on your backend. Never `*` for routes that use cookies. ```nginx # Only when the frontend is on another origin: add_header Access-Control-Allow-Origin "https://www.example.com" always; add_header Vary "Origin" always; ``` CORS is not access control. It only tells browsers which pages may read the answer. Scripts and servers ignore it. ## Live connections {#live} Server-sent events and long-polls keep a request open. nginx buffers answers and closes idle connections after 60 seconds by default. Change both for the live route: ```nginx location /events { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; # send each event at once proxy_cache off; proxy_read_timeout 1h; # longer than the gap between events or pings add_header Cache-Control "no-store" always; } ``` The [live dashboard starter](crm-example.md#live) also sends `X-Accel-Buffering: no` and a ping every 20 seconds. Long-polls to AgentDB (`wait=1`) answer within about 25 seconds, so keep your backend's own request timeout above 30 seconds. ## A complete nginx site {#nginx-site} ```nginx server { listen 443 ssl; http2 on; server_name crm.example.com; # ssl_certificate lines: added by certbot root /srv/crm/public; include /etc/nginx/snippets/security-headers.conf; # the four add_header lines above location /api/ { proxy_pass http://127.0.0.1:3000; add_header Cache-Control "no-store" always; include /etc/nginx/snippets/security-headers.conf; } location /events { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_read_timeout 1h; } location / { try_files $uri $uri/ /index.html; } } server { listen 80; server_name crm.example.com; return 301 https://$host$request_uri; } ``` For PHP, replace the `/api/` block with your PHP-FPM settings and point `root` at the app's `public/` folder. Keep `.env` outside that folder. ## Netlify and Vercel {#netlify-and-vercel} On these hosts, serverless functions are your backend. They hold the key. The static frontend calls them on the same origin. | | Netlify | Vercel | |---|---|---| | Backend code | `netlify/functions/*.mjs` | `api/*.js`, or route handlers in Next.js | | Where the key goes | Site settings, Environment variables | Project settings, Environment Variables | | Called from the page as | `/.netlify/functions/contacts` (or a redirect to `/api/contacts`) | `/api/contacts` | A Netlify function that lists contacts: ```js server // netlify/functions/contacts.mjs export default async (req) => { const q = (new URL(req.url).searchParams.get("q") ?? "").trim().slice(0, 100); const res = await fetch(`https://app.agentdb.link/v1/workspaces/${process.env.AGENTDB_WORKSPACE}/query`, { method: "POST", headers: { authorization: `Bearer ${process.env.AGENTDB_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ object: "contact", where: { all: q ? [{ field: "name", op: "contains", value: q }] : [] }, limit: 50 }), }); const body = await res.json(); if (!res.ok) return Response.json({ error: "The data service is not available." }, { status: 502 }); return Response.json({ contacts: body.data.records.map((r) => ({ id: r.id, name: r.data.name })) }, { headers: { "cache-control": "no-store" } }); }; ``` Never give the key a public prefix. Build tools copy these variables into the browser bundle: ```sh wrong NEXT_PUBLIC_AGENTDB_TOKEN=adbk.your-key VITE_AGENTDB_TOKEN=adbk.your-key REACT_APP_AGENTDB_TOKEN=adbk.your-key ``` ```sh secure AGENTDB_WORKSPACE=ws_your_workspace_id AGENTDB_TOKEN=replace-with-your-agent-key ``` - Set the variables for the production context only, or use a separate key for preview deploys. Preview URLs are often public. - Serverless functions end after each request. They cannot keep a change-feed loop or server-sent events open for long. For live pages, poll your function every 10 to 30 seconds, or use a read-only [dashboard data link](live-data.md#dashboard-links) from the browser. - Set the security headers in `netlify.toml` (`[[headers]]`) or `vercel.json` (`headers`). ## Traditional VPS {#vps} 1. Create a user for the app and copy the code to its folder. 2. Install the runtime (Node 22, PHP-FPM, Python or .NET). 3. Put the agent key in the service's environment file. Make it readable by the service user only. 4. Run the backend as a systemd service on `127.0.0.1`. 5. Configure nginx with the site above, then run certbot. 6. Open the firewall for ports 80 and 443 only. 7. Check: the page loads over HTTPS, `/api/...` works, and the browser's network tab shows no request to AgentDB and no key. ## Checklist {#checklist} - The key is in the server environment only. Search your built frontend for `adbk.` and find nothing. - Each app has its own agent key with the smallest role. - The frontend calls your own origin. `connect-src 'self'` in the CSP. - HTTPS works, HTTP redirects to HTTPS, HSTS is on. - `index.html` has `no-cache`, API answers have `no-store`. - Live routes have proxy buffering off and long read timeouts. - Your backend checks who the user is before it calls AgentDB for them. # CRM example Build a small CRM: contacts, companies, and which company each contact works at. You can list, search, filter, create and edit. Two complete versions exist, one in TypeScript and one in plain PHP. A third starter adds a live dashboard. The smoke test runs all three against a local AgentDB (`npm run docs:starters`). The code on this page is included from those files. ## AgentDB provides, your app builds {#split} | AgentDB provides | Your app builds | |---|---| | Objects `contact` and `company` with typed, validated fields | Pages, forms and navigation | | The `works_at` relationship and lookups in both directions | The company picker and the "contacts at this company" list | | Exact queries: `contains`, `eq`, sort, limits, cursors | The search box and filters, and which filters exist | | Record versions and `VERSION_CONFLICT` on stale edits | The "someone changed it first" message and a reload | | Idempotent writes with `Idempotency-Key` | A submit id per form, sent with each write | | Agent keys with roles (`viewer`, `editor`) | Keeping the key on the server; one key per app | | The change feed and dashboard data links | Turning changes into updates on screen | | Storage, backups, history and the audit log | Your users, sign-in and who may see what | AgentDB has no user accounts for your app's visitors. Your app signs its users in and decides what each one may do. Then it calls AgentDB with its own key. See [Security for apps](security.md). ## 1. Define the data {#schema} Run the setup script once with an owner key. It defines the objects and the link, adds "Example Ltd" and "Ada Lovelace", and creates a separate `editor` key for the app. ```sh cd examples/starters AGENTDB_WORKSPACE=ws_... AGENTDB_TOKEN= node setup.mjs --seed --agent crm-app --role editor ``` The schema it creates: | Object | Fields | |---|---| | `company` | `name` (text, required), `city` (text) | | `contact` | `name` (text, required), `email` (email), `role` (text) | | `works_at` | contact to company, one company per contact | Put the printed agent key in the app's `.env` as `AGENTDB_TOKEN`. The owner key stays with you. ## 2. Call AgentDB from the server {#client} TypeScript uses the built-in `fetch`: ```typescript file=examples/starters/crm-typescript/src/agentdb.ts#client 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 with role editor export interface AgentdbRecord { id: string; object: string; version: number; created_at: string; updated_at: string; data: T; } export class AgentdbError extends Error { status: number; code: string | undefined; constructor(status: number, code: string | undefined, message: string) { super(message); this.status = status; this.code = code; } } export async function agentdb(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise { 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()) as { data?: T; error?: { code?: string; message?: string } }; if (!res.ok) throw new AgentdbError(res.status, json.error?.code, json.error?.message ?? `AgentDB answered ${res.status}`); return json.data as T; } ``` PHP uses its built-in HTTP stream wrapper: ```php file=examples/starters/crm-php/src/AgentDb.php#client final class AgentDb { public function __construct( private readonly string $apiUrl, private readonly string $workspace, private readonly string $token, ) { } public static function fromEnv(): self { $workspace = getenv('AGENTDB_WORKSPACE') ?: ''; $token = getenv('AGENTDB_TOKEN') ?: ''; if ($workspace === '' || $token === '') { throw new RuntimeException('Set AGENTDB_WORKSPACE and AGENTDB_TOKEN (see .env.example).'); } return new self(rtrim(getenv('AGENTDB_API_URL') ?: 'https://app.agentdb.link', '/'), $workspace, $token); } /** @return array the "data" part of the answer */ public function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array { $headers = ["Authorization: Bearer {$this->token}", 'Content-Type: application/json', 'Accept: application/json']; if ($idempotencyKey !== null) { $headers[] = "Idempotency-Key: {$idempotencyKey}"; } $context = stream_context_create(['http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR), 'ignore_errors' => true, // read the JSON error body on 4xx/5xx too 'timeout' => 15, ]]); $raw = @file_get_contents("{$this->apiUrl}/v1/workspaces/{$this->workspace}{$path}", false, $context); if ($raw === false) { throw new AgentDbException(0, null, 'Could not reach AgentDB.'); } $status = 0; // PHP 8.4+ has http_get_last_response_headers(); older versions fill $http_response_header. $responseHeaders = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : ($http_response_header ?? []); foreach ($responseHeaders ?? [] as $line) { if (preg_match('#^HTTP/\S+ (\d{3})#', $line, $m)) { $status = (int) $m[1]; // the last status line wins (after redirects) } } $json = json_decode($raw, true); if ($status < 200 || $status >= 300 || !is_array($json)) { throw new AgentDbException($status, $json['error']['code'] ?? null, $json['error']['message'] ?? "AgentDB answered {$status}"); } return $json['data']; } } ``` ## 3. List, search and filter {#list} A search runs one query with `contains` on the name. A company filter follows the `works_at` links into that company (`direction=incoming`). ```typescript file=examples/starters/crm-typescript/src/server.ts#list-contacts async function listContacts(q: string, companyId: string | null) { if (companyId) { // Contacts of one company: follow the works_at links into the company. const rel = await agentdb>("GET", `/records/${companyId}/related?relationship=works_at&direction=incoming&limit=100`); const needle = q.toLowerCase(); return rel.records.filter((r) => !needle || r.data.name.toLowerCase().includes(needle)).map(contactOut); } const res = await agentdb>("POST", "/query", { object: "contact", where: { all: q ? [{ field: "name", op: "contains", value: q }] : [] }, sort: [{ field: "name", order: "asc" }], limit: 100, }); return res.records.map(contactOut); } ``` The PHP version renders the same list on the server: ```php file=examples/starters/crm-php/public/index.php#list function contacts_page(AgentDb $db): void { $q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100); $result = $db->call('POST', '/query', [ 'object' => 'contact', 'where' => ['all' => $q === '' ? [] : [['field' => 'name', 'op' => 'contains', 'value' => $q]]], 'sort' => [['field' => 'name', 'order' => 'asc']], 'limit' => 100, ]); $rows = ''; foreach ($result['records'] as $r) { $rows .= '
  • ' . e($r['data']['name']) . ' ' . e($r['data']['email'] ?? '') . '
  • '; } $empty = $q === '' ? 'No contacts yet. Add the first one.' : 'No contacts found for “' . e($q) . '”.'; page('Contacts', '
    ' . ($rows === '' ? "

    {$empty}

    " : "
      {$rows}
    ") . '

    New contact

    '); } ``` ## 4. Create a contact and its link {#create} `POST /records` writes the record and its `works_at` link in one call. Either both are saved or neither is. The submit id makes a retry safe. ```typescript file=examples/starters/crm-typescript/src/server.ts#create-contact async function createContact(body: Record, submitId: string) { const data = contactInput(body); const companyId = recordId(body.company_id); const rec = await agentdb>( "POST", "/records", // The record and its link are written together, in one call. { object: "contact", data, links: companyId ? [{ relationship: "works_at", target_record_id: companyId }] : [] }, `crm-ts.contact.${submitId}`, ); return contactOut(rec); } ``` ## 5. Edit with version checks {#edit} The edit form keeps the record's `version`. AgentDB refuses the save with `VERSION_CONFLICT` when someone saved first. Moving a contact to another company removes the old link and adds a new one. ```typescript file=examples/starters/crm-typescript/src/server.ts#update-contact async function updateContact(id: string, body: Record) { const version = Number(body.version); if (!Number.isInteger(version) || version < 1) throw new HttpError(400, "The edit form is missing the record version."); const data = contactInput(body); // Optimistic concurrency: AgentDB refuses the write with VERSION_CONFLICT if someone saved first. const rec = await agentdb>("PATCH", `/records/${id}`, { version, data: { name: data.name, email: data.email ?? null, role: data.role ?? null }, }); if ("company_id" in body) { // Move the contact: remove the old works_at link, then add the new one. const wanted = recordId(body.company_id); const current = await companyOf(id); if (current && current.id !== wanted) await agentdb("DELETE", `/links/${current.link_id}`); if (wanted && current?.id !== wanted) await agentdb("POST", "/links", { relationship: "works_at", source_record_id: id, target_record_id: wanted }); } return contactOut(rec); } ``` The PHP version does the same with a hidden `version` field: ```php file=examples/starters/crm-php/public/index.php#save function contact_form_page(AgentDb $db, ?string $id, string $method): void { $error = null; $status = 200; $current = $id === null ? null : $db->call('GET', "/records/{$id}"); $company = $id === null ? null : company_of($db, $id); $values = [ 'name' => $current['data']['name'] ?? '', 'email' => $current['data']['email'] ?? '', 'role' => $current['data']['role'] ?? '', 'company_id' => $company['id'] ?? '', 'version' => (string) ($current['version'] ?? ''), ]; if ($method === 'POST') { $values = ['name' => field('name', 200), 'email' => field('email', 254), 'role' => field('role', 200), 'company_id' => record_id($_POST['company_id'] ?? null) ?? '', 'version' => field('version', 20)]; $data = ['name' => $values['name'], 'email' => $values['email'] ?: null, 'role' => $values['role'] ?: null]; try { if ($values['name'] === '') { throw new AgentDbException(422, 'VALIDATION_ERROR', 'A contact needs a name.'); } if ($id === null) { $links = $values['company_id'] === '' ? [] : [['relationship' => 'works_at', 'target_record_id' => $values['company_id']]]; $rec = $db->call('POST', '/records', ['object' => 'contact', 'data' => array_filter($data), 'links' => $links], 'crm-php.' . hash('sha256', session_id() . $_SESSION['csrf'] . $values['name'] . $values['email'])); $id = $rec['id']; } else { // The version from the form: AgentDB refuses the write if someone saved in between. $db->call('PATCH', "/records/{$id}", ['version' => (int) $values['version'], 'data' => $data]); if (($company['id'] ?? '') !== $values['company_id']) { if ($company !== null) { $db->call('DELETE', "/links/{$company['link_id']}"); } if ($values['company_id'] !== '') { $db->call('POST', '/links', ['relationship' => 'works_at', 'source_record_id' => $id, 'target_record_id' => $values['company_id']]); } } } header('Location: /contact?id=' . rawurlencode($id) . '&saved=1', true, 303); return; } catch (AgentDbException $e) { [$status, $error] = match (true) { $e->errorCode === 'VERSION_CONFLICT' => [409, 'Someone changed it first. Reload the page to see the latest version, then edit again.'], in_array($e->status, [400, 422], true) => [422, $e->getMessage()], default => throw $e, }; } } http_response_code($status); page($id === null ? 'New contact' : 'Edit contact', contact_form($values, all_companies($db), $company, $error, isset($_GET['saved']))); } ``` ## 6. Loading, empty and error states {#states} The backend turns AgentDB errors into short messages and fitting status codes: ```typescript file=examples/starters/crm-typescript/src/server.ts#error-mapping function errorAnswer(e: unknown): [number, string] { if (e instanceof HttpError) return [e.status, e.message]; if (e instanceof AgentdbError) { if (e.code === "VERSION_CONFLICT") return [409, "Someone changed this contact first. Reload it and try again."]; if (e.code === "NOT_FOUND") return [404, "Not found."]; if (e.status === 400 || e.status === 422) return [400, e.message]; // validation errors are safe to show console.error("AgentDB call failed:", e.status, e.code, e.message); } else console.error(e); return [502, "The data service is not available. Try again in a moment."]; } ``` The TypeScript page uses one helper for every list. It shows "Loading…", then the rows, an empty message or the error: ```typescript file=examples/starters/crm-typescript/src/client/app.ts#states /** One place for loading, empty and error states: the list is replaced, the status line says why. */ async function show(list: HTMLElement, status: HTMLElement, load: () => Promise, row: (item: T) => HTMLElement, empty: string) { status.textContent = "Loading…"; status.className = "status"; list.setAttribute("aria-busy", "true"); try { const items = await load(); list.replaceChildren(...items.map(row)); status.textContent = items.length ? "" : empty; } catch (e) { list.replaceChildren(); status.textContent = `Could not load: ${(e as Error).message}`; status.className = "status error"; } finally { list.removeAttribute("aria-busy"); } } ``` The page calls only its own backend: ```typescript file=examples/starters/crm-typescript/src/client/app.ts#api /** Calls the backend and turns an error answer into an Error with the backend's message. */ async function api(method: string, path: string, body?: unknown): Promise { const res = await fetch(path, { method, headers: body === undefined ? {} : { "content-type": "application/json", "x-submit-id": crypto.randomUUID() }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = (await res.json().catch(() => ({}))) as T & { error?: string }; if (!res.ok) throw new Error(json.error ?? `Request failed (${res.status}).`); return json; } ``` ## 7. Live updates {#live} The `live-dashboard` starter shows the number of contacts and the latest changes, and updates about one second after any change. - **On the server**, one loop follows the change feed with a read-only `viewer` key. `wait=1` makes AgentDB answer as soon as something changes. - **To the browser**, the server pushes a fresh summary with server-sent events. The page has no key. ```javascript file=examples/starters/live-dashboard/server.mjs#change-feed /** 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 } } } ``` ```javascript file=examples/starters/live-dashboard/server.mjs#sse function events(req, res) { res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no", // tells nginx not to buffer this response ...SECURITY_HEADERS, }); res.write("retry: 3000\n\n"); // the browser reconnects after 3 s if the connection drops if (current) res.write(`event: contacts\ndata: ${JSON.stringify(current)}\n\n`); viewers.add(res); // A comment line every 20 s keeps proxies from closing an idle connection. const ping = setInterval(() => res.write(": ping\n\n"), 20_000); req.on("close", () => { clearInterval(ping); viewers.delete(res); }); } ``` ```javascript file=examples/starters/live-dashboard/public/app.js#listen const source = new EventSource("/events"); source.addEventListener("open", () => { state.textContent = "Live"; state.className = "state live"; }); source.addEventListener("contacts", (e) => { const data = JSON.parse(e.data); document.getElementById("count").textContent = String(data.count); document.getElementById("at").textContent = new Date(data.at).toLocaleTimeString(); document.getElementById("empty").hidden = data.latest.length > 0; document.getElementById("latest").replaceChildren( ...data.latest.map((c) => { const li = document.createElement("li"); li.textContent = c.email ? `${c.name} (${c.email})` : c.name; // text, never HTML return li; }), ); state.textContent = "Live"; state.className = "state live"; }); source.addEventListener("problem", (e) => { state.textContent = JSON.parse(e.data).message; state.className = "state warn"; }); // EventSource reconnects by itself; this only tells the viewer. source.addEventListener("error", () => { state.textContent = "Connection lost. Reconnecting…"; state.className = "state warn"; }); ``` **No backend?** Create a read-only dashboard data link (`POST /v1/workspaces/{ws}/dashboards`) and long-poll `data_url?since=&wait=1` from the page. It holds no key. You can add a password, an expiry and allowed origins. See [Live data](live-data.md#dashboard-links). ## Run it {#run} | Starter | Run | |---|---| | TypeScript | `cd examples/starters/crm-typescript && npm install && npm run build && npm start` | | PHP | `cd examples/starters/crm-php && php -S 127.0.0.1:8000 -t public public/index.php` | | Live dashboard | `cd examples/starters/live-dashboard && node --env-file=.env server.mjs` | Each starter needs a `.env` made from its `.env.example`. To put one online, see [Deployment](deployment.md). # Concepts This page explains the words the rest of the documentation uses. After reading it, you can design your own data model. You will also know how AgentDB keeps your data safe from double writes and lost updates. ## Workspaces {#workspaces} A **workspace** is one isolated project. It has its own objects, records, files and settings. Nothing in one workspace can see another workspace. - **Id:** starts with `ws_` and never changes. - **Name:** a readable name that you can change. - **Slug:** a short handle such as `example-crm`. It is unique in your account. - **Default workspace:** your first workspace. Calls that name no workspace use it. An account can have several workspaces. Create one in the app, with `agentdb workspace create "Example CRM"`, or ask your agent. There are two kinds of API key. An **account key** reaches every workspace of your account. A **workspace key** reaches exactly one workspace. A key that asks for a workspace it cannot reach gets `NOT_FOUND`. ## Objects and fields {#objects-and-fields} An **object** is a type of record, like a table in a spreadsheet. Examples are `contact`, `company` and `invoice`. A **field** is one typed value on an object, like a column. Every field has a name and a type. AgentDB checks each value against its type on every write. ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact", "description": "People we talk to"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "email", "type": "email", "unique": true}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "stage", "type": "enum", "enum_values": ["lead", "customer"]}} ``` AgentDB has more than 25 field types. Common ones are `text`, `integer`, `boolean`, `date`, `datetime`, `enum`, `decimal`, `currency`, `email` and `file`. Each type is described in [Field types](reference/field-types.html). Some field options matter often: - **unique:** no two records may have the same value. A unique field can identify a record during bulk upserts. - **required:** every record must have a value. You can add a required field only while the object has no records. - **enum_values:** the allowed values of an `enum`, `status` or `multi_select` field. You can change the schema later. Rename or retype a field, or remove it. Each change can run as a dry run first. AgentDB keeps a schema history, and you can undo a change. Removed fields come back with their values. See the whole schema at any time: ```cli agentdb schema inspect ``` ## Relationships {#relationships} A **relationship** is a named link type between two objects. For example, a contact `works_at` a company. - The **source object** is where the link starts: `contact`. - The **target object** is where it points: `company`. - The **cardinality** is `one` or `many`. With `one`, each contact links to at most one company. ```mcp {"tool": "schema_define_object", "arguments": {"name": "company"}} ``` ```mcp {"tool": "schema_define_relationship", "arguments": {"name": "works_at", "source_object": "contact", "target_object": "company", "cardinality": "one"}} ``` You can follow a link in both directions. Outgoing goes from contact to company. Incoming goes from company to its contacts. ## Records {#records} A **record** is one row of an object, such as one contact. Every record has: - an `id` that starts with `rec_`, - the `object` it belongs to, - its field values under `data`, - a `version` number, - `created_at` and `updated_at` times. ```mcp {"tool": "records_create", "arguments": {"object": "contact", "data": {"email": "ada@example.com", "stage": "lead"}}} ``` A field without a value and a field set to `null` mean the same thing. Setting a field to `null` in an update removes its value. Deleting a record also removes its links and attachments. The record goes to the recycle bin first. You can restore it there for 30 days by default. ## Versions {#versions} Every record starts at version 1. Each change adds one. An update must name the version you last read: ```mcp {"tool": "records_update", "arguments": {"record_id": "$ADA", "version": 1, "data": {"stage": "customer"}}} ``` Did someone else change the record after you read it? Then your update fails with `VERSION_CONFLICT`, and nothing is written. Read the record again, apply your change and try again. This way no update silently overwrites another. ```mcp {"tool": "records_update", "arguments": {"record_id": "$ADA", "version": 1, "data": {"stage": "lead"}}} ``` ## Idempotency keys {#idempotency} A network error can leave you unsure whether a write happened. An **idempotency key** makes a retry safe. 1. Send the write with a key you choose, such as `create-ada-2`. 2. If you are unsure it worked, send the same write with the same key again. 3. AgentDB returns the original result and writes nothing twice. ```rest curl -X POST https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/records \ -H "Authorization: Bearer $AGENTDB_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-bob-1" \ -d '{"object":"contact","data":{"email":"bob@example.com"}}' ``` ```rest curl -X POST https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/records \ -H "Authorization: Bearer $AGENTDB_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-bob-1" \ -d '{"object":"contact","data":{"email":"bob@example.com"}}' ``` The second call returns the same record. The response header `Idempotent-Replayed: true` tells you it was a replay. Some rules: - Use a new key for each new intention. - The same key with a different request fails with `IDEMPOTENCY_KEY_REUSED`. - A request that failed stored nothing. Fix it and retry with the same key. - AgentDB keeps keys for 7 days, per workspace. MCP tools take an `idempotency_key` argument. CLI commands take `-k` or `--idempotency-key`. ## Next steps - [Query your data](querying.md) with filters, sorting and totals. - [Files and imports](files-and-imports.md) to bring in existing data. # Query your data With AgentDB you find exactly the records you need. You can: - filter records by field values and by linked records, - sort and page through large results, - count, sum and average without paging, - search text across records by words, - save a query as a view and run it again later. Every query is exact and typed. AgentDB never guesses what you meant. ## Example data {#example-data} The examples on this page use deals that belong to companies. Your agent would set this up with a few tool calls: ```mcp {"tool": "schema_define_object", "arguments": {"name": "company"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "company", "name": "name", "type": "text", "unique": true}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "company", "name": "country", "type": "text"}} ``` ```mcp {"tool": "schema_define_object", "arguments": {"name": "deal"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "title", "type": "text", "unique": true}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "stage", "type": "enum", "enum_values": ["open", "won", "lost"]}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "amount", "type": "integer"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "closed_on", "type": "date"}} ``` ```mcp {"tool": "schema_define_relationship", "arguments": {"name": "for_company", "source_object": "deal", "target_object": "company", "cardinality": "one"}} ``` ```mcp {"tool": "records_upsert_batch", "arguments": {"object": "company", "match_on": "name", "records": [{"data": {"name": "Example Ltd", "country": "NL"}}, {"data": {"name": "Sample GmbH", "country": "DE"}}]}} ``` ```mcp {"tool": "records_upsert_batch", "arguments": {"object": "deal", "match_on": "title", "records": [{"data": {"title": "Website redesign", "stage": "open", "amount": 12000}}, {"data": {"title": "Support contract", "stage": "won", "amount": 4800, "closed_on": "2026-09-14"}}, {"data": {"title": "Data migration", "stage": "won", "amount": 9500, "closed_on": "2026-10-02"}}, {"data": {"title": "Training day", "stage": "lost", "amount": 1500, "closed_on": "2026-10-20"}}]}} ``` ```mcp {"tool": "relationships_link_batch", "arguments": {"source_object": "deal", "relationship": "for_company", "links": [{"source_key": {"title": "Website redesign"}, "target_key": {"name": "Example Ltd"}}, {"source_key": {"title": "Data migration"}, "target_key": {"name": "Example Ltd"}}, {"source_key": {"title": "Support contract"}, "target_key": {"name": "Sample GmbH"}}]}} ``` ## Filter records {#filter} `records_query` takes the object, a `where` filter, a sort and a limit. All conditions in `where.all` must match. ```mcp {"tool": "records_query", "arguments": {"object": "deal", "where": {"all": [{"field": "stage", "op": "eq", "value": "open"}, {"field": "amount", "op": "gte", "value": 10000}]}, "sort": [{"field": "amount", "order": "desc"}], "limit": 20}} ``` The same query over REST and from the CLI: ```rest curl -X POST https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/query \ -H "Authorization: Bearer $AGENTDB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"object":"deal","where":{"all":[{"field":"stage","op":"eq","value":"open"}]}}' ``` ```cli agentdb query --json '{"object":"deal","where":{"all":[{"field":"stage","op":"eq","value":"open"}]}}' ``` ### Operators {#operators} Which operators work depends on the field type. | Operator | Meaning | Works on | |----------|---------|----------| | `eq`, `neq` | equal, not equal | most types | | `in` | equal to one of up to 100 values | most types | | `lt`, `lte`, `gt`, `gte` | less than, greater than | numbers, dates, times, text | | `is_null`, `is_not_null` | has no value, has a value | every type | | `contains`, `starts_with` | text contains, text starts with, ignoring case | text, email, url, phone, rich text | | `contains`, `has_any`, `has_all` | the list holds an item, any item, all items | multi_select, list | | `within_bbox`, `near` | inside a box, near a point | geo | Some rules to know: - `eq` on text is exact and case-sensitive. Use `contains` or `starts_with` to ignore case. - A field without a value never matches `eq`, `neq` or a range. Only `is_null` finds it. - `boolean` fields take only `eq`, `neq` and the null operators. - You can filter on the built-in fields `id`, `created_at` and `updated_at`. - A query holds at most 20 conditions. `agentdb_capabilities {"operation": "records_query"}` lists the exact operators per field type. ### Filter by linked records {#filter-by-link} A relationship condition matches records whose linked records match. This finds deals for companies in NL: ```mcp {"tool": "records_query", "arguments": {"object": "deal", "where": {"all": [{"relationship": "for_company", "where": {"all": [{"field": "country", "op": "eq", "value": "NL"}]}}]}}} ``` Add `"direction": "incoming"` to follow links that point at the queried object. A query holds at most 3 relationship conditions. ## Sort and page {#sort-and-page} - `sort` takes one field with `"order": "asc"` or `"desc"`. Ties are sorted by `id`, so the order is stable. - Without `sort`, records come oldest first. - Fields without a value come first when ascending and last when descending. - `limit` is 1 to 200. The default is 50. When more records match, the result holds a `next_cursor`. Send it as `cursor` with the identical query to get the next page. A `next_cursor` of `null` means you have them all. ```mcp {"tool": "records_query", "arguments": {"object": "deal", "sort": [{"field": "amount", "order": "desc"}], "limit": 2}} ``` ```mcp {"tool": "records_query", "arguments": {"object": "deal", "sort": [{"field": "amount", "order": "desc"}], "limit": 2, "cursor": "$NEXT"}} ``` Add `"fields": ["title", "amount"]` to return only those fields. Archived records stay hidden unless you add `"include_archived": true`. ## Count and total {#aggregate} Use `records_aggregate` for counts, sums, averages, minimums and maximums. AgentDB computes them, so you never page through records to add them up. ```mcp {"tool": "records_aggregate", "arguments": {"object": "deal", "group_by": ["stage"], "metrics": [{"op": "count"}, {"op": "sum", "field": "amount"}]}} ``` Group date fields by `hour`, `day`, `week`, `month` or `year`: ```mcp {"tool": "records_aggregate", "arguments": {"object": "deal", "where": {"all": [{"field": "stage", "op": "eq", "value": "won"}]}, "group_by": [{"field": "closed_on", "bucket": "month"}], "metrics": [{"op": "sum", "field": "amount"}]}} ``` - Group by up to 3 fields, with up to 10 metrics. - `where` uses the same filter as `records_query`. - `count` needs no field. Every other metric needs one. From the CLI, `agentdb records count deal --by stage` gives a quick count per stage. ## Full-text search {#full-text-search} Full-text search finds records by words, ranked by relevance. Turn it on once per object. By default AgentDB indexes every text-like field. ```mcp {"tool": "search_configure", "arguments": {"object": "deal", "enabled": true}} ``` From then on, AgentDB keeps the index current on every write. Search with `records_search`: ```mcp {"tool": "records_search", "arguments": {"object": "deal", "query": "website redesign"}} ``` ```cli agentdb search query deal migration ``` - Every word must occur. Add `"match": "any"` to match at least one word. - The last word also matches as a prefix, so `migr` finds "migration". - Punctuation is ignored. A search holds at most 8 words. - Each hit has a score, the matched fields and highlighted snippets. - Add a `where` filter to search only matching records. ## Saved views {#saved-views} A saved view is a named query that you or your agent can run again. Views are versioned. A view is private by default. With `"visibility": "workspace"`, everyone in the workspace can use it, each with their own permissions. ```mcp {"tool": "views_create", "arguments": {"name": "open_deals", "object": "deal", "definition": {"where": {"all": [{"field": "stage", "op": "eq", "value": "open"}]}, "sort": [{"field": "amount", "order": "desc"}]}, "visibility": "workspace"}} ``` ```mcp {"tool": "views_run", "arguments": {"view": "open_deals"}} ``` ```cli agentdb views run open_deals ``` A view can also describe how to show its records: as a table, list, kanban board, calendar, timeline or report. You can share a view read-only through a link. See [Dashboards and sharing](dashboards-and-sharing.md). ## Next steps - [Concepts](concepts.md) explains objects, fields and relationships. - [Troubleshooting](troubleshooting.md) explains errors such as `INVALID_QUERY`. # Query performance AgentDB keeps your queries fast without SQL. You never need to know how indexes work. AgentDB watches how each query runs. When an index would make a query much faster, AgentDB says so in the query result. You or your agent then decide whether to create it. AgentDB never creates an index by itself. ## When AgentDB suggests an index {#when} AgentDB checks a query only when its object has 5,000 records or more. Smaller objects are fast without indexes. AgentDB suggests an index when all of these are true: - The query filters with `eq`, `in`, `lt`, `lte`, `gt`, `gte`, `is_null` or `starts_with`, or it sorts by a field. - The query reads at least 5,000 records. - The index would cut the records read at least fivefold. - The field has no index yet. AgentDB does not suggest an index in these cases: - The filter uses `neq` or `contains`. An index can't answer these. - The field has few values that are all common. An example is a `status` with two values that each cover half the records. - The field is a list, an object, rich text or a yes/no field. - The filter is on a related object. ## What the hint looks like {#hint} A query result can carry a `performance` object next to the records. All other fields stay the same. ```json { "records": ["..."], "next_cursor": "...", "performance": { "index_recommended": true, "object": "contact", "field": "status", "reason": "Filtering contact by status reads about 80,000 records to find about 80 matches. An index on status lets AgentDB read only the matching records.", "evidence": { "duration_ms": 36, "records_scanned_estimate": "about 80,000", "records_matching_estimate": "about 80", "records_in_object_estimate": "about 80,000", "estimate": true, "plan": "full scan of contact records" }, "suggested_operation": "indexes_create", "suggested_arguments": { "object": "contact", "field": "status", "name": "contact_status" }, "can_create": true, "limits": { "used": 1, "max_per_object": 5, "used_in_workspace": 1, "max_per_workspace": 20 }, "write_overhead": "Each index adds a little work to every create, update and delete of that object's records, and uses some storage. Create indexes only for filters and sorts you run often.", "policy": "recommend" } } ``` The numbers are estimates. AgentDB samples about 1,000 records to make them, and `estimate: true` marks them. The same hint reaches you at most once a day. You can ask for it again at any time (see below). `can_create` is `false` when your key or role can't create indexes. Only owners and admins without permission rules can. Ask one of them to create it. AgentDB says when a limit stops a recommendation. Each object can have 5 indexes, and each workspace 20. At the limit, the hint has `index_recommended: false` and `limit_reached: true`. Drop an index you no longer need first. ## Create the index {#create} Create the index with the suggested arguments. Here the object is small, so the index is only an example. ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "status", "type": "text"}} ``` ```mcp {"tool": "indexes_create", "arguments": {"object": "contact", "field": "status", "name": "contact_status"}} ``` In the AgentDB app, open the Schema tab. Each object has a Performance section with its indexes and recommendations. Owners and admins see a Create index button there. Each index adds a little work to every write of that object. Create indexes only for filters and sorts you run often. One index per field is enough. ## See all recommendations {#list} Ask for the current recommendations at any time. AgentDB lists them from the queries run recently, slowest first. The list also shows your existing indexes and the limits. ```mcp {"tool": "indexes_list", "arguments": {"include_recommendations": true}} ``` Over REST, call `GET /v1/workspaces/{ws}/indexes?recommendations=1`. With the CLI, run `agentdb indexes list --recommendations`. AgentDB keeps recent queries in memory only. After a restart the list starts empty. ## Check that the index helped {#verify} Add `"explain": true` to a query. The result then always has `performance.explain` with the time the query took and a short plan. ```mcp {"tool": "records_query", "arguments": {"object": "contact", "where": {"all": [{"field": "status", "op": "eq", "value": "churned"}]}, "explain": true}} ``` Run the query once before and once after you create the index. Compare `duration_ms`. After the index, `plan` names the index, for example `uses index contact_status`. `records_aggregate` accepts `explain` too. ## For operators {#operators} Set `AGENTDB_QUERY_ADVICE=off` to turn query advice off. Queries then never carry `performance`, and the list of recommendations stays empty. Creating an index also refreshes the statistics SQLite uses to plan queries. This takes about one pass over the records. # Files and imports With AgentDB you can: - keep files next to your records, such as a contract PDF on a deal; - share one file through a link that expires; - import a spreadsheet of contacts without pasting it into a chat; - export your data at any time. Your AI does most of this for you. This page shows the calls it makes, so you know what happens. ## Upload a file Your AI sends a small file as base64 with `files_upload`. It can attach the file to a record in the same call. ```mcp {"tool": "files_upload", "arguments": {"filename": "notes.txt", "content_type": "text/plain", "content_base64": "SGVsbG8gZnJvbSBFeGFtcGxlIEx0ZC4K", "folder": "notes", "tags": ["example"]}} ``` The answer holds the file id, its size, its SHA-256 and its status. - **Size:** inline uploads go up to 4 MiB by default. `workspace_info` shows the exact limit. - **Bigger files:** the REST API and the CLI upload in two steps, and resume large uploads in parts. - **Content never changes.** To change a file, upload a new version. The file id stays the same. - **Folders and tags** help you find files later. A folder is a label such as `contracts/2026`. From a terminal, the CLI uploads a file from disk: `agentdb files upload ./contract.pdf --record rec_... --role contract`. ## Attach a file to a record One file can belong to several records. Each attachment has a role, such as `invoice_pdf` or `photo`. In practice your AI attaches files to records you already have. The example first creates a client record: ```mcp {"tool": "schema_define_object", "arguments": {"name": "client"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "client", "name": "name", "type": "text", "unique": true}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "client", "data": {"name": "Example Ltd"}}} ``` ```mcp {"tool": "files_attach", "arguments": {"file_id": "$FILE_ID", "record_id": "$CLIENT_ID", "role": "contract"}} ``` `files_list` with a `record_id` shows the files of one record. `files_detach` removes an attachment and keeps the file. ## Download a file `files_download` returns the bytes as base64. Your AI also sees images as images. ```mcp {"tool": "files_download", "arguments": {"file_id": "$FILE_ID"}} ``` ## Share a file with a link A signed link lets someone open one file without an API key. Use it for a dashboard, an email or a web page. ```mcp {"tool": "files_link_create", "arguments": {"file_id": "$FILE_ID", "expires_in_seconds": 3600}} ``` - **Expiry:** from 60 seconds to 7 days. The default is 1 hour. - **Who can open it:** anyone who has the link, until it expires. - **Stop it early:** `files_link_revoke` ends the link at once. Deleting the file ends it too. - **Access follows you:** if you lose access to the file, your links stop working. - **Inline display:** only images, audio and video open in the browser. Other files always download. ## File types and media AgentDB accepts the file types it can verify. That includes images, PDF, Office documents, text, CSV, JSON, audio and video. - AgentDB checks the first bytes of every upload against the declared type. - A file that does not match is refused with `CONTENT_MISMATCH`. - HTML, SVG and program files are refused by default. - For images, audio and video, AgentDB reads the size and duration. You find them in the file's `metadata`. - Text files are indexed. `files_search` finds words in file names and in text content. See [File types](reference/file-types.html) for the full list. > Store only synthetic or non-sensitive files while the service is in beta. ## Import a CSV, JSON or JSONL file You can import hundreds of rows in one call. The file goes straight to AgentDB over HTTPS. It does not pass through the chat. The steps: 1. Your AI defines the object and a unique field to match on, such as `email`. 2. It calls `imports_start` and gets a one-time upload link. 3. It uploads the file to that link. Or it gives you a browser link to drop the file. 4. It calls `imports_inspect` to see the columns, types and possible problems. 5. It calls `imports_run` with `dry_run: true` first. Then it runs the import for real. The upload link works once, for 30 minutes. It is not an API key and cannot read data. ```mcp {"tool": "imports_start", "arguments": {"filename": "contacts.csv"}} ``` Your AI then uploads the file with the `upload_url` from the answer: ```sh curl -sS --fail-with-body -T contacts.csv '' ``` A small file can also go through `files_upload`. The import tools accept its `file_id`. The next examples use that route. ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "name", "type": "text"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "email", "type": "email", "unique": true}} ``` ```mcp {"tool": "files_upload", "arguments": {"filename": "contacts.csv", "content_type": "text/csv", "content_base64": "bmFtZSxlbWFpbApBZGEgTG92ZWxhY2UsYWRhQGV4YW1wbGUuY29tCkdyYWNlIEhvcHBlcixncmFjZUBleGFtcGxlLmNvbQo="}} ``` The file holds two rows: Ada Lovelace (`ada@example.com`) and Grace Hopper (`grace@example.com`). ```mcp {"tool": "imports_inspect", "arguments": {"file_id": "$CSV_ID", "object": "contact"}} ``` `imports_inspect` reports per column: filled and empty counts, duplicates, the detected type and sample values. It also suggests match keys and spots spelling variants. ```mcp {"tool": "imports_run", "arguments": {"file_id": "$CSV_ID", "object": "contact", "match_on": "email", "dry_run": true}} ``` A dry run does the whole import and then rolls it back. The report shows exactly what a real run would do. ```mcp {"tool": "imports_run", "arguments": {"file_id": "$CSV_ID", "object": "contact", "match_on": "email"}} ``` How an import behaves: - **Matching:** a row with a new `email` creates a record. A known `email` updates that record. - **Errors:** each failed row is listed with its line, identifier, field and error code. - **Partial or all-or-nothing:** by default valid rows are imported. With `atomic: true`, one failed row stops the whole import. - **Links:** `links` connects each row to an existing record, such as a contact to its company. - **Missing link targets:** AgentDB never invents a missing company. `on_unresolved_link` decides what happens to that row. - **Messy values:** AgentDB never changes values by itself. It suggests fixes, and applies only the `normalize` rules you approve. - **Large files:** one call handles up to 10,000 rows. Continue with `rows.offset` for the rest. - **Retries:** run the same import again and nothing is duplicated. Rows report `unchanged`. From a terminal: `agentdb import contacts.csv --object contact --match-on email --dry-run`. ## Export your data Owners and admins can export records, links, schema and file metadata at any time. The export is NDJSON: one JSON object per line. ```rest curl https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/export \ -H "Authorization: Bearer $AGENTDB_TOKEN" ``` - `?objects=contact,company` limits the export to some objects. - `?format=bundle` returns a `.tar.gz` that also holds the file bytes. - Your AI can call `records_export` for small exports, up to 2 MiB. - From a terminal: `agentdb data-export --output backup.ndjson`, or add `--bundle` for files too. `workspace_import` loads an export into a new workspace. It never merges into an existing one. # Dashboards and sharing You can show your data to people who have no AgentDB account. AgentDB offers three ways: - **Live dashboards:** totals and short lists that update by themselves. AgentDB hosts the page. - **Published views:** one saved view, read-only, through an expiring link. - **Custom dashboards:** a small website your AI builds, hosted by AgentDB. This is experimental. None of them puts an API key in a web page. Each link shows only what you chose, and you can stop it at any time. ## Create a live dashboard Ask your AI: "Make a live dashboard of my contacts by stage." It calls `dashboards_create` with a list of panels. - A panel shows **totals** (`aggregate`, like `records_aggregate`) or a **short list** (`query`, at most 50 rows). - A dashboard has at most 12 panels. - A panel displays as `number`, `bar`, `line` or `table`. The example first creates a small contact list: ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "name", "type": "text"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "stage", "type": "enum", "enum_values": ["lead", "customer"]}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "contact", "data": {"name": "Ada Lovelace", "stage": "lead"}}} ``` Now the dashboard: ```mcp {"tool": "dashboards_create", "arguments": { "title": "Contacts", "panels": [ {"title": "Contacts by stage", "aggregate": {"object": "contact", "group_by": ["stage"], "metrics": [{"op": "count"}]}}, {"title": "Latest", "query": {"object": "contact", "sort": [{"field": "created_at", "order": "desc"}], "limit": 5}, "fields": ["name", "stage"]} ] }} ``` The answer holds two links. AgentDB shows them only once. - **`page_url`:** a page AgentDB hosts. It is live, works on a phone and follows light or dark mode. Share it as it is. - **`data_url`:** the same panels as JSON, for a page you or your AI design. You can also set `layout` (1 to 4 columns), a panel `width`, and a `format` such as currency or percent. ## Build your own page on the data link `data_url` needs no API key, and any web page can fetch it. It returns the dashboard with a `marker`. For live updates, call `data_url?since=&wait=1` in a loop. AgentDB answers about a second after the data changes. When nothing changes, it answers `{"unchanged": true}` after about 25 seconds. ```js 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 `usage` part of the create answer holds a ready loop with retries. Your AI can paste it into the page. ## Share a dashboard {#share-a-dashboard} You choose who can read a dashboard. You set this when you create it, or later with `dashboards_update`. | Setting | Who can read | |---------|--------------| | `access: "link"` (default) | Anyone with the link. | | `access: "password"` | Anyone with the link who also types the password (10 to 200 characters). | | `access: "key"` | Only server-side apps with an AgentDB key. There is no hosted page. | ```mcp {"tool": "dashboards_update", "arguments": {"dashboard_id": "$DASHBOARD_ID", "access": "password", "password": "violet harbour lantern"}} ``` Give the password to people separately. Never write it into a page. More controls: - **Expiry:** new link dashboards expire after 90 days. Set `expires_in_days` (1 to 365), or `null` for no expiry. - **Allowed origins:** `allowed_origins: ["https://intranet.example.com"]` refuses browsers on other websites. This is not a secret, so combine it with a password or an expiry. - **New link:** `dashboards_rotate_token` gives new links. The old ones stop at once. - **Stop it:** `dashboards_revoke` ends the dashboard and every open page at once. Your data does not change. - **Who looks:** `dashboards_list` shows the last time someone opened it and views per day. It stores counts only. Owners and admins also see every dashboard in the app, under **Settings → Dashboards**. There you can open, renew or revoke one. ```mcp {"tool": "dashboards_list", "arguments": {}} ``` To change the panels, read the dashboard with `dashboards_get` first. Then call `dashboards_update` with its current `version`. The link stays the same. ## Publish a saved view A saved view is a named query on one object. You can publish it read-only through a link. People see a table; pages can use its `data_url`. ```mcp {"tool": "views_create", "arguments": {"name": "leads", "object": "contact", "definition": {"where": {"all": [{"field": "stage", "op": "eq", "value": "lead"}]}, "fields": ["name", "stage"]}, "visibility": "workspace"}} ``` ```mcp {"tool": "views_publish", "arguments": {"view": "$VIEW_ID", "expires_in_hours": 24}} ``` - The link expires after 7 days by default. `expires_in_hours` goes up to one year. - `access: "password"` asks for a password first. - The view runs with your permissions, now and later. It never shows more than you may see. - `fields` narrows the columns further. Files show as short-lived signed links. - `views_unpublish` stops the link at once. ## Custom dashboards (experimental) A custom dashboard is a small website your AI builds: HTML, CSS, JavaScript, images and fonts. AgentDB hosts it at its own address, with live, read-only data. > Custom dashboards may not be available on every server. They may change before version 2.0. How your AI builds one: 1. `dashboard_sites_create` with a title and a sharing mode. 2. `dashboard_sites_upload` with the files. `index.html` is required. 3. `dashboard_sites_preview` for a private preview (optional). 4. `dashboard_sites_publish` to make a version live. Publish an older version to roll back. ```json {"tool": "dashboard_sites_create", "arguments": {"title": "Sales team dashboard", "mode": "link_password", "password": "violet harbour lantern 42"}} ``` Sharing modes: | Mode | Who can open it | |------|-----------------| | `only_me` (default) | Only the owner, signed in with two-step sign-in. | | `link_password` | Anyone with the link and the shared password. | | `people` | Only the email addresses you invite. They need no account. | | `link` | Anyone with the link. If it shows workspace data, a person must approve this in the AgentHub. | The page reads its data from sources declared in `manifest.json`, such as `fetch("/_data/pipeline")`. It cannot call other servers or load outside scripts. Bundle any chart library into the package. See [Approvals and the AgentHub](hub-and-approvals.md#approve-or-reject) for how a person approves a request. # Automation AgentDB can act when your data changes, or at a time you choose. With it you can: - tell your own service about new or changed records, with a **webhook**; - create a task every Monday morning, with a **scheduled job**; - start onboarding when a contact becomes a customer, with a **flow**; - let agents and people hand work to each other in **channels** and the **inbox**; - book meetings in a **calendar**. AgentDB stores the rules and runs them. It never runs an AI model itself. An action writes a record, sends a webhook or schedules another job. Your agent does the actual work. The examples on this page use a small contact list and a task list: ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "name", "type": "text"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "stage", "type": "enum", "enum_values": ["lead", "customer"]}} ``` ```mcp {"tool": "schema_define_object", "arguments": {"name": "task"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "task", "name": "title", "type": "text"}} ``` ## Webhooks A webhook sends an HTTPS `POST` to your endpoint when something happens. You choose the events, and optionally the objects. ```mcp {"tool": "webhooks_create", "arguments": {"name": "Contact changes", "url": "https://hooks.example.com/agentdb", "events": ["record.created", "record.updated"], "objects": ["contact"]}} ``` - **The secret is shown once.** It starts with `whsec_`. Store it on your server. `webhooks_rotate_secret` gives a new one. - **Events** include record, relationship, schema, file, message, inbox and approval events. `"*"` means all. - **Payloads carry ids, not values.** Your service fetches the record with its own key. - **Private addresses are refused.** The URL must be public HTTPS. - **Test it:** `webhooks_test` sends a signed test delivery. ### Verify the signature Every request carries `X-AgentDB-Signature: t=,v1=`. The signature is an HMAC-SHA256 of `.`, keyed with your secret. Reject requests older than 5 minutes. ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.groupBy(header.split(","), (p) => p.slice(0, p.indexOf("="))); const t = Number(parts.t?.[0]?.slice(2)); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); return (parts.v1 ?? []).some((v) => { const got = Buffer.from(v.slice(3), "hex"); return got.length === expected.length && timingSafeEqual(got, expected); }); } ``` During a secret rotation, the header holds two `v1` values. The code above accepts either one. ### Delivery - **At least once.** The same event can arrive twice. Remove duplicates with the `X-AgentDB-Event-Id` header. - **In order.** Each webhook gets events in the order they happened. - **Retries:** AgentDB retries a failed delivery 10 times over about 2.4 hours. Any 2xx answer counts as success. - **Log:** `webhooks_deliveries` shows each attempt. `webhooks_redeliver` sends a failed delivery again. Only owners and admins manage webhooks. ## Scheduled jobs A job runs an action at a set time. There are three kinds of schedule: | Schedule | Example | |----------|---------| | Once | `{"type": "once", "at": "2027-01-15T09:00:00Z"}` | | Cron | `{"type": "cron", "expression": "0 9 * * 1-5", "timezone": "Europe/London"}` | | Repeat rule (RRULE) | `{"type": "rrule", "rule": "FREQ=MONTHLY;BYDAY=-1FR", "start": "2027-01-01T10:00", "timezone": "Europe/London"}` | Check a schedule before you save it. `schedule_preview` lists the next times and creates nothing. ```mcp {"tool": "schedule_preview", "arguments": {"schedule": {"type": "rrule", "rule": "FREQ=MONTHLY;BYDAY=-1FR", "start": "2027-01-01T10:00", "timezone": "Europe/London"}, "count": 3}} ``` This job creates a task every Monday at 09:00, London time: ```mcp {"tool": "jobs_create", "arguments": {"name": "Weekly digest", "schedule": {"type": "cron", "expression": "0 9 * * 1", "timezone": "Europe/London"}, "action": {"type": "create_record", "object": "task", "data": {"title": "Write the weekly digest"}}}} ``` Actions: - `create_record`: for example a task record your agent checks. - `update_record`: change one record. - `webhook`: send a signed delivery to one of your webhooks. - `run_flow`: run a flow, for example over many records. What AgentDB guarantees: - **Each time runs once.** A record action takes effect exactly once, also after a restart. - **Retries:** a failed action is retried, up to 5 times by default. `jobs_retry_run` tries a failed run again. - **Missed times:** after downtime, `missed` decides. The default `run_once` runs only the latest missed time. - **Time zones** use IANA names, such as `Europe/London`. Daylight saving changes are handled. - **Pause:** `jobs_update` with `status: "paused"`. Resume with `status: "active"`. ## Flows A flow is a rule: "when this happens, do that". It reacts to records being created, updated or deleted, or to a field changing. ```mcp {"tool": "flows_create", "arguments": { "name": "New customer -> onboarding task", "trigger": {"type": "field.changed", "object": "contact", "fields": ["stage"]}, "where": {"all": [{"field": "stage", "op": "eq", "value": "customer"}]}, "actions": [{"type": "create_record", "object": "task", "data": {"title": "Onboard {{record.data.name}}"}}] }} ``` Values such as `{{record.data.name}}`, `{{record.id}}` or `{{today+30d}}` are filled in when the flow runs. Actions: - `set_field` changes the record that started the flow. - `create_record` and `delete_record` add or remove records. - `notify` puts an item in someone's inbox. - `webhook` sends a signed delivery. - `schedule` runs an action later, for example after a week. - `propose` asks a person for approval first. See [Approvals and the AgentHub](hub-and-approvals.md). Test a flow on a real record. `flows_test` runs the actions and then rolls them back. Nothing is written or sent. ```mcp {"tool": "records_create", "arguments": {"object": "contact", "data": {"name": "Ada Lovelace", "stage": "customer"}}} ``` ```mcp {"tool": "flows_test", "arguments": {"flow_id": "$FLOW_ID", "record_id": "$CONTACT_ID", "event": "record.updated", "fields": ["stage"]}} ``` A flow ignores its own writes. Chains of flows stop after 3 steps by default, so flows cannot loop forever. ## Channels and messages A channel holds work messages between agents and people. Messages stay until you remove them or the channel's retention ends. ```mcp {"tool": "channels_create", "arguments": {"name": "sales", "topic": "Deals and handoffs"}} ``` ```mcp {"tool": "messages_post", "arguments": {"channel_id": "$CHANNEL_ID", "body": "Example Ltd signed. Please start onboarding.", "priority": "high"}} ``` - **Public** channels are readable by everyone in the workspace. **Private** channels are visible to members only. - A message is Markdown. It can also carry a structured `payload`, such as a task handoff. - Mentions such as `@agent:agt_...` put an item in that agent's inbox. - Every message gets a sequence number. An agent reads new messages with `channels_pending` and confirms them with `channels_ack`. - `dedupe_key` makes a post safe to repeat. ## Inbox Each agent, member and key has an inbox. It collects mentions, direct messages, assignments and notifications. ```mcp {"tool": "workspace_info", "arguments": {}} ``` ```mcp {"tool": "inbox_notify", "arguments": {"recipient": "$ME", "title": "Contract from Example Ltd arrived", "text": "Please review it today."}} ``` ```mcp {"tool": "inbox_list", "arguments": {}} ``` `inbox_update` marks items as read or done. People see their inbox in the AgentHub. The inbox is experimental and may change before version 2.0. ## Calendar Calendars book time for people, agents and resources, such as a meeting room. ```mcp {"tool": "calendars_create", "arguments": {"name": "Team", "timezone": "Europe/London"}} ``` ```mcp {"tool": "events_create", "arguments": {"calendar_id": "$CALENDAR_ID", "title": "Kick-off with Example Ltd", "start": "2027-01-12T10:00", "duration_minutes": 60}} ``` - `calendar_find_slots` finds a free time for several calendars and people. It books nothing. - `recurrence` takes a repeat rule, such as `FREQ=WEEKLY;BYDAY=MO`. - `reminders` send `calendar.reminder` events before the event starts. - `calendar_export_ics` and `calendar_import_ics` exchange events with other calendar apps. # Approvals and the AgentHub Some changes are too important to leave to an AI alone. With approvals, you decide which writes need a person first. For example: "any deal above 10,000 needs an admin". How it works: 1. An owner or admin creates an **approval policy**. 2. An agent tries a covered change. AgentDB refuses it with `APPROVAL_REQUIRED`. 3. The agent **proposes** the same change, with a reason and evidence. 4. A person **approves or rejects** it in the AgentHub. 5. On approval, AgentDB makes the change exactly once. The agent reads the outcome. The **AgentHub** is the page in the AgentDB app where people do this. It also shows your inbox, channels and reports. ## Create an approval policy The example uses a small deal list: ```mcp {"tool": "schema_define_object", "arguments": {"name": "deal"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "name", "type": "text"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "deal", "name": "amount", "type": "integer"}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "deal", "data": {"name": "Example Ltd renewal", "amount": 5000}}} ``` This policy covers updates that raise a deal above 10,000: ```mcp {"tool": "approval_policies_create", "arguments": { "name": "Large deals need a person", "operations": ["records.update"], "objects": ["deal"], "condition": {"field": "amount", "op": "gt", "value": 10000}, "applies_to": ["agent", "key"] }} ``` Main settings: | Setting | Meaning | Default | |---------|---------|---------| | `operations` | Which writes, such as `records.update`, `records.*` or `*` | required | | `objects`, `fields` | Only these objects, or only changes to these fields | all | | `condition` | A threshold on a proposed value, such as `amount > 10000` | none | | `applies_to` | Who is covered: `agent`, `user`, `key`, `form` | `agent` | | `approvers` | Minimum role, named members, and allowed kinds | admins and owners | | `quorum` | How many different people must approve (1 to 5) | 1 | | `expires_in_seconds` | Time to decide (60 seconds to 30 days) | 7 days | | `human_only` | Only a signed-in person may decide | `true` | | `distinct_approver` | The person who proposed can never decide | `false` | `applies_to: ["agent"]` covers agent keys and AI connectors such as Claude or ChatGPT. Add `key` to cover API keys too. Only owners and admins manage policies. A human-only policy protects itself: weakening or deleting it needs a person, too. ## Propose a change The agent tries the update. The policy stops it: ```mcp {"tool": "records_update", "arguments": {"record_id": "$DEAL_ID", "version": 1, "data": {"amount": 25000}}} ``` The agent proposes the same change with `approvals_submit`. It adds a reason and evidence for the person who decides. ```mcp {"tool": "approvals_submit", "arguments": { "operation": "records_update", "arguments": {"record_id": "$DEAL_ID", "version": 1, "data": {"amount": 25000}}, "justification": "The customer signed the larger order.", "evidence": {"source": "signed order form"} }} ``` What AgentDB does with a proposal: - It checks the input, exactly as the real write would. - It checks that the agent could make this change itself. A proposal never adds rights. - It stores a **preview**: for an update, each field's old and new value. - It records the record's version. If the record changes meanwhile, the proposal fails safely. Agents can also propose a change without a policy. Then one owner or admin must approve it within 7 days. ## Approve or reject a change {#approve-or-reject} A person decides in the AgentHub. Open it at `https://app.agentdb.link/app/hub` and sign in. 1. Open the **Overview** tab. **Waiting for your decision** lists the proposals you may decide. 2. Read the preview, the reason and the evidence. The exact action is shown too. 3. Choose **Approve**, **Reject** or **Request changes**. Add a comment if you like. 4. For a high-risk policy, type the approval id to confirm. An agent can send you a direct link to one proposal: `https://app.agentdb.link/app/hub?approval=apr_...`. If you are not signed in, AgentDB brings you back to it after sign-in. Rules for deciding: - The person who proposed a change can never decide it. - By default, only a signed-in person decides. API keys, agents, AI connectors and the CLI cannot. - The approver must be allowed to make the change themselves. - Reject and request changes end the proposal. The agent can then submit a new one. An API key that tries to decide a human-only proposal gets an error. The error includes the AgentHub link: ```mcp {"tool": "approvals_decide", "arguments": {"approval_id": "$APPROVAL_ID", "decision": "approve"}} ``` ## Follow the outcome The agent reads the proposal with `approvals_get` until its status is final. ```mcp {"tool": "approvals_get", "arguments": {"approval_id": "$APPROVAL_ID"}} ``` | Status | Meaning | |--------|---------| | `pending` | Waiting for a decision. | | `executed` | Approved, and the change was made. | | `failed` | Approved, but the change could not be made. The error is stored. | | `rejected`, `changes_requested` | A person said no, or asked for changes. | | `expired`, `cancelled` | Nobody decided in time, or the proposal was withdrawn. | `approvals_list` shows proposals, for example with `status: "pending"`. `approvals_cancel` withdraws a pending proposal. AgentDB runs an approved change exactly once. A second approval or a retry never applies it twice. ## The AgentHub The AgentHub shows all your workspaces in one place. It has five tabs: | Tab | What you see | |-----|--------------| | Overview | Proposals to decide, notifications, recent activity, and schedules | | Inbox | Your mentions, direct messages, assignments, reminders and failures. Mark them read or done. | | Channels | The channels you belong to. Read and post messages. | | Reports | Saved views with the `report` display | | Settings | Your notification email choices, per workspace | Your role and permissions apply everywhere in the AgentHub. Nobody else can read your inbox, also not the owner. ## Notifications AgentDB puts an inbox item in your inbox when something needs you: - a proposal you may decide, or a decision on your own proposal; - a calendar reminder or a mention in a channel; - a flow `notify` action or a form submission; - a scheduled job or webhook that failed after its last retry (owners and admins). Email is optional. In **Settings**, you choose per workspace: - **Approval requests:** off, immediate or a daily digest. The default is immediate. - **Everything else:** off, immediate or a daily digest. The default is off. Notification emails contain no record values. They name the kind of item and link to the AgentHub. Some servers do not send email; the Settings tab then says so. Webhooks can follow approvals too. Subscribe to `approval.submitted`, `approval.decided`, `approval.executed` or `approval.expired`. See [Automation](automation.md). # App packages An app package gives your workspace a ready-made data model in one call. You get objects, fields, links and saved views. Optional extras are automations and sample data. Install one when you want to start quickly. For example, ask your AI: "Install the CRM and add my first customer." ## Available packages | Template | What you get | |----------|--------------| | `agent_crm` | Companies, contacts, leads, deals in a pipeline, notes, activities and follow-up tasks. The recommended CRM. | | `agent_todos` | Tasks with subtasks, priorities, assignees, due dates, dependencies and repeats. | | `agent_kanban` | Kanban boards over the same tasks. Columns follow the task status. | | `agent_projects` | Projects, milestones, deliverables, team members and progress over shared tasks. | | `support` | A help desk: tickets, customers, response-time targets and escalations. | | `recruit` | Vacancies, candidates, applications, interviews and evaluations. | | `property` | Properties, owners, tenants, listings, leases and maintenance requests. | | `inventory` | Products, variants, locations, suppliers, a stock ledger and purchase orders. | | `assets` | An equipment register with assignments, warranties, repairs and maintenance plans. | | `cms` | Website pages and posts with Markdown, SEO fields and translations. | | `forms` | An inquiry object for public website forms, with a triage status. | | `content` | Campaigns, posts per channel and performance numbers. | `crm` is an older, smaller CRM. Use `agent_crm` for new workspaces. The [app package reference](reference/app-packages.html) lists every object, field and view. All task-based packages share one `task` object. So a CRM follow-up is also a to-do, and a card on the Kanban board. ## See what an install will do Start with a dry run. It shows every object, field, link and view the install would create. Nothing is written. ```mcp {"tool": "packages_install", "arguments": {"template": "agent_crm", "dry_run": true}} ``` The answer has a `summary` with counts, and an `actions` list. Each action says `create`, `unchanged`, `conflict` or `skipped`. A conflict means your workspace already has that item in a different form. AgentDB then keeps your version. ## Install a package ```mcp {"tool": "packages_install", "arguments": {"template": "agent_crm"}} ``` What an install does, and does not do: - **Additive.** It only adds objects, fields, links and views. It never removes or retypes a field. - **Safe to repeat.** A second install of the same version changes nothing. - **Your data stays.** An install never touches your records. - **Upgrades** add new items and keep fields you changed. Going back to an older version is refused. - **Uninstall** removes the views and automations. Your objects, fields and records stay. Options: - `automations: true` also adds the package's flows, jobs and approval policies. Only owners and admins can add them. - `sample_data: true` adds a set of made-up demo records, once. - `version` installs a specific version. `packages_list` shows the available templates and what is installed. ```mcp {"tool": "packages_list", "arguments": {}} ``` ## Example: a small CRM After installing `agent_crm`, add a company and a contact. The contact links to the company by its unique name, so you do not need the record id. ```mcp {"tool": "records_create", "arguments": {"object": "company", "data": {"name": "Example Ltd", "domain": "example.com"}}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "contact", "data": {"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com"}, "links": [{"relationship": "works_at", "target_key": {"name": "Example Ltd"}}]}} ``` Now add a deal and link it to the company. Money fields hold an amount and a currency. A deal moves through fixed stages, from `lead` to `won` or `lost`. A new deal starts at `lead`. ```mcp {"tool": "records_create", "arguments": {"object": "deal", "data": {"title": "Example Ltd renewal", "amount": {"amount": "12000.00", "currency": "EUR"}, "stage": "lead"}}} ``` ```mcp {"tool": "relationships_link", "arguments": {"relationship": "for_company", "source_record_id": "$DEAL_ID", "target_record_id": "$COMPANY_ID"}} ``` Find every contact who works at Example Ltd: ```mcp {"tool": "records_query", "arguments": {"object": "contact", "where": {"all": [{"relationship": "works_at", "where": {"all": [{"field": "name", "op": "eq", "value": "Example Ltd"}]}}]}}} ``` The CRM also has saved views. `views_render` with `agent_crm_pipeline` shows the deals by stage. ```mcp {"tool": "views_render", "arguments": {"view": "agent_crm_pipeline"}} ``` ## Example: a to-do list with a board Install the to-do app and the Kanban board. Both use the same `task` object. ```mcp {"tool": "packages_install", "arguments": {"template": "agent_todos"}} ``` ```mcp {"tool": "packages_install", "arguments": {"template": "agent_kanban"}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "task", "data": {"title": "Call Example Ltd about the renewal", "priority": "high", "due_date": "2027-01-15"}}} ``` The task now shows in the to-do views and as a card on the board. AgentTodos makes sure a task with open dependencies cannot be marked done. ## Example: import your contacts into the CRM You already have a spreadsheet of contacts. After installing `agent_crm`: 1. Import the companies, matching on the unique `name`. 2. Import the contacts, matching on the unique `email`. 3. Link each contact to its company with the `works_at` relationship. `imports_run` does step 3 in the same call, with a `links` entry for the company column. See [Files and imports](files-and-imports.md#import-a-csv-json-or-jsonl-file). ## Try it with sample data `sample_data: true` adds made-up records, so you can explore before you add your own. For the CRM that is a few agencies, contacts, leads, deals in every stage, activities and notes. All names are invented. Tip: install sample data in a separate test workspace, so it never mixes with real records. # Accounts and security This page shows how to keep your AgentDB account and data safe. You can: - sign in with a password, an emailed link or a passkey; - turn on two-step sign-in; - create, review and revoke API keys; - give each AI agent its own key and role; - invite people and limit what they see; - delete a workspace you own. > AgentDB is in beta. Store only synthetic or non-sensitive data. ## Your account An account belongs to one email address. It can own several workspaces. Each workspace is a separate project with its own data. Create an account at `https://app.agentdb.link/app/sign-up`. AgentDB emails a confirmation link, valid for 24 hours. You can sign in with your password after you confirm the address. ## Sign in {#sign-in} People sign in to the app at `https://app.agentdb.link/app/login`. There are three ways: | Way | How | |-----|-----| | Password | Your email address and password. | | Emailed sign-in link | Ask for a link at `/app/sign-in`. It works once, for 15 minutes. | | Passkey | Choose **Sign in with a passkey**. Your device asks for a fingerprint, face or PIN. | The emailed link always works, also for accounts without a password. **Password rules:** 12 to 256 characters. AgentDB refuses common passwords and your email address. A few unrelated words make a strong password. **Forgot your password?** Open `/app/forgot-password`. AgentDB emails a reset link, valid for 30 minutes. A reset signs out every browser session. API keys and connected apps keep working. **Wrong passwords** slow down further attempts, up to 15 minutes. There is no permanent lockout. A browser session ends after 12 hours without use, and after 7 days at most. ## Two-step sign-in Two-step sign-in asks for a second proof after your password or emailed link. Turn it on in **Settings → Sign-in and security**. Your email address must be confirmed first. Second factors: - **Authenticator app:** a 6-digit code from an app such as 1Password, Google Authenticator or Aegis. - **Passkey:** your device's fingerprint, face or PIN. You can add up to 20, each with a name. - **Recovery codes:** 10 single-use codes. AgentDB shows them once, when two-step sign-in turns on. Save them. How to set up an authenticator app: 1. Open **Settings → Sign-in and security**. 2. Choose **Set up an authenticator app**. Enter your password when asked. 3. Scan the QR code with your app. 4. Enter the 6-digit code to switch it on. 5. Save the 10 recovery codes in a safe place. A passkey sign-in counts as both steps, so it needs no password. AgentDB emails you when a factor is added or removed, and when a recovery code is used. It also emails you when a new browser signs in. Security changes need a sign-in from the last 12 hours. Examples: a new password, a new email address, or a new factor. > If you lose every factor and every recovery code, contact support. Support checks your identity before it resets two-step sign-in. Two-step sign-in protects people. API keys, agent keys and AI connectors are not affected by it. ## API keys An API key lets an agent, a script or the CLI use AgentDB. It starts with `adbk.` and goes in the `Authorization` header: ```sh export AGENTDB_TOKEN="adbk.key_..." ``` There are two kinds: - **Account key:** reaches every workspace of your account. Create it in **Settings → Account API keys**. - **Agent key:** belongs to one named agent in one workspace. See the next section. Key rules: - **AgentDB shows a key once.** Store it in a password manager or a secret store. - **Never put a key in a web page.** Use a [dashboard link](dashboards-and-sharing.md#share-a-dashboard) instead. - **Never paste a key into a chat.** AI connectors such as Claude sign in through their own connection. To **rotate** an account key: 1. Create a new key in **Settings → Account API keys**. 2. Put the new key in your tools and scripts. 3. Revoke the old key in the same list. It stops working at once. The list shows each key's label, start, creation date and last use. Revoking also ends the file links that key created. **Key review.** Keys can exist before you confirm your email address, for example when an agent signed up for you. After you confirm, AgentDB lists those keys, connected apps and data links. You choose **Keep** or **Revoke** for each one, before you can use the app. ## Named agents Give each AI agent its own key and role. Then you can see what each agent did, and stop one without stopping the rest. ```mcp {"tool": "agents_create", "arguments": {"name": "support-bot", "role": "editor", "expires_in_hours": 720}} ``` - The key is shown once. Give it only to that agent. - The role is `viewer`, `editor` or `admin`. It can never be higher than your own role. - `expires_in_hours` makes the agent stop by itself. - Every action of the agent is logged as `agent:`. - An admin agent can create sub-agents. They never get more rights than their parent. ```mcp {"tool": "agents_list", "arguments": {}} ``` `agents_revoke` stops an agent at once. Its keys stop working, and so do the agents it created. ## Roles and permissions Every person and agent in a workspace has a role: | Role | Can | |------|-----| | viewer | Read records and the schema. | | editor | Also create, change and delete records. | | admin | Also change the schema, and manage agents, permission rules and lower members. | | owner | Everything, including admins. Only the owner can delete the workspace. | **Permission rules** narrow access further. A rule can hide an object or a field, or make it read-only. Rules apply to viewers, editors and agents. They never apply to owners or admins. This rule makes the `salary` field invisible to editors: ```mcp {"tool": "schema_define_object", "arguments": {"name": "employee"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "employee", "name": "salary", "type": "integer"}} ``` ```mcp {"tool": "permissions_set", "arguments": {"subject": "role:editor", "object": "employee", "field": "salary", "access": "none"}} ``` A hidden field disappears from reads, filters, totals, the change feed and exports. With `owner_field`, an agent sees only its own records. For example, only the tasks assigned to it. Check what you may do with `access_inspect`: ```mcp {"tool": "access_inspect", "arguments": {}} ``` ## Members Invite people to a workspace by email, with a role. Owners and admins invite from **Settings** in the app, or with the API. ```json {"email": "ada@example.com", "role": "editor"} ``` - The invitation link works once, for 7 days. - The person needs no account first. Accepting the invitation signs them in. - An admin manages only members with a lower role. The owner manages everyone. - A role change or removal takes effect on the next request. Owners and admins see changes to members, agents and permission rules in the access log. ## Delete a workspace Only the owner can delete a workspace. This works only in the app, never with an API key or an AI agent. 1. Sign in to the app as the owner. 2. Export your data first, if you want a copy. See [Files and imports](files-and-imports.md#export-your-data). 3. Open **Settings** and choose to delete the workspace. 4. Type the workspace name exactly to confirm. The workspace then waits 30 days before AgentDB removes it: - During those 30 days, the workspace is read-only for you, and closed to everyone else. - Dashboards, links, webhooks and scheduled jobs stop at once. - AgentDB emails a reminder 3 days before the removal. - To cancel, open **Settings** and choose **Restore**. Everything comes back as it was. After 30 days, AgentDB removes the data and the files for good. # Troubleshooting This page helps you fix a failed call quickly. Look up the error code, read what it means and follow the fix. The last section helps when your AI client misses a tool. ## How errors look {#error-shape} Every error carries a stable `code`, a readable `message`, a `retryable` flag and `details`. Over REST, the error comes with an HTTP status: ```json {"request_id": "req_...", "error": {"code": "NOT_FOUND", "message": "...", "retryable": false, "details": {}}} ``` Over MCP, the tool result says `"ok": false`: ```json {"ok": false, "error": {"code": "NOT_FOUND", "message": "...", "retryable": false, "details": {}}} ``` Retry only when `retryable` is `true`, or when you send the same idempotency key. See [Idempotency keys](concepts.md#idempotency). To try the examples below, first create a small object: ```mcp {"tool": "schema_define_object", "arguments": {"name": "contact"}} ``` ```mcp {"tool": "schema_define_field", "arguments": {"object": "contact", "name": "score", "type": "integer"}} ``` ```mcp {"tool": "records_create", "arguments": {"object": "contact", "data": {"score": 50}}} ``` ## VALIDATION_ERROR and related codes {#validation-error} **Meaning:** the input breaks a rule. Nothing was written. Related codes are more specific: - `FIELD_TYPE_MISMATCH`: a value does not fit the field type. - `UNKNOWN_FIELD`: the object has no field with that name. - `REQUIRED_FIELD_MISSING`: a required field has no value. - `INVALID_QUERY`: a filter, operator or sort is not allowed. - `INVALID_CURSOR`: a cursor does not belong to this query. ```rest curl -X POST https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/records \ -H "Authorization: Bearer $AGENTDB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"object":"contact","data":{"score":"high"}}' ``` **What to do:** read `message` and `details`. They name the field and the rule. Fix the input and send it again. Check the schema with `schema_inspect` when you are unsure. ## VERSION_CONFLICT {#version-conflict} **Meaning:** someone changed the record after you read it. Your update named an old `version`. Nothing was written. ```mcp {"tool": "records_update", "arguments": {"record_id": "$REC", "version": 7, "data": {"score": 60}}} ``` **What to do:** read the record again with `records_get`. Apply your change to the new values. Send the update with the new version. `details.current_version` shows the version to expect. ## NOT_FOUND {#not-found} **Meaning:** the record, object, file or workspace does not exist. You also get `NOT_FOUND` for a workspace your key cannot reach. AgentDB does not tell the two cases apart, on purpose. ```mcp {"tool": "records_get", "arguments": {"record_id": "rec_aaaaaaaaaaaaaaaaaaaaaaaaaa"}} ``` **What to do:** check the id and the workspace. Run `workspace_list` to see the workspaces your key reaches. ## RATE_LIMITED {#rate-limited} **Meaning:** you sent too many requests in a short time. The error is retryable. **What to do:** wait, then retry. When `details.retry_after_seconds` is present, wait that long. Use the same idempotency key for a write. Send bulk data in batches with `records_upsert_batch` instead of one call per record. ## QUOTA_EXCEEDED {#quota-exceeded} **Meaning:** the workspace is full, or it reached a limit such as the number of saved views. AgentDB refuses new writes. Reading keeps working. **What to do:** delete records or files you no longer need. Run `workspace_info` or `agentdb workspace info` to see your quotas and use. ## UNSUPPORTED_OPERATION {#unsupported-operation} **Meaning:** this server has no operation with that name. The name may be misspelled or from another version. ```mcp {"tool": "agentdb_execute", "arguments": {"operation": "records_teleport", "arguments": {}}} ``` **What to do:** do not retry the same name. `details.available_operations` lists names you can use. Or search with `agentdb_capabilities`. See [Discover operations](connect-your-ai.md#discover-operations). ## APPROVAL_REQUIRED {#approval-required} **Meaning:** an approval policy covers this write. A person must approve it first. Nothing was written. **What to do:** do not retry the write. Propose it with `approvals_submit`, with the tool name, its arguments and a reason. A person approves or rejects it in the AgentHub. The change runs once when approved. See [Hub and approvals](hub-and-approvals.md#approve-or-reject). ## Sign-in and key errors {#auth-errors} These errors mean AgentDB does not accept your key, or your key may not do this. The CLI exits with code 3 for all of them. - `UNAUTHENTICATED` (HTTP 401): the key is missing, wrong, revoked or expired. - `FORBIDDEN` (HTTP 403): the key is valid, but its role or scopes do not allow this call. - `NOT_LOGGED_IN` (CLI only): the CLI has no saved key and no `AGENTDB_TOKEN`. - `WORKSPACE_SUSPENDED` or `WORKSPACE_PENDING_DELETION` (HTTP 403): the workspace is closed for now. ```rest curl https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/schema \ -H "Authorization: Bearer adbk.not-a-real-key" ``` **What to do:** 1. Check the header. It must be `Authorization: Bearer`, one space, then the full key. 2. In the CLI, run `agentdb login --api-url https://app.agentdb.link` again. 3. For `FORBIDDEN`, ask a workspace owner or admin for the access you need. 4. Lost or leaked key? See [Accounts and security](accounts-and-security.md). ## CLI exit codes {#cli-exit-codes} The CLI prints JSON to standard output. Its exit code tells a script what happened: | Exit code | Meaning | Example codes | |-----------|---------|---------------| | 0 | Success | | | 2 | Invalid input or a full workspace | `VALIDATION_ERROR`, `BATCH_REJECTED`, `QUOTA_EXCEEDED` | | 3 | Not logged in or not allowed | `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_LOGGED_IN` | | 4 | Not found | `NOT_FOUND`, `UNSUPPORTED_OPERATION` | | 5 | Conflict | `VERSION_CONFLICT`, `UNIQUE_VIOLATION`, `IDEMPOTENCY_KEY_REUSED` | | 6 | Server error or another error | `RATE_LIMITED`, `INTERNAL` | | 7 | Network error | `NETWORK_ERROR` | ```cli agentdb records get rec_aaaaaaaaaaaaaaaaaaaaaaaaaa ``` A bulk upsert with any failed item also exits with code 2. ## My AI client does not see a new tool {#missing-tool} Your AI client loads the tool list when it connects. It keeps that list until it reconnects. So after an AgentDB update, a new tool can be missing. 1. Start a new chat. Many clients reload the tool list then. 2. Still missing? Reconnect the client. In claude.ai, disconnect and connect the AgentDB connector. In Claude Code, run `/mcp`, choose agentdb and reconnect. 3. In ChatGPT, refresh the connector, or remove it and add it again. Your agent can also work without a reload. It calls `agentdb_capabilities` with `client_tools`, the names of the tools it has. The answer lists what it misses. It then runs each missing operation through `agentdb_execute`. ```mcp {"tool": "agentdb_capabilities", "arguments": {"client_tools": ["records_create", "records_query"]}} ``` ## Still stuck? {#still-stuck} Note the `request_id` from the error response. It helps support find your request. # Operation reference ## Workspaces (workspaces) Find, create, select, rename, delete and restore workspaces (projects). ### signup - Stability: stable - REST: POST /v1/signup Create an account, workspace and account key (shown once) ### signup.config - Stability: stable - REST: GET /v1/signup Signup availability, terms and quotas ### workspace.import - Stability: stable - REST: POST /v1/workspaces/{ws}/workspace-imports Import a data-export bundle (a file of this workspace) into a NEW workspace of your account (dry_run first) ### workspace.info - Stability: stable - REST: GET /v1/workspaces/{ws} Workspace details and quota usage ### workspace_create - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: workspace_create · required: name - REST: POST /v1/account/workspaces - CLI: agentdb workspace create Create a new, empty workspace (an isolated project with its own schema and records) in the user's account. Needs an account key. Returns its id; pass it as workspace on later calls. Example arguments: ```json {"name":"Example CRM","slug":"example-crm"} ``` ### workspace_delete - Stability: stable · added in 1.0.0-rc.8 - MCP tool: workspace_delete · required: confirm_name, workspace - REST: POST /v1/workspaces/{ws}/delete - CLI: agentdb workspace delete Not available to agents since rc.8: always FORBIDDEN (reason explorer_only). Deleting a workspace is only possible in the explorer: tell the person to sign in to /app as the owner and use the Settings tab (offer a final export first). Example arguments: ```json {"workspace":"example-crm","confirm_name":"Example CRM"} ``` ### workspace_get - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: workspace_get Show one workspace (id, name, slug, default) by id, slug or name; without workspace, the default one. Example arguments: ```json {"workspace":"example-crm"} ``` ### workspace_info - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: workspace_info - CLI: agentdb workspace info Show the workspace this connection is bound to, your permission scopes and service limits. principal {kind, id, role, key_type, via}: key_type is account (reaches all your workspaces) or workspace, as in workspace_list; via is oauth (MCP connector), api_key, session or automation. Example arguments: ```json {} ``` ### workspace_list - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: workspace_list - REST: GET /v1/account/workspaces - CLI: agentdb workspace list List the workspaces this connection can use (id, name, slug, default). Use it to find a workspace the user names. Also returns key_type (account or workspace) and via (oauth, api_key or session), the same values workspace_info reports. Example arguments: ```json {} ``` ### workspace_rename - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: workspace_rename · required: workspace - REST: PATCH /v1/workspaces/{ws} - CLI: agentdb workspace rename Change a workspace's name and/or slug. Its id, schema and data stay the same. Example arguments: ```json {"workspace":"example-crm","name":"Example CRM (archive)"} ``` ### workspace_restore - Stability: stable · added in 1.0.0-rc.8 - MCP tool: workspace_restore · required: workspace - REST: POST /v1/workspaces/{ws}/restore - CLI: agentdb workspace restore Not available to agents since rc.8: always FORBIDDEN (reason explorer_only). A pending deletion is cancelled in the explorer: the owner signs in to /app and presses Restore under Settings. Example arguments: ```json {"workspace":"example-crm"} ``` ### workspace_select - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: workspace_select · required: workspace Resolve the workspace the user means (by id, slug or exact name) and use it from now on: pass the returned id as workspace on every later call. The server keeps no selection between calls; agentdb mcp proxy also remembers it for this session. Ambiguous or unknown names return an error listing the candidates: ask the user. Example arguments: ```json {"workspace":"Example CRM"} ``` ## Schema (schema) Object types, typed fields, unique keys, indexes; history and undo. ### indexes_create - Stability: stable · added in 1.0.0-rc.6 - MCP tool: indexes_create · required: name, object, field - REST: POST /v1/workspaces/{ws}/indexes - CLI: agentdb indexes create Add a named index on a field that you filter or sort on often (max 5 per object, 20 per workspace). Renames keep it working. AgentDB tells you when one helps: records_query and records_aggregate return performance.index_recommended with suggested_arguments for this tool. Indexes help only on large objects (5,000+ records) with selective filters (eq/in/ranges, not neq or contains) or sorts. Each index adds work to every write of that object. To verify, rerun the query with explain=true and compare performance.explain.duration_ms and plan. Example arguments: ```json {"name":"contact_status","object":"contact","field":"status"} ``` ### indexes_drop - Stability: stable · added in 1.0.0-rc.6 - MCP tool: indexes_drop · required: index - REST: DELETE /v1/workspaces/{ws}/indexes/{index} - CLI: agentdb indexes drop Remove a named index. Example arguments: ```json {"index":"contact_email"} ``` ### indexes_list - Stability: stable · added in 1.0.0-rc.6 - MCP tool: indexes_list - REST: GET /v1/workspaces/{ws}/indexes - CLI: agentdb indexes list Named field indexes and the limits (5 per object, 20 per workspace). include_recommendations=true also returns index recommendations from queries run recently (ranked by total time, with evidence, can_create and the suggested indexes_create arguments). Check here before creating an index so you never add a redundant one. Example arguments: ```json {"include_recommendations":true} ``` ### schema.object.get - Stability: stable - REST: GET /v1/workspaces/{ws}/object-types/{object} schema object get ### schema_define_field - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: schema_define_field · required: name, type, object - REST: POST /v1/workspaces/{ws}/object-types/{object}/fields Add a typed field to an object type. Types: text, integer, boolean, date (YYYY-MM-DD), datetime (RFC 3339 with timezone), enum (needs enum_values), number (finite float), decimal (exact, string "12.50"; options.scale, default 2), currency ({amount: "12.50", currency: "EUR"}; options.scale, options.currencies) - use decimal/currency for money, time (HH:MM[:SS]), timezone (IANA name), duration (whole seconds or ISO 8601 like PT1H30M), multi_select (array of strings; enum_values optional), list (options.item_type), json (object, max 32 KB, depth 8), email, url (http/https), phone (E.164 like +31201234567), address ({line1,line2,line3,city,region,postal_code,country}), geo ({lat, lon}), rich_text (Markdown without HTML), formula (computed: options.expression like "price * quantity", options.result_type number|text|boolean), status (enum_values + options.transitions {from: [to...]}, options.initial), user_ref (a person's account id acc_…), agent_ref (an agent id agt_…), file (file id fil_…; options.multiple for a list; query has/is_null). See docs/FIELD-TYPES.md. unique=true makes it usable as an upsert match key. required=true is only allowed while the object has no records. To change or remove a field later use schema_update_field / schema_remove_field. Example arguments: ```json {"object":"contact","name":"email","type":"text","unique":true} ``` ### schema_define_object - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: schema_define_object · required: name - REST: POST /v1/workspaces/{ws}/object-types Create a new object type (e.g. 'contact'). Names are lowercase snake_case. Fails with ALREADY_EXISTS if it exists. Example arguments: ```json {"name":"contact","description":"People we talk to"} ``` ### schema_define_relationship - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: schema_define_relationship · required: name, source_object, target_object - REST: POST /v1/workspaces/{ws}/relationship-types Create a directed relationship type from source_object to target_object (e.g. contact.works_at -> company). cardinality 'one' allows one target per source record. Example arguments: ```json {"name":"works_at","source_object":"contact","target_object":"company","cardinality":"one"} ``` ### schema_define_unique_key - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: schema_define_unique_key · required: fields, object - REST: POST /v1/workspaces/{ws}/object-types/{object}/unique-keys Declare 2-5 fields that together identify one record (e.g. first_name + last_name + company). Then use them as match_on in records_upsert_batch and imports_run. Fails with SCHEMA_CONFLICT listing duplicates if existing records already share a combination. Records missing any of the fields are not constrained. Example arguments: ```json {"object":"contact","fields":["first_name","last_name"]} ``` ### schema_docs - Stability: stable · added in 1.0.0-rc.6 - MCP tool: schema_docs - REST: GET /v1/workspaces/{ws}/docs Generated API documentation for THIS workspace, for building an app on it: an OpenAPI fragment with a JSON Schema per object, example create/query/upsert bodies and markdown. Only shows what you may see. Example arguments: ```json {"object":"contact"} ``` ### schema_history - Stability: stable · added in 1.0.0-beta.3 - MCP tool: schema_history - REST: GET /v1/workspaces/{ws}/schema/history - CLI: agentdb schema history Schema change history, newest first: each change with the definition before and after, who made it, when, whether it can be undone and how many values were archived. Filter by object (and field). Use it to see earlier definitions or to find the change_id for schema_undo. Example arguments: ```json {"object":"contact","limit":10} ``` ### schema_inspect - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: schema_inspect - REST: GET /v1/workspaces/{ws}/object-types - REST: GET /v1/workspaces/{ws}/schema - CLI: agentdb schema inspect List every object type with its typed fields (name, type, required, unique, enum values) and relationship types. Call this first. Example arguments: ```json {} ``` ### schema_object_rules - Stability: stable · added in 1.0.0-rc.8 - MCP tool: schema_object_rules · required: object - REST: GET /v1/workspaces/{ws}/object-types/{object}/rules Record rules of one object: append_only (stored records can't be updated, deleted, merged or archived except by an owner; APPEND_ONLY) and completion_rules (a field may move to some values only while every linked record is in given values; BLOCKED_BY_DEPENDENCY). Packages such as AgentTodos set them. Example arguments: ```json {"object":"deal"} ``` ### schema_object_rules_set - Stability: stable · added in 1.0.0-rc.8 - MCP tool: schema_object_rules_set · required: object - REST: POST /v1/workspaces/{ws}/object-types/{object}/rules Set the record rules of one object (owners only). append_only true/false; completion_rules replaces the list ([] or null removes it). Omitted keys keep their value. Rules are checked against the schema first and apply to every later write (REST, MCP, CLI, imports). Audited as schema.object.rules. Example arguments: ```json {"object":"audit_entry","append_only":true} ``` ### schema_remove_field - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: schema_remove_field · required: object, field - REST: DELETE /v1/workspaces/{ws}/object-types/{object}/fields/{field} Remove a field and its value from every record. dry_run=true shows how many values would be removed; the real removal needs confirm=true. Fields in a unique key must have the key removed first. The values are archived (see values_kept_until); schema_undo with the returned change_id restores the field and its values. Example arguments: ```json {"object":"contact","field":"fax","dry_run":true} ``` ### schema_remove_unique_key - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: schema_remove_unique_key · required: fields, object - REST: DELETE /v1/workspaces/{ws}/object-types/{object}/unique-keys Remove a composite unique key (by its fields). Records are not changed. Example arguments: ```json {"object":"contact","fields":["first_name","last_name"]} ``` ### schema_undo - Stability: stable · added in 1.0.0-beta.3 - MCP tool: schema_undo · required: change_id - REST: POST /v1/workspaces/{ws}/schema/changes/{change}/undo - CLI: agentdb schema undo Reverse one schema change: restore a field's previous definition and its archived original values (rename, retype, enum/required/unique changes), bring back a removed field with its values, remove a field that was added, or re-add/remove a unique key. Validates every record first and changes nothing on conflict; dry_run=true previews; confirm=true is needed when the undo removes values or can't restore everything. Only the latest state can be undone: later changes to the same field must be undone first (newest first). The undo is itself a change you can undo. Example arguments: ```json {"change_id":"sch_example","dry_run":true} ``` ### schema_update_field - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: schema_update_field · required: object, field - REST: PATCH /v1/workspaces/{ws}/object-types/{object}/fields/{field} Rename a field (rename_to; values move with it), change its type (every existing value must convert, e.g. text->integer only if all values are whole numbers; any type -> text always works), or change enum_values, required, unique or description. Validates all records first and changes nothing on failure (SCHEMA_CONFLICT with examples). options replaces the type-specific settings (e.g. decimal scale, status transitions, formula expression). dry_run=true previews affected records. Lossy conversions (datetime->date) need confirm=true. Record ids and versions stay the same. Example arguments: ```json {"object":"contact","field":"email","rename_to":"work_email","dry_run":true} ``` ### schema_update_relationship - Stability: stable · added in 1.0.0-rc.6 - MCP tool: schema_update_relationship · required: object, relationship - REST: PATCH /v1/workspaces/{ws}/relationship-types/{object}/{relationship} Make a relationship required (every source record must be linked) or optional, or change its description. Making it required is refused while records lack the link; dry_run=true lists them. AgentDB never creates targets. Example arguments: ```json {"object":"contact","relationship":"works_at","required":false,"dry_run":true} ``` ## Records (records) Create, read, update, upsert, delete, archive, restore and merge records. ### records.history - Stability: stable - REST: GET /v1/workspaces/{ws}/records/{record}/history records history ### records_archive - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_archive · required: ids - REST: POST /v1/workspaces/{ws}/records/archive - CLI: agentdb records archive Archive records: they keep their links and files but are left out of queries, aggregates, search and related lists unless include_archived=true. records_get still returns them (with archived_at). Undo with records_unarchive. Example arguments: ```json {"ids":["rec_example"]} ``` ### records_changes - Stability: stable · added in 1.0.0-beta.2 - MCP tool: records_changes - REST: GET /v1/workspaces/{ws}/changes What changed in the workspace since a sequence number: records created/updated/deleted, schema changes, imports (action, id, version, time). Start with after=0 or the marker from a previous call; pass next_after to continue. Use it to keep a summary or another system in sync. Example arguments: ```json {"after":0,"limit":100} ``` ### records_create - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: records_create · required: object, data - REST: POST /v1/workspaces/{ws}/records - CLI: agentdb records create Create one record. data keys must be defined fields with correctly typed values; unknown fields are rejected. Example arguments: ```json {"object":"contact","data":{"first_name":"Ada","last_name":"Example","email":"ada@example.test"}} ``` ### records_delete - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: records_delete · required: record_id - REST: DELETE /v1/workspaces/{ws}/records/{record} - CLI: agentdb records delete Delete one record (and its links and attachments; attached files are kept). Pass version to delete only if it is unchanged. Returns NOT_FOUND for unknown ids. Example arguments: ```json {"record_id":"rec_example","version":2} ``` ### records_delete_batch - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: records_delete_batch · required: object - REST: POST /v1/workspaces/{ws}/records/delete Delete up to 1000 records of one object, by ids or by a where filter (same format as records_query). With where, expected_count must equal the number of matching records or nothing is deleted (CONFIRMATION_REQUIRED tells you the real count). Run with dry_run=true first to see the count and sample ids. ids mode: atomic=true (default) deletes nothing if an id is missing. Returns deleted, ids, links_removed, attachments_removed. Example arguments: ```json {"object":"contact","where":{"all":[{"field":"email","op":"is_null"}]},"expected_count":3,"dry_run":true} ``` ### records_duplicates - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_duplicates · required: object, fields - REST: POST /v1/workspaces/{ws}/duplicates - CLI: agentdb records duplicates Suggest likely duplicate records of one object by fields (mode exact, normalized (fold level: trim, case, accents, punctuation (default) or company) or similar (trigram similarity >= threshold)), with evidence and the level matched. Suggestions only: AgentDB never merges on its own. Example arguments: ```json {"object":"contact","fields":["email"],"mode":"normalized","level":"case"} ``` ### records_get - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: records_get · required: record_id - REST: GET /v1/workspaces/{ws}/records/{record} - CLI: agentdb records get Fetch one record with its current version. Example arguments: ```json {"record_id":"rec_example"} ``` ### records_merge - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_merge · required: survivor_id, merged_ids, versions - REST: POST /v1/workspaces/{ws}/records/merge - CLI: agentdb records merge Merge records into a survivor: field_choices {field: record_id} is required for every conflicting field; links and attachments move to the survivor; merged records go to the recycle bin. Audited; undo with records_merge_undo while they are in the recycle bin. Example arguments: ```json {"survivor_id":"rec_example","merged_ids":["rec_example_2"],"versions":{"rec_example":3,"rec_example_2":1}} ``` ### records_merge_preview - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_merge_preview · required: survivor_id, merged_ids - REST: POST /v1/workspaces/{ws}/merge-preview - CLI: agentdb records merge-preview Preview merging records into a survivor: every field value, the conflicts you must choose for, the links and files that would move, and the versions to pass to records_merge. Example arguments: ```json {"survivor_id":"rec_example","merged_ids":["rec_example_2"]} ``` ### records_merge_undo - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_merge_undo · required: merge_id - REST: POST /v1/workspaces/{ws}/merges/{merge}/undo - CLI: agentdb records merge-undo Undo a merge: restores the merged records, their links and files, and the survivor's earlier values (if it has not changed since). Example arguments: ```json {"merge_id":"mrg_example"} ``` ### records_merges - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_merges - REST: GET /v1/workspaces/{ws}/merges - CLI: agentdb records merges Merge history (newest first), optionally for one survivor. Example arguments: ```json {"record_id":"rec_example","limit":10} ``` ### records_purge - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_purge · required: ids - REST: POST /v1/workspaces/{ws}/records/purge - CLI: agentdb records purge Delete records in the recycle bin for good. Cannot be undone. Example arguments: ```json {"ids":["rec_example"]} ``` ### records_recycle_bin - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_recycle_bin - REST: GET /v1/workspaces/{ws}/recycle-bin - CLI: agentdb records recycle-bin Deleted records that can still be restored (records_delete moves records to the recycle bin), with their deletion time and purge_after date. Example arguments: ```json {"object":"contact","limit":20} ``` ### records_restore - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_restore · required: ids - REST: POST /v1/workspaces/{ws}/records/restore - CLI: agentdb records restore Restore deleted records from the recycle bin with their id, data, links and attachments. Fails (nothing changes) on a taken unique value, a missing required link target, or values that no longer fit the schema (allow_dropped_values=true restores without them and lists them). dry_run=true previews. Example arguments: ```json {"ids":["rec_example"],"dry_run":true} ``` ### records_unarchive - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_unarchive · required: ids - REST: POST /v1/workspaces/{ws}/records/unarchive - CLI: agentdb records unarchive Bring archived records back into queries. Example arguments: ```json {"ids":["rec_example"]} ``` ### records_update - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: records_update · required: version, data, record_id - REST: PATCH /v1/workspaces/{ws}/records/{record} - CLI: agentdb records update Change some fields of a record. version must equal the record's current version or the call fails with VERSION_CONFLICT. null removes a field. A patch that changes nothing keeps the version. Example arguments: ```json {"record_id":"rec_example","version":1,"data":{"email":"ada.new@example.test"}} ``` ### records_upsert - Stability: stable · added in 1.0.0-beta.3 - MCP tool: records_upsert · required: object, match_on, data - REST: POST /v1/workspaces/{ws}/records/upsert-one - REST: POST /v1/workspaces/{ws}/records/upsert - CLI: agentdb records upsert Create-or-update ONE record by its match key: "id", a unique field, or a declared composite key (match_on: ["first_name","last_name","company_id"], defined once with schema_define_unique_key). Returns status created | updated | unchanged and the record. A missing key value fails (never guessed); a match never merges with a different record. Pass version to require it is unchanged. Example arguments: ```json {"object":"contact","match_on":"email","data":{"email":"ada@example.test","first_name":"Ada"}} ``` ### records_upsert_batch - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: records_upsert_batch · required: object, match_on, records - REST: POST /v1/workspaces/{ws}/records/upsert Create-or-update up to 500 records of one object in one transaction. match_on is "id" (update existing records), a unique field name, or a list of fields forming a composite unique key (schema_define_unique_key). dry_run=true validates every item and reports what would happen (failed items carry identifier, field and error) without writing. atomic=true (default): any failing item rejects the whole batch with BATCH_REJECTED and nothing is written. atomic=false: valid items are saved and failures are listed per index. Items without version overwrite the matched record (last write wins); pass version to require it is unchanged. Items missing the match_on value fail by default; on_missing_match_key="skip" reports them as skipped instead (never created). Duplicate match keys in one batch reject the whole batch. client_ref on an item is echoed in its result. match_fold "case" (or "trim") matches text keys ignoring case/spacing: the stored key is kept, keys equal once folded count as duplicates, and two stored records that collide once folded fail with MATCH_FOLD_COLLISION (never merged). Prefer type email for e-mail fields. Example arguments: ```json {"object":"contact","match_on":"email","dry_run":true,"records":[{"client_ref":"line 2","data":{"email":"ada@example.test","first_name":"Ada"}},{"client_ref":"line 3","data":{"email":"bob@example.test","first_name":"Bob"}}]} ``` ## Relationships (relationships) Link and unlink records; list a record's links. ### relationships_link - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: relationships_link · required: relationship, source_record_id, target_record_id - REST: POST /v1/workspaces/{ws}/links Link two records using a relationship defined on the source record's object type. Example arguments: ```json {"relationship":"works_at","source_record_id":"rec_example","target_record_id":"rec_example_company"} ``` ### relationships_link_batch - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: relationships_link_batch · required: source_object, relationship, links - REST: POST /v1/workspaces/{ws}/links/batch Create up to 500 links of one relationship in one transaction. Name each side by record id (source_record_id/target_record_id) or by one unique field value (source_key/target_key, e.g. {"email": "a@x.example"} -> {"name": "Acme"}). atomic=true (default): any failing link rejects the batch with BATCH_REJECTED and nothing is written. atomic=false: valid links are saved and failures are listed. skip_existing=true reports already-linked pairs as exists instead of failing. client_ref on a link is echoed in its result. Example arguments: ```json {"source_object":"contact","relationship":"works_at","links":[{"client_ref":"line 2","source_key":{"email":"ada@example.test"},"target_key":{"name":"Example Ltd"}}]} ``` ### relationships_list - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: relationships_list · required: relationship, record_id - REST: GET /v1/workspaces/{ws}/records/{record}/related List records linked to a record. direction 'outgoing' follows a relationship defined on this record's object; 'incoming' finds records that link to it. Page with after=next_after. Example arguments: ```json {"record_id":"rec_example","relationship":"works_at","direction":"outgoing"} ``` ### relationships_unlink - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: relationships_unlink · required: link_id - REST: DELETE /v1/workspaces/{ws}/links/{link} Remove one link between records. The records themselves are not changed. Example arguments: ```json {"link_id":"lnk_example"} ``` ## Query and search (query_search) Exact queries (operators per field type), roll-ups, totals by date bucket, full-text search. ### records_aggregate - Stability: stable · added in 0.2.0-alpha.8 - MCP tool: records_aggregate · required: object - REST: POST /v1/workspaces/{ws}/aggregate Server-side totals: metrics [{op: count|sum|avg|min|max, field?, as?}] over records matching where (same format as records_query), optionally group_by up to 3 fields (date/datetime as {"field","bucket":"hour|day|week|month|year"}). Returns groups [{group, metrics}]. sum/avg need integer, boolean, number, duration, decimal or currency fields (decimal and currency are exact; currency needs one currency per group). Absent values are ignored by sum/avg/min/max and form their own null group. Like records_query it may return performance with an index recommendation; explain=true returns duration_ms and a plan summary. Example arguments: ```json {"object":"deal","where":{"all":[{"field":"stage","op":"eq","value":"open"}]},"group_by":[{"field":"closed_on","bucket":"month"}],"metrics":[{"op":"count"},{"op":"sum","field":"amount"}]} ``` ### records_query - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: records_query · required: object - REST: POST /v1/workspaces/{ws}/query Exact typed query. where.all is an AND of {field, op, value} conditions or one-level {relationship, direction, where} conditions. Built-in fields id, created_at, updated_at are queryable. Absent values only match is_null. Results are ordered by one sort field then id; use cursor=next_cursor for the next page. Large objects: the result may include performance {index_recommended, field, reason, evidence, suggested_operation: indexes_create, suggested_arguments, can_create}; explain=true always returns performance with duration_ms and a plan summary (use it to compare before and after an index). Example arguments: ```json {"object":"deal","where":{"all":[{"field":"stage","op":"eq","value":"open"}]},"sort":[{"field":"amount","order":"desc"}],"limit":20} ``` ### records_search - Stability: stable · added in 1.0.0-rc.6 - MCP tool: records_search · required: object, query - REST: POST /v1/workspaces/{ws}/search Full-text search over text-like fields of one object (enable once per object with search_configure). Every word must occur (match=all) or any word (match=any); the last word matches as a prefix. Ranked (BM25), with highlighted snippets, an optional exact where filter and next_cursor paging. Hidden fields are never searched. Example arguments: ```json {"object":"contact","query":"ada example","match":"all","limit":10} ``` ### records_search_semantic - Stability: experimental · added in 1.0.0-rc.6 - MCP tool: records_search_semantic · required: object, query - REST: POST /v1/workspaces/{ws}/search/semantic Semantic ranking of records by meaning (OFF by default; FEATURE_DISABLED unless the operator enabled a provider). Ranks the records an exact where filter selects; never replaces exact filters. Experimental: may change before 2.0. Example arguments: ```json {"object":"note","query":"customer asked about pricing","limit":5} ``` ### search_config - Stability: stable · added in 1.0.0-rc.6 - MCP tool: search_config - REST: GET /v1/workspaces/{ws}/search/config - CLI: agentdb search config Objects with a full-text index and their indexed fields. Example arguments: ```json {} ``` ### search_configure - Stability: stable · added in 1.0.0-rc.6 - MCP tool: search_configure · required: object - REST: POST /v1/workspaces/{ws}/search/configure - CLI: agentdb search configure Enable (and build) or disable the full-text index of an object. Default: every text-like field (text, rich_text, email, url, phone, enum, status, multi_select, list, address). The index then stays current on every write. Example arguments: ```json {"object":"contact","enabled":true,"fields":["first_name","last_name","email"]} ``` ## Files (files) Upload, attach, download, version, search and share files. ### files.content.get - Stability: stable - REST: GET /v1/workspaces/{ws}/files/{file}/content Download file bytes (Range: bytes=a-b supported) ### files.content.patch - Stability: stable - REST: PATCH /v1/workspaces/{ws}/files/{file}/content Resumable upload: one part at Upload-Offset (application/octet-stream) ### files.content.put - Stability: stable - REST: PUT /v1/workspaces/{ws}/files/{file}/content Upload file bytes (application/octet-stream) ### files.create - Stability: stable - REST: POST /v1/workspaces/{ws}/files files create ### files.link.list_all - Stability: stable - REST: GET /v1/workspaces/{ws}/file-links Signed file links of the workspace ### files.link.view - Stability: stable - REST: GET /v1/file-links/{token} Open a signed file link (public; Range supported) ### files.thumbnail.get - Stability: stable - REST: GET /v1/workspaces/{ws}/files/{file}/thumbnail Thumbnail JPEG made by the media worker (rc.8; worker off by default) ### files.upload_status - Stability: stable - REST: GET /v1/workspaces/{ws}/files/{file}/upload files upload status ### files.version.content.get - Stability: stable - REST: GET /v1/workspaces/{ws}/files/{file}/versions/{version}/content Download one version's bytes (Range supported) ### files.version.content.patch - Stability: stable - REST: PATCH /v1/workspaces/{ws}/files/{file}/versions/{version}/content Resumable upload part of a pending version ### files.version.content.put - Stability: stable - REST: PUT /v1/workspaces/{ws}/files/{file}/versions/{version}/content Upload the bytes of a pending version ### files.version.create - Stability: stable - REST: POST /v1/workspaces/{ws}/files/{file}/versions files version create ### files.waveform.get - Stability: stable - REST: GET /v1/workspaces/{ws}/files/{file}/waveform Audio waveform peaks made by the media worker (rc.8; worker off by default) ### files_attach - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: files_attach · required: file_id, record_id - REST: POST /v1/workspaces/{ws}/attachments - CLI: agentdb files attach Attach an uploaded file to a record with a role (e.g. 'invoice_pdf', 'photo'). The same file can be attached to several records. Example arguments: ```json {"file_id":"fil_example","record_id":"rec_example","role":"contract"} ``` ### files_delete - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: files_delete · required: file_id - REST: DELETE /v1/workspaces/{ws}/files/{file} - CLI: agentdb files delete Delete a file and its bytes. Fails with FILE_IN_USE while it is attached unless force=true (which detaches it everywhere). Example arguments: ```json {"file_id":"fil_example"} ``` ### files_delete_version - Stability: stable · added in 1.0.0-rc.6 - MCP tool: files_delete_version · required: file_id, version - REST: DELETE /v1/workspaces/{ws}/files/{file}/versions/{version} - CLI: agentdb files delete-version Delete an earlier version of a file, or cancel a pending replacement. The current version can't be deleted. Example arguments: ```json {"file_id":"fil_example","version":1} ``` ### files_detach - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: files_detach · required: attachment_id - REST: DELETE /v1/workspaces/{ws}/attachments/{attachment} - CLI: agentdb files detach Remove one attachment. The file itself is kept. Example arguments: ```json {"attachment_id":"att_example"} ``` ### files_download - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: files_download · required: file_id - CLI: agentdb files download Return a file's bytes as base64 (up to max_inline_file_bytes). Images are additionally returned as image content. Example arguments: ```json {"file_id":"fil_example"} ``` ### files_get - Stability: stable · added in 0.2.0-alpha.4 or earlier - MCP tool: files_get · required: file_id - REST: GET /v1/workspaces/{ws}/files/{file} - CLI: agentdb files get File metadata: filename, content_type, size, sha256, status and the records it is attached to. Example arguments: ```json {"file_id":"fil_example"} ``` ### files_link_create - Stability: stable · added in 1.0.0-rc.6 - MCP tool: files_link_create · required: file_id - REST: POST /v1/workspaces/{ws}/files/{file}/links Signed, expiring link to one file for a dashboard or public app: works in /