Skip to content

Custom Scopes

You can extend the scope system by creating custom scope plugins in your own modules.

Creating a custom scope plugin

  1. Create a class that extends AiContextScopeBase
  2. Place it in src/Plugin/AiContextScope/ within your module
  3. Add the #[AiContextScope] attribute with plugin metadata
  4. Implement getValues() to return available scope values
  5. Implement doGetDetectedValue() (return NULL when 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 label as 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_item scope produces the route name ai_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 scoring
  • expandSubscribedValues() — extra keys the SQL prefilter must include so matching items are not dropped. Return NULL when 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 remove
  • shouldScrubAfterEntityDelete() — scrub after this entity delete
  • getStaleStoredValuesForEntityDelete() — return the deleted entity's stored ID for a cheap strip, or NULL for a full scan
  • shouldScrubAfterBundleDelete() — scrub after this bundle delete
  • shouldScrubAfterSettingsChange() — 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 of value_id => label pairs (passed by reference)
  • $scope_id -- the plugin ID of the scope being altered (e.g., use_case, language, site_section)