Skip to main content

Plugin System — Extend FleetQ

FleetQ's plugin system lets you package custom functionality as a standard Composer package and drop it into any self-hosted installation. Plugins are first-class citizens: they register MCP tools, add signal connectors, contribute outbound channels, inject AI middleware, extend the sidebar, and add dashboard widgets — all without touching core application code.

Scenario: A team builds a fleet-crm-sync plugin that ingests HubSpot contact signals, adds a crm_contact_update MCP tool, and displays a CRM health widget on the dashboard. Installing it is a single composer require.

How it works

A plugin is a Composer package that:

  1. Implements the FleetPlugin interface.
  2. Ships a service provider that extends FleetPluginServiceProvider.
  3. Declares itself in composer.json using Laravel auto-discovery with a custom fleet key.

On boot, FleetQ registers the plugin in its PluginRegistry, upserts a row in plugin_states, and wires up all declared capabilities. If the plugin is disabled in Settings, the boot phase is skipped entirely — no event listeners, no tools.

Package manifest

Declare your plugin in composer.json alongside the standard Laravel auto-discovery entry:

your-plugin/composer.json
{
  "name": "acme/fleet-crm-sync",
  "description": "FleetQ plugin — HubSpot CRM sync",
  "type": "library",
  "require": {},
  "autoload": {
    "psr-4": { "Acme\\CrmSync\\": "src/" }
  },
  "extra": {
    "laravel": {
      "providers": ["Acme\\CrmSync\\CrmSyncServiceProvider"]
    },
    "fleet": {
      "plugin":      "acme-crm-sync",
      "name":        "CRM Sync",
      "min-version": "1.0.0"
    }
  }
}

The FleetPlugin interface

Your plugin class implements App\Contracts\FleetPlugin:

src/CrmSyncPlugin.php
namespace Acme\CrmSync;

use App\Contracts\FleetPlugin;

class CrmSyncPlugin implements FleetPlugin
{
    public function getId(): string      { return 'acme-crm-sync'; }
    public function getName(): string    { return 'CRM Sync'; }
    public function getVersion(): string { return '1.0.0'; }

    public function register(): void
    {
        // Container bindings only — no event listeners here
    }

    public function boot(): void
    {
        // Routes, blade directives, macros
    }
}

The service provider

Extend FleetPluginServiceProvider and declare your capabilities as arrays — no manual wiring needed:

src/CrmSyncServiceProvider.php
namespace Acme\CrmSync;

use App\Providers\FleetPluginServiceProvider;
use App\Contracts\FleetPlugin;

class CrmSyncServiceProvider extends FleetPluginServiceProvider
{
    // Event listeners: EventClass => [ListenerClass, ...]
    protected array $listen = [
        \App\Domain\Signal\Events\SignalIngested::class => [
            \Acme\CrmSync\Listeners\SyncContactOnSignal::class,
        ],
    ];

    // MCP tools — automatically registered in AgentFleetServer
    protected array $mcpTools = [
        \Acme\CrmSync\Mcp\CrmContactUpdateTool::class,
        \Acme\CrmSync\Mcp\CrmContactSearchTool::class,
    ];

    // Inbound signal connectors
    protected array $signals = [
        \Acme\CrmSync\Connectors\HubSpotWebhookConnector::class,
    ];

    // Outbound delivery connectors
    protected array $outbound = [
        \Acme\CrmSync\Connectors\HubSpotOutboundConnector::class,
    ];

    // AI middleware (rate limiting, filtering, enrichment, ...)
    protected array $aiMiddleware = [
        \Acme\CrmSync\Middleware\CrmContextMiddleware::class,
    ];

    // Artisan commands (registered when running in console)
    protected array $commands = [
        \Acme\CrmSync\Console\SyncCrmContacts::class,
    ];

    // Panel extensions (sidebar nav, dashboard widgets, pages)
    protected array $panels = [
        \Acme\CrmSync\Panels\CrmDashboardPanel::class,
    ];

    protected function createPlugin(): FleetPlugin
    {
        return new CrmSyncPlugin;
    }
}
Override bootAddon() instead of boot() if you need extra boot logic. The base boot() handles disable checks and all declarative registrations before calling bootAddon().

Declarative capabilities

Array property What it registers
$listen Laravel event listeners. Map EventClass => [Listener, ...].
$mcpTools MCP tool classes. Tagged as fleet.mcp.tools and auto-appended to AgentFleetServer.
$signals Inbound signal connector classes implementing InputConnectorInterface.
$outbound Outbound delivery connectors implementing OutboundConnectorInterface.
$aiMiddleware AI gateway middleware (rate limiting, enrichment, filtering). Tagged as fleet.ai.middleware.
$livewire Livewire component namespaces. Map alias => FQNamespace.
$panels Panel extensions that add sidebar links, pages, and dashboard widgets.
$commands Artisan commands. Registered only when running in console.

Optional interfaces

HasPluginSettings — settings UI

Implement HasPluginSettings on your FleetPlugin class to inject a settings tab into the platform's Settings page:

php
class CrmSyncPlugin implements FleetPlugin, HasPluginSettings
{
    // ...

    public function settingsComponent(): string
    {
        return \Acme\CrmSync\Livewire\CrmSettingsForm::class;
    }
}

PanelExtension — sidebar, pages, and widgets

Implement PanelExtension to register sidebar navigation items and dashboard widgets:

php
use App\Contracts\PanelExtension;
use App\Domain\Shared\DTOs\NavigationItem;

class CrmDashboardPanel implements PanelExtension
{
    public function pages(): array
    {
        return [\Acme\CrmSync\Livewire\CrmContactsPage::class];
    }

    public function navigationItems(): array
    {
        return [
            new NavigationItem(
                label: 'CRM Contacts',
                route: 'crm.contacts',
                icon:  'user-group',
                order: 90,
            ),
        ];
    }

    public function dashboardWidgets(): array
    {
        return [\Acme\CrmSync\Livewire\CrmHealthWidget::class];
    }
}

HasPluginMeta — namespaced model metadata

Any model that uses the HasPluginMeta trait exposes namespaced key-value storage in its meta JSONB column. Each plugin's data is isolated under its own ID — plugins cannot overwrite each other's data.

php
// Store data on the agent under your plugin's namespace
$agent->setPluginMeta('acme-crm-sync', 'hubspot_contact_id', 'abc123');

// Read it back
$contactId = $agent->getPluginMeta('acme-crm-sync', 'hubspot_contact_id');

// Read all metadata your plugin stored
$all = $agent->allPluginMeta('acme-crm-sync');

// Clean up
$agent->forgetPluginMeta('acme-crm-sync', 'hubspot_contact_id');

The HasPluginMeta trait is available on Agent, Skill, Experiment, and other core models that carry a meta JSONB column.

Installing a plugin

Self-hosted

Install via Composer and run migrations if the plugin ships any:

bash
composer require acme/fleet-crm-sync
php artisan migrate

On the next request, FleetQ auto-discovers the service provider and registers the plugin. You'll see it appear in Settings → Plugins where you can enable or disable it.

Cloud (managed)

On the managed cloud, teams cannot run composer require. The platform operator pre-installs and whitelists plugins via FLEET_CLOUD_PLUGINS. Each team can then enable or disable any whitelisted plugin from Settings → Plugins — no server access required.

Disabling a plugin skips its entire boot phase — event listeners, MCP tools, and routes are not registered. Any persisted data (plugin meta, settings) is preserved and restored when re-enabled.

External plugin providers

For packages that manage their own autoloading outside of your app's composer.json (e.g. plugins loaded from a custom path), list their service providers in .env:

.env
FLEET_EXTERNAL_PLUGIN_PROVIDERS=Acme\CrmSync\CrmSyncServiceProvider,Acme\Analytics\AnalyticsServiceProvider

Configuration reference

Variable Default Description
FLEET_PLUGINS true Set to false to disable all plugins globally.
FLEET_CLOUD_PLUGINS (empty) Cloud only. Comma-separated plugin IDs the platform operator exposes to teams. Teams opt-in/out per plugin.
FLEET_EXTERNAL_PLUGIN_PROVIDERS (empty) Comma-separated FQCNs of service providers to register at boot, for packages outside Composer's autoloader.

Self-hosted vs cloud

Self-hosted

  • Install any plugin with composer require
  • Enable / disable globally in Settings → Plugins
  • plugin_states rows have team_id = NULL
  • No whitelist required

Cloud (managed)

  • Platform operator installs & whitelists plugins via FLEET_CLOUD_PLUGINS
  • Each team opts in or out from their plugins page
  • plugin_states rows are per-team (team_id = uuid)
  • Teams cannot install arbitrary packages