Skip to content

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.yml should constrain the AI Context version. config/install should ship ai_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 canonical agent:content_editor value object, from AiContextConsumerId::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 AiContextConsumerId to claim the request
  • Return NULL to 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_type plus id for a saved entity. The entity is loaded to read its bundle and taxonomy terms.
  • entity_type plus bundle for 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 settings is 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 writes inherit. Runtime follows ai_context.settings:selection_mode.
  • Set minimal, relevant, or broad for that mode. The first save writes it.
  • The base sets inherit. The Automator type sets minimal.
  • 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:

  1. The instance push_enabled flag (or the type default when the key is omitted or null)
  2. The type isEnabled() kill switch (enabled on ai_context.consumer_type_settings.{type}; there is no definition alter hook)
  3. 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 by AiContextConsumerTypeRouteSubscriber. Router callers use routeRequest(). Type plugins implement resolveConsumerId().
  • Type plugins use inherited getPluginId() (agent). Instance getInstanceId() is the instance ID (content_editor). getConsumerId() is the canonical agent:{id}.
  • fromConsumer() accepts a string ID. AiContextConsumerId is the value object for validation and parts. Build IDs with fromParts() / fromString(). SEPARATOR is private. isValidType() is @internal.
  • getRenderedContext() / getResult() / fromParameters() / getResultFromSelection(): consumerId merges canonical config. Pulls without a consumer log as none. Run a built selection with getResultFromSelection().
  • 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.