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.
#Where the key lives
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.
AGENTDB_API_URL=https://app.agentdb.link
AGENTDB_WORKSPACE=ws_your_workspace_id
AGENTDB_TOKEN=replace-with-your-agent-keyNever echo the key into a page, a JavaScript variable or a data- attribute.
#The AgentDB client
PHP's built-in HTTP stream wrapper is enough. ignore_errors lets the client read AgentDB's JSON error answer.
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<string, mixed> 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
Each page is a function. The contact list runs one query with an optional contains filter:
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 .= '<li><a href="/contact?id=' . e($r['id']) . '">' . e($r['data']['name']) . '</a> <span class="muted">' . e($r['data']['email'] ?? '') . '</span></li>';
}
$empty = $q === '' ? 'No contacts yet. <a href="/contacts/new">Add the first one</a>.' : 'No contacts found for “' . e($q) . '”.';
page('Contacts', '<form method="get" role="search"><label>Search <input type="search" name="q" value="' . e($q) . '" maxlength="100"></label> <button>Search</button></form>'
. ($rows === '' ? "<p class=\"empty\">{$empty}</p>" : "<ul class=\"list\">{$rows}</ul>")
. '<p><a class="button" href="/contacts/new">New contact</a></p>');
}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:
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
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
HttpOnlyandSameSite=Lax.
#Loading, empty and error 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
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 from the browser. See Live data.
#Run it
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.phpOn Apache or nginx with PHP-FPM, point the document root at public/. See Deployment.