adt / ai-chat
AI chat over your database: an Anthropic Managed Agents conversation engine with safe read-only SQL tools over anonymized views
Requires
- php: >=8.1
- adt/doctrine-anonymization: ^0.2
- doctrine/orm: ^2.14 || ^3.0
Requires (Dev)
README
An AI chat over your own database. The engine runs an Anthropic Managed Agents conversation whose tools are safe, read-only SQL over the anonymized views of adt/doctrine-anonymization - so the model can analyze your data without ever seeing personal details.
composer require adt/ai-chat
What it is (and is not)
The package is the conversation engine: session handling, the polling loop, tool execution, agent provisioning, prompt building and output redaction. It ships no HTTP client, no queue and no frontend - those live in your project behind two small interfaces, because every project already has its own.
ManagedAgentsClient (you implement) ──► AgentTurnRunner ──► TurnResult
│
ToolHandler (SQL / charts / export) ◄──────┘
│
ReadOnlyQueryExecutor (adt/doctrine-anonymization, read-only account, anon schema)
Complete working example
The examples/ directory is a full reference integration you can copy
into a project and adjust:
| File | Shows |
|---|---|
GuzzleManagedAgentsClient.php |
the transport - raw HTTP with the beta header, over Guzzle |
Entity/Conversation.php, Entity/Agent.php |
entities built from the package traits, ownership on your side |
Entity/Message.php |
a minimal transcript entity (the package has no message contract on purpose) |
ChatService.php |
the coordinator - web request stores + dispatches, worker runs the turn, redacts and persists |
QueueMessageDispatcher.php |
the background hand-off (here adt/background-queue) incl. the consumer wiring |
config.neon |
complete DI registration of everything above |
ui/ChatControl.php + .latte |
the frontend as a Nette component: AJAX signals, polling, CSV download endpoint, tool-data whitelisting |
ui/ChatPresenter.php + .latte |
a page hosting the control incl. the CDN includes (Chart.js, marked, DOMPurify) |
ui/assets/aiChatControl.js, ui/assets/ai-chat.css |
the complete browser side: conversations, markdown rendering, charts, tables, context gauge, CSV buttons |
The examples are not autoloaded by the package - they are a starting point, not an API.
Wiring
1. Implement the transport
ManagedAgentsClient maps 1:1 to the Managed Agents REST endpoints (beta). Use
your existing HTTP client with its logging and error handling:
class MyAiClient implements ADT\AiChat\Client\ManagedAgentsClient { ... }
2. Implement the dispatcher
A turn takes minutes, so it must run in a worker. MessageDispatcher::dispatch()
hands the message to your queue; the worker then runs the turn:
class QueueMessageDispatcher implements ADT\AiChat\Dispatch\MessageDispatcher { public function dispatch(int|string $conversationId, string $userMessage): void { $this->queue->publish('aiChatProcessMessage', compact('conversationId', 'userMessage')); } }
3. Map the entities
Conversation and agent mapping are your Doctrine entities implementing the package interfaces; the traits carry the default mapping, your side adds the id and the owner relation:
#[ORM\Entity] class AiChatConversation implements ADT\AiChat\Entity\ConversationInterface { use ADT\AiChat\Entity\ConversationTrait; #[ORM\ManyToOne(targetEntity: User::class)] public User $user; // ownership stays on your side ... } #[ORM\Entity] class AiChatAgent implements ADT\AiChat\Entity\AgentInterface { use ADT\AiChat\Entity\AgentTrait; ... }
4. Provision the agent
Run right after regenerating the anonymized views (same deploy step). Idempotent: an existing agent is updated in place (same id, new version) with the current system prompt and tool definitions:
[$mapping, $created] = $provisioner->ensureProvisioned($schemaName);
5. Run a turn (in the worker)
$runner = new AgentTurnRunner($client, $toolHandler, $provisioner->resolver($schemaName)); $result = $runner->run($conversation, $userMessage, function (?string $sessionId) use ($em): void { $em->flush(); // persist the session id as soon as it changes }); $text = $piiFilter->filter($result->text, $piiValueCollector->collect($result->toolData)); // store $text + $result->toolData + $result->tokensInput/Output as a message
The runner handles: ignoring pre-existing session events, executing custom tools
and sending their results back, token accounting (window usage = last request incl.
cache counters; output summed), a retry with a fresh session when a stale one
refuses the message, and hard stops (maxPolls, maxToolCalls) that surface as
TurnTimeoutException - which is never retried, so a message cannot be duplicated.
Attachments
Users can attach files (images, PDFs, text/CSV) to a message. Upload each file to
the Anthropic Files API via ManagedAgentsClient::uploadFile() in the web request,
pass only the returned file ids through your queue, and hand them to the runner as
Attachment objects - it sends them as image/document content blocks of the same
user.message, so the model reads the files directly from its context:
$file = $client->uploadFile('export.csv', $contents, 'text/plain'); $result = $runner->run($conversation, $userMessage, $persist, [ new Attachment($file['id'], 'text/plain', 'export.csv'), ]);
Media types follow the content-block rules: image/jpeg|png|gif|webp become image
blocks, application/pdf and text/plain document blocks. Upload other text
formats (CSV, Markdown, logs) as text/plain - the filename keeps the extension.
Tools
ToolHandler is the default ToolExecutor:
| Tool | Purpose |
|---|---|
get_database_schema |
tables of the anon schema; columns incl. #[Description] texts |
execute_sql |
read-only SELECT, row-capped, over the read-only account |
render_chart / render_table |
displayable results for your frontend |
export_csv |
validates the SQL and returns a download token; your endpoint streams the rows later via ReadOnlyQueryExecutor::streamQuery() (never through the model) |
Different tool set? Implement ToolExecutor yourself.
Output redaction
PiiResponseFilter redacts e-mails, phone numbers, bank accounts and secrets from
the final reply by pattern, and - via PiiValueCollector - the literal values that
appeared in personal-data columns of SQL results (names, birth dates), which no
regex can recognise. Replacement labels and the phone prefix are configurable for
localized chats.
System prompt
SystemPromptBuilder renders a template with a {{tableList}} placeholder; only
table names go into the prompt (columns are fetched on demand by the schema tool),
which keeps every inference cheap and clear of the hard system length limit.
A generic default template ships in src/resources/system-prompt.md - copy it and
tune the wording to your domain.
Tests
composer install vendor/bin/codecept run unit
The suite drives the full turn loop against a scriptable fake of the Managed Agents API (tool round-trips, history isolation, timeouts, stale-session retry, token accounting) plus the tool handler, prompt builder and redaction - no network and no database.