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:
- Implements the
FleetPlugininterface. - Ships a service provider that extends
FleetPluginServiceProvider. - Declares itself in
composer.jsonusing Laravel auto-discovery with a customfleetkey.
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:
{
"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:
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:
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;
}
}
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:
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:
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.
// 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:
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.
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:
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_statesrows haveteam_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_statesrows are per-team (team_id = uuid)- Teams cannot install arbitrary packages