Skip to content

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:

namespace App\Admin\Controllers;

class Ai extends \Pramnos\Application\Controllers\AiController
{
}

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:

$theirs = (new Connection('openai'))->with(['api_key' => $key, 'own' => true]);

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): ?string before anything is sent (a refusal sets limited); record($context, $c, $usage, $cost) after every round that reported usage, tool rounds included. Plans, monthly tokens, credits.
  • Consent — refusal(array $context, string $provider): ?string before anything is sent, and asked again by a data rule before an answer moves (a refusal sets consent).
  • Router — context(array $context): array completes the context (the request's organisation); route(array $context, Connection $c): Connection may 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. Extend Policy\DefaultRouter, which fills in the signed-in user. An organization_id that is present and null means 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:

'llm' => ['admin_sections' => [App\Llm\PlanLimitsSection::class]],

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.

  1. 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.
  2. 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_runs and the owner is told.
  • A question tells the owner with Chat\RoutineReady, or the class chat.routine_notification names, 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, or chat.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_local showed 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 to chat/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.

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() and taskSignInUrl(): 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; AiController answers 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:

  • type is bar, hbar, line, area, pie or doughnut.
  • series holds numbers only, one per label. A pie or doughnut takes one series.
  • title, subtitle, x, y, stacked and min are 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.js draws 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; }} before pf-chat.js loads. 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.

<?php $this->insert('../partials/chat_widget'); ?>

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 owners the 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, then onopen(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:

<?php echo \Pramnos\Llm\Analyst\Analyst::headerButton(); ?>

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 refuses INTO OUTFILE, FOR UPDATE and LOCK 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_timeout on PostgreSQL, max_statement_time on MariaDB, MAX_EXECUTION_TIME on 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().