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.
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). 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.
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. 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.
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.
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 the stored result to
the new event instead of appending a second context block.
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.