AgentDBDocs

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.

Shell
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

PHP's built-in HTTP stream wrapper is enough. ignore_errors lets the client read AgentDB's JSON error answer.

PHPexamples/starters/crm-php/src/AgentDb.php
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:

PHPexamples/starters/crm-php/public/index.php
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:

PHPexamples/starters/crm-php/public/index.php
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 HttpOnly and SameSite=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

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