Custom Scopes
You can extend the scope system by creating custom scope plugins in your own modules.
Creating a custom scope plugin
- Create a class that extends
AiContextScopeBase - Place it in
src/Plugin/AiContextScope/within your module - Add the
#[AiContextScope]attribute with plugin metadata - Implement
getValues()to return available scope values - Implement
doGetDetectedValue()(returnNULLwhen the scope has no contextual auto-detection)
Example
namespace Drupal\my_module\Plugin\AiContextScope;
use Drupal\ai_context\Attribute\AiContextScope;
use Drupal\ai_context\Plugin\AiContextScope\AiContextScopeBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[AiContextScope(
id: 'department',
label: new TranslatableMarkup('Department'),
description: new TranslatableMarkup('Department this context applies to.'),
display_weight: 55,
scoring_weight: 60,
)]
class AiContextScopeDepartment extends AiContextScopeBase {
public function getValues(): array {
return [
'marketing' => $this->t('Marketing'),
'engineering' => $this->t('Engineering'),
'sales' => $this->t('Sales'),
];
}
protected function doGetDetectedValue(): ?string {
return NULL;
}
}
The plugin is automatically discovered by the AiContextScopeManager.
Clear caches after adding a new plugin.
Override isAvailable() when the scope depends on site configuration that
may be missing (for example, Language requires more than one language).
Unavailable and disabled scopes are hidden from forms, listing pills,
consumer subscription summaries, and the scope field formatter, and
ignored at runtime. Stored item values and consumer subscriptions are
kept on save so they apply again when the scope is enabled and
available. Do not delete those values from extractFormValues() or
a settings form. Saving as Global or as a subcontext that inherits
parent scope still clears stored scope on the item.
When a dynamic option itself can disappear (a deleted term, bundle, or
config row), implement AiContextScopeStoredValueInterface so the
cleanup service can ask this plugin which stored values are stale.
Do not invent a second cleanup path. See
AiContextScopeStoredValueInterface
and ai_context.scope_cleanup.
If getValues() labels come from config or entities, override
getValuesCacheableMetadata() so cached displays invalidate when
those labels change. The base class already includes this scope's
settings config and varies cached labels by interface language.
If isAvailable() depends on other config, override
getAvailabilityCacheableMetadata() so cached listings and item
views invalidate when availability changes.
Storage model for custom scopes (1.0)
Custom scope plugins in 1.0 should store values in the context item scope
field. Extend AiContextScopeBase and use the default
getValuesFromEntity() implementation (which resolves parent inheritance via
getScopeItem()). Read and write grouped values through getScope() /
setScope(). Default Content, recipes, and create() use Field API
deltas; see
Storing scope values.
The field stores a plugin ID (scope_id, max 64 characters) and one
value (value, max 255 characters) per delta. Those values are
stable identifiers, not arbitrary payloads. Do not store prose,
rendered content, or other large data in a scope value. Site Section
custom patterns are stored as custom: plus the path, so the pattern
itself can be at most 248 characters.
Custom entity-field storage, delete cleanup, and form lifecycle hooks are
not part of the public extension contract in 1.0. Scope-field custom
scopes are indexed automatically with exact stored values. Do not write
to the index table or call AiContextScopeIndexService directly. If
stored values have wildcard or hierarchical matching, implement
AiContextScopeValueMatchingInterface so scoring and SQL prefiltering
stay consistent. The built-in entity item scope uses a separate DER
field as an internal implementation detail.
Minimal selection only sees values stored in the scope field and Specific
Entities matches on that built-in entity_items field. Values stored only
in a custom field or other external store are unsupported in Minimal: the
capability split in #3586419 lets a custom scope declare exact-match or
situational eligibility, but Minimal prefilter candidacy still requires
scope-field-indexed values (custom-storage index participation is a
planned follow-up). Such items can still appear in Relevant or Broad,
which scan the full catalog. See
How Minimal finds candidate items
for the mechanism.
Automatic routes and settings tabs
When a scope plugin is discovered, the module automatically generates:
- A settings route named
ai_context.settings.scope.{plugin_id}at the URL path/admin/config/ai/context/settings/scope/{plugin-id}. - A local task tab using the plugin's
labelas the tab title. Scope tabs form their own tab group on the per-scope settings pages, next to an "All" tab that links back to the Scope overview (core renders at most two tab levels, so they cannot nest under the Settings trail). The group is hidden on the Scope overview page itself, which lists and links the scope pages instead.
Custom scopes are interleaved with built-in scopes according to their declared
display_weight in context item forms, the Details panel, and scope settings
tabs. Lower display weights appear first, with plugin ID used to break ties.
You do not need to define routes or local tasks manually.
Plugin ID vs URL path
The plugin ID and the URL path use slightly different conventions:
- Route name keeps the plugin ID verbatim, including underscores. For
example, the
entity_itemscope produces the route nameai_context.settings.scope.entity_item. - URL path converts underscores to dashes for readability, so the same
scope is served at
/admin/config/ai/context/settings/scope/entity-item.
When linking to a scope settings page from your own code (for example, from
getManageRoute()), always use the route name with underscores -- never the
URL path:
public function getManageRoute(): ?array {
return ['route_name' => 'ai_context.settings.scope.entity_item'];
}
When the route has placeholders, also include route_parameters (see the
getManageRoute() examples below).
If a scope plugin is removed (e.g., by uninstalling the module that
provides it, or via
hook_ai_context_scope_info_alter()),
its route and tab are removed automatically. Prefer isAvailable() over
removing your own definition when a scope depends on an optional module:
an unavailable scope keeps its settings tab, is skipped by matching and
scoring, and preserves stored values. Languages and Specific Entities
both work that way. The scope plugin manager invalidates the local_task cache
tag whenever its definitions are cleared, so derived tabs stay in sync
without a full cache rebuild.
Attribute parameters
| Parameter | Type | Description |
|---|---|---|
id |
string |
Unique plugin ID |
label |
TranslatableMarkup |
Human-readable label |
description |
TranslatableMarkup\|null |
General description, shown on the scope overview and settings pages. See Choosing a description method to give context item forms different text. |
display_weight |
int |
Controls the order of this scope in forms, tabs, and listings. Lower values appear first (Drupal #weight convention). Must be a positive integer. Default: 100. Custom scopes can use intermediate values (for example, 55 to appear between Languages at 50 and Site Sections at 60). See Scope API for the built-in display weight list. |
scoring_weight |
int\|null |
Controls subscription scoring influence. Higher values give this scope more influence. NULL (the default) is treated as 1. Values below 1 are also treated as 1. Scopes that do not support subscriptions are never scored regardless of this value. See Scope API for the built-in scoring weight list. |
deriver |
string\|null |
Optional deriver class |
icon_class |
string\|null |
Optional CSS class for UI icons (e.g. ai-icon--scope-use-case) |
Scope icons
Scope plugins can declare an optional icon_class on the
#[AiContextScope] attribute. That is the supported way to associate an icon
with your scope.
For your custom scope, declare icon_class on the plugin and use that
CSS class in your render array or form element. Core scope icon resolution is
module-internal; contrib UI should use scope labels or your own icons rather
than calling getScopeIconClass() or injecting
plugin.manager.ai_context_scope (see API stability).
Core scopes ship Phosphor SVG icons under icons/scope/ and CSS classes in
css/ai_context_item.css. Custom scopes must ship their own CSS (and SVG
assets if needed) for any icon_class they declare or set via the alter
hook. The attribute value is the CSS class name; your render array, form,
or theme hook must attach the library that defines those styles (for example
#attached['library'][] = 'my_module/scope_icons').
#[AiContextScope(
id: 'department',
label: new TranslatableMarkup('Department'),
icon_class: 'ai-icon--scope-department',
)]
class AiContextScopeDepartment extends AiContextScopeBase {
// ...
}
Built-in scope plugins define a PLUGIN_ID constant equal to their plugin ID.
Reference those constants when interacting with built-in scopes from module
code.
Optional overrides
The base class provides sensible defaults. Override these methods to customize behavior:
supportsSubscriptions()
Return FALSE to hide this scope from consumer configuration forms and keep
it out of subscription scoring. Because the scope is then absent from the
form, saves copy any previously stored values back rather than treating
their absence as deletion (see the value-preservation notes in the
Scope API). Useful for scopes that apply
automatically (like Global or Entity Item).
public function supportsSubscriptions(): bool {
return FALSE;
}
supportsExactMatch()
Whether a strict matchesRequestContext() match enters the
exact-match group in every mode, including Minimal, and whether items
indexed under this scope are Minimal prefilter candidates (see
Capabilities in the Scope API). Defaults to
FALSE — exact-match is opt-in. Declare it when a positive match means
the item was authored for exactly this situation, as Specific Entities
does for the current page. Leave it off for filter-style scopes: Roles
supports neither subscriptions nor exact match, because a role match is
never an inclusion reason. Languages also disables subscriptions (a
language tag is a restriction, not an affinity) but still declares
situational-match.
Before #3586419, returning supportsSubscriptions(): FALSE made a scope
implicitly exact-match eligible. If your custom scope relied on that,
add an explicit supportsExactMatch(): TRUE.
public function supportsExactMatch(): bool {
return TRUE;
}
supportsSituationalMatch()
Return TRUE to have a strict matchesRequestContext() match enter the
situational-match group in Relevant mode and above, without requiring
a consumer subscription. Defaults to FALSE. Situational matching runs
against every loaded candidate item and Relevant and Broad scan the full
published catalog, so keep matchesRequestContext() cheap: resolve the
current value once and memoize it, and never load entities or resolve
paths per item.
public function supportsSituationalMatch(): bool {
return TRUE;
}
allowsMultiple()
Return FALSE to render radio buttons instead of checkboxes (single
selection).
isDynamic()
Return TRUE if values come from an external source (database, config, API)
rather than being hardcoded. Dynamic scopes may change at runtime.
getDetectedValue()
Return the detected value for contextual detection. For example, the Language scope returns the content language, and the Site Section scope returns the matching section for the request path.
getValuesFromEntity()
Return the scope values configured on a context item. The base implementation
resolves effective scope through AiContextItem::getScopeItem(), so a
subcontext item that inherits its parent's scope returns the parent's values:
public function getValuesFromEntity(AiContextItem $item): array {
return $item->getScopeItem()->getScopeValues($this->getId());
}
If you override this method, keep resolving through getScopeItem(). Reading
$item->getScopeValues() (or $item->getScope()) directly bypasses parent
inheritance: for an inheriting child the item's own stored scope is empty, so your
scope would contribute nothing while the other scopes still reflect the
parent — an inconsistent effective scope, and the selector and scope index
would disagree. See
Effective scope and parent inheritance.
Do not override this method to read values from another field or store. Minimal will not find those items: prefilter candidacy requires scope-field-indexed values regardless of the #3586419 capability methods (custom-storage index participation is a planned follow-up). See Storage model for custom scopes (1.0).
matchesRequestContext()
Implement custom matching logic for how this scope determines whether a context item matches the request context.
AiContextScopeValueMatchingInterface
Implement this optional interface only when stored values can match more than one other value (for example a parent value that matches many children). The resolver uses exact intersection and unchanged index keys unless the plugin implements both methods:
getMatchingSubscribedValues()— subscribed values satisfied by the item, used for scoringexpandSubscribedValues()— extra keys the SQL prefilter must include so matching items are not dropped. ReturnNULLwhen those keys cannot be enumerated; the prefilter then keeps all candidates.
Both methods are required together. See Wildcard and hierarchical matching.
AiContextScopeStoredValueInterface
Implement this optional interface when stored options can become invalid after a delete or settings change. The cleanup service only strips values; this plugin decides which stored values are stale and whether a Drupal change should trigger a scrub:
getStaleStoredValues()— the subset of stored values to removeshouldScrubAfterEntityDelete()— scrub after this entity deletegetStaleStoredValuesForEntityDelete()— return the deleted entity's stored ID for a cheap strip, orNULLfor a full scanshouldScrubAfterBundleDelete()— scrub after this bundle deleteshouldScrubAfterSettingsChange()— scrub after this plugin's settings config is saved
Do not diff against a raw getValues() list when that list is empty
(Taxonomy Terms) or when some stored values are valid without appearing
as checkboxes (Site Section custom: patterns). Honor
getAlteredValues() so hook-added IDs are not removed. Use Case,
Global, and Entity Item do not implement this interface.
See Stale stored values.
getManageRoute() and getManageLabel()
Provide a link to an admin page where this scope's values can be managed.
Return a route info array from getManageRoute(), and the link text from
getManageLabel().
Most scope settings routes need only route_name:
public function getManageRoute(): ?array {
return ['route_name' => 'ai_context.settings.scope.entity_item'];
}
When the route has placeholders, include route_parameters as well (as the
Tag scope does for its vocabulary overview page):
public function getManageRoute(): ?array {
return [
'route_name' => 'entity.taxonomy_vocabulary.overview_form',
'route_parameters' => ['taxonomy_vocabulary' => 'ai_context_tags'],
];
}
Dynamic label cacheability
Plugins extending AiContextScopeBase automatically implement the optional
AiContextScopeCacheableMetadataInterface. Cached formatters and listings use
it to vary translated labels and invalidate output when label sources
or runtime availability change. Override
getUnalteredValuesCacheableMetadata() when labels depend on entities
or configuration beyond the scope's own settings. Override
getAvailabilityCacheableMetadata() when isAvailable() depends on
external configuration.
Implementing AiContextScopeInterface directly remains supported. The
cacheability interface is optional so plugins that do not implement it
do not gain new required methods. Direct implementers of the companion
interface must include getAvailabilityCacheableMetadata().
buildSettingsForm(), validateSettingsForm(), submitSettingsForm()
Add custom settings beyond the default enabled checkbox.
For full API details, see the Scope API reference.
Altering scope values
Use hook_ai_context_scope_values_alter() to add, modify, or remove values
from any scope plugin without writing a full plugin. See
Hooks.
function my_module_ai_context_scope_values_alter(
array &$values,
string $scope_id,
): void {
if ($scope_id === 'use_case') {
$values['custom_workflow'] = t('Custom Workflow');
}
}
Parameters:
$values-- associative array ofvalue_id => labelpairs (passed by reference)$scope_id-- the plugin ID of the scope being altered (e.g.,use_case,language,site_section)