Octibiz
Demo

Durchsucht Website und Dokumentation gemeinsam. Enter zeigt alle Treffer, Esc schließt.

AI access and automation

For AI access you have to do nothing

That is the whole message of this section, and it is meant literally.

Every API operation of your plugin becomes a tool for AI access on its own. The tool catalogue is derived from the same description as the API. There is no second list for you to maintain, and no registration step.

Permissions, brand scope and the audit trail bind in the API, not in the tool. An AI call can never do more than the human whose identity it carries. The audit trail records the actor type, so a call made by an AI is distinguishable.

The module switch applies automatically too

The catalogue carries the responsible module switch for each operation. If your module is off, your operations disappear from the tool list without you doing anything.

After an API change

In development the catalogue has to be regenerated:

bin/console cache:clear
make mcp-operations

The first step is not optional. Without it, new operations are missing from the catalogue, and they are missing silently: no error, no warning, they are simply not there.

When a package is installed, the export runs as part of the sequence.

What deliberately does not become a tool

A plain controller, for instance the receiver of a callback from a foreign system, does not appear in the catalogue. That is intentional: such endpoints are machine-to-machine routes with their own verification. They do not belong in the hands of an AI.

If you want something to appear as a tool, build it as an API resource.

Automation

Here you do have to act. Four contracts, all collected through their tag — implementing is enough, there is no registration:

What you want to contributeContractTag
An action that an automation performsAutomationActionInterfaceapp.automation_action
A trigger an automation reacts toTriggerProviderInterfaceapp.automation.trigger_provider
Making that trigger selectableTriggerCatalogProviderInterfaceapp.automation.trigger_catalog
A comparison operator for conditionsConditionOperatorInterfaceapp.automation.condition_operator

All four live under App\Platform\Automation\.

A trigger, complete

Both trigger contracts belong in one class. That is how the shipped Pages plugin does it in PagesTriggerProvider:

<?php

declare(strict_types=1);

namespace Acme\Shipping\Catalog;

use Acme\Shipping\Event\ShipmentDelivered;
use Acme\Shipping\Event\ShipmentDispatched;
use App\Platform\Automation\Engine\TriggerCatalogProviderInterface;
use App\Platform\Automation\Engine\TriggerProviderInterface;

final class AcmeShippingTriggerProvider implements TriggerProviderInterface, TriggerCatalogProviderInterface
{
    /** @var array<class-string, string> */
    private const KEYS = [
        ShipmentDispatched::class => 'acme_shipping.shipment.dispatched',
        ShipmentDelivered::class => 'acme_shipping.shipment.delivered',
    ];

    /** Maps a concrete event to trigger keys. Feeds the runtime. */
    public function triggerKeysFor(string $eventClass): array
    {
        $key = self::KEYS[$eventClass] ?? null;

        return null === $key ? [] : [$key];
    }

    /** Enumerates the same keys for the editor. Feeds `GET /api/v1/automation-catalog`. */
    public function authoringTriggerKeys(): array
    {
        return array_values(self::KEYS);
    }
}

That is the whole file.

Implement only TriggerProviderInterface and you build a trigger nobody can select. It works, it fires, it is tested — but it is not in the catalogue that the editor and the AI access query. That is the most common stumbling block here, and exactly why both contracts sit in the same class.

An action

AutomationActionInterface requires two methods:

public function getType(): string;
public function execute(ActionContext $context): ActionResult;

Complete, with the rules that apply:

<?php

declare(strict_types=1);

namespace Acme\Shipping\Automation;

use Acme\Shipping\Entity\Shipment;
use App\Platform\Automation\Action\ActionContext;
use App\Platform\Automation\Action\ActionResult;
use App\Platform\Automation\Action\AutomationActionInterface;
use Doctrine\ORM\EntityManagerInterface;

final class MarkShipmentDeliveredAction implements AutomationActionInterface
{
    /**
     * The machine key as the class's OWN constant.
     *
     * A guard in the interface reads the set of all action types from the source and knows
     * exactly two forms: a literal or `self::…`. Reference the constant of ANOTHER class and
     * it fails there as a parse error — the type would otherwise hang silently outside the
     * set and nobody would have noticed its missing label.
     */
    public const TYPE = 'acme_shipping_mark_delivered';

    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    public function getType(): string
    {
        return self::TYPE;
    }

    public function execute(ActionContext $context): ActionResult
    {
        $ulid = $context->rawString('shipmentUlid');
        if ('' === $ulid) {
            return ActionResult::failure('acmeShipping.noShipment');
        }

        $shipment = $this->em->getRepository(Shipment::class)->findOneBy(['ulid' => $ulid]);
        if (!$shipment instanceof Shipment) {
            return ActionResult::failure('acmeShipping.shipmentNotFound:'.$ulid);
        }

        // Dry run: check, but change nothing.
        if ($context->isDryRun) {
            return ActionResult::success(\sprintf('Dry run: %s left unchanged.', $ulid));
        }

        $shipment->setStatus('delivered');
        $this->em->flush();

        return ActionResult::success(\sprintf('Shipment %s set to delivered.', $ulid));
    }
}

Three things about this are not obvious:

  • You have to honour $context->isDryRun yourself. The dry run is a request, not a lock. Ignore

it and you change data in a preview.

  • The sentences in ActionResult are a diagnostic log, not user-facing text. They end up in the

run's log and stay in the system language. The interface does not render them.

  • No audit entry of your own. The change is recorded on save anyway, with the actor type

automation. An extra entry would be a duplicate per field change.

Validate your action on save

The core does not know the required fields of your action. Without your own validation an incomplete configuration only surfaces at run time, and then it hits the recipient rather than the author.

AutomationActionConfigValidatorInterface closes that gap — implemented by the same action class:

public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void

The third parameter is the reason the method takes three: a disabled draft may be incomplete, enabling it may not.

public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void
{
    if (!$automationActive) {
        return; // draft: anything goes
    }

    if ('' === (string) ($config['shipmentUlid'] ?? '')) {
        throw new \InvalidArgumentException('shipmentUlid is mandatory when enabling.');
    }
}

A comparison operator

ConditionOperatorInterface is the smallest of the four contracts:

public function operator(): string;
public function matches(mixed $actual, mixed $expected): bool;

Labels

Triggers and actions are labelled through keys, not through text. The convention is binding, because the catalogue computes it:

automation.trigger.<lowerCamel(trigger key)>
automation.action.<lowerCamel(action type)>

So acme_shipping.shipment.delivered becomes automation.trigger.acmeShippingShipmentDelivered.

They live in the locale file of your interface, not in the backend. Details in translations.

Fields for the action form

If your action needs its own input fields in the editor, they come from the interface manifest, not from the backend. See interface.

How this relates to events

A trigger sits on an event. The events after a change feed the fan-out for automations and callbacks; the events before a change deliberately do not, they exist for objection.

So if you want something to fire an automation, you need an event after the change. The full catalogue is in the event reference.

An event can exempt itself where it would otherwise create a feedback loop: a change that fires an automation which performs the same change again.

What goes wrong, and why

SymptomCause
New operation missing from the tool catalogueCache not cleared before the export
Trigger runs but cannot be selectedTriggerCatalogProviderInterface missing
Action only fails at run time, at the recipientAutomationActionConfigValidatorInterface missing
Label shows the raw keyLocale file missing, or the key does not follow automation.trigger.<lowerCamel>
A dry run changes data anyway$context->isDryRun not honoured in execute()
An automation calls itselfEvent after the change without an exemption from the fan-out

Next

plugin.automation · Available from version 0.6.22