Search by

adt / ai-chat

appsdevteam

AI chat over your database: an Anthropic Managed Agents conversation engine with safe read-only SQL tools over anonymized views

Package info

github.com/AppsDevTeam/ai-chat

pkg:composer/adt/ai-chat

Statistics

Installs: 1 013

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.11.1 2026-11-08 08:53 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/rateLimitPerHour across 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 to processQueuedMessage(), which takes it as the fourth argument too. BackgroundQueueMessageDispatcher puts context into 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 the page field 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

  • ConversationInterface has getSessionAgent() / 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 ConversationTrait have to add the methods.

  • The agent resolver runs at the start of every turn, not only for a new session; AgentResolution has an optional fingerprint. A custom resolver without it keeps reusing sessions as before.

  • Existing conversations have no session_agent yet: 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)

  • MessageInterface has getSentContent() / 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 MessageTrait have to add the methods.

  • ChatEndpoint::clientMessage() returns sent_content; the widget renders it (label sentContent, icon sentContent, styles .ai-chat__sent*).

  • Nothing else changes until you pass a Pseudonymizer to ChatService - 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() returns Http\Download (was CsvExport, which now implements it) - getHeaders() and stream() work as before. It serves the files of project Tool\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 around processQueuedMessage().
  • PiiResponseFilter no 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.exportCsv stays for the CSV of a table.
  • New Mcp\DownloadStore for files of MCP tools.

Upgrading to 0.11

  • ManagedAgentsClient: createAgent()/updateAgent() take ?array $multiagent (updateAgent() always sends it - null clears it), new retrieveSession(), listSessionEvents($sessionId, $limit, $page, $order). Own implementations must follow; GuzzleManagedAgentsClient does.
  • AiChatOptions: maxPolls is gone - the turn ends on inactivity (idleTimeoutSeconds, default 300) or the cap (maxTurnSeconds, default 3600); interruptGraceSeconds is 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, message interrupted (bool, default false). turn_progress holds subagent tasks - it is marked for anonymization.
  • ChatOptions::$pendingTurnSeconds counts from the last activity of the worker, not from the message - keep it above idleTimeoutSeconds.
  • New endpoint action cancel (urls.cancel of the widget, labels stop, 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 model and effort: 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() and ChatEndpoint::send() take a $sender, load()/poll() a $viewer, conversations()/load() a $canManage flag. Messages carry sender (null without the field).
  • The model and effort can be switched within a conversation (see Model selection). MessageInterface has new get/setModel() and get/setEffort() (the trait implements them) - generate a migration for the new nullable columns model and effort of the message (fill existing rows from their conversation). ChatService::isModelLocked() is gone; AgentTurnRunner::run() takes an optional $history callback. Messages carry model and effort.
  • render_table / render_chart arguments 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__sender and .ai-chat__model-tag labels; 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

  • ConversationInterface has new get/setModel(), get/setEffort(), get/setRating(), get/setRatingComment() and get/setRated() (the trait implements them) - generate a migration for the new nullable columns model, effort, rating, rating_comment and rated.
  • ManagedAgentsClient::createSession() takes a fourth ?array $model (the session override); custom implementations must accept it.
  • New widget config modelSelection and URL rate, new labels and icons
  • Widget config params and AiChat::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

  • ConversationInterface has a new setCreated() (the trait implements it).
  • AiChatException carries an optional translation key - TurnTimeoutExceptions now have one, so translate them via describeError().
  • The example GuzzleManagedAgentsClient, ChatService, dispatcher and UI moved into the package (Client\, ChatService, Dispatch\, Http\, assets/); the example Message entity is replaced by MessageInterface + MessageTrait (adds an attachments column).

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:

  1. 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,
  2. a PiiDetector - OpenAiCompatiblePiiDetector asks 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 ...,
  3. 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, default DEFAULT_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.