KI-Zugang und Automatisierung
Für den KI-Zugang musst du nichts tun
Das ist die ganze Nachricht dieses Abschnitts, und sie ist ernst gemeint.
Jede API-Operation deines Plugins wird von selbst zu einem Werkzeug für den KI-Zugang. Der Werkzeug-Katalog entsteht aus derselben Beschreibung wie die API. Es gibt keine zweite Liste, die du pflegen müsstest, und keine Anmeldung.
Rechte, Marken-Schnitt und Prüfspur binden dabei in der API, nicht im Werkzeug. Ein KI-Aufruf kann nie mehr als der Mensch, dessen Identität er trägt. In der Prüfspur steht die Urheber-Art, ein Aufruf durch eine KI ist also unterscheidbar.
Auch die Modul-Sperre gilt automatisch
Der Katalog trägt je Operation den zuständigen Modul-Schalter. Ist dein Modul ausgeschaltet, verschwinden deine Operationen aus der Werkzeug-Liste, ohne dass du etwas tust.
Nach einer API-Änderung
Im Entwicklungsbetrieb muss der Katalog neu erzeugt werden:
bin/console cache:clear
make mcp-operations
Der Zwischenschritt ist nicht optional. Ohne ihn fehlen neue Operationen im Katalog, und zwar still: Kein Fehler, keine Warnung, sie sind einfach nicht da.
Beim Einspielen eines Pakets läuft der Export von selbst mit.
Was bewusst kein Werkzeug wird
Ein einfacher Controller, etwa der Empfänger eines Rückrufs von einem fremden System, taucht nicht im Katalog auf. Das ist Absicht: Solche Endpunkte sind Maschine-zu-Maschine-Strecken mit eigener Prüfung. Sie gehören nicht in die Hand einer KI.
Wenn du willst, dass etwas als Werkzeug erscheint, bau es als API-Ressource.
Automatisierung
Hier musst du etwas tun. Vier Verträge, alle über ihr Kennzeichen eingesammelt — implementieren genügt, es gibt keine Registrierung:
| Was du beisteuern willst | Vertrag | Kennzeichen |
|---|---|---|
| Eine Aktion, die eine Automatisierung ausführt | AutomationActionInterface | app.automation_action |
| Einen Auslöser, auf den eine Automatisierung reagiert | TriggerProviderInterface | app.automation.trigger_provider |
| Denselben Auslöser auswählbar machen | TriggerCatalogProviderInterface | app.automation.trigger_catalog |
| Einen Vergleichsoperator für Bedingungen | ConditionOperatorInterface | app.automation.condition_operator |
Alle vier liegen unter App\Platform\Automation\.
Ein Auslöser, vollständig
Die beiden Auslöser-Verträge gehören in eine Klasse. So macht es auch das ausgelieferte Pages-Plugin 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',
];
/** Ordnet ein konkretes Ereignis den Auslöser-Schlüsseln zu. Speist die Laufzeit. */
public function triggerKeysFor(string $eventClass): array
{
$key = self::KEYS[$eventClass] ?? null;
return null === $key ? [] : [$key];
}
/** Zählt dieselben Schlüssel für den Baukasten auf. Speist `GET /api/v1/automation-catalog`. */
public function authoringTriggerKeys(): array
{
return array_values(self::KEYS);
}
}
Das ist die ganze Datei.
Wer nur TriggerProviderInterface implementiert, baut einen Auslöser, den niemand auswählen kann. Er funktioniert, er feuert, er ist getestet — aber er steht nicht im Katalog, den der Baukasten und der KI-Zugang abfragen. Das ist der häufigste Stolperstein an dieser Stelle, und genau deshalb stehen beide Verträge in derselben Klasse.
Eine Aktion
AutomationActionInterface verlangt zwei Methoden:
public function getType(): string;
public function execute(ActionContext $context): ActionResult;
Vollständig, mit den Regeln, die dabei gelten:
<?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
{
/**
* Der Maschinen-Schlüssel als EIGENE Konstante der Klasse.
*
* Ein Wächter in der Oberfläche liest den Nenner aller Aktions-Typen aus dem Quelltext
* und kennt genau zwei Formen: ein Literal oder `self::…`. Verweist du auf die Konstante
* einer ANDEREN Klasse, fällt das dort als Lesefehler auf — der Typ hinge still ausserhalb
* des Nenners und seine fehlende Beschriftung wäre niemandem aufgefallen.
*/
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);
}
// Trockenlauf: prüfen, aber nichts ändern.
if ($context->isDryRun) {
return ActionResult::success(\sprintf('Dry-Run: %s nicht geändert.', $ulid));
}
$shipment->setStatus('delivered');
$this->em->flush();
return ActionResult::success(\sprintf('Sendung %s auf zugestellt gesetzt.', $ulid));
}
}
Drei Dinge, die daran nicht offensichtlich sind:
$context->isDryRunmusst du selbst beachten. Der Trockenlauf ist eine Bitte, keine Sperre.
Wer ihn ignoriert, ändert Daten in einer Vorschau.
- Die Sätze in
ActionResultsind Diagnose-Protokoll, kein Nutzertext. Sie landen im Protokoll
des Laufs und bleiben in der Systemsprache. Die Oberfläche zeigt sie nicht an.
- Kein eigener Prüfspur-Eintrag. Die Änderung wird beim Speichern ohnehin protokolliert, mit
der Urheber-Art automation. Ein zusätzlicher Eintrag wäre eine Dublette je Feldänderung.
Deine Aktion beim Speichern prüfen
Der Kern kennt die Pflichtfelder deiner Aktion nicht. Ohne eigene Prüfung fällt eine unvollständige Konfiguration erst beim Lauf auf, und dann trifft es den Empfänger, nicht den Autor.
AutomationActionConfigValidatorInterface schließt die Lücke — implementiert von derselben Aktions-Klasse:
public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void
Der dritte Parameter ist der Grund, warum die Methode drei bekommt: Ein ausgeschalteter Entwurf darf unvollständig sein, beim Einschalten nicht.
public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void
{
if (!$automationActive) {
return; // Entwurf: alles erlaubt
}
if ('' === (string) ($config['shipmentUlid'] ?? '')) {
throw new \InvalidArgumentException('shipmentUlid ist beim Einschalten Pflicht.');
}
}
Ein Vergleichsoperator
ConditionOperatorInterface ist der kleinste der vier Verträge:
public function operator(): string;
public function matches(mixed $actual, mixed $expected): bool;
Beschriftungen
Auslöser und Aktionen werden über Schlüssel beschriftet, nicht über Text. Die Konvention ist verbindlich, weil der Katalog sie berechnet:
automation.trigger.<kleinCamel(Auslöser-Schlüssel)>
automation.action.<kleinCamel(Aktions-Typ)>
Aus acme_shipping.shipment.delivered wird also automation.trigger.acmeShippingShipmentDelivered.
Sie liegen in der Sprachdatei deiner Oberfläche, nicht im Backend. Details in Übersetzungen.
Felder für das Aktions-Formular
Braucht deine Aktion eigene Eingabefelder im Baukasten, kommen sie über das Manifest der Oberfläche, nicht aus dem Backend. Siehe Oberfläche.
Der Zusammenhang zu Ereignissen
Ein Auslöser sitzt auf einem Ereignis. Die Ereignisse nach einer Änderung speisen den Fan-out für Automatisierungen und Rückrufe; die Ereignisse vor einer Änderung tun das bewusst nicht, sie dienen dem Widerspruch.
Wenn du also willst, dass etwas eine Automatisierung auslöst, brauchst du ein Ereignis nach der Änderung. Der vollständige Katalog steht in der Ereignis-Referenz.
Ein Ereignis kann sich davon ausnehmen, wenn es sonst eine Rückkopplung erzeugte: Eine Änderung, die eine Automatisierung auslöst, die dieselbe Änderung erneut vornimmt.
Was schiefgeht, und woran es liegt
| Symptom | Ursache |
|---|---|
| Neue Operation fehlt im Werkzeug-Katalog | Zwischenspeicher vor dem Export nicht geleert |
| Auslöser läuft, ist aber nicht auswählbar | TriggerCatalogProviderInterface fehlt |
| Aktion scheitert erst zur Laufzeit beim Empfänger | AutomationActionConfigValidatorInterface fehlt |
| Beschriftung zeigt den rohen Schlüssel | Sprachdatei fehlt, oder der Schlüssel folgt nicht automation.trigger.<kleinCamel> |
| Ein Trockenlauf ändert trotzdem Daten | $context->isDryRun in execute() nicht beachtet |
| Eine Automatisierung ruft sich selbst auf | Ereignis nach der Änderung ohne Ausnahme vom Fan-out |
Weiter
- MCP-Server anbinden — die Klientenseite
- MCP-Werkzeug-Katalog — was ein Klient tatsächlich sieht
- Ereignis-Referenz — worauf sich ein Auslöser setzen kann
- Backend — wo die Verträge stehen