Running tools 

Tools are small, admin-curated PHP functions the model may call mid-generation. Where a normal completion answers in one shot, a tool run is a bounded agent loop: the model may ask to call a tool, nr-llm executes it, feeds the result back, and re-asks — until the model answers or an iteration cap is reached. The v1 consumer is the interactive Tool Playground.

The Tool Playground — the only surface that runs the agent TOOL loop — is admin-only (editors run one-shot tasks and decide approvals in Web > AI Tasks, which never executes tools directly). The runtime itself applies a two-tier gate: each tool declares requiresAdmin(), and ToolLoopService drops admin-only tools when the acting backend user is not an administrator. Most built-in tools require admin because a tool runs with full TYPO3 privileges, has no per-record authorization, and its return value egresses both to the configured LLM provider and to the rendered backend output; only a few read-only, scope-limited tools are offered to non-admin users.

The built-in tools 

nr-llm ships forty-two read-only tools and sixteen writing tools. Each is a reference implementation of the security contract: model-chosen arguments are validated and scoped, volumes are capped, and secret-bearing output is either redacted or gated behind a separate _raw variant. Thirty-eight ship enabled; the three unredacted _raw variants (get_env_raw, get_php_info_raw and list_be_users_raw), fetch_external_url (see Reading external web pages) and all sixteen writing tools (update_page_metadata, set_page_social_image, set_file_alternative_text, update_fal_asset_meta, move_content_element, create_content_element_draft, create_page_draft, create_translation_draft, attach_file_to_content_element, create_record_draft, update_content_element, publish_record, delete_record, copy_record, move_page, replace_file_reference) ship disabled and must be enabled deliberately. Many require admin; the read-only structure, content and file tools (get_pagetree, get_tca, get_full_tca, get_table_schema, get_flexform_schema, fluid_resolve, search_records, get_page_content, read_records, get_record_history, resolve_url, validate_tca, list_fal_storages, browse_fal_folder, search_fal_files, get_fal_references, find_missing_files) are offered to non-admin backend users — those self-enforce the acting user's TYPO3 permissions (page-show rights, tables_select) inside the tool, so a non-admin only ever sees what the backend already grants them (see ADR-042). fetch_external_url is offered to non-admins too; it reads public web pages only, which no TYPO3 permission covers, under the controls in Reading external web pages.

The two tools below are the fullest illustrations of the contract:

fetch_logs
Returns the most recent sys_log entries, newest first, with an optional PSR level filter and a limit (default 20, hard-capped at 50). Personally-identifying fields — the client IP, the backend user id and the serialized payload — are redacted by omission, because the result egresses to the external provider.
read_fal_asset_meta
Returns read-only metadata (file name, MIME type, size, title, alternative text) for a single managed file (sys_file) by its uid. The uid is model-chosen and therefore injection-steerable, so the lookup is storage-scoped (default: the default storage). A uid in a non-permitted storage returns the same neutral "not found or not permitted" string as a missing uid — the model cannot enumerate arbitrary files.

The remaining tools follow the same pattern:

list_fal_storages
The file storages this run may touch (uid, name, driver, status flags). The effective set is the configured allow-list, intersected for non-admins with their file mounts; the server-side base path is never part of the output.
browse_fal_folder
One FAL folder: subfolders (with file count), then files with size and MIME type. Storage-relative identifiers only; anything unresolvable collapses into one neutral denial. Capped at 100 entries.
search_fal_files
Substring search over file name and metadata title/alternative within the accessible storages. %/_ in the query match literally; missing files are excluded.
get_fal_references
Where a file is used: sys_file_reference rows as `table:uid (field)`, hidden references marked. Soft references (RTE links, plain URLs) are not tracked — stated in the output so "no references" is never read as "safe to delete". Non-admins only see references from tables they may read.
find_missing_files
sys_file records whose physical file is gone (missing = 1) — the "broken image" diagnosis. The total count is always reported next to the capped listing.
get_env / get_env_raw
Process environment variables. get_env redacts secret-looking values (password, token, key, secret, salt, DSN, …); get_env_raw returns them unredacted (database password, encryption key) and ships disabled.
get_php_info / get_php_info_raw
PHP runtime configuration. get_php_info is redacted; get_php_info_raw returns the full, secret-bearing phpinfo detail and ships disabled.
get_pagetree
The backend page tree (uid, title, doktype) as a depth-indented outline; deleted and hidden pages are excluded — structure only, no content.
get_tca
The TYPO3 TCA schema: with no argument it lists the configured table names; with a table argument it returns that table's field definitions.
list_be_groups
The backend user groups (uid, title).
list_be_users / list_be_users_raw
Backend users. list_be_users omits credentials (password hashes and MFA secrets are never included); list_be_users_raw returns the full non-credential profile columns and ships disabled.
search_records
Full-text search across the tables that define TCA searchFields. Returns compact table:uid hits with a short excerpt around the match. Credential and nr-llm configuration tables are never searched; non-admins are limited to their tables_select tables and to hits on pages they may show.
get_page_content
One page's header data plus its content elements in column/sorting order (uid, colPos, CType, header, a short bodytext excerpt). Non-admins need page-show permission; only admins see hidden elements (marked [hidden]).
read_records
Generic equality-filtered read of one TCA table — never raw SQL. Fields are validated against the TCA and credential-like columns are silently dropped; the same table gates as search_records apply.
get_record_history
One record's change history from sys_history, newest first: when, which backend user, which action, and per modification the changed fields as old → new values. Values of credential-like fields are never rendered — only the fact that they changed. Same table gates as read_records, and non-admins additionally need page-show access on the record's page.
resolve_url
Map a URL (or path) of this instance to the page that serves it: site, language, page uid/title/slug and route arguments. Routing only — no request is sent; foreign hosts cannot match by construction. Non-admins need page-show permission on the resolved page.
get_typoscript
The resolved frontend TypoScript (setup or constants) effective on a page, with a dotted path drill-down and capped output. Admin-only — constants routinely carry API keys — and credential-like values render as [redacted] on top of that.
get_tsconfig
The rootline-merged Page TSconfig effective on a page, with the same path drill-down, output cap and redaction as get_typoscript. Admin-only.
get_last_exception
The newest exception/error from the TYPO3 file logs with its parsed stack trace and the surrounding source lines of the project frames inlined. index steps back through older errors, search filters by message, class or component. Admin-only.
read_source
A line-numbered range of one project source file. Paths must resolve inside the project root; dotfiles, var/* (except var/log), config/system, settings.php/additional.php, key material and credential paths are structurally unreadable. Admin-only.
search_code
Literal-substring (or opt-in regex) search across the project's source files, returning path:line hits. Vendor, var and dot directories are never searched; matched credential lines are value-redacted. Admin-only.
probe_url
One GET against a URL of this instance: status, key headers, timing and a short body excerpt — and on a 5xx the matching exception from the TYPO3 logs is appended automatically. Foreign hosts and non-http(s) schemes are denied; redirects are reported, not followed. Admin-only.
get_full_tca
The TCA index: the names and titles of all accessible tables, each with a pointer to get_table_schema. A navigation aid so the model can traverse the schema without the whole (multi-megabyte) TCA being sent at once. The same table gates as get_table_schema apply. Optional filter and extension narrow the list.
get_table_schema
One table's schema in a readable form: control settings plus, per field, its type and — for relational fields — the foreign table and relation kind (the value over get_tca). Sensitive tables are denied for every user; credential-like columns show name and type only.
get_flexform_schema
The data structure of a TCA FlexForm field, rendered as sheets and fields. When the field selects one of several structures by a pointer, the available keys are listed so a follow-up call can pass ds_pointer. Same table gates as get_table_schema.
fluid_resolve
Which physical Fluid file backs a template, partial or layout name in an extension: the candidate paths in override order with an exists flag and the winning path — to debug a wrong or missing template. Paths only. (Resolves an extension's own Resources/Private paths; TypoScript override root paths need a live rendering context and are not reflected.)
validate_tca
Structural TCA checks: ctrl.label/ctrl.type naming undefined columns, foreign_table references to unknown tables, showitem entries referencing undefined columns or palettes. One table or all accessible tables; findings name schema keys, never record data.
check_typoscript
Scans the TypoScript effective on a page (constants and setup) for syntax errors — invalid lines, unbalanced braces, @import matching no file — using the same core scanner as the backend's TypoScript module. Reports source and line number only, never the offending line's content (a constants line may carry an API key). Admin-only.
list_extensions
The installed (active) extensions: key, version, composer name and title — no package paths. Admin-only.
get_site_config
Without arguments the configured sites (identifier, base, root page); with identifier that site's configuration flattened to dotted key: value lines. Credential-like keys (camelCase included, e.g. apiKey) render as [redacted]. Admin-only.
list_scheduler_tasks
The scheduler tasks with next execution, disabled flag and a last-run-failed marker. The serialized task object is never unserialized; degrades gracefully when EXT:scheduler is absent. Admin-only.
get_system_status
One compact block: TYPO3/PHP/database versions, application context, composer mode, OS family, timezone — no paths, no hostnames. Admin-only.
list_deprecations
The newest distinct messages from the deprecation log, deduplicated with a ×count suffix and project paths relativized — the upgrade work list. Admin-only.
list_middlewares
A PSR-15 middleware stack (frontend or backend) in execution order with identifiers and classes. Admin-only.
site_rag_query
Curated evidence about the website's own public content for a question: source id, title, URL and match excerpt per source, retrieved from the best available search index — EXT:solr, ke_search, indexed_search or a database fallback — and labelled with the answering backend (ADR-049). Index-level filtering is always public-only (what the anonymous visitor could read).
site_fetch_source
The full indexed text behind a site_rag_query source id, capped at 8000 characters — for reading a promising source beyond its excerpt.
fetch_external_url
One public web page from the internet as readable text: title, headings, main text and links, wrapped as untrusted third-party content. Ships disabled; see Reading external web pages.

The writing tools 

Sixteen tools change anything at all: update_page_metadata, set_page_social_image, set_file_alternative_text, update_fal_asset_meta, move_content_element, create_content_element_draft, create_page_draft, create_translation_draft, attach_file_to_content_element, create_record_draft, and the six that act on existing pages and content elements — update_content_element, publish_record, delete_record, copy_record, move_page and replace_file_reference. All sixteen write through the TYPO3 DataHandler, as the acting backend user, in the live workspace only, on one record per call — plus, for a delete, a copy or a page move, what core carries along with that record, which the approval card counts (ADR-135, ADR-146, ADR-180, ADR-197, ADR-198, ADR-199).

What holds for all of them:

  • They ship disabled, sit in their own editing group, and are not offered until both the group and the tool are enabled.
  • Every call pauses for a human decision (ADR-134). The approval card names the record and the values, together with the values the write would REPLACE (ADR-136); nothing is written until somebody presses Approve, and the approver must themselves be permitted to run the tool (ADR-133).
  • Permissions are enforced twice: the tool checks the acting user's own rights first, and the DataHandler enforces its own rules on top. A non-admin therefore writes only what the backend already lets them write.
  • A field the user lacks the "exclude field" grant for is dropped by the DataHandler without an error. Every one of them re-reads the record afterwards and reports that as a failure rather than as a successful write.
  • A call the tool would refuse is refused whole rather than applied in part, and a record the acting user may not reach is refused with the same words as a record that does not exist — so a refusal never confirms that a uid exists.
  • They act on live rows only. A workspace draft of a page, a content element or a file reference is not there for them: it is not written, copied, counted or shown on an approval card, even when its uid is named (ADR-198).
  • update_page_metadata and update_content_element bind a field's before and after to the whole value: two short values in full, otherwise the section that differs, where it starts, and the length and a short hash of both values — so a change past the visible part, such as an appended link, still shows. A character a reader cannot see, such as a zero-width space or a direction mark, is written as its code point.
update_page_metadata
Sets a fixed set of descriptive fields on one page. Editable: title, subtitle, nav_title, abstract, description, keywords and — when EXT:seo is installed — seo_title, og_title, og_description, twitter_title, twitter_description and twitter_card. The last one is a select: its value must be one of the items the TCA declares, and the refusal names them. Anything else (slug, hidden, doktype, fe_group, perms_*, no_index, the image relations …) is refused. Authorised by the acting user's page-edit right; the DataHandler then enforces tables_modify and the field-level grants. The two social images have a writer of their own, set_page_social_image.
set_page_social_image

Sets the social preview image of one page — og_image (Open Graph) or twitter_image — to an existing managed file, identified by its sys_file uid. Both columns come with EXT:seo; without the extension the call is refused and says so. It creates exactly one sys_file_reference and never uploads, moves or renames a file (ADR-195).

Authorised on both ends: the acting user needs page-edit rights on the page, and the file has to lie in a permitted storage inside that user's own file mounts. Either failure is refused in the same words as a page or file that does not exist. Both columns are exclude fields, so a non-admin editor also needs the field-level grants pages:og_image / pages:twitter_image — without them the whole call is refused before anything is written, because the DataHandler would otherwise create the reference and drop the page's side of it in silence. A column an installation hides from non-admins (displayCond of HIDE_FOR_NON_ADMINS) is refused for every non-admin the same way, and the refusal names the condition.

Three things worth knowing before enabling it:

  • The field must accept the file. Both columns accept the image extensions the installation configures (common-image-types); a .txt is refused rather than written.
  • An image that is already there stops the call. The refusal names the file referenced now; replace is the only way past it and it deletes that reference first — recoverably (deleted = 1) and in sys_log — and the approval card says so on its own line.
  • Default-language pages only. A translated page is refused and the default-language page named; whether a translation follows its parent or carries its own image is a page-properties decision (allowLanguageSynchronization) the tool leaves to the editor. And because TYPO3 saves a page's translations along with the page, an editor whose allowed languages leave out a language the page is translated into, or who may not edit one of its translations — a translation carries page permissions of its own — is refused before anything is written; the refusal names the translation.

The page's own reference count is re-read afterwards, because EXT:seo reads it before it looks for the file: a reference the page does not count is one nothing renders. When that check fails, the new reference is deleted again and the page is written back to what it held; the tool then reads the page once more and the message says whether it is back as the call found it or what is still there, and names anything TYPO3 reported while putting it back — on a translated page that can be a translation it could not re-synchronise, which the tool reports rather than repairs.

set_file_alternative_text

Sets the alternative text (sys_file_metadata.alternative) of one managed file, identified by its sys_file uid — the accessibility gap an editor most often leaves behind. It writes that one field and nothing else.

Authorised by the same storage allow-list and file mounts as the read-only FAL tools; core's own file-metadata permission check (a writable file mount) then applies inside the DataHandler, so a read-only mount is refused there.

Two limits worth knowing before enabling it:

  • It never creates a metadata record. A file that carries none is refused, in the same words as a file the user may not reach — so the model cannot tell "not yours" from "not indexed".
  • It writes the live, default-language record only and takes no language argument. A translation, and a draft version of the same record in a workspace, are both left alone. Translated alternative texts stay a backend job (ADR-135).

An empty string is accepted and is the correct value for a decorative image.

update_fal_asset_meta

Sets the title and the description (sys_file_metadata.title, sys_file_metadata.description) of one managed file, identified by its sys_file uid, and the copyright notice where EXT:filemetadata provides the column. Any of the fields may be given; at least one is required.

It is a second tool rather than a wider set_file_alternative_text because the two are deliberately field-disjoint: this one does not write the alternative text and that one writes nothing else, so no file field has two writers and an approver never has to work out which of two cards won.

It shares the alternative-text tool's whole permission surface — the same storage allow-list and file mounts, core's writable-mount check inside the DataHandler, the same neutral refusal, no metadata record ever created, and the live default-language record only.

Two behaviours worth knowing before enabling it:

  • A field the call leaves out keeps its stored value; an empty string clears the field it names. The two are not the same, and a call that sets the title never touches a description somebody wrote by hand.
  • Core marks title as an exclude field and description not, so a non-admin editor needs the field-level grant sys_file_metadata:title for the title. Without it the whole call is refused before anything is written, rather than the DataHandler dropping the title in silence and applying the description — which would leave the asset described by half of an approved call.

The title is bounded in bytes, not characters: the column is tinytext and holds 255 bytes, so accented and non-Latin characters count for more than one.

attach_file_to_content_element

References an existing managed file from one content element, appended to one of its file fields (image, assets or media). It creates exactly one sys_file_reference. It never uploads, moves, copies or renames a file, and never touches sys_file_metadata — the file has to be there already.

Authorised on both ends: the acting user needs content-edit rights on the element's page, and the file has to lie in a permitted storage inside that user's own file mounts. Either failure is refused in the same words as an element that does not exist.

Three things worth knowing before enabling it:

  • The field must accept the file. Each file field declares which extensions it takes, and they differ — image accepts fourteen, assets twenty-seven, media anything. A .docx on image is refused rather than written, because the FormEngine would reject the relation anyway.
  • The field is only inferred when it is unambiguous. When the element's type offers exactly one of the three, that one is used; when it offers several, the call must name the one it means. The tool does not pick.
  • Calling twice attaches the file twice. That is the declared effect (NON_IDEMPOTENT_WRITE), not an accident: two references to one image are a valid thing to want, so nothing deduplicates behind the caller's back.

The new reference is always appended last, and the element's own reference count is re-read afterwards. A write that left the relation inconsistent is reported as a failure and the reference is removed again.

move_content_element

Moves one content element to a page and a column. The element keeps its uid, its content, its language, its history and its references — only its place changes, which is what makes it the least committal write in the set.

Both ends are authorised: the acting user needs content-edit rights on the source page as well as on the target, because moving an element out of a page edits that page's content too. An after_content_uid anchor must sit on the target page and in the same language; a wrong anchor is refused rather than silently corrected.

The destination column is always stated explicitly on the wire, so the element lands where the approval card said it would even when the anchor element sits in a column the caller did not expect.

create_content_element_draft

Creates one content element on a page — the first tool that brings a record into being. Three things bound it:

  • It is always hidden. There is no argument to switch that off: publishing is a separate act with a separate audience, and the approval that let the tool run approved a draft.
  • The content types are read from the installation's TCA at call time, under an exclusion rule (ADR-196). A type is offered unless it is denied by name — list (the legacy plugin element), html, shortcut, div, every menu_* — or it is a plugin, known by its Extbase registration or by the plugins or forms item group, or its form holds a column whose payload is not prose: a FlexForm, inline children, a group or folder reference, a slug, a password. File, category and link relations do not exclude a type; the draft leaves them empty, so textmedia is offered and gets its media through attach_file_to_content_element. The tool description lists what the installation offers.
  • The field set is the type's own scalar columns: headline, body text, column, language and position as arguments, and every further input, text, select (static items), check, number, datetime, radio, color or email column of the chosen type through fields, validated against its TCA type as the DataHandler reads it. A check with several items, a check limited by maximumRecordsChecked and an input or email with eval unique stay in the form and are refused as keys, because TYPO3 would change them in silence; so is a column the type's TCA declares readOnly, and a read-only header or bodytext refuses the call. Identity, position, visibility, publication, audience and translation columns are refused by name; a wrong key or value refuses the whole call and names the columns the type offers. The chosen type is checked against the acting user's explicit allow-list, and the exclude-field grants per column — the tool's own columns included — before the write; a datetime is handed over as the integer both supported cores store. The page's TSconfig narrows all of this per page, as it narrows the backend form: TCEFORM.tt_content.CType.keepItems and removeItems; disabled and config.readOnly on a fields column, on bodytext and on header — a hidden or read-only header refuses the call, since the header is required, and like the backend form the tool does not read config.readOnly for a radio column; and keepItems and removeItems on a select column in fields, on colPos for the column argument and on sys_language_uid for the language argument — each also under types.<CType> — are honoured, and the refusal names the rule. addItems is not read, and a column that is readOnly in the TCA stays refused even where a page sets config.readOnly = 0. This is still not a generic record API: the table is fixed and a relation is never an argument.

In a language other than the default one the element is created standalone, without a translation parent. That is refused on a page which already holds connected translations in that language (ADR-193): a standalone element beside them is what the page module reports as Inconsistent content detected. The refusal sends the model to the default language and to create_translation_draft. A page whose TSconfig sets mod.web_layout.allowInconsistentLanguageHandling is exempt, as it is from core's warning.

bodytext is refused for a type whose form does not show it, such as header. Otherwise it reaches the DataHandler and its RTE transformation exactly as an editor's input does. It is bounded in length and not otherwise filtered — an editor may write the same markup by hand, and a tool enforcing a stricter rule than the CMS would be enforcing a rule that does not exist.

create_page_draft

Creates one standard page under a parent page — the first tool that brings a page into being (ADR-180). It is bounded the same way the element draft is, and a little further:

  • It is always hidden, with no argument to switch that off. Core's own default for a new page is hidden as well; the tool does not rely on it — an installation that sets TCAdefaults.pages.hidden = 0 gets the same read-back, and a page that came out visible is deleted again.
  • It is always a standard page in the default language. Shortcuts, mount points, links, folders and translations are out of reach; a translation has its own tool.
  • The field set is fixed: title, navigation title, position (parent and an optional after_page_uid that must be a subpage of the same parent). The URL segment is generated by the DataHandler from the title.

Authorised by the acting user's new page permission on the parent; the DataHandler then enforces tables_modify, the page-type grant and the field-level grants on top.

It creates the page and nothing on it — one record per call, as for every writer. The success message hands the model the new uid and names create_content_element_draft as the tool to put text on it, so "a page with an introduction" is two calls and two approvals, each showing one record.

create_translation_draft

Translates one page or content element into another language by running core's own localize command, so connected-mode translations, inline children and every localisation hook behave exactly as they do in the backend.

The result is hidden, which is the part core does not do: localize copies the source's visibility, so a translation of a live page would go live the moment it was created.

An existing translation stops the call and is named in the refusal. The overwrite argument is the only way past it: it deletes that translation first — recoverably (deleted = 1) and in sys_log — and the approval card says so on its own line. A language the record's site does not define is refused before anything is created.

The text is then machine-translated (ADR-209): every text column of the record's type that holds text in the source — headline, subheader, body, page title, navigation title, description and the like, but not the page's author — is translated from the source record and written into the translation. Rich text is sent as HTML, so its markup survives. A translation longer than a column's maximum length is cut to it, and the result says so. The translator argument chooses deepl or llm (the default); a translator the installation has not configured is refused. The glossary of the record's site for the language pair applies (see Glossaries). The approval card names the translator, the fields, fields the user may not edit, and whether the site keeps a glossary for the pair.

If a translation fails, comes back empty or is cut off at the translator's output limit, nothing is written: the draft keeps the copied source text and the result says The text was NOT machine-translated with the reason. If the write itself fails, the result names which fields hold the translation and which still hold the source text.

Identical translations are answered from a cache for a day. A cached answer does not reach the translator, so it passes no budget check and records no usage. Saving or deleting a glossary, an LLM configuration, a model, a provider, a skill or a prompt snippet empties that cache.

create_record_draft

Creates one record in a TCA table that has no dedicated writer — the fallback for extension tables such as a news record (ADR-197). It serves a table by exclusion:

  • pages and tt_content have writers of their own and are refused by name; sys_* tables, the tables no tool may read (be_users, sys_log, the nr_llm and nr_vault tables …) and tables declared adminOnly, hideTable or readOnly are refused as well.
  • A table another registered creator declares through RecordCreatorInterface::getCreatedTables() is refused and the tool named — read at call time, so an extension that ships its own creator withdraws the fallback the day it is installed. The builtin creators declare tt_content (create_content_element_draft), pages (create_page_draft) and both (create_translation_draft). A declaration that cannot be read refuses the call and names the tool.
  • The extension configuration tools.createRecordDraft.deniedTables (comma-separated table names, empty by default) excludes further tables on one installation. It can only narrow the list, never widen it, and a configuration that cannot be read refuses every table.
  • A table without a "disabled" enable column is refused, because nothing this extension writes may be visible before a human unhides it.

Only scalar columns can be set — input, text, number, email, color, datetime (as a timestamp), check, radio and select with static items — and only those the record type's form shows as writable on that page: a column the TCA declares readOnly is refused unless page TSconfig TCEFORM.<table>.<column>.config.readOnly = 0 lifts it, as it does in the form (a radio excepted, whose readOnly the form does not let page TSconfig change). Relations, files, FlexForms, links and slugs are not arguments; hidden, uid, pid, the language, timing, ownership and versioning columns are refused by name. Values are checked against the record type's TCA — its columnsOverrides included — before anything is written: the items of a select, max and range, 0/1 for a check, a valid address for an email. The record type is the one the DataHandler gives the record — the call's value for the type column, else TCAdefaults from the page's TSconfig, then from the acting user's, else the column's default — and the tool writes it explicitly where the acting user may write the type column, leaving it to the DataHandler otherwise; a TCAdefaults value that names no record type of the table is refused. Every column the record type marks required must be given. A value the DataHandler would rewrite is refused rather than written: a column with an eval the DataHandler acts on (upper, alphanum, unique, uniqueInPid, an extension's registered evaluation …; a token it ignores is ignored here too), an eight-digit colour on a column without opacity, a text shorter than its min, a decimal with more than two decimal places (TYPO3 stores two), a decimal the range check would clamp. So is what the page's TSconfig takes out of the backend form: a column TCEFORM.<table>.<column>.disabled hides, a select value keepItems or removeItems filters out, a column TCEFORM.<table>.<column>.config.readOnly makes read-only, and a record type its type field does not offer on that page — the refusal names the rule.

The record is always hidden and always in the default language. Authorised by the acting user's tables_modify grant, the content-edit permission on the page or folder, and the field-level grants for every column the call sets — asked before the write, because the DataHandler drops such a column in silence. What TYPO3 still rewrites — a hook of the installation, a grant the pre-check does not model — is read back afterwards, the default language included, and the record type unless it is core's fallback type and the tool could not write it; a record that does not carry what was approved is deleted again and the columns are named.

The approval card names each column with the record type's TCA label — a showitem field;Label, else a columnsOverrides label, else the column's own — in English, never in the viewer's language, because the card is compared byte for byte when the run resumes: published_at (Published at): "2026-09-10T10:00:00+00:00". A page TSconfig label override (TCEFORM.<table>.<column>.label) is not applied, although the backend form shows it: the card must not depend on the page or on the viewer's language.

It declares no editor action — it has no record an editor would select — so it is reached through the assistant only, never from a record's context menu.

update_content_element
Changes fields of one existing content element (ADR-198). The field set is the one create_content_element_draft offers for a new element of the same type — the scalar columns of the type's form, header and body text included — checked by the same rules (ADR-196): a value outside a select's items, a number outside its range, a column the page's TSconfig hides or makes read-only is refused, and the refusal names the columns the type offers. An element whose type the exclusion rule leaves out — raw HTML, plugins, menus, shortcuts, a form holding a FlexForm or inline children — is refused whole; relations and the identity, position, visibility, publication, audience and translation columns are never fields. A column a translation takes from its default-language element (`l10n_mode = exclude`) is refused and the element to change named. The approval card shows every column's change, bound to the whole value. Where some columns take and others do not, the answer says which. Authorised by content-edit rights on the page, the record-level rights and the field-level grant for every column.
publish_record
Clears the hidden flag of one page or content element — the step after a human has reviewed a draft — and changes nothing else. Start and stop times and access groups stay as they are; the approval card names them, and a hidden default-language record behind a translation, where they still keep the record from visitors. A record that is not hidden is not written. Needs page-edit rights on a page or content-edit rights on an element's page, and the field-level grant for the hidden column.
delete_record
Deletes one page or content element with core's delete command. The row is flagged deleted and stays recoverable from the recycler. What core deletes with it is counted on the approval card first: the translations of a default-language record, and for a page its subpages (their uids, ten and then "and N more"), their translations, and the records stored on the pages table by table. The counts include records the acting user cannot see; they are counts only, never titles. A page with subpages is refused unless the call sets include_subpages; a branch of more than 50 pages is refused outright, and a site root is never deleted. The card also counts the records the reference index says still point at the record — links and shortcuts that will break. Needs delete rights on a page (and on every page of its branch), content-edit rights for an element, and the right to edit every translation that goes along; for a page, also tables_modify for every table with records on it and the languages of the content on it. A delete that would take a workspace draft along is refused: core discards the versions and new workspace translations of what it deletes for good, and strands the other drafts on a deleted page. Publish or discard them in their workspace first.
copy_record
Copies one content element to a page and column, or one page under a parent, with core's copy command (ADR-199). The copy and every translation copied with it are always hidden, whatever core's hideAtCopy, the user's preferences and page TSconfig say, and a page is always copied without its subpages: core would hide only the top page of a copied branch. What happens to the translations of a default-language record depends on the TYPO3 release and the target's site: TYPO3 14 and 13.4.25 or later copy none outside a site and, inside one, a translation only where the site has its language (for an element, where the target page is translated into it); one core refuses fails the copy, which is then taken back. Before 13.4.25, core asks no site: it copies every page translation, and drops an element translation the target page is not translated into without an error. The card states the rule of the running core. The answer says how many were copied. A page's content is copied with the page. A translation is refused by itself, as are a page translation as target, a site root, and a standalone element beside connected translations on the target page (ADR-193).
move_page
Moves one default-language page to a new parent or directly after a sibling. Its content, translations and subpages move with it, and it keeps its uid and its URL path — core does not regenerate the slug on a move. The approval card says so, counts the subpages that move along, and warns when the page moves into another site, into a site or out of every site. Asks the permissions core asks: delete rights on the page and new-page rights on the new parent for a new parent, edit rights within the same parent. A site root, a translation, a page translation as parent and a target inside the page's own branch are refused.
replace_file_reference
Points one existing file reference on a content element's image, assets or media field at another existing file, or removes the reference; attach_file_to_content_element only appends. A replacement takes the old reference's position, and nothing of the old reference is carried over — its title, alternative text, description and crop described the old file; the card says which texts the new reference will show. The file itself is never changed, and the new file must lie in a permitted storage inside the acting user's file mounts and carry an extension the field accepts. Core deletes the translated references of the old one with it: the card names them, the call is refused where the acting user may not change the translated element they sit on, and afterwards each translated element's reference count is set and read back.

Registering a tool 

A tool is a PHP class that implements Netresearch\NrLlm\Service\Tool\ToolInterface:

getSpec(): ToolSpec
Returns the declaration the model receives — a name, a description, and a JSON-Schema parameters block. Build it with ToolSpec::function($name, $description, $parameters).
execute(array $arguments): string
Runs the tool with the model-provided arguments and returns a plain string that is fed back into the conversation as a tool turn.
getGroup(): string
The tool's group — a short, stable identifier used to enable or disable whole families of tools at once. Built-ins use content, editing, structure, system, accounts and configuration; third-party tools declare their own group (recommended: the providing extension's key). See Tool groups.

The interface carries #[AutoconfigureTag('nr_llm.tool')], so a class is auto-registered simply by implementing it — no central registration file to edit. ToolRegistry collects every tagged tool through a DI iterator and indexes it by spec name; two tools with the same name is a developer error and fails fast at container build.

A tool that creates records also implements Netresearch\NrLlm\Service\Tool\RecordCreatorInterface and lists the tables its records land in from getCreatedTables(). The generic create_record_draft then steps back from those tables and names your tool instead (ADR-197).

When you write a tool, honour the security contract: treat $arguments as attacker-influenced (the model is steerable by injected skill prose), validate and scope every input (cap volumes, scope identifier lookups), and never return secrets — the result leaves the instance.

Managing tools 

The AI > Operation > Tools module lists every registered tool with its global enable state and lets an admin toggle it. A disabled tool is refused on every run, everywhere — the runtime gate is fail-closed, so a disabled tool can never be offered to the model regardless of a skill's allowed-tools or the per-run selection in the playground. Some built-in tools (for example get_env_raw and get_php_info_raw) ship disabled by default because they return unredacted, secret-bearing output; enable them only deliberately.

A tool that is refused never reaches the model, so by itself the model cannot tell it from a tool that does not exist. An extension that runs a chat can ask NetresearchNrLlmServiceToolUnavailableToolsResolverInterface for the tools one configuration and one backend user will NOT be offered, each with the gate that holds it back — toolDisabled, requiresAdmin, configurationGroup or trustZone — and pass that to the model, which can then say "there is a tool for that, it is not enabled for you" (ADR-201). A builtin tool the trust-zone gate only observes is offered, and is not in that list; a remote (MCP) tool is always enforced by that gate, so above the ceiling it is listed in either mode. Remote tools are listed for administrators only.

The Tools management module listing each built-in tool with an Enabled or Disabled badge and an Enable/Disable toggle

The Tools module — each registered tool with its global enable state and a toggle. The _raw variants show as Disabled, the redacted tools as Enabled; the Default badge marks a tool sitting at its shipped state.

A tool that carries an editor action declaration (ADR-152) reads differently in that list: it shows an icon, its translated name, one sentence written for a human, and the record types it addresses — instead of the wire name and the description written for the language model. Nine of the sixteen writing tools declare one — create_record_draft has no subject record and declares none (ADR-197), and the six of ADR-198 are reached through the assistant only — and the wire name stays visible as the technical detail the toggle acts on. A read-only tool is unchanged.

The declaration is presentation only. It does not decide whether a tool writes — that is the tool's declared effect — and it changes nothing about how a call is fenced, approved or audited.

What editors see 

The declaration is what the Editor Action Center (ADR-158) renders. It lives in the editor module Web > AI tasks and appears in two places: as an AI actions catalogue reachable from that module, and as an AI actions entry in the context menu of a record — a page or a content element — which opens the catalogue narrowed to the actions that address that record.

An editor is offered an action only when all of the following hold, and every one of them is an administrator's decision:

  • the writing tool is enabled in this module (all sixteen ship disabled);
  • its group — editing — is enabled, and where the default LLM configuration restricts tool groups, editing is among them;
  • the tool's data class is within the configured provider's trust-zone ceiling;
  • the backend user holds tasks_use and has the module ticked in their group;
  • the backend user may use the default LLM configuration itself — where that configuration restricts Allowed backend groups, the user is in one of them (see Backend user permissions).

That last point is checked again when the action is started, so an editor outside those groups cannot start a run by naming the action directly either.

Starting an action creates an ordinary agent run restricted to that one tool. Because the tool declares a write, the run suspends before it touches anything and the change appears on an approval card with its preview — the editor is redirected straight to that inbox. Nothing is written until someone approves.

The record an action is offered on is the record its arguments name, which is not always the record it writes: Create content element draft is offered on a page, because the page is what it must be told, and the element it creates is the result. Where an action needs something the selected record cannot supply — Move content element needs a target page — the editor names it in the note, and the approval card shows the destination that was resolved from it.

Files have no context-menu entry yet: the file list identifies a file by its combined identifier rather than by uid, so Set alternative text is listed in the catalogue but has no per-record entry point.

Several records at once 

Once a record is selected — that is, when the catalogue was opened from a record's context menu — each action there also offers Run this on several records (ADR-162). The catalogue opened from the module menu has no record and therefore no bulk entry point either; an action needs a subject, and this module picks none. That page takes a list of record numbers from the same table, seeded with the record that was selected, and shows, before anything starts, which of them the action can run on, which are skipped and why, and what the batch is expected to cost in requests, tokens and money.

At most 100 entries of that list are read at all. A longer paste is cut there and the page says so, because everything past the cut would otherwise become a table row and a record number in a message no one can read.

Starting it creates one ordinary run per record. There is no bulk mode: each record gets its own approval card with its own preview, and an approver decides them one at a time. At most 20 records are started in one press, because the runs execute inside the one backend request.

The AI budget is checked once per run, so a batch can run out of budget partway through. When that happens the batch stops and names the records the stop kept from starting — the record it stopped on was run, and is reported separately. Nothing is left half-written: the runs that did start are proposals awaiting approval, not changes.

Runs that ended for some other reason are reported by kind — failed, stopped by a guardrail, cancelled, or simply finished without proposing a change — so a batch in which everything failed does not read like one in which nothing needed changing.

The estimate on that page is deliberately rough and says so: it does not count the system prompt and skills the runtime adds, and its upper price assumes every request returns the configured token ceiling. It shows no price range at all unless the model record carries both an input and an output price and the configuration sets an output ceiling — an absent range means "unknown", which 0.00 would not. Treat it as an order of magnitude, not an invoice.

Tool groups 

Every tool belongs to a group (its getGroup() value). The built-in groups carry a translated name in the module header beside their identifier; a group a third-party extension brings has no translated name and shows its identifier alone. The built-in taxonomy:

Group Tools
content search_records, get_page_content, read_records, get_record_history
structure get_pagetree, get_tca, read_fal_asset_meta, get_full_tca, get_table_schema, get_flexform_schema, resolve_url, validate_tca
system get_env (+ raw), get_php_info (+ raw), fetch_logs, probe_url, list_extensions, list_scheduler_tasks, get_system_status, list_deprecations, list_middlewares
accounts list_be_users (+ raw), list_be_groups
configuration get_typoscript, get_tsconfig, fluid_resolve, check_typoscript, get_site_config
code get_last_exception, read_source, search_code
files list_fal_storages, browse_fal_folder, search_fal_files, get_fal_references, find_missing_files
rag site_rag_query, site_fetch_source
web fetch_external_url — the only group that reaches hosts outside the installation
editing update_page_metadata, set_page_social_image, set_file_alternative_text, update_fal_asset_meta, attach_file_to_content_element, move_content_element, create_content_element_draft, create_page_draft, create_translation_draft, create_record_draft, update_content_element, publish_record, delete_record, copy_record, move_page, replace_file_reference — the only WRITING group

Groups can be switched on three levels, and the result cascades fail-closed — a tool is offered only when every level permits it:

  1. Centrally in the Tools module: each group header carries an Enable/Disable group toggle. A disabled group refuses all of its tools — including same-group tools installed later — and a per-tool override can not re-enable a tool inside a disabled group (predictable, fail-closed). Per-tool toggles keep working but take effect only once the group is enabled again.
  2. Per configuration: the Allowed tool groups field on an LLM configuration restricts agent runs with that configuration to tools of the selected groups (empty = all groups). This intersects with a skill's allowed-tools declaration.
  3. Per run in the playground: the tool checkboxes are grouped, and each group checkbox (de)selects its children.

Third-party extensions declare their own group per tool; the recommended value is the extension key, so an admin can disable an extension's whole tool family with one toggle. The design is recorded in ADR-043.

Network egress policy per group 

Network egress is governed per tool group and is fail-closed (ADR-061). Each group has a declared egress scope; a group with no declaration may make no outbound request:

Scope Meaning
none No outbound network request (the default for every group).
own_site Only the instance's own configured site hosts, resolved through SiteFinder — the exact allow-listing probe_url applies, now lifted to the group boundary.
configured_endpoint Only the service endpoint the site configuration declares, such as the search backend of the rag tools (ADR-093).
external_filtered Public internet hosts, each one passed through the address guard of ADR-202: no private, loopback, link-local or metadata address is reachable, whatever the operator's host lists say.

The system group (which carries probe_url) is granted own_site, rag is granted configured_endpoint and web (which carries fetch_external_url) is granted external_filtered; every other group is none. There is no unfiltered "any host" scope, so a newly installed or mis-declared tool group can never egress to an arbitrary target. The diagnostics tools that share the system group (get_env, fetch_logs …) never make a network request, so the grant does not loosen them.

Reading external web pages 

fetch_external_url reads one public web page and returns its title, headings, main text and links as plain text (ADR-202). Pages of this installation stay with probe_url and site_rag_query.

It ships disabled. To offer it, enable the web group and the tool in the Tools module; a configuration that restricts Allowed tool groups must also list web. It is not admin-only: public web content is none of the system or cross-user data the admin tier guards.

Every call waits for a human approval by default. The URL the model chooses leaves the installation, and the model is steerable by the pages and skills it reads, so a run could be led to put data it has seen into a query string. The approval card shows the host and the full query string. The approval can be switched off only together with an allowlist (below).

What it refuses, for the first URL and again for every redirect target:

  • schemes other than http and https, and URLs with credentials;
  • hosts of this installation — every site base and base variant;
  • ports other than 80/443, unless the allowlist names host:port;
  • numeric host forms such as 2130706433 or 0177.0.0.1, and host names with non-ASCII characters (pass the xn-- form instead);
  • any host whose DNS answer contains a private, loopback, link-local, CGNAT, multicast, reserved, NAT64 or cloud-metadata address (169.254.169.254, fd00:ec2::254), IPv4-mapped and other IPv6 forms of those included;
  • a host that does not resolve in DNS — names that exist only in /etc/hosts are unreachable on purpose;
  • any request an HTTP proxy would carry (see below);
  • every request when PHP's curl extension is missing, because the connection could not be pinned.

The connection is pinned to addresses that were checked, so a DNS answer that changes between the check and the connect cannot redirect it. It follows at most three redirects, stops the download at 2 MiB, parses at most 256 KiB of HTML (deeply nested markup is reduced to its text instead), returns at most 20,000 characters (fewer for scripts with multi-byte characters, so the whole result stays under 48,000 bytes) and gives up after 20 seconds in total. One DNS lookup is bounded by the system resolver; set options timeout:2 attempts:1 in /etc/resolv.conf to shorten it. Only HTML and plain text are read.

The text comes back between BEGIN/END UNTRUSTED EXTERNAL WEB CONTENT markers that carry a random value per call, with a note that it is reference material, not instructions — the same shape skill bodies carry (ADR-061). Error messages repeat no text the remote server chose, apart from a redirect target, cut and labelled as untrusted.

The request comes from the installation's own address. A service that trusts that address — a partner API that allow-lists your server, an intranet reached through a public address — is not a private range and is not refused; put it on the denylist.

Four extension settings govern it (Admin Tools > Settings > Extension Configuration > nr_llm):

tools.fetchExternalUrl.allowedHosts
Comma-separated host names (example.org, *.example.org for subdomains but not the apex) or IP addresses (192.0.2.10, 2001:db8::1, [2001:db8::1]). Append :8443 (after the brackets for IPv6) to allow that port only; list the plain entry as well to keep the default port. When set, only these hosts are fetched. Empty (default) allows every public host.
tools.fetchExternalUrl.deniedHosts
Hosts that are never fetched, in the same forms, checked before the allowlist. A name entry matches the host name in a URL; an address entry matches an IP address in a URL and every address a host name resolves to.
tools.fetchExternalUrl.skipApprovalWithAllowlist
Lets calls run without approval — only while the allowlist is non-empty.
tools.fetchExternalUrl.allowViaProxy
By default a fetch is refused when an HTTP proxy would carry it — the TYPO3 proxy setting ($GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy']), or HTTPS_PROXY (HTTP_PROXY on the command line) unless NO_PROXY excludes the host — because the proxy resolves the host itself and the address checks cannot reach past it. This setting permits it, only while the allowlist is non-empty.

Neither list can admit a private address: an allow-listed host that resolves to one is still refused.

Using the Tool Playground 

The playground lives in AI > Operation > Playground and is admin-only. It is a sibling of the Tools management module: the playground runs the loop, while the Tools module governs which tools exist and are enabled.

The Tool Playground module with the LLM configuration picker, an empty prompt box, the Run button, and the Available tools panel

The playground shell — the configuration picker, prompt box and the Tools available to this run panel, which lists every registered tool with the default-enabled ones pre-checked and the disabled _raw variants unchecked.

  1. Pick an LLM configuration from the dropdown. Its vault-stored API key, model, temperature and system prompt are what the loop actually runs on — the playground never falls back to a default model.
  2. Type a prompt. Optionally open the override panels to force-inject skills (added on top of the configuration's own), force-add snippets (inserted as leading system messages), override the system prompt, cap the max rounds, or tick capture raw provider response.
  3. Click Run — or Dry run to assemble the prompt and inspect exactly what would be sent without calling the model.
  4. Read the inspector — live from the moment you click Run. A summary strip reports rounds, tool calls, the prompt/completion token split, estimated cost, wall time and status. The step list is the nr_llm ↔ LLM dialog in order: each round opens with a Context budget step, then its outbound request (the messages sent and the tools offered) appears the instant it goes out, a waiting indicator shows while the model works, then the response and each tool execution stream in. Select a step to open its detail — requests carry Messages sent and Tools offered; responses carry Structured, Raw JSON and Thinking. The model's final answer closes the run.
  5. The Context budget step says where the window went for the round that follows it, so you can act on the component that is yours to change rather than only learn that history was dropped. It has two tabs:

    Where the window went
    The window, the output reserve, the safety margin and the resulting budget, then four component lines — transcript, tool schema, system prompt (incl. snippets) and skills — that sum to the estimated total, plus what is left. On this surface the system-prompt line always reads counted in the transcript and the skills line always reads 0: the agent loop builds both into the transcript before the fit measures it, so their content is already on the transcript line. The table says so under the figures. A send whose reserve exceeds the whole window is handed to the provider unmeasured, and the step reports no accounting instead of a window of zero.
    Injected context
    Every snippet and skill this run injects, by name only, with the data class it declared (ADR-144) or not classified, and the strictest class across all of them. The list covers the snippets and skills you force-injected for this one run as well as the configuration's own, and the input-context gate reads the same list (ADR-164): a source shown here is a source that is gated. Forcing a snippet the configuration's trust zone would refuse therefore refuses the run, naming that snippet.
A completed tool run — the summary strip, the ordered step list and the selected step's detail tabs for a two-iteration agent loop

A completed run — the summary strip (rounds, tool calls, token split, wall time, status), the ordered step list of the nr_llm ↔ LLM dialog, and the selected step's detail: here round 1 requested the list_be_users tool, whose result is fed back so round 2 can answer.

The Tools available to this run list lets you narrow a single run to a subset of the globally-enabled tools (the full list and the global enable/disable controls live in the Tools module). Raw-response capture is off unless you tick it, so ordinary runs never retain the provider's raw payload. Every displayed string — tool arguments, tool results (which may include sys_log content), and the final answer — is rendered escaped; HTML is only ever shown inside a sandboxed preview, never injected into the page.

Each run is bounded by the iteration cap (default 5) and, when the configuration's backend user has a budget, by the per-iteration budget pre-flight. If the cap is hit with tools still pending, a final tool-free completion synthesises a closing answer and the run is marked truncated. The aggregated token usage is reported; the monetary cost is recorded in the usage table by the middleware pipeline.

Ollama model-capability dependency 

Tool calling depends on the model, not just the provider. For Ollama, only function-calling-capable models — for example llama3.1, mistral, qwen2.5 — return tool calls. A model without function-calling support simply answers the prompt directly and never calls a tool; the loop ends gracefully on the first plain answer. If a configured Ollama model never seems to use the available tools, verify it is one of the function-calling models for your Ollama version.

Gating tools with allowed-tools in a skill 

A skill's SKILL.md front-matter may carry an allowed-tools key that gates which tools the skills attached to a configuration (or task) grant for a run. The resolution is fail-closed on declaration, computed over the configuration's effective skills (enabled, non-orphaned, at or above the instance trust floor, deduped):

  • Absent (no skill declares allowed-tools) — no opinion; all registered tools are offered.
  • Declared list — the union of the declared lists across the effective skills; only those tools are offered (intersected with what is actually registered, so an unknown name is dropped).
  • Declared empty (allowed-tools: []) — declares zero tools; if no other effective skill widens the set, the run gets no tools and is a single plain completion.

A disabled or orphaned skill never grants tools. The allow-list is enforced both when the tools are offered to the model and again when a tool call is executed, so a prompt injection cannot reach a tool the skills did not grant.

The effective set is not the same as what ends up in the prompt. The skill-block byte budget (skills.maxBytes, see Managing skills) is applied after the allow-list has been computed, while the block is assembled. A skill dropped for the budget therefore still contributes its allowed-tools, but its usage rules never reach the model. That order is deliberate: a budget-aware union would let the drop of the last declaring skill collapse the allow-list to "no restriction" — every registered tool — which is a looser gate, not a tighter one. Keep skills.maxBytes above the composed size of the skills you rely on if you want their prose to arrive alongside the tools it describes; every drop is logged as a warning.

See ADR-038 for the runtime design and security rationale.