Consumer type API
Consumer types are Drupal attribute plugins. They describe how a kind of
thing receives context. AI Context ships the public contract, the
agent type, and the automator type. Other types are implemented
and owned by the consuming project.
This page is the public plugin API. The consumer type manager, route subscriber, and listing forms are internal.
Ownership
Implement the plugin in the consuming project when that project can own the type:
- Preferred: a small integration submodule. Its
info.ymlshould constrain the AI Context version.config/installshould shipai_context.consumer_type_settings.{type}. - Lighter: put the plugin in the main module. There is no version enforcement and no install-time settings default.
The real test is what internals the plugin touches. A type that reads config entities through entity storage and matches on emitted request tags can live in AI Context. A type that reaches into another module's final classes must live with those classes.
Do not add a CKEditor type to AI Context. That stays in the AI CKEditor project.
AI Context ships automator because Automators stay in AI core and
AI core does not take a dependency on CCC. That type is gated on
moduleExists('ai_automators'), enumerates ai_automator config
entities, and matches only ai_automator:id:{id}.
Do not add an assistant type. The generic path excludes
ai_agents and ai_assistant_api so those calls stay on
the agent path. That exclusion (EXCLUDED_REQUEST_TAGS on
the router) is not a setting. Those tags already receive
context on the agent path; making the list configurable
would allow double injection or stealing agent requests.
The same list holds the module's own ai_context and
ai_context_subcontext tags (AiContextConsumerRouter::INTERNAL_REQUEST_TAG
and ::SUBCONTEXT_REQUEST_TAG). Conditional subcontext resolution
dispatches a chat request from inside selection; routing it would
select context for a request that is already inside selection and
recurse without bound. The generic subscriber also refuses to
re-enter while one of its selections is running, so a type that
routes on a broad tag cannot reopen the recursion.
Registering a type
Extend AiContextConsumerTypeBase and use the
AiContextConsumerType attribute. Plugin IDs must not contain a
colon. The colon is reserved for canonical instance IDs
{type}:{instance}.
#[AiContextConsumerType(
id: 'example',
label: new TranslatableMarkup('Example'),
description: new TranslatableMarkup('Example consumers.'),
host_entity_type: 'example_thing',
)]
final class AiContextConsumerTypeExample extends AiContextConsumerTypeBase {
}
Set host_entity_type when instances are 1:1 with a host
entity and the instance ID is that entity ID. Omit it when
instances come from plugins or configuration.
No definition alter hook
Consumer types have no hook_ai_context_consumer_type_info_alter().
The type plugin manager does not call alterInfo(). A site that
needs to turn a type off uses the enabled kill switch on
ai_context.consumer_type_settings.{type} (Consumer Types listing,
or config). Missing type settings mean the type is enabled.
That is a push gate, not discovery. A disabled type still appears
in listings so stored overrides can be edited. Pull-based tools
keep working. To mark a type that cannot work on this site,
override isAvailable() instead of unsetting the definition.
See Push gates and Hooks.
Instances
getInstances() must be built from the data that defines the
instances (plugin definitions, entities, config). Never hardcode a
list. It returns an AiContextConsumerInstanceCollection.
Read cacheability from CacheableDependencyInterface; there is no
getCacheableMetadata().
Each instance has:
getInstanceId()— the instance ID (content_editor)getConsumerId()— the canonicalagent:content_editorvalue object, fromAiContextConsumerId::fromParts($type, $instanceId)- A label
- An optional description
getLabelRoute($instanceId) may return a route to the owning object's
admin page (the listing name link). For the Automator type, this is the
field configuration edit form when the configured entity type, bundle,
and field exist and the current user can open Field UI. It falls back
to the Automator edit form when the field configuration cannot be
loaded, Field UI is not available, or the current user cannot access
the field form. Return NULL if there is no page.
That is not the CCC consumer editor.
Host entity delete
getConsumerIdForDeletedEntity() maps a deleted host entity to that
type's consumer ID so hook_entity_delete() can drop only that row
from ai_context.consumers. The consumers object itself stays.
AiContextConsumerTypeBase implements the 1:1 case from the
host_entity_type attribute. Agent sets ai_agent. Automator
sets ai_automator. Types with no host omit the key and get
NULL. Override the method only when the instance ID is not
the entity ID.
Do not gate on isEnabled() or isAvailable(): a leftover row
can still exist after a kill switch or a missing module. A type
whose create() or mapping throws is skipped and logged so one
broken plugin cannot abort every entity delete. The same skip
omits that type from the consumers listing and from routing.
Check Recent log messages.
Mapping is skipped entirely while the module that provided the
deleted entity's type is being uninstalled, tracked by
hook_module_preuninstall(). An uninstall deletes every host entity
at once, which means the type is going away rather than one consumer
being removed, so the saved overrides stay and list as Off (type
unavailable). This is generic: any type whose hosts come from an
optional module gets the behavior without implementing anything.
isAvailable() cannot express it, because core clears
core.extension only after the config entities are deleted.
Because an unavailable type enumerates nothing, its leftover rows
have no instance to edit. The consumer editor builds a stand-in
instance for them, but only from a row already saved in
ai_context.consumers, so an arbitrary consumer ID still 404s.
Direct implementations of the interface (without the base class) must add the method. After 1.0.0 this is a public API addition with a base default; see API stability.
Matching provider requests
The consumer router (AiContextConsumerRouter) is an AI Context
service. It is not Drupal's routing system. The router itself is
internal. Callers use routeRequest(). The router does not have
resolveConsumerId(); that method lives on the type plugin.
Types advertise routing tags with getRoutingRequestTags()
(for example ai_ckeditor). Those are AI request tags, not taxonomy
or cache tags. The router then calls resolveConsumerId() on
matching types:
- Return an
AiContextConsumerIdto claim the request - Return
NULLto decline
The router already excludes ai_agents and ai_assistant_api
before it calls plugins. Types do not need to decline those tags.
Unmatched tags (no type advertised them) load no consumer config and no context items. Two types resolving different IDs fail closed as ambiguous.
The Automator type advertises ai_automator and claims a request
only when tags include exactly one ai_automator:id:{id} and that
ai_automator config entity exists. It does not reconstruct the
instance from entity type, bundle, or field name. Those tags are
ambiguous when a field has more than one automator.
Until AI core emits the ID tag from RuleBase::getTags(), an
internal subscriber (AiContextAutomatorIdTagSubscriber) adds it
when the field properties uniquely identify one automator, or
when a field widget action click maps to one automator on that
field. Multi-value fields post {uuid}_{delta} for values after
0; those names still map.
AiContextAutomatorWidgetOnlySubscriber uses the same click
to skip the other automators on that field when the clicked
automator is allowed to push. Remove both subscribers when core
ships the tag and widget clicks run only the clicked instance.
Request entity and page path
Consumer requests often arrive on an API-proxy route, such as the AI
CKEditor request endpoint. Its Drupal route and HTTP path describe
the endpoint, not the content being edited. Entity Types, Specific
Entities, Taxonomy Terms, and Site Sections need the real target, so
the caller must send it as entity_context request metadata on the
chat input (InputBase::setRequestMetadataValue()):
entity_typeplusidfor a saved entity. The entity is loaded to read its bundle and taxonomy terms.entity_typeplusbundlefor an unsaved entity, for example a node add form. Entity Types matches the bundle; Specific Entities and Taxonomy Terms have nothing to match. A loaded entity wins over the bundle hint when both are present.path: the page the visitor is working on, so Site Sections can match its patterns instead of the endpoint path.
Config entities on the proxy route or in agent tokens (an Editor,
a text format) are ignored, so they cannot shadow these hints.
When no path is sent, AI Context falls back to a same-site
Referer on XMLHttpRequest and JSON requests only.
entity_type plus id loads the entity to read its bundle and
taxonomy terms. loadContentEntity() still checks view access
before field data is used. The bundle and path hints are
untrusted client values used only for string matching. They never
load or grant access to an entity on their own. The bundle hint
is stored only for content entity types.
Type settings vs instance overrides
| Store | What it holds |
|---|---|
ai_context.consumer_type_settings.{type} |
Type-wide settings. enabled is the push kill switch. |
ai_context.consumers |
Per-instance overrides (push_enabled, subscriptions, limits, settings) |
Missing type settings mean the type is enabled. Instance
defaults come from getInstanceDefaults(). Scopes still use
defaultConfiguration() for ai_context.scope_settings.{id}.
Leftover type settings after a plugin is gone show as stale rows on
the Consumer Types listing. Removing them deletes that config object
only, not instance rows in ai_context.consumers.
Saving type settings merges keys from submitTypeSettingsForm().
A NULL value clears that key so exported config only carries keys a
site builder set, matching scope settings. Keys omitted from the
array stay in config.
Schema pattern:
- Shared base type for
ai_context.consumer_type_settings.* - A wildcard fallback for unknown types
- Per-type entries that extend the base
- Per-instance
settingsis dynamically typed (ai_context.consumer_settings.{type}, with a*fallback)
The * fallbacks are bare mappings that permit no keys. A type that
stores anything under type settings beyond enabled, or anything at
all under per-instance settings, must ship its own
ai_context.consumer_type_settings.{type} or
ai_context.consumer_settings.{type} schema entry. Otherwise config
validation rejects those keys as unsupported.
The shared consumer editor never edits type-specific instance
settings; it preserves whatever the row already stores. Types that
need a UI for those values own that form.
The Agent type ships settings.loop_aware and
isLoopAware($instanceId). Read the typed bag with
getSettings() on stored or resolved consumer config.
isLoopAware() is the Agent helper over that bag. Loop-aware
and Debug / Explore stay on that type. They are not generic
consumer API.
Selection mode defaults
getInstanceDefaults()['selection_mode'] sets both unsaved
runtime and the Context selection mode radio when the consumer
has no stored selection_mode.
- Omit the key or set
inherit(AiContextSelection::SELECTION_MODE_INHERIT): Site default. The first save writesinherit. Runtime followsai_context.settings:selection_mode. - Set
minimal,relevant, orbroadfor that mode. The first save writes it. - The base sets
inherit. The Automator type setsminimal. - An unrecognized value is treated as inherit.
A stored inherit value follows the site setting. A saved row
that omits the key uses the type default. Do not special-case
plugin IDs in the consumer form.
Push gates
Automatic push runs only when all of these are true:
- The instance
push_enabledflag (or the type default when the key is omitted or null) - The type
isEnabled()kill switch (enabledonai_context.consumer_type_settings.{type}; there is no definition alter hook) - The type
isAvailable()environment gate (for example, an optional module the type integrates with is missing)
Override isAvailable() when the type cannot work on this site.
The base returns TRUE. The Agent type does not override it
because ai_agents is a hard dependency. The Automator type
returns FALSE when ai_automators is not installed.
Override getAvailabilityCacheableMetadata() when
isAvailable() depends on modules or other site state. The
base returns empty metadata. Automator adds
config:core.extension so installing or uninstalling
ai_automators drops a cached empty list and an Unavailable
listing row. Start getInstances() cacheability with
createInstanceCacheability() so those tags travel with the
enumeration. The router also merges this metadata onto
cached instances and the routing map.
isPushAllowed() is TRUE only when all three pass. The generic
subscriber reports unavailable types as type_disabled.
Pull-based tools ignore those three gates.
Invocation result
After the generic subscriber handles a chat request, it writes an
ai_context key on event metadata and on the caller's chat input
debug data (ChatInput::getDebugData(), not request metadata). An
absent key means the request was not handled. The debug-data copy
also marks the input as processed: dispatching the same ChatInput
again (a tool-call inner request) re-publishes a stored pushed
result to the new event instead of appending a second context block.
Any other stored status is not replayed, so the same input is routed
again on a later call.
Compare statuses to AiContextInvocationResult constants
(PUSHED, NO_ITEMS, PUSH_DISABLED,
TYPE_DISABLED, STALE, DECLINED, AMBIGUOUS). The payload
never includes prompt or context content.
NO_ITEMS under an anonymous session means the entity query ran with
accessCheck(TRUE) and every item failed the view access check, not that
routing failed. View access to context items is permission-based, not
per-item: anonymous sessions can only view published items, and only when
the anonymous role has the Use Published AI Context in AI Features
(access published ai context) permission. Granting it exposes every
published context item to anonymous selection — there is no per-item
grant — so review the published catalog before enabling it for a public
chatbot. Scopes narrow what a consumer selects; they are not an access
control.
See API stability.
Naming notes
These names stay as they are:
- Router means
AiContextConsumerRouter(tag → consumer ID), not Drupal routes. Type settings routes are added byAiContextConsumerTypeRouteSubscriber. Router callers userouteRequest(). Type plugins implementresolveConsumerId(). - Type plugins use inherited
getPluginId()(agent). InstancegetInstanceId()is the instance ID (content_editor).getConsumerId()is the canonicalagent:{id}. fromConsumer()accepts a string ID.AiContextConsumerIdis the value object for validation and parts. Build IDs withfromParts()/fromString().SEPARATORis private.isValidType()is@internal.getRenderedContext()/getResult()/fromParameters()/getResultFromSelection():consumerIdmerges canonical config. Pulls without a consumer log asnone. Run a built selection withgetResultFromSelection().- There are two “request context” ideas: scope matching
(
matchesRequestContext(), page/entity) and the provider-call snapshot (AiContextProviderRequestContext). - The invocation-result metadata key is
ai_context.
Convenience APIs
getRenderedContext() and getResult() stay as caller-opt-in
helpers. Their trailing $selectionMode is public and nullable:
NULL uses the site default (relevant). broad is available by
choice. minimal and relevant use the scope index for prefiltering;
only broad walks the full catalog. Automatic injection never widens a
consumer's stored mode; these helpers may. Use
getResultFromSelection() when you already have an
AiContextSelection.
See Services.