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 || ^0.3 || ^0.4
- doctrine/orm: ^2.14 || ^3.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- codeception/codeception: ^5.0
- codeception/module-asserts: ^3.0
- guzzlehttp/guzzle: ^7.5 || ^8.0.1
- symfony/cache: ^6.4 || ^7.0
Suggests
- adt/background-queue: For ADT\AiChat\Dispatch\BackgroundQueueMessageDispatcher
- guzzlehttp/guzzle: For ADT\AiChat\Client\GuzzleManagedAgentsClient, the default transport, and ADT\AiChat\Pseudonymization\OpenAiCompatiblePiiDetector
- nette/http: For ADT\AiChat\AttachmentUpload::fromNetteFileUpload()
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-08 08:54:27 UTC
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
Three layers, use as much as fits your project:
| Layer | Classes | You provide |
|---|---|---|
| Engine | AgentTurnRunner, ToolHandler, AgentProvisioner, SystemPromptBuilder, PiiResponseFilter |
a ManagedAgentsClient (or the bundled Guzzle one) |
| Chat | ChatService, ChatOptions, AttachmentPolicy, Dispatch\BackgroundQueueMessageDispatcher |
two Doctrine entities (traits included) and a queue |
| HTTP + widget | Http\ChatEndpoint, Http\CsvExport, assets/ai-chat.js + assets/ai-chat.css |
a few routes/signals calling the endpoint |
| MCP server | Mcp\McpServer, Mcp\McpResponse |
one authenticated POST route (see MCP server) |
browser (ai-chat.js) ──► your routes ──► ChatEndpoint ──► ChatService ──► MessageDispatcher ──► queue
▲ │
└── processQueuedMessage() (worker) ◄─┘
│
AgentTurnRunner ──► ManagedAgentsClient
│
ToolHandler ──► ReadOnlyQueryExecutor (anon schema)
A turn takes minutes (polling, tool round-trips), so the web request only stores the message and dispatches it; a worker runs the turn and stores the (redacted) reply, which the widget picks up by polling.
Complete working example
The examples/ directory is a reference integration for Nette:
| File | Shows |
|---|---|
Entity/Conversation.php, Entity/Message.php, Entity/Agent.php |
the entities built from the package traits |
config.neon |
complete DI registration incl. the background-queue callback |
ui/ChatPresenter.php + .latte |
signals translating HTTP requests to ChatEndpoint calls, the CSV download, the widget root |
ui/ChatTranslator.php |
localizing the user-facing errors |
The examples are not autoloaded by the package - they are a starting point, not an API. adt/fancyadmin ships a complete integration (grid of conversations, chat in the side panel).
Wiring
1. The transport
Client\GuzzleManagedAgentsClient (needs guzzlehttp/guzzle) sends the beta
headers and turns failures into ApiUnavailableException with a user-safe detail.
Extend it (request(), send(), createHttpClient()) for logging or retries, or
implement Client\ManagedAgentsClient over your own HTTP client - it maps 1:1 to
the REST endpoints.
The API key is either a string, or a Client\ApiKeyProvider asked on every request
- for a key per tenant (entered in the tenant's settings) that a long-running queue
consumer picks up without a restart. Agents, environments, sessions and files
belong to the workspace of the key they were created with, so after a key change
drop the agent mapping (the next session provisions a new agent, step 5) or call
ensureProvisioned()right away to validate the key - and sessions started under the old key cannot continue.
Event stream. The worker gets the events of a turn from the live session stream
(GET /sessions/{id}/events/stream, server-sent events) the moment the agent emits
them - a tool call, the reply, the end of the turn - instead of polling the event list
every pollIntervalMicroseconds (500 ms) on every step. It is an optional capability
of the client, Client\SessionEventStreamer; GuzzleManagedAgentsClient has it (over
a plain PHP stream, needs allow_url_fopen). A client without it, a stream that does
not open or breaks, or a quiet stream (a buffering proxy, checked every 5 s) falls back
to the event list - no event is lost or handled twice, the runner remembers what it saw.
Push to the widget. Without it the widget polls the reply (250 ms at first, then
slower). Pass a Push\ChatPushPublisher to ChatService (pushPublisher:, autowired in
DI) and the worker announces every change of a conversation (progress of the turn, the
reply, an error); the endpoint responses carry push ({url, topic, token}), the widget
subscribes and polls right away on every update. With a connected push the widget does
not poll on its own at all - a lost push (hub restart, dropped connection at the wrong
moment) is fixed by reloading the page; when the push disconnects or is not offered, the
widget polls as before. Only the signal goes through the push, never the content.
Push\MercurePushPublisher works with a Mercure hub (standalone, or built into FrankenPHP
and Caddy): a private topic per conversation (topicPrefix tells apart tenants sharing
the hub), tokens as the current protocol wants them (RFC 9068 at+jwt, the
authorization_details claim, iss/aud of the hub's issuer and
resource_identifier; the older mercure claim is there too). The widget sends the
token in the Authorization header (fetch stream - the hub refuses tokens in the URL), so
put the hub on the same origin as the app (e.g. /.well-known/mercure proxied to it).
new MercurePushPublisher( publishUrl: 'http://hub:8080/.well-known/mercure', // as the worker reaches it subscribeUrl: '/.well-known/mercure', // as the browser reaches it publisherKey: $publisherKey, subscriberKey: $subscriberKey, topicPrefix: 'myapp/' . $tenant, issuer: 'https://myapp.example', audience: 'https://myapp.example/.well-known/mercure', );
2. The entities
Conversation, message and agent mapping are your Doctrine entities; the traits carry the columns incl. their anonymization attributes, your side adds the ids and the relations:
#[ORM\Entity] class AiChatConversation implements ADT\AiChat\Entity\ConversationInterface { use ADT\AiChat\Entity\ConversationTrait; #[ORM\ManyToOne(targetEntity: User::class)] public User $user; // the owner, named by ChatOptions::$ownerField ... } #[ORM\Entity] class AiChatMessage implements ADT\AiChat\Entity\MessageInterface { use ADT\AiChat\Entity\MessageTrait; #[ORM\ManyToOne(targetEntity: AiChatConversation::class)] protected ConversationInterface $conversation; // ChatOptions::$conversationField ... } #[ORM\Entity] class AiChatAgent implements ADT\AiChat\Entity\AgentInterface { use ADT\AiChat\Entity\AgentTrait; ... }
ChatService creates conversations and messages with new $class(), so the
constructors must not require arguments.
MessageTrait carries the model and effort the message was sent or answered
with, ConversationTrait the user's
rating (1 / -1), rating_comment (anonymized as free text) and rated.
3. The chat service and the queue
$chat = new ChatService($em, $client, $toolHandler, $queryExecutor, $provisioner, new BackgroundQueueMessageDispatcher($backgroundQueue), // or your MessageDispatcher $piiFilter, $piiValueCollector, new ChatOptions(AiChatConversation::class, AiChatMessage::class, ownerField: 'user'), );
- web request:
enqueueMessage($conversation, $text, $uploads)- empty-message and rate-limit checks (rateLimitPerMinute/rateLimitPerHouracross all conversations of the owner), attachments to the Files API, the user message stored, the title set, the message dispatched - worker:
processQueuedMessage($conversationId, $text, $attachments)- runs the turn, redacts the reply and stores it; failures are stored as an error reply (no exception escapes back to the queue, a retry would duplicate the message)
With adt/background-queue register the callback
aiChatProcessMessage: [@ADT\AiChat\ChatService, processQueuedMessage].
Run the turn as the user who asked. The queue worker has nobody signed in, so
project tools and permission-scoped queries would see nothing. setUserScope() gets
the author of the turn - the sender of the latest user message (senderField),
otherwise the owner (getTurnAuthor()) - and the turn to run; sign them in for its
duration only, the worker serves other users next:
$chat->setUserScope(function (?User $user, callable $turn) use ($securityUser): void { $user !== null ? $securityUser->runAs($user, $turn) : $turn(); // stateless, in memory });
A scope that throws before the turn runs stores an error reply, like a failed turn.
Everything taking an owner scopes the data to it - a foreign conversation reads as
"not found". Who may see whose conversations is your decision
(findConversation() skips the check for privileged views).
4. The HTTP side and the widget
ChatEndpoint returns the JSON payloads of the widget actions (conversations,
load, send, poll, cancel, rate, delete) and the streamed CSV of the export tool
(export). Failures come back as {success: false, error}. Only displayable tool
results reach the browser - SQL, schema and table names stay on the server.
The widget is a vanilla ES module (npm package adt-ai-chat in this repository):
import { mountAll } from 'adt-ai-chat'; import 'adt-ai-chat/ai-chat.css'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; import Chart from 'chart.js/auto'; mountAll(document, { marked, DOMPurify, Chart });
<div data-ai-chat='{"urls": {"conversations": "...", "load": "...", "send": "...", "poll": "...", "cancel": "...", "delete": "...", "export": "..."}, "mode": "full"}'></div>
mode: "single" renders one conversation without the list (a side panel); without
conversationId the first message starts a new conversation. Other options:
readOnly, canManage, urlParam (see Shared conversations), locale, accept/maxAttachments/maxAttachmentBytes (take them
from AttachmentPolicy), modelSelection (ModelSelection::toClient()), labels
(all texts), icons (Font Awesome classes by default), pollIntervalMs (poll
interval for the first 15 s of a turn, default 250 ms; then 2 s, 5 s and 10 s),
fitViewport, params (extra parameters sent with every request and export
link, e.g. {"userId": 5} for an administrator viewing someone's conversations;
AiChat.get(root).setParams({...}) changes them and reloads the list). The root
dispatches ai-chat:conversation, ai-chat:deleted, ai-chat:navigate and ai-chat:reply
({message, element} - a fresh reply with its rendered element, e.g. to read it aloud)
events. No inline styles or scripts -
works under a strict CSP; colours are CSS variables (--ai-chat-*).
5. Provisioning the agent
Nothing to do in the deploy: AgentProvisioner::resolver() runs at the start of
every turn, creates the agent and environment when the schema has none, and updates
the same agent (new version, same id) when the system prompt or the tool
definitions changed. A session keeps the agent version it was created with, so the
conversation remembers it (ConversationTrait::$sessionAgent, the fingerprint): a
conversation whose session runs on an older agent starts a new session with the
transcript on its next message - new tools reach running conversations too. Changes are detected by a fingerprint stored on the mapping
(AgentTrait::$fingerprint), so an unchanged agent costs no API call. The deploy
thus needs no API key, and a broken key of one tenant fails only that tenant's
chat.
Provisioning on demand - e.g. from a console command, or to validate a newly entered API key - updates the agent even when it looks current:
[$mapping, $created] = $provisioner->ensureProvisioned($schemaName);
Upgrading from 0.6: the trait adds a nullable fingerprint column (migration in
your project); existing agents have none and are updated once by the next session.
Running a turn without ChatService
// What ChatService::processQueuedMessage() does for you: $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 that surface as TurnTimeoutException - which
is never retried, so a message cannot be duplicated:
AiChatOptions::$idleTimeoutSeconds- no new event for that long: the runner asks for the session status and goes on while it is still running (a long query, a thinking model), otherwise it interrupts the session and gives up;$maxTurnSeconds- a hard cap of the whole turn;$maxToolCalls- per thread (the main agent and every subagent).
Pass a TurnMonitor as the last argument of run() to stop a turn
(isCancelled(): the runner sends user.interrupt and returns what the agent wrote
as TurnResult::$interrupted) and to receive its progress (onProgress(): tool
calls and subagents). ChatService uses ConversationTurnMonitor, which reads
turnCancelled and writes turnActivity/turnProgress of the conversation.
Stopping a reply and subagents
ChatEndpoint::cancel() (the widget's urls.cancel) marks the running turn as
cancelled; the worker interrupts the agent and stores what it has as a message with
interrupted = true - an ordinary reply, the conversation goes on in the same
session. The poll response carries the turn state (turn: activity, subagents with
their status and number of queries); the widget restarts its time limit whenever the
activity changes, so it only gives up on a worker that went silent.
AgentProvisioner(..., subagents: true) makes the agent a coordinator that may
delegate independent parts of a question to copies of itself
(multiagent: {type: "coordinator", agents: [{type: "self"}]}); each copy has the
same tools and its own context window. Subagent calls are executed by the same tool
handler. The reply then holds a subagent entry per delegated thread (task and
report, PII-filtered like the reply); the widget shows it as a collapsed card with
the charts and tables of that thread. Subagents cost more tokens - enable them per
tenant or deployment where the questions are broad enough.
Model selection
ChatOptions::$modelSelection lists the models (and thinking effort levels) the
user may pick for a conversation:
new ChatOptions(AiChatConversation::class, AiChatMessage::class, modelSelection: new ModelSelection( models: [ 'claude-sonnet-5-5' => ['label' => 'Claude Sonnet 5.5', 'contextWindowTokens' => 1000000], 'claude-opus-5-5' => ['label' => 'Claude Opus 5.5', 'contextWindowTokens' => 1000000], ], efforts: ['medium', 'high', 'max'], // [] = send none, the model default defaultModel: 'claude-sonnet-5-5', // null = the first model defaultEffort: 'high', ), );
The widget shows a model and an effort select (pass modelSelection from
ModelSelection::toClient() in its config) and sends them with every message;
ChatEndpoint::send($owner, $id, $text, $uploads, $model, $effort) validates them
and stores them on the message. The conversation runs on the choice of its latest
message (ChatService::getModelChoice()), it keeps none of its own. The session starts with
agent: {type: "agent_with_overrides", id, model: {id, effort}}, so one agent
serves every model. A session cannot switch models, so switching within a
conversation drops its session: the next turn starts a new one and sends the earlier
transcript ahead of the message (ChatService::buildHistory() - the texts, the SQL
and the rendered tables and charts, the newest ChatOptions::$historyMaxChars).
The same happens when an expired session is replaced. AI replies show the model
that wrote them. The model select shows the context window next to the label
(Claude Opus 5.5 (1M)) and the context gauge follows the selected model: its window,
and once the model or effort differs from the latest message's, the estimated size of
the new session (contextUsage.historyTokens - the first reply's input plus the
transcript) with the contextSwitch label.
Without models (the default) the selection is off, messages keep a null model and run on the model of the agent. Only offer models that support the listed effort levels (e.g. not Haiku 4.5, which takes none).
Model router. Pass a ModelRouter to ChatService (modelRouter:, autowired in DI)
to pick the model of a new conversation from its first message - a quick command to a
small fast model, an analysis to a large one. It is asked only for the first message and
only when the user did not pick a model; later messages keep it (a switch would start a
new session). It runs in the web request, keep it fast (rules, a local classifier); an
unknown id or null means the default. ModelSelection(selectable: false) hides the
selects in the widget, so the router (or the default) decides alone.
Conversation router. A message the user did not aim at a conversation himself (a
dictated command typed into whatever conversation was open last) may start a new one
instead. Pass a ConversationRouter to ChatService (conversationRouter:, autowired
in DI) and call ChatEndpoint::send(..., routeConversation: true) for such messages; the
router gets the text, the time of the last message and the last AI reply (when the reply
is the last message - an answer to its question belongs to the conversation) and says
whether it continues the conversation. A new conversation is cheaper and safer: every turn sends the whole
conversation again and the prompt cache lasts only minutes, so continue only a clear
follow-up shortly after the last message. The widget learns the conversation from the
response and shows the new one. The page marks such a message in the ai-chat:before-send
event (e.detail.body.append('routeConversation', '1')), the server passes the field on.
Without a router the flag does nothing; someone else's conversation is never left.
Rating
With urls.rate in the widget config the owner can rate a conversation that has a
reply - thumbs up / down plus an optional comment; a second click on the active
thumb takes the rating back. A read-only view shows the rating and the comment, the
conversation list marks rated conversations. On the server:
// POST conversationId, rating ('1', '-1' or '' to clear), comment $rating = $request->getPost('rating'); $response = $endpoint->rate($owner, $conversationId, $rating !== '' && $rating !== null ? (int) $rating : null, $request->getPost('comment'));
Only the owner rates - pass the signed-in user, never the owner of a read-only view.
ChatOptions::$ratingCommentMaxLength (2000) cuts longer comments.
Shared conversations and links
With ChatOptions::$senderField other users may write into a conversation - a
colleague continuing your analysis. Map the field on the message entity (nullable,
it stays empty for AI replies):
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] protected ?User $sender = null;
$endpoint->setSenderFormatter(fn(User $user): string => $user->getName()); // the owner's conversation, written by the signed-in colleague $endpoint->send($owner, $id, $text, $uploads, $model, $effort, sender: $me); // readOnly false = may write, canManage false = may not rate or delete $endpoint->conversations($owner, false, $owner === $me); $endpoint->load($owner, $id, false, $owner === $me, viewer: $me); $endpoint->poll($owner, $id, $afterId, viewer: $me);
User messages then carry sender: {name, self, owner} and the widget shows the
name above every message the owner of the conversation did not write, and above
the owner's messages for the other readers. The rate limit counts the messages per sender. Who may write into
whose conversation is your decision, as with reading.
A new message is refused (aiChat.error.turnPending) while the previous one of
the conversation waits for its reply - two turns must not run in one session at
once. ChatOptions::$pendingTurnSeconds (900) releases it after a lost turn.
Widget config urlParam: "conversation" keeps the open conversation in that query
parameter of the page URL, so the address can be sent to someone; a page opened
with it loads the conversation. Whose conversations to list for such a link (the
owner's, when the viewer may see them) is up to the page - e.g. pass the owner in
params.
Attachments
Users can attach files (images, PDFs, text/CSV) to a message. ChatService checks
them against AttachmentPolicy (count, size, media types), uploads them to the
Files API in the web request and passes only the file ids through the queue; the
runner sends them as image/document content blocks of the same user.message.
Without ChatService:
$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. Other text formats
(CSV, Markdown, logs) go as text/plain - the filename keeps the extension.
Project tools and downloads
Beyond the built-in tools write your own Tool\Tool (name, definition, handle())
and put them together with the ToolHandler:
$tools = new CompositeToolExecutor($toolHandler, [new MonthlyReportTool(...)]); // to ChatService and AgentProvisioner instead of $toolHandler (the agent is updated // on the next session) and/or to the McpServer
A tool that gives the user a file implements Tool\ExportTool: handle() returns
{type: export, token, filename, title?} (plus what the model should see) and the
widget renders a download button from it. The file is made only when the user clicks:
ChatEndpoint::export() finds the stored call by the token and calls
export($input, $result) of the tool - in the web request of whoever downloads, so
check their permission there - and returns a Http\Download (FileDownload for a
file on disk, CsvExport for export_csv). Send its headers and stream it:
$download = $endpoint->export($owner, $conversationId, $messageId, $token) ?? throw new NotFound; foreach ($download->getHeaders() as $name => $value) { header("$name: $value"); } $download->stream();
The button label is labels.download (the client-side CSV of a table keeps
labels.exportCsv).
A tool that opens a page of your application (a record the user asked for)
returns {type: navigate, url, title?} - url a path on your site (/clients/12;
anything else never reaches the browser), title what the button says, e.g. the
subject's anchor [CLIENT #12] (the AI sees the anchor, the user its label). As soon
as the tool of the main agent ran (the poll response carries it in turn.navigate,
the closing sentence of the agent comes later) the widget dispatches a cancelable
ai-chat:navigate {url, title, auto: true}, once per page and turn; a page that can show the URL without leaving the chat (load it behind
a chat panel) calls preventDefault(). Clicks on links to your site in replies and
on the button (labels.open, icon open) dispatch the same event with auto: false; unhandled, they open a new tab and an unhandled auto navigation does nothing.
Page context
The widget posts the page the user is on (page: path and query) with every message.
Turn it into a note for the AI and pass it as ChatEndpoint::send(..., context: $note),
e.g. [Note: the user has the card of [CLIENT #12] open - "him" means this client.] - the
AI then knows which record "this client" is. The context goes to the AI after the
(pseudonymized) message, only in that turn: the transcript, the title and the history of
a new session keep the message alone; sentContent of the reply shows it. page comes
from the browser - check access before you describe the record, and use subject anchors
rather than names (the context is not pseudonymized).
MCP server
Mcp\McpServer publishes the data tools (get_database_schema, execute_sql) over
the Model Context Protocol, so other agents -
Claude Code, Claude Desktop, a claude.ai connector - can query the same anonymized
data the chat uses. Transport is Streamable HTTP, stateless: every JSON-RPC
message is a POST answered by one application/json response (no SSE, no session).
Supported protocol versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05.
$server = new McpServer( $toolHandler, // the same ToolExecutor the chat uses new PiiResponseFilter(), // applied to every string of every tool result serverName: 'my-app', serverVersion: '1.0.0', instructions: McpServer::DEFAULT_INSTRUCTIONS, // shown to the client model logger: $auditLogger, // one info record per tool call ); // in your controller, AFTER authenticating the caller: $response = $server->handle($request->getMethod(), $request->getBody(), ['user' => $userId]); // -> $response->status, $response->headers, $response->body (null = no body)
Personal tokens. McpEndpoint does the whole endpoint - HTTP method, bearer
token, then the server run as the token owner:
// your token entity: implements Mcp\McpToken, uses Mcp\McpTokenTrait (name, token_hash, // expires_at, last_used_at) + its own id, owner association and creation time $endpoint = new McpEndpoint($server, new McpTokenAuthenticator($em, McpTokenEntity::class, $policy)); // in the controller (no session, no CSRF, no sign-in redirect): $response = $endpoint->handle($request->getMethod(), $request->getHeader('Authorization'), $request->getBody());
McpTokenPolicy is yours: canSignIn($owner) (the same rules as a sign-in; false =
401), isAllowed($owner) (the permission for external AI; false = 403) and
runAs($owner, $handle) - sign the owner in statelessly when project tools need it,
otherwise just return $handle(). Tokens are made by McpTokenGenerator::generate()
and stored as McpTokenGenerator::hash() (SHA-256 hex); the readable token is shown
once. lastUsedAt is written at most once a minute. Your own API for external AI can
reuse the same tokens: McpTokenAuthenticator::authenticate($authorizationHeader)
returns the token or throws McpTokenException (status 401/403, reason for your
own message).
Without the token classes, authentication and authorization stay in your application:
check the bearer token and the user's permission first and answer
McpResponse::unauthorized() / McpResponse::forbidden() otherwise. The tools are read-only over the read-only
account, results are row-capped like in the chat, and chat-only tools (charts,
tables, CSV export) are never exposed.
Files over MCP. A tool result is text, so an MCP tool gives a file as a
short-lived link: Mcp\DownloadStore keeps the file under a random token (only its
hash on disk, valid an hour by default) and you route e.g. mcp/download/<token>
(DownloadStore::TOKEN_PATTERN) to find(). The link needs no Authorization header -
the client downloads it with curl -o file.xlsx "<url>". Tokens in URLs are left
alone by PiiResponseFilter.
Connect a client:
claude mcp add --transport http my-app https://example.com/mcp --header "Authorization: Bearer <token>"
adt/fancyadmin ships the integration (per-user tokens, endpoint presenter, ACL).
Translations
User-facing errors are AiChatExceptions with a translation key; pass a
translator to ChatService::setTranslator(fn(string $key, array $params, string $default): string). Without it the English defaults are used. Keys:
| Key | Params |
|---|---|
aiChat.error.emptyMessage |
|
aiChat.error.rateLimitMinute, aiChat.error.rateLimitHour |
limit |
aiChat.error.tooManyAttachments |
max |
aiChat.error.attachmentTooLarge |
name, max (MB) |
aiChat.error.attachmentType, aiChat.error.attachmentUploadFailed |
name |
aiChat.error.serviceUnavailable |
detail |
aiChat.error.turnTimeout, aiChat.error.tooManyToolCalls |
|
aiChat.error.conversationNotFound, aiChat.error.invalidExport |
|
aiChat.error.piiCheckUnavailable |
|
aiChat.error.unknownModel |
model |
aiChat.error.unknownEffort |
effort |
aiChat.error.invalidRating |
|
aiChat.error.turnPending |
|
aiChat.error.unexpected |
|
aiChat.export.empty, aiChat.export.failed |
The widget texts are passed in its labels config (incl. sentContent, the
summary of the "what the AI received" block).
Upgrading to 1.11.1
With a connected push the widget no longer polls on its own (1.11.0 polled every 2 s as a fallback).
Upgrading to 1.11
Nothing breaks. New: push to the widget - Push\ChatPushPublisher (optional last
argument pushPublisher of ChatService), Push\MercurePushPublisher, push in the
load/send/poll responses (Push to the widget).
Upgrading to 1.10
Nothing breaks. The worker reads the live event stream of the session instead of
polling (Event stream) when the client implements the new
Client\SessionEventStreamer - GuzzleManagedAgentsClient does. A custom
ManagedAgentsClient keeps polling until it implements it.
Upgrading to 1.9
ConversationRouter::continues() gets a third argument ?string $lastReply - the last
AI reply, so an answer to its question stays in the conversation. Add it to your router
(1.8 with the router was out for a few hours only).
Upgrading to 1.8
Nothing breaks. New: ConversationRouter (optional last argument conversationRouter
of ChatService), ChatEndpoint::send(..., routeConversation:),
ChatService::continuesConversation() and the widget event ai-chat:before-send
(Model selection).
Upgrading to 1.7
Nothing breaks. New: ModelRouter (optional last argument modelRouter of
ChatService) and ModelSelection(selectable: false) / ModelSelection::has()
(Model selection).
Upgrading to 1.6
MessageDispatcher::dispatch()has a fourth parameter?string $context = null- a custom dispatcher has to add it and hand it over toprocessQueuedMessage(), which takes it as the fourth argument too.BackgroundQueueMessageDispatcherputscontextinto the job only when there is one, so a callback without the parameter keeps working until the project passes a context.- New:
ChatEndpoint::send(..., context:)and thepagefield the widget posts with every message (Page context). - New: the widget event
ai-chat:reply{message, element}for a fresh reply. Pseudonymizer::restore(links: true)replaces an anchor the AI linked itself ([[CLIENT #3]](/clients/3)) as a whole - before, the nested link showed as raw markdown. The URL of the AI is dropped, the resolver's one is used.
Upgrading to 1.5
Nothing breaks. A page the agent opens ({type: navigate}) is opened while the turn
still runs - from its progress (turnProgress.navigate, turn.navigate of the poll
response), not after the reply. The widget polls every 250 ms for the first 15 s of
a turn (pollIntervalMs, before 2 s), then 2 s up to a minute, 5 s up to three, then
10 s. A project that set pollIntervalMs sets only the first phase now. A custom
TurnMonitor gets navigate in the progress array.
Upgrading to 1.4
-
ConversationInterfacehasgetSessionAgent()/setSessionAgent()- the trait implements them and maps a new column, so every project needs a migration:ALTER TABLE ai_conversation ADD session_agent VARCHAR(64) DEFAULT NULL;
Implementations without
ConversationTraithave to add the methods. -
The agent resolver runs at the start of every turn, not only for a new session;
AgentResolutionhas an optionalfingerprint. A custom resolver without it keeps reusing sessions as before. -
Existing conversations have no
session_agentyet: their next message starts a new session with the transcript (once), on the current agent.
Upgrading to 1.3
Nothing breaks. New: tool results {type: navigate} and the event ai-chat:navigate
(Project tools and downloads), resolver interfaces
PiiSubjectCandidates and PiiSubjectLinker and the candidatesNote argument of
Pseudonymizer (Input pseudonymization). Projects with a
custom labels / icons config may add open.
Upgrading to 1.2 (input pseudonymization)
-
MessageInterfacehasgetSentContent()/setSentContent()- the trait implements them and maps a new column, so every project needs a migration:ALTER TABLE ai_message ADD sent_content LONGTEXT DEFAULT NULL;
Implementations without
MessageTraithave to add the methods. -
ChatEndpoint::clientMessage()returnssent_content; the widget renders it (labelsentContent, iconsentContent, styles.ai-chat__sent*). -
Nothing else changes until you pass a
PseudonymizertoChatService- see Input pseudonymization for its table.
Upgrading to 1.1
Nothing breaks. New MCP token classes (McpToken, McpTokenTrait, McpTokenGenerator,
McpTokenPolicy, McpTokenAuthenticator, McpEndpoint) - see MCP server. The trait maps
the columns adt/fancyadmin already has (name, token_hash, expires_at,
last_used_at), so switching needs no migration there.
Upgrading to 1.0
ChatEndpoint::export()returnsHttp\Download(wasCsvExport, which now implements it) -getHeaders()andstream()work as before. It serves the files of projectTool\ExportTools too, not only export_csv.ChatService::findExport()returns{tool, input, result}of any tool whose result is{type: export}(was{sql, filename, title}of export_csv).- New
Tool\Tool,Tool\ExportTool,Tool\CompositeToolExecutor- drop your own composite executors and export lookups. - New
ChatService::setUserScope()/getTurnAuthor()- drop worker wrappers that only sign the user in aroundprocessQueuedMessage(). PiiResponseFilterno longer redacts secrets inside URLs (e-mails, accounts and phones in them still are).- Widget: server-side downloads are labelled
labels.download(default "Download");labels.exportCsvstays for the CSV of a table. - New
Mcp\DownloadStorefor files of MCP tools.
Upgrading to 0.11
ManagedAgentsClient:createAgent()/updateAgent()take?array $multiagent(updateAgent()always sends it - null clears it), newretrieveSession(),listSessionEvents($sessionId, $limit, $page, $order). Own implementations must follow;GuzzleManagedAgentsClientdoes.AiChatOptions:maxPollsis gone - the turn ends on inactivity (idleTimeoutSeconds, default 300) or the cap (maxTurnSeconds, default 3600);interruptGraceSecondsis how long a stopped turn waits for the session to settle. The constructor is(pollIntervalMicroseconds, idleTimeoutSeconds, maxToolCalls, systemPromptMaxLength, maxTurnSeconds, interruptGraceSeconds)- update positional arguments.- New columns (migration in your project): conversation
turn_activity,turn_progress,turn_cancelled, messageinterrupted(bool, default false).turn_progressholds subagent tasks - it is marked for anonymization. ChatOptions::$pendingTurnSecondscounts from the last activity of the worker, not from the message - keep it aboveidleTimeoutSeconds.- New endpoint action
cancel(urls.cancelof the widget, labelsstop,stopping,interrupted,subagent,subagentStatus,toolCalls). - Long sessions: events are read newest first with paging, so turns beyond the first 1000 events of a session are no longer missed.
Upgrading to 0.10
- The conversation no longer stores
modelandeffort: the model of a conversation is the one of its latest message (ChatService::getModelChoice()). Drop both columns from the conversation table. Messages with a null model ran on the agent model - fill them, or the first message with a model selection starts a new session:
UPDATE ai_message SET model = 'claude-sonnet-4-6', effort = 'high' WHERE model IS NULL;
(your agent model and table name). ConversationInterface lost
get/setModel() and get/setEffort().
Upgrading to 0.9
- A message cannot be sent while the previous one of the conversation still waits
for its reply (
aiChat.error.turnPending,ChatOptions::$pendingTurnSeconds). - Optional
ChatOptions::$senderField(a new nullable column on your message entity) for conversations several users write into;ChatService::enqueueMessage()andChatEndpoint::send()take a$sender,load()/poll()a$viewer,conversations()/load()a$canManageflag. Messages carrysender(null without the field). - The model and effort can be switched within a conversation (see Model
selection).
MessageInterfacehas newget/setModel()andget/setEffort()(the trait implements them) - generate a migration for the new nullable columnsmodelandeffortof the message (fill existing rows from their conversation).ChatService::isModelLocked()is gone;AgentTurnRunner::run()takes an optional$historycallback. Messages carrymodelandeffort. render_table/render_chartarguments the model sends as JSON-encoded strings are decoded (ToolHandler::normalizeRenderResult()), stored ones too.- Widget:
canManage(rating and deleting, default not read-only),urlParam, the.ai-chat__senderand.ai-chat__model-taglabels; polling stops at the reply, not at the first new message. Install it from the same tag (yarn add adt-ai-chat@github:AppsDevTeam/ai-chat#v0.9.0).
Upgrading to 0.8
ConversationInterfacehas newget/setModel(),get/setEffort(),get/setRating(),get/setRatingComment()andget/setRated()(the trait implements them) - generate a migration for the new nullable columnsmodel,effort,rating,rating_commentandrated.ManagedAgentsClient::createSession()takes a fourth?array $model(the session override); custom implementations must accept it.- New widget config
modelSelectionand URLrate, new labels and icons - Widget config
paramsandAiChat::setParams()for extra request parameters; install the widget from the same tag (yarn add adt-ai-chat@github:AppsDevTeam/ai-chat#v0.8.0) (rateUp,rateDown) - both features stay off without them.
Upgrading to 0.4
ConversationInterfacehas a newsetCreated()(the trait implements it).AiChatExceptioncarries an optional translation key -TurnTimeoutExceptions now have one, so translate them viadescribeError().- The example
GuzzleManagedAgentsClient,ChatService, dispatcher and UI moved into the package (Client\,ChatService,Dispatch\,Http\,assets/); the exampleMessageentity is replaced byMessageInterface+MessageTrait(adds anattachmentscolumn).
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) |
More tools? Add Tool\Tools with Tool\CompositeToolExecutor (see Project tools and downloads); a completely different 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.
Input pseudonymization
The anonymized views protect the data in the database, not what the user types:
"Text Jane Doe, born 1.2.1980, phone 777 123 456". With a Pseudonymization\Pseudonymizer
the text goes to the AI with anchors instead of personal data:
- data that belongs to exactly one data subject (patient, client ...) →
[SUBJECT #123], the subject's id - not personal data, the AI uses it in SQL and its tools right away, - anything else →
[PERSON_1],[EMAIL_1],[ADDRESS_1]...; the value is stored with the conversation (your pseudonym entity), the same value keeps its anchor.
The stored reply gets the real values back (restore(), after the
PiiResponseFilter - otherwise the filter would redact the restored e-mails and
phones), the AI only ever has the anchors. The reply also remembers the question
as the AI received it (sent_content, without the note for the AI); the widget
shows it collapsed above the reply.
Data is found by:
- patterns (
Pseudonymizer::DEFAULT_PATTERNS, CZ/SK flavoured: birth numbers, e-mails, phones, bank accounts, IBAN) - work without anything else; birth numbers and IBANs must pass their checksum and a phone is not an amount (125 000 000 Kč), so order numbers and sums stay readable, - a
PiiDetector-OpenAiCompatiblePiiDetectorasks an OpenAI-compatible chat completions API (a qwen or llama on your infrastructure, the text must not leave it) for names in any form, addresses, birth dates, document numbers ..., - your
PiiSubjectResolver- which subject a finding belongs to (by its e-mail, birth number, name ...), what stays readable (names of employees) and findings from your own data (names matched against your subjects).
Only whole spans are replaced: "Jan" does not touch "Janovice".
#[ORM\Entity] #[ORM\UniqueConstraint(name: 'uniq_ai_pseudonym_anchor', columns: ['conversation_id', 'anchor'])] class AiPseudonym implements PseudonymInterface { use PseudonymTrait; // anchor VARCHAR(40), value TEXT #[ORM\ManyToOne(targetEntity: AiChatConversation::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] protected ConversationInterface $conversation; ... } final class ClientResolver implements PiiSubjectResolver { public function resolve(PiiFinding $finding): int|string|null { $ids = match ($finding->kind) { PiiFinding::EMAIL => $this->clients->idsByEmail($finding->text), PiiFinding::NAME => $this->clients->idsByName($finding->firstName, $finding->lastName), default => [], }; return count($ids) === 1 ? $ids[0] : null; } public function isReadable(PiiFinding $finding): bool { return $this->staff->has($finding); } public function findInText(string $text): array { return []; } public function label(int|string $subject): ?string { return $this->clients->get($subject)?->getFullName(); } } $pseudonymizer = new Pseudonymizer( $em, AiPseudonym::class, new OpenAiCompatiblePiiDetector($url, 'qwen3', apiKey: $key, required: true, logger: $logger), new ClientResolver(...), subjectAnchor: '[CLIENT #%s]', // labels: [PiiFinding::NAME => 'PERSON', ...], note: '...', patterns: [...] ); $chat = new ChatService(..., logger: $logger, pseudonymizer: $pseudonymizer, piiCheckListener: $audit);
Ambiguous names and links. A resolver may also implement:
PiiSubjectCandidates::candidates($finding)- the few subjects a finding without a subject may be (two clients of the same name). The text for the AI gets a line per such anchor (candidatesNote, defaultDEFAULT_CANDIDATES_NOTE): "[PERSON_1] may be one of the subjects [CLIENT #12], [CLIENT #45] - ask the user which one is meant". In the reply the user sees their names.PiiSubjectLinker::url($subject)- the record of the subject. The stored reply (and reports of subagents) then show subjects as markdown links[label](<url>);restore($conversation, $text, links: true).
Required check. With required: true (the default) a detector that is not
configured, fails or returns no JSON stops the message: nothing goes to the AI, no
anchor is created and the conversation gets the error reply
aiChat.error.piiCheckUnavailable. With required: false (local development
without the model) the patterns and the resolver still apply. The log never
contains the user's text or the model's reply.
Audit. A PiiCheckListener is told about every message: checked() with the
counts of replaced spans per anchor kind (PseudonymizedText::$findings, never
the values) and whether the detector ran, unavailable() when the required check
stopped it.
Attachments are uploaded to the Files API already in the web request and are
not pseudonymized - turn them off (AttachmentPolicy(maxCount: 0)) when you
pseudonymize.
Exclude the pseudonym entity from the anonymized schema: its values must never reach the AI.
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), the chat service and endpoint over an in-memory SQLite database (ownership, rate limits, attachments, error replies, the tool-data whitelist, CSV export, pseudonymized turns), the Guzzle transport over a mock handler, plus the tool handler, prompt builder, redaction and pseudonymization - no network.