Language models¶
Pramnos\Llm calls a language model, lets it use tools, logs every call with what it cost, and
gives an administrator one screen — /admin/Ai/settings — for keys, models, prices and the log. What the
framework cannot decide for an application (who pays, who agreed, where data may go) is a policy
the application supplies.
Enable the llm feature — php pramnos init --features=auth,llm for a new project; an existing
one adds 'llm' to features in app/app.php and runs migrate. It creates llm_calls, puts AI in the admin
menu and schedules llm:prune daily. A project that existed before needs the admin wrapper
src/Admin/Controllers/Ai.php, which init writes for a new one:
Asking¶
use Pramnos\Llm\Client;
use Pramnos\Llm\Connection;
$answer = (new Client(new Connection(feature: 'chat')))
->about(['purpose' => 'chat', 'organization_id' => $orgId])
->ask('You answer in one sentence.', [['role' => 'user', 'content' => $question]]);
echo $answer['ok'] ? $answer['reply'] : $answer['says'];
Both ask() and askWithTools() answer an array:
| key | |
|---|---|
ok |
whether there is a reply |
reply |
the model's words |
says |
why not, in a sentence somebody can act on, when ok is false |
usage |
the provider's usage object, summed over the rounds |
cost |
in USD; null when any round's model has no price |
calls |
the tools called, ['name' => …, 'arguments' => …], in order |
context |
the last round's prompt tokens: the conversation as the model saw it |
finish |
the provider's finish_reason |
tags |
the data tags whose results reached the model (see Data rules) |
provider |
the provider that answered — not the one it started on, if the answer moved |
limited, consent |
present when a limit or a consent policy refused |
A 200 with no words in it is a failure with a reason: the usual one is a reasoning model that
spent the whole output budget thinking (finish_reason: length).
about() sets what lands on the log row: purpose (so the log can answer what is costing
this), userid (the signed-in user when not given), organization_id, and anything else.
Providers and connections¶
Four are built in, all through the OpenAI-compatible /chat/completions: openai, deepseek,
gemini and mistral. Each keeps its own settings, so several stay configured at once:
llm_<provider>_api_key, _base_url, _model, _temperature, _max_tokens, _reasoning.
llm_provider chooses the one in use. Everything is edited on /admin/Ai/settings; keys are stored
encrypted (they need APP_KEY) and never printed back.
An application adds providers, overrides fields of the built-in ones, and names its default:
// app/app.php
'llm' => [
'default' => 'deepseek',
'providers' => [
'openai' => ['default_model' => 'gpt-4.1-mini'], // merged over the built-in
'openai_shared' => ['label' => 'OpenAI, shared project', // a provider of its own
'base_url' => 'https://api.openai.com/v1',
'token_param' => 'max_completion_tokens'],
],
],
What an entry can say: label, base_url, api_key_url, models_path, balance_path,
token_param (max_tokens or max_completion_tokens — the wrong one is a 400),
reasoning_param (thinking for DeepSeek's switch, effort with reasoning_models and
efforts for OpenAI-style), supports_temperature, cache_key_param, model_pattern (which of
/models are chat models), default_model, models (the curated list and what each is for),
requires_consent and place, and transport — a Pramnos\Llm\Transport\Transport class for a
provider that does not speak the OpenAI shape.
A Connection is one provider with its key, model and parameters. with([...]) replaces fields
for one call and returns a new connection — an organisation's own key:
own is what ownKey() reports to a limits policy: usually a call nobody else pays for.
check() asks the provider whether the key works and says what is wrong in words;
models() reads the provider's catalogue (falling back to the curated list); balance() and
hasCredit() read the balance where a provider publishes one (DeepSeek does).
Features¶
An application names its uses of a model, and each can have its own provider, model, temperature,
budget and reasoning switch, set on /admin/Ai/settings:
'llm' => [
'features' => ['chat' => 'Ask AI', 'writer' => 'The article writer'],
'follows' => ['writer' => 'chat'], // left empty, `writer` follows `chat`
],
new Connection(feature: 'writer') then applies them. A model or reasoning switch set for one
provider is not asked of another when an answer moves.
Tools¶
use Pramnos\Llm\Tools;
use Pramnos\Mcp\PublicRegistry;
$tools = Tools::fromMcp(PublicRegistry::visibleTo(['mcp']), skip: ['whoami']);
$answer = (new Client())->askWithTools($systemPrompt, $messages, $tools, rounds: 6);
A tool is a plain array — name => ['description', 'parameters' => JSON schema, 'run' => callable]
— so an application's own closures sit beside the MCP ones. run gets the decoded arguments; a
result that is not a string is JSON-encoded; what it throws is told to the model as an error
rather than ending the answer. Results are cut at 20,000 characters.
The loop: the model answers tool_calls, each is run, its result goes back, until it answers in
words. Text the model writes in a round that also calls a tool is part of the reply: the
reply is every such text, in order, then the last round's, a blank line apart. A model can write
its whole answer and call a tool once more in the same turn, and its last round is then a
postscript. The same texts are also returned apart: final is the last round's alone, and steps
the texts written beside tool calls. A chat stores final as the message and the steps in its
extra, and the bundled chat screens fold them into a closed "What it did on the way", so lines
such as "Let me check…" are neither the start of the answer, nor in its copy, nor in the history
the next question replays. The call log records that text beside the tool calls: line. The
last round forbids tools and tells the model not to claim anything no tool did —
without it a model that spent every round researching writes as if the work were done. Two more
handles for tasks that must end in a write:
$due = ['create_draft' => fn (array $calls) => !in_array('create_draft', array_column($calls, 'name'), true)];
$verify = fn (string $reply, array $calls) => str_contains($reply, 'saved') && $calls === []
? 'Nothing was saved. Call create_draft, or say it was not done.'
: null;
$client->askWithTools($prompt, $messages, $tools, 6, $due, $verify);
A due tool is forced on the round before the last when its test holds. verify reads the answer against the calls made; once, a correction is sent back with a required call and three more rounds. The rejected answer, and the text written before it, are dropped: the reply is the one written after the correction.
Policies¶
Each defaults to "allow, record nothing", and is a class named in app.php (built once per
process) or set at runtime with Llm::use():
'llm' => [
'limits' => App\Llm\PlanLimits::class, // Pramnos\Llm\Policy\Limits
'consent' => App\Llm\Consent::class, // Pramnos\Llm\Policy\Consent
'router' => App\Llm\Router::class, // Pramnos\Llm\Policy\Router
'rules' => [App\Llm\GoogleData::class], // Pramnos\Llm\Policy\DataRule
],
Limits—refusal(array $context, Connection $c): ?stringbefore anything is sent (a refusal setslimited);record($context, $c, $usage, $cost)after every round that reported usage, tool rounds included. Plans, monthly tokens, credits.Consent—refusal(array $context, string $provider): ?stringbefore anything is sent, and asked again by a data rule before an answer moves (a refusal setsconsent).Router—context(array $context): arraycompletes the context (the request's organisation);route(array $context, Connection $c): Connectionmay hand back another connection before anything is sent: an organisation's own provider or key, a cheaper project for material that may go to it. ExtendPolicy\DefaultRouter, which fills in the signed-in user. Anorganization_idthat is present andnullmeans no organisation — the analyst says it on purpose — so fill a tenant in only when the key is missing (array_key_exists, not??).
Llm::listen(callable) is told about every call after it is logged — for what an application
keeps per call beside the log, such as what an article cost to write. A listener that throws is
logged and does not end the answer.
Data rules¶
Some data may only go to some providers — figures from Google's APIs that an application's terms keep away from one provider, say. A tool marks its result with a tag, always or by what it returned:
Tools::tag($tools, 'analytics_report', 'google');
Tools::tag($tools, 'articles', 'google', fn (string $result, array $args) => ($args['article_id'] ?? 0) > 0);
and a Policy\DataRule for the tag says which connections accepts() it and offers an
alternative() (asking consent for it is the rule's job). When a tagged result comes back on a
connection that does not accept it, the answer moves to the alternative before the result is
sent anywhere — with the output budget it started on, and without the first provider's own fields
in the history. With no alternative the model is told the rule's refusalResult() in place of the
result. $answer['tags'] says which tags reached the model, so a conversation holding such data
can stay where it may go: $client->moveFor('google') moves before anything is asked, or
returns false when nothing may take it.
The call log and costs¶
llm_calls holds every call — failures especially, each with a sentence in error. The cost is
frozen at write time from the unit prices, so correcting a price does not restate history, and
it is null for a model nobody priced: a total adding noughts for it would be confidently low.
CallLog::summary() and byModel() say how many calls were not priced. llm_log = 0 stops
logging; llm:prune keeps 90 days. created_at is written and compared in UTC
(CallLog::instant(), which is Database\Instant::utc()): with its offset on PostgreSQL, and as
the bare UTC wall clock on MySQL and MariaDB, which drops an offset. Either way the windows hold
whatever zone PHP or the database session is in, and a value read back is UTC.
Prices are USD per million tokens in three buckets — cache_hit, cache_miss, output —
because a cached token can cost a fiftieth of an uncached one; an entry may add off_peak with
peak_utc hours. They are a setting (llm_prices), edited on /admin/Ai/settings and seeded from
Pricing::SEED. Costs are estimates and the screen says so; Billing\OpenAiCosts reads what
OpenAI actually billed, by project, with an Admin key, and works out the balance from the top-ups
recorded beside it (new OpenAiCosts(projects: ['llm_openai_other_project']) reports more
projects).
The administration screen¶
/admin/Ai/settings (ability admin.ai, usertype 90) is the one place these settings live: the provider
in use, each provider's key, model and parameters, a key test, the features, the prices, OpenAI
billing, and the call log with the problems on their own. An application puts its own settings
on the same screen rather than a second one beside it:
A Pramnos\Llm\Admin\Section has a key() and a title(), render()s its fields (escaped HTML,
no <form> and no button — the screen adds both, with the session's token), and save(array
$input): ?string stores what was posted, answering what is wrong or null. A section whose
render() throws shows the error in its card instead of taking the screen down. A card that only
shows something — a month's spend per plan — implements Pramnos\Llm\Admin\Card instead: key(),
title() and render(), declared in the same list, drawn with no form and no Save button.
Conversations¶
Pramnos\Llm\Chat keeps conversations: what was asked, what the model answered, the tools each
answer called and what it cost, what it remembers about each person, questions waiting in the
queue, and questions asked again on a schedule. What a conversation is about — its prompt, its
tools, what an answer must have done — is the application's, in an assistant:
// app/app.php
'llm' => ['chat' => [
'assistant' => App\Llm\Analyst::class, // Pramnos\Llm\Chat\Assistant
'memory' => ['max' => 40, 'chars' => 300],
]],
use Pramnos\Llm\Chat\Chat;
$chat = Chat::conversations(); // the configured assistant, as the person signed in
$answer = $chat->ask(null, 'How did last week go?');
if ($answer['ok']) {
$id = $answer['conversation_id']; // continue it with $chat->ask($id, '…')
} else {
echo $answer['says']; // why: scope, empty, missing, tags, consent, limit, provider
}
$chat->list(); // this person's conversations, latest first
$chat->find($id); // one, with its messages, as the assistant presents them
$chat->delete($id);
The tables are chat_conversations, chat_messages, chat_memory, chat_asks, ai_consents and
ai_routines, with the installation's table prefix (#PREFIX#chat_conversations in a query of
your own). A table that already exists under its name is left as it is, except
for what the framework needs to write to it: tags, extra and space are added, and an
organization_id that refuses a null is made to take one — a conversation outside any
organisation, the analyst's always, is written with none. The llm_tables health check says
when an application's own tables still differ.
Whose conversations¶
A Chat\Scope is one person and, for an application that has them, the organisation they act
in. Scope::current() reads the request through the router's context(); a queued or scheduled
run builds it from its row. Inside an organisation only that organisation's rows are seen, and
outside one only the rows without one — a conversation written outside an organisation never
shows inside one.
The assistant¶
Extend Chat\BaseAssistant, which answers every hook with a default:
| hook | |
|---|---|
connection(Scope) |
the connection answers go out on — the chat feature's by default |
systemPrompt(Turn), context(Turn) |
the instructions, and what the model is told about the person; what it remembers is added after |
tools(Turn) |
this answer's tools; the memory's and the schedule's are added by the framework |
due(Turn), verify(Turn) |
what an answer must end with, and what it is checked against — see askWithTools() |
writes() |
the tools that change something: a later answer is told which an earlier one called |
question(Turn, $q) |
the question as the model reads it, with what the caller passed in extra worked in |
answered(Turn, $message) |
after it is stored: attach what the answer produced, and decorate the message |
present(Scope, $messages) |
stored messages, decorated for the screen |
acting(Scope, $run) |
a queued or scheduled answer, run inside whatever the application's tools need set up |
heldRefusal($tag) |
said when a conversation holds data of a tag nowhere available may take |
routineRefusal(Scope, $adding) |
why this person may not have scheduled questions, or null — $adding true when one more becomes active (created or resumed), where a limit on active ones belongs |
timezone(Scope) |
the clock "every morning at 8" is meant on — the server's by default |
conversationUrl($id) |
where a conversation is read, for a notification |
rounds(), turns() |
tool rounds per answer (16), turns sent back (30) |
A Chat\Turn carries the scope, the conversation, whether it is a scheduled run, the messages,
the documents attached in the conversation, the caller's extra, and bag — the application's,
for the length of the turn: what question() puts in it reaches tools() and answered().
A turn sends back the last turns() turns, never starting on an answer, each earlier answer
followed by a note of the tools that wrote in it; without the note, a later answer sees no result
for a change it made and says it changed nothing. A note the model copies into its reply is taken
out. A conversation whose earlier answers carried data of a tag (tags, see
Data rules) is moved to a connection that may take it before anything is asked,
or refused with heldRefusal(). Only an answered turn is stored — question and answer together.
An attachment is a document's words: ask(…, attachment: ['name' => 'release.docx', 'text' => …]).
Pramnos\Llm\DocumentText::from($name, $bytes) reads .docx, .odt, .txt, .md and .eml
(the email's subject, sender and words, and the documents it carries); pictures() takes a
document's photos, and keepPictures($name, $bytes, $store) keeps them through
$store(string $path, string $filename): ?string — the application's media library — and lists
their addresses after the words.
Memory¶
The model is given remember, forget, search_conversations and read_conversation, and told
what it remembers in every prompt. It remembers only when asked to by the conversation — nothing
extracts memories behind the person's back — and at most memory.max entries of memory.chars
characters. Chat\Memory lists and forgets them for a screen. Searching is case-insensitive; on
MySQL it also ignores accents, as its collation does, and on PostgreSQL it does not.
Asking in the background¶
An answer with tools can take a minute. Chat\Asks::start($scope, $conversationId, $question,
$attachment, $extra) writes a waiting row and queues a chat_ask task, which the queue worker
answers without being registered — as its author, through acting() — and marks answered or
failed. The screen follows it with find($scope, $askId). Asks::onAnswered(callable) is told
each answer with the job, extra included — where else the answer should go. A listener is
registered in the process that answers, so one a provider registers is missing from a worker that
boots no providers. An ask with extra['conversations'] needs none: run() posts it back to its
conversation itself, once per ask.
Scheduled tasks: questions, reminders, reports¶
Chat\Routines keeps every scheduled AI task in ai_routines. There are three kinds:
ask, a question asked again into its owner's conversation;reminder, the owner's own words sent back as a notification with no model asked, so it costs nothing;report, asked without a conversation, with read-only tools, kept as a run and sent to its readers — see Shared reports.
$tasks = new \Pramnos\Llm\Chat\Routines(new \Pramnos\Llm\Chat\Conversations(\Pramnos\Llm\Chat\Chat::assistant()));
$tasks->create($scope, ['title' => 'Morning', 'prompt' => 'What should I know today?', 'cadence' => 'daily', 'at' => '08:00']);
$tasks->create($scope, ['kind' => 'reminder', 'title' => 'Invoice', 'prompt' => 'Send the invoice to Acme',
'cadence' => 'once', 'in_minutes' => 60, 'conversation_id' => $asked]);
$tasks->create($scope, ['title' => 'Board pack', 'prompt' => '…', 'cadence' => 'monthly_weekday', 'nth' => 2, 'day' => 2, 'at' => '09:00']);
Cadences:
| Cadence | When |
|---|---|
daily |
Every day at at |
weekly |
Every week, on day 1 (Monday) to 7 (Sunday) |
monthly |
Every month, on day 1 to 28 |
monthly_weekday |
The nth (1–4, or 5 for the last) day of the month: the second Tuesday, the last Friday |
once |
At run_at (YYYY-MM-DD HH:MM), or in_minutes from now |
Times are on the task's clock, its timezone. A run_at is read on the asker's clock, and one
that has passed is refused. A template in chat.routine_templates fills in what a request leaves
out. mine(), find(), setActive() and delete() act within a person's scope. runs($id) is
a task's history: each run's outcome, content, cost and conversation, newest first.
What the model can do. In a conversation, never in a scheduled run (a task that schedules tasks has no end), the model has four tools:
| Tool | What it does |
|---|---|
schedule_task |
A question asked again, or a reminder ("remind me in an hour about X": cadence once, in_minutes 60), linked to the conversation it was asked in, a first question's too, once that conversation exists (Routines::linkMade()). kind is required, and a call without it is refused: a model that left it out once turned a reminder into a question that cost a call |
update_scheduled_task |
Change one by id: its words, time or cadence, or pause and resume it |
list_scheduled_tasks |
This person's tasks, with when each runs next and any that stopped and why |
cancel_scheduled_task |
Delete one by id |
list_reports |
The shared reports here this person may receive, and whether they do |
subscribe_report, unsubscribe_report |
Receive a shared report by id, or stop |
schedule_task also makes a report (kind report), shared with the organisation or the
installation (owner) when the person asks and the policy lets them, and told only when there is
something new (notify relevant).
A duplicate is refused by the tool itself. A task that already does the same thing (same
kind, same words or title, same time) comes back as an error that names its id and points to
update_scheduled_task. A description asking the model not to duplicate is one it can ignore.
Routines::update($scope, $id, $changes) is the same change in code, and PUT …/routines/{id}
in the chat API takes the same fields.
How runs happen. llm:routines runs every minute. A task runs with its own space's
assistant: the analyst's (space admin) with the analyst, not with the chat assistant the tick
was built with.
- It claims what is due. One statement moves the next run on and holds the task, only if the task is still due as it was read, so a second tick or a second host claims nothing and no task runs twice.
- It runs what it claimed there and then. With
'llm' => ['chat' => ['task_queue' => true]]it queues each one instead, and a worker runs it, so a report that reads for a minute holds up no other.
After each run:
- The run is recorded in
ai_task_runsand the owner is told. - A question tells the owner with
Chat\RoutineReady, or the classchat.routine_notificationnames, constructed with the title, whether it worked, why not, and the link. - A report's reader whose subscription asks for email (
subscribe($userId, $id, email: true), or "Receive it by email too" on the scheduled page) gets the report itself by email:RoutineReady::toMail(), with its title, its words drawn from Markdown, and the link to ask about it. The notification class is given the report's words and that choice as its fifth and sixth arguments, and an application's own class written for four may ignore them. A failed run sends no email. - Every model call a scheduled run makes is marked with its task, in
llm_calls.routine_id. The analyst's spend shows the tasks' runs apart from conversations. The purpose stays the space's, because it is what routes the call. - A reminder tells the owner with
Chat\TaskReminder, orchat.reminder_notification, given the words and the link. - Both go to the bell, and as a push where the owner has a browser subscribed.
'llm' => ['chat' => ['notify_via' => ['database']]]keeps them to the bell; the list names the channels allowed (database,push). - A one-off is done once it ran.
- A notification's link that was built with no site address, as in a cron run with no
APP_URL, starts from the site's root rather than from the page the notification is opened on. - Deleting a task removes its readers and the record of its runs.
- Times read back from the database are read as UTC, which is how they are written. MySQL answers
them without an offset, so
next_run_localshowed a time shifted by PHP's zone.Routines::utc()does the reading. - Three failed runs in a row pause a task, and the owner is told why. Resuming it starts the count again.
The question a run asks carries the day on the owner's clock.
Chat\TaskPolicy, or the class chat.task_policy names, holds the rules around tasks. Every
method has a default, so an application overrides only what it has:
| Method | Default |
|---|---|
refusal($scope, $kind, $adding) |
The assistant's routineRefusal(). A reminder is asked as a run, never as adding, so it does not count against a limit on active tasks. An application that offers reminders without scheduled questions returns null for them |
scope($task) |
The maker, in the organisation and space it was made in |
timezone($task) |
The zone it was made in; a task about a site returns that site's |
stillAllowed($task) |
true. An application with organisations checks the maker is still a member: false pauses the task and tells its maker |
canManage($scope, $owner) |
user always; organization inside an organisation at usertype 80 or more; global at 90 or more |
canSee($userId, $task) |
Whether a person may read a shared report and subscribe: its maker; for an organisation's, a member() of it; then the readers its maker chose (audience), or — when none were chosen — a usertype no lower than the maker's, because a report runs with the maker's rights |
audienceUsers($names), audienceNames($ids) |
The named readers: found by username or email (name => userid), and shown back by username |
member($userId, $orgId) |
true. An application with organisations answers here |
audience($task) |
The maker, and each subscriber canSee() still allows, checked at every run |
resourceLabel($task) |
''. What a task is about as people read it ("GeekDom", not "property 42"), for the notification's title and the report's question |
resourceExists($task) |
true. False pauses a report and tells its maker, without counting a failure |
link($task, $run) |
A report's run: chat/report/{run}. Otherwise the conversation |
onRan($task, $run) |
Nothing. For a message in another chat, or for charging; what it throws is logged |
maxFailures() |
3 |
Shared reports¶
A report is a task of kind report. Its owner says who may read it:
owner |
Readers | Who may make one (default) |
|---|---|---|
user |
Its maker | Anybody |
organization |
Members of its organisation, by subscription | usertype 80 or more, inside an organisation |
global |
The whole installation, by subscription | usertype 90 or more |
$tasks->create($scope, ['kind' => 'report', 'owner' => 'organization', 'notify' => 'relevant',
'title' => 'Weekly bookings', 'prompt' => 'Bookings this week against last week, by property',
'cadence' => 'weekly', 'day' => 1, 'at' => '08:00', 'resource_type' => 'property', 'resource_id' => 42]);
$tasks->subscribe($userId, $reportId); // null, or why not
$tasks->reports($scope); // the shared reports here, with `subscribed` and `latest_run`
How a report runs. Conversations::report() asks once, as its maker, and keeps nothing as a
conversation:
- The assistant's write tools (
Assistant::writes()), the memory and the scheduling tools are left out. What the maker told the assistant about themselves is not in the prompt either, since others read the result. - The run is kept in
ai_task_runs: content, cost, the model's steps, and who was told. - The run is charged once, however many people read it.
- A failure tells the maker only. Readers get reports, not errors.
Told only when it matters. With notify relevant, the model decides through a
report_relevance tool. It is forced on the round before the last, because a decision asked for
only in words is one a model forgets. A run that decides there is nothing new tells nobody and is
still kept. A run with no decision tells everyone, so a report is never silent by omission.
Who reads it. Readers are re-checked at every run through audience(), so a member who lost
the right stops receiving it. The default canSee() is strict: a reader of lower standing than
the maker would see what the maker's rights read. An application with roles says here who may
read, such as the readers of the property the report is about.
The maker chooses. A shared report can name its readers, kept in ai_routines.audience:
everyone from a role up, people by name, or both. They are still only inside the organisation for
an organisation's report. The choice opens the report to people below the maker's standing, on
purpose: they read what the maker's rights read.
$tasks->create($scope, ['kind' => 'report', 'owner' => 'global', … ,
'audience_role' => 'admin', // a role, as app.php's `roles` sets it
'audience_users' => 'maria, nikos@example.com', // usernames or emails
]);
$tasks->create($scope, [… , 'audience' => ['min_usertype' => 90, 'users' => [7, 12]]]);
$tasks->update($scope, $id, ['audience_role' => '', 'audience_users' => '']); // back to the default
The scheduled page offers both fields beside "For" when a report is shared, and the open report's
card lets its maker change them (scheduledupdate with audience). One person's task has no
readers to choose.
Asking about a report. A report's notification links to chat/report/{run}
(ChatPageController::report(), or POST …/runs/{id}/continue). That opens the run as a
conversation of the reader's own, holding the report, through Routines::continueRun($viewer,
$runId). What they ask next runs with their own rights, not the maker's. Opening the same run
again continues the same conversation while it exists (kept per reader in the run's
extra.opened).
Personal data. Routines::forUser($userId) answers what is kept about a person: the tasks
they made, everywhere, and the reports they read.
When a member leaves. ownedBy($orgId, $userId) lists what a person has in an organisation:
their tasks, and the shared tasks they read. transfer($orgId, $from, $to, $only = null) hands
those tasks to another member:
- Each task keeps its schedule and history, but not its conversation.
- Each is checked again as the new owner's, and one they may not run stays paused, saying why.
- The leaver's subscriptions are removed.
- The new owner is told.
// before removing a member, once somebody chose who takes over
$tasks->transfer($orgId, $leaving, $successor);
Asking who takes over. handoverChoices($userId) answers what a leaving person is asked,
organisation by organisation: their shared tasks (reports their organisation or the installation
receives), their personal ones, and the successors who may take them,
TaskPolicy::successors($orgId, $leaving). By default that is the organisation's other active
members, and nobody outside an organisation. handover($userId, [$orgId => ['to' => $id,
'personal' => true]]) performs the choice: shared tasks go to the successor, the person's own
only with personal, and a successor the policy does not offer moves nothing.
The account's delete page asks this and hands over before the erase (see the Authentication Guide). An application whose member removal is its own flow does the same with two calls:
// app/Controllers/OrganizationMembers.php — removing a member who was asked who takes over
$tasks = new \Pramnos\Llm\Chat\Routines(new \Pramnos\Llm\Chat\Conversations(\Pramnos\Llm\Chat\Chat::assistant()));
$offered = $tasks->handoverChoices($leaving); // draw a choice of successor per organisation
$tasks->handover($leaving, [$orgId => ['to' => (int) $_POST['successor'], 'personal' => !empty($_POST['personal'])]]);
// then remove the membership; what was not handed over stays the leaver's, paused by
// TaskPolicy::stillAllowed() at its next run if the application checks membership there
Agreement to a provider¶
Policy\StoredConsent keeps who agreed to their data going to which provider, and checks it only
for a provider whose entry says requires_consent:
'llm' => ['consent' => \Pramnos\Llm\Policy\StoredConsent::class],
$consent = new \Pramnos\Llm\Policy\StoredConsent();
$consent->give($orgId, $userId, 'deepseek'); // $orgId null in an application without organisations
$consent->withdraw($orgId, $userId, 'deepseek');
A call for a person needs that person's agreement; a background call with no person needs
anybody's in the organisation; a call with neither is refused — unless
'consent_options' => ['allow_without_organization' => true] says calls outside an organisation
carry nobody's data.
Screens for conversations¶
Three ways to put conversations in front of people, all over the same hooks.
The JSON API¶
One line in the application's API routes mounts every endpoint:
\Pramnos\Llm\Chat\Api\Routes::register($router);
// An application's own controller, and paths of its own for some of them
\Pramnos\Llm\Chat\Api\Routes::register($router, App\Api\Chat::class, [
'routines' => '/routines',
'consent' => '/ai/consent',
]);
| path (default) | |
|---|---|
GET /chat |
this person's conversations, and the limits a screen shows |
POST /chat |
ask — content, conversation_id, attachments: [{name, data}] with the bytes in base64; 202 with ask_id, or 200 with the answer under answer_in_request |
GET /chat/{id}, DELETE /chat/{id} |
one conversation with its messages; forget it |
GET /chat/asks/{id} |
a queued question: waiting, answered with its conversation_id, or failed with says |
GET /chat/memory, DELETE /chat/memory/{id} |
what it remembers; forget one |
GET/POST /chat/routines, PUT/DELETE /chat/routines/{id}, POST /chat/routines/{id}/run |
scheduled tasks, with the templates, cadences, kinds, and the owners this person may use; active in a PUT pauses or resumes |
GET /chat/routines/{id}/runs?limit= |
a task's runs, newest first: its own, or a shared report the person may read |
GET /chat/runs/{id}, POST /chat/runs/{id}/continue |
one run with its task; open it as a conversation of the person's own (conversation_id) |
GET /chat/reports |
the shared reports here the person may receive, with subscribed and latest_run |
POST/DELETE /chat/routines/{id}/subscribe |
receive a shared report (email in the body), or stop |
GET/POST/DELETE /chat/consent?feature= |
a feature's provider (chat by default), whether it needs agreement and whether it was given; agree; withdraw |
A refusal is {status, error}; one for want of agreement adds consent_required and provider,
so a screen can ask on the spot. The provider checked — whether it is set up, whether it needs
agreement — is the one the router sends this person to (purpose chat, or the feature's name),
so an organisation on its own provider or its own key is asked about that one.
Conversations::connection() answers it. Each decision an application makes is a protected method of
Chat\Api\ChatController, overridden in a subclass:
| hook | |
|---|---|
refusal($action) |
called first by every action; a response stops it — a plan that does not include it |
beforeAsk($scope, $input) |
after the agreement check: a limit, a charge |
storePicture($path, $filename) |
keep an attached picture and answer its address; without it pictures are not kept |
extra($input) |
what travels with the question to the assistant's question() |
limits($scope) |
the limits the screen shows — add this month's use |
routinesAnswer($scope, $answer) |
the scheduled-questions list — add a plan's limit on active ones |
scope(), conversations(), asks(), routineService() |
where everything comes from |
The page, without a SPA¶
init --features=…,llm writes src/Controllers/Chat.php, extending
Chat\Web\ChatPageController, with views in each theme under views/chat/. /chat lists the
conversations, shows one, and asks. Answers are drawn by Chat\Markdown::toHtml(), which escapes
first; a table it draws carries table table-sm for Bootstrap and daisyUI and its spacing and
rules inline for any other theme, and scrolls on its own when wider than the answer. Under each, which tools it read and with what. The ⚙ holds the model, the limits, what it
remembers and the agreement; a bar shows how much of the model's context the last answer used.
With assets/js/pf-chat.js the page asks in place: Enter sends and Shift+Enter is a new line;
files are picked with 📎, dropped on the box or pasted, and held as chips (ten at most, 5 MB
together, of the kinds the server reads); the question shows at once with a thinking line —
the application's own lines after a few seconds, 'llm' => ['chat' => ['waiting_lines' =>
[…]]], and a promise after a minute — and when the answer is there the page's own HTML is
fetched and swapped in, without a reload. Every word the script says comes from the page, so it
is translated with the rest. Without JavaScript the page posts and refreshes itself until the
answer is there.
A question is a bubble on the right and an answer is text on the left, each under a line with
whose it is — "You", or the assistant's name ('chat' => ['name' => …], 'analyst' => ['name' =>
…]) — and when, in the reader's own zone: "today 22:50", or the date for another day. Under each
answer, ⧉ Copy puts its Markdown on the clipboard, not its HTML. Over each table in an answer,
⧉ Copy table puts its rows on the clipboard tab-separated, which a spreadsheet pastes as cells. On
Bootstrap the question's bubble also carries its colour inline, for an admin theme still on
Bootstrap 3, which has no bg-body-tertiary. A question asked in place is drawn
the same way at once.
The scheduled page¶
/chat/scheduled (ChatPageController::scheduled(), linked from the conversations page, with a
view in each theme under views/chat/scheduled.html.php) shows a person what is scheduled for
them, without asking the model:
- Their tasks: what each is (question, reminder, report), when it runs and next, whether it is paused and why it stopped. Each has buttons to see its runs, run it now, pause or resume, and delete it.
- The shared reports they may receive (
Routines::reports()), each with a button to receive it or stop. - One task's runs, with
?task=, newest first. A report's runs have an Ask about it link tochat/report/{run}. - A form to schedule one:
- what it is and, when the policy lets them share, for whom (
TaskPolicy::canManage()); - whether readers are told every time or only when something is new;
- a template;
- the title and the words;
- how often, at what time, the weekday or the day of the month, which weekday of the month, or a moment for a one-off.
- what it is and, when the policy lets them share, for whom (
A one-off further than ten years ahead is refused.
Its POST actions are schedule, scheduledupdate (active), scheduleddelete, scheduledrun
and scheduledsubscribe (on). Each carries the session's token and says what it did, or why
not, as a flash message.
The page is the trait Chat\Web\SchedulesTasks, so it can be served elsewhere. A controller
using it answers six hooks:
taskScope(): whose tasks;taskConversations(): the conversations they are made over;scheduledUrl($action): the page, or one of its actions;taskReportUrl($runId),taskChatUrl()andtaskSignInUrl(): where a report's run opens, the conversations, and where somebody not signed in is sent.taskDenied(), optional: true when the request stops here, already answered. Every action asks it first;AiControlleranswers with the admin floor, as its other actions do.scheduledExtras(?array $task): array, optional: markup of the controller's own —form, printed inside the form before its button;task, on the open task's card. Printed as given, so escape what goes in.afterScheduled(array $task, array $input): void, optional: called with the task just made and the whole form, the controller's own fields among it, before the redirect. Not called when the form was refused.afterAudienceChanged(array $task, array $before): void, optional: called when a report's readers were changed from its card, with the task as it is and as it was, to subscribe the people newly named, for example.static audienceRoles(): array, optional: the roles readers are chosen by,role => usertype, lowest first. An application whose roles differ from the framework's returns its own.
A flash message carrying a value is t('… %s …', $value), so a translation catches the whole
message. next_run_local is in figures (19/10/2026 09:00), which read the same in any language.
The view reads the addresses from $this->urls. AiController serves the analyst's at
/admin/Ai/scheduled, with /admin/Ai/report/{run} for its reports. A report's link and an analyst
conversation's are built the same way, by AnalystAssistant::areaUrl(): the admin area's address
where the process has one, otherwise admin/ under the site's — so a link made by the site's cron
still leads into the administration. The analyst's page links
there only where the application lets the analyst schedule, through its routineRefusal(). An
analyst that may schedule tells its model so, and counts times on the analyst's clock
(Analyst::timezone()).
Charts¶
An answer can carry a chart: a fenced block with the info string chart and one JSON object.
```chart
{"type": "bar", "title": "Sign-ups per month", "labels": ["Jan", "Feb", "Mar"],
"series": [{"name": "2025", "data": [12, 19, 7]}, {"name": "2026", "data": [15, 22, 30]}],
"y": "people", "stacked": false}
```
The fields:
typeisbar,hbar,line,area,pieordoughnut.seriesholds numbers only, one per label. A pie or doughnut takes one series.title,subtitle,x,y,stackedandminare optional.
How it is drawn:
Chat\Markdown::toHtml()turns a block that is a chart into<div data-pf-chart="…">with the code inside it. A block that is not a chart stays code.pf-chat.jsdraws it as SVG over the code: axes with round steps, a key, and each figure as a shape's tooltip.- Your own chart library: set
window.PfChat = {chart: function (element, spec) { …; return true; }}beforepf-chat.jsloads. Return false to leave a chart to the built-in drawing. - Copy and export keep the original block.
The analyst's prompt tells the model when to chart and to keep the figures as text as well —
Markdown::CHARTS, through AnalystAssistant::charts(). Return '' from it for an analyst that
never charts. A chat assistant of your own adds Markdown::CHARTS to its prompt to chart.
Example questions sit above a new conversation, and a click puts one in the box, to send or to change first:
'llm' => [
'chat' => ['suggestions' => ['Which posts did best this month?', 'Draft a reply to this email']],
'analyst' => ['suggestions' => ['Who signed up this week?', 'What failed in the queue today?']],
],
pf-chat.js is one of the framework's page scripts, which composer update does not place in
the web root: php bin/pramnos project:resync --js --all does, after an upgrade that brings a
page using a new one. Until then the page works as a plain form; the framework_scripts health
check and a warning in the log, the first time a page asks for the missing file, both name the
command. The hooks are the API's — beforeAsk(),
storePicture(), extra(), scope() — and a refusal comes back as the page's error message.
A question from anywhere on the site: include the widget in the layout. It is drawn only for somebody signed in.
The SPA components¶
A Svelte SPA with the llm feature gets components/llm/Chat.svelte, Scheduled.svelte and
AiConsent.svelte, with lib/llm/chat.js (the API client and what the screens share),
lib/llm/markdown.js (marked, then DOMPurify) and their tests under __tests__/llm/ — a
directory of their own, so they cannot land on an application's own chat screen or its tests.
An existing project takes them with php bin/pramnos project:resync --spa-components --all, which adds
marked and dompurify to package.json and leaves every component the project changed as it is
(see the Console Guide). They are the project's files from then on.
<script>
import Chat from '../components/llm/Chat.svelte';
import { go } from '../lib/router.js';
let { route = null } = $props();
</script>
<Chat
conversationId={Number(route?.query?.get('c')) || 0}
onopen={(id) => go('/chat', id ? { c: id } : null, { replace: true })}
scheduledHref="/scheduled"
paths={{ routines: '/routines', consent: '/ai/consent' }}
/>
Chat keeps no address of its own: the open conversation comes in as conversationId and goes
out through onopen(id). Everything else an application changes without editing the file:
| prop | |
|---|---|
client, or api and paths |
the API: chatClient(api, paths) from lib/llm/chat.js by default |
extra |
an object, or a function returning one, sent with each question; extraUsed() once it went |
canAsk, onRefusal(err) |
hold the Ask button; take a refusal — return true when the application said it itself |
suggestions |
questions that fill the box when pressed — they do not ask |
toolLabel(tool) |
how a tool an answer read is named under it |
t |
the translator — every string goes through it, lib/i18n.svelte.js's by default |
consentNotice(consent) (snippet) |
the agreement's wording — legal text the application owns |
question (bindable), placeholder |
what is in the box — a prompt another screen prepared — and its placeholder |
waitingLine(ms) |
a line under Thinking, asked each second of waiting; null shows nothing. By default a sentence after a minute |
onanswer(messages) |
told the conversation each time an answer has been read back — open what it produced |
formatDate, numberFormat |
how a conversation's date and the limits' figures are shown |
scheduledHref |
where Scheduled lives; without it there is no Scheduled link |
privacyUrl |
the privacy policy, linked from the consent notice; without it there is no link |
conversationHref(id) |
the conversations become links the application's router follows, and open in a new tab |
header (snippet) |
the title, in place of Ask AI |
message(message, change) (snippet) |
drawn under each answer; change(patch) updates the message and calls onmessagechange(id, patch) |
onmessagechange(id, patch) |
told when change(patch) updated a message, to persist it |
composer (snippet) |
beside the Ask button: a cost, what the question is about |
aside (snippet), asideOpen (bindable) |
a panel beside the conversation from lg, over it below that, closed with ✕ |
AiConsent wraps what needs agreement and draws nothing of it until agreement is given, when the
provider in use asks for it; its consent is bindable. kind names the feature asked about
(chat by default), notice(consent) replaces the wording and keeps the buttons.
Scheduled does for a SPA what /chat/scheduled does for a page:
- It lists, creates (from a template or by hand), pauses, runs and deletes questions, reminders and reports, with every cadence: once, daily, weekly, monthly, and a weekday of the month.
- A report offers whom it is for, from the
ownersthe API says this person may use, and when readers are told. - It shows a task's runs, and opens a report's run as a conversation of the reader's own through
continueRun, thenonopen(id). - It lists the reports shared with this person, to receive or stop.
- It opens a routine's conversation with
onopen(id).
The administrators' analyst¶
/admin/Ai — Ask, the AI item in the admin menu — answers questions about the installation
itself — how many signed up last month, which emails were opened, what failed in the queue, what
the models cost and for whom — from the installation's own tables. The settings are a link away,
at /admin/Ai/settings; an application whose settings are reached from its own menu hides the link
under Ask's heading by overriding settingsLink() to return ''. Ask still opens the settings
while no key is set. Both sit behind one gate: the ability admin.ai and the usertype in the
application's Admin\Controllers\Ai::$requiredUserType. The page is the conversation page at
the analyst's own addresses, with everything pf-chat.js does there; its waiting lines are
'llm' => ['analyst' => ['waiting_lines' => […]]].
Nothing invites a question that can only fail. Until a provider's key is set:
- Ask AI is left out of the user menu, and the widget draws nothing (Chat::available(), with
the asking person's own routing);
- the admin header has no Ask, and the side panel no Ask tab (Analyst::available());
- the admin menu has no AI item, and /admin/Ai opens the settings. The settings stay one
click away from Settings (AI: providers & keys), where the key is set.
A menu item of an application's own does the same with when:, a closure asked each time the
menu is drawn:
new NavItem('user.writer', 'Writer', $base . 'writer', NavSection::User, when: static fn (): bool => Chat::available());
An Ask link for the admin header, beside the search, drawn only for somebody who may open the page:
A new project's layouts have it; a project's own header takes that line.
As JSON¶
The same analyst answers in the conversation API's shape under /admin/Ai/api/ — chat,
chat/{id}, asks/{id}, memory, memory/{id}, consent — for a screen of the application's
own, such as the SPA's components/llm/Chat.svelte:
const client = chatClient(adminApi, {
chat: '/admin/Ai/api/chat', asks: '/admin/Ai/api/asks',
memory: '/admin/Ai/api/memory', consent: '/admin/Ai/api/consent',
});
It is the admin area, so it goes with the session's cookie; anything but GET needs the session's
token in the X-CSRF-Token header. A JSON body and a form are both read.
Kept apart¶
An administrator's analyst conversations, what it remembers and its queued questions are in the
admin space (Chat\Scope's third argument): never in their Ask AI list, nor the other way
round. They are outside any organisation, because the analyst reads the whole installation — the
call goes to the router with organization_id null, purpose analyst. The connection is the
analyst feature's when the application declares one ('llm' => ['features' => ['analyst' =>
…]]), otherwise the installation's. Agreement, when the provider asks for it, is the
administrator's own (a consent row with no organisation), asked on the page. The analyst runs no
scheduled reports.
Other people's conversations, read only¶
/admin/Ai/all lists the other administrators' Ask conversations — owner, title, last change, how
many messages and what they cost, filtered by owner or a word in the title — for whoever has
admin.ai.all: root alone in usertype mode, as admin.chat.all is (Analyst\Review::readsAll();
AiController::readsAll() is the seam to override). The Ask page links to it from the ⚙ under
its title, beside the settings — a reader with neither link sees no gear. One opens
as its owner sees it, under a banner saying whose it is: the questions, the answers, the tools each
answer used with their input, and the model calls made while it was answered — the call log's
rows, /admin/Ai/call/{id}, where what each tool returned is in the messages sent. The calls are
matched by time: those of the owner's analyst between one question and the next. The steps and
tools are folded, as on Ask's page, and pf-chat.js draws the answers' charts.
Nothing there writes: no question box, no delete, no memory, no agreement. Each view of somebody
else's conversation is recorded in the audit log as ai.conversation_viewed, the owner as its
target and the conversation as its object. Anybody else gets a 404.
The reader's own conversations are left out of the list and the owner filter
(Review::list(['except' => $id]), Review::owners($id)): they are in the Ask column already, where
they are not read only, and /admin/Ai/all?c= with one of them redirects there.
What it reads¶
Every tool only reads, and answers figures over a window — days (30 by default, at most 366)
or from/to, grouped by day, week or month. A day is a day in the analyst's zone:
'llm' => ['analyst' => ['timezone' => 'Europe/Athens']], or the application's default zone. The
prompt names it, and each answer's window says which it used.
| tool | |
|---|---|
users |
accounts by type, active and validated; sign-ups and sign-ins per period; never signed in |
sessions |
online now (last 15 minutes), people and guests, each hour of the last day |
organizations |
by type, active, created per period; the largest by active members (name and count) |
mailing_lists |
subscribers per list by status; subscriptions, confirmations and unsubscriptions per period |
email |
sent, opened (proxy-only apart), clicked and their rates, by list and period; subjects sent to at least three; the most clicked links without their query strings; and history, every message sent by status and module with the failures by module (Email\SendHistory, as the dashboard counts it) |
push |
sent, delivered and failed, by kind and status; the commonest failures |
queue |
tasks by state, waiting by kind, failures by kind with their first error line, run times |
llm_spend |
calls, tokens and cost by provider, model and purpose; scheduled tasks' runs apart, by task (llm_calls.routine_id); failures; spend per organisation (id and name) |
log-analytics, log-errors, status |
the MCP tools of the same names |
user |
one account the administrator names: type, state, dates — its email only with include_email |
audience_segments, audience_rules, audience_experiments |
segments with members and movement; rules with status, trigger and how they are doing, one with how many went through each step; A/B tests with their results — offered where their tables are, each behind its screen's ability (admin.segments, admin.rules, admin.experiments) |
audience_why |
why one account is or is not in a segment, or would or would not come into a rule — tagged personal |
audience_draft |
the one tool that writes, and only drafts: a segment comes back as its count and a link that opens the builder filled in, nothing saved; a rule is saved as a draft, which does nothing until an administrator switches it on |
Personal data¶
The provider is a third party, so nothing an aggregate answers names a person, and every
result passes through Analyst\Redactor before the model reads it: email addresses become
a***@d***.com; tokens, payment card numbers (Luhn-checked, and not run into letters, as the
digits inside an id are), phone numbers (international, or
local with separators) and IP addresses become [token], [card], [phone], [ip]. A number
without any separator is left alone — it is as likely an order number or a Unix time. A subject
sent to fewer than three recipients is left out, since a personalised one carries a name no mask
can recognise. The user tool's answer is tagged personal, so a data rule can
keep it from a provider that may not have personal data.
Its own SQL, and files to download¶
For a question no tool answers, the analyst can write SQL — when the application turns it on:
'llm' => ['analyst' => [
'sql' => true, // `schema` and `sql`: one SELECT, up to 200 rows to the model
'export' => true, // `schema` and `export_csv`: up to 100,000 rows to a CSV file
'sql_seconds' => 30, // how long a statement may run
'schema' => [ // what the catalogue cannot say
'orders' => ['about' => 'One customer order.', 'columns' => ['status' => '1 open, 2 paid, 3 refunded']],
],
]],
schema lists the tables, or one table's columns with their keys, and the application's notes,
keyed without the table prefix. sql answers rows. export_csv writes them to a file. A list of
up to export_inline rows (100 by default) also comes back whole, and the model is told to show
it as a table beside the link. A longer one gives the model only the link, the count and five
rows. An optional title names the file, in Latin letters when it was written in Greek:
7-energa-meli-2a91…, saved as energa-meli-<date>.csv. The file has a UTF-8 byte-order mark, so a
spreadsheet reads non-Latin text. It is kept seven days outside the web root, in
var/analyst-exports/, and downloads from /admin/Ai/export/<name> only for the administrator
it was made for.
The link is built in the queue worker, which has no request to take a host from and often runs the
site's application, not the area's. So the question carries the area's URL as its request saw it
(Desk::extra()), and the link is built under it: absolute, and in the area, with nothing to
configure. Without one — a question not asked through a page — it falls back on the configured
prefix under APP_URL, or from the host's root (AdminArea::address()).
A statement is guarded four times, by Analyst\ReadOnlySql:
- it must be one SELECT or WITH with no writing keyword anywhere (
Security\ReadOnlyQuery) — which also refusesINTO OUTFILE,FOR UPDATEandLOCK IN SHARE MODE; - it may not call a function that waits, locks or reads the server's files —
SLEEP,BENCHMARK,GET_LOCK,LOAD_FILE,pg_sleep,pg_read_file, advisory locks,dblink; - it runs in a read-only transaction that is rolled back, so the database refuses a write the lexer missed;
- it stops after
sql_seconds—statement_timeouton PostgreSQL,max_statement_timeon MariaDB,MAX_EXECUTION_TIMEon MySQL.
A statement without LIMIT is given one. Where the installation set database_readonly_dsn,
the statement runs through that account, as db-inspect does. Then the database's own
privileges are the boundary.
Rows go through the redaction like every tool's answer. sql and export_csv are tagged
personal, since a statement can read anybody's row.
From any administration page¶
The admin header's Ask opens the analyst in a panel beside the page, about that page. A
question carries the screen and the record it shows, and the analyst gets the page's help as
background. With the help feature on, its help tool answers from the installation's help pages. See
Administration help and the side panel.
The application's figures¶
'llm' => [
'admin_tools' => [App\Llm\PostsPublishedTool::class, new App\Llm\Credits($wallet)],
'analyst' => ['assistant' => App\Llm\Analyst::class],
],
admin_tools are Pramnos\Mcp\McpToolInterface — class names built with no arguments, or
instances — beside the framework's, redacted the same way. One that implements ScopedMcpTool
is served to MCP clients holding its scope as well (see the
MCP guide). They should only read; the framework
cannot check that. An Analyst\AnalystAssistant subclass tells the model about the business
through about(), or changes the tools through tools().
A tools() that cuts what some people may read decides by $turn->rightsOf(), not by
$turn->scope->userId. The two differ when others read the answer too — @Ask in a conversation
(see the Conversations guide) — where rightsOf() is the
conversation's least privileged reader and scope->userId is who asked and pays.
$turn->shared() says the answer is read by others. There the framework leaves out the asker's
memory and export_csv, and tells the model which of the asker's tools it may not use:
public function tools(\Pramnos\Llm\Chat\Turn $turn): array
{
$reader = ToolAccess::user($turn->rightsOf());
return array_filter(parent::tools($turn), static fn ($tool, $name): bool => ToolAccess::allows($reader, (string) $name), ARRAY_FILTER_USE_BOTH);
}
Whether it looks people up is peoplePolicy(). The framework's rule keeps to figures and opens
one account only when the administrator names it. Where finding and counting people by name is
ordinary administration, replace the rule rather than contradicting it in about(), so the model
gets one rule and not two that disagree:
protected function peoplePolicy(): string
{
return 'Finding and counting accounts by name is ordinary administration here; do it when asked.';
}
The same analyst speaks over MCP. mcpInstructions() is what the MCP endpoint sends with
initialize, and a remote assistant passes it to its model. It contains:
about();- the analyst's time zone;
peoplePolicy();- that masked values stay masked;
- which tool to reach for:
toolGuidance(), or by default "schema and sql are the last resort".
Without the tool line, a model asked about the application's visits writes SQL against the schema instead of calling the tool made for visits:
protected function toolGuidance(): string
{
return 'Visits, bookings and their counts: visits_report. Reach for sql only when no tool answers.';
}
An application that charges for the
analyst, or limits it, overrides analystDesk() on its Admin\Controllers\Ai and returns an
Analyst\Desk with its own beforeAsk().