Plugin author guide — Foundations
About 1092 wordsAbout 4 min
The three things every Spora plugin needs before it can do anything else: the package shape (a Composer package of type: "spora-plugin"), the manifest (plugin.json), and the entry-point class (a PHP class that implements PluginInterface).
The complete plugin system reference (load order, manifest validation, boot-time stamp cache, security model) lives in Concepts → Plugin system. This chapter is the “first commit” walkthrough.
What a Spora plugin is
A Spora plugin is a Composer package — installable via composer require and shipped to Packagist like any other PHP library — that contributes runtime capabilities to a Spora deployment:
- Tools callable by an agent (web search, image generation, calendar ops).
- LLM drivers that plug into the driver factory alongside OpenAI and Anthropic.
- Migrations that create plugin-owned database tables.
- Agent templates that bundle a system prompt + tool activations + per-operation auto-approve defaults into a one-click Agent. See Agent templates.
A plugin is identified by a Composer package with type: "spora-plugin". On install, the spora-ai/installer Composer plugin routes the package to the host Spora’s plugins/{slug}/ directory and the host’s PluginLoader picks up its manifest on the next request.
Two reference layouts:
- Skeleton template —
spora-ai/spora-plugin-skeleton. Copy this repo to bootstrap a new plugin. - Production example —
spora-ai/spora-plugin-minimax. Four tools (image, speech, music, video) — lyrics are operations on the music tool, not a separate tool — plus one migration and custom DI bindings.
Standard layout
spora-plugin-foo/
├── composer.json # type: "spora-plugin"
├── plugin.json # manifest (slug, class, description, icon)
├── plugin.schema.json # (optional) $schema pointer for editor hints
├── src/
│ ├── FooPlugin.php # entry-point class (FQCN = Spora\Plugins\Foo\FooPlugin)
│ └── Tools/
│ └── FooTool.php # at least one tool class
├── database/
│ └── migrations/
│ └── foo_000001_create_xyz_table.php
├── agent-templates/ ← optional: ship curated Agent templates
│ └── research-assistant.json
└── tests/
└── Unit/
└── Tools/
└── FooToolTest.phpThe directory name is up to you (Composer picks files by namespace, not directory name), but it must match the slug used in plugin.json and as the migration filename prefix.
plugin.json manifest
The full JSON Schema lives at plugin.schema.json in the framework repo. JSON-schema validation rejects extra fields outright (additionalProperties: false); Spora\Plugins\PluginLoader separately validates the two required fields (slug and class) and refuses to load if they are missing or malformed.
Required fields
| Field | Type | Description |
|---|---|---|
slug | string | Machine identifier. Matches ^[a-z0-9][a-z0-9_-]*$. Stable across releases — used as the component key in schema_versions and as the migration filename prefix. |
class | string | FQCN of the entry-point class. Must implement Spora\Plugins\PluginInterface and resolve via PSR-4 autoloading. |
Optional fields
| Field | Type | Description |
|---|---|---|
description | string | Short human-readable description surfaced by the inventory UI. Max 500 chars. |
icon | string | Icon shown next to the plugin in admin UIs. Three accepted forms — bundled name ("puzzle", "brain", "globe"…), a full <svg> string, or a raw SVG path string. Defaults to "puzzle" when omitted. See Plugin system → Bundled icons for the full palette. |
Minimal example
{
"slug": "acme-search",
"class": "Spora\\Plugins\\AcmeSearch\\AcmeSearchPlugin"
}Full example
{
"slug": "acme-search",
"class": "Spora\\Plugins\\AcmeSearch\\AcmeSearchPlugin",
"description": "Web search via the Acme API.",
"icon": "globe"
}What is not in the manifest
The previous schema (<= v0.5.x) accepted version, dependencies, autoload, and file overrides. Those were dropped when PluginLoader switched to PSR-4-only entry-point resolution:
- Version is taken from
composer.json. Noversionfield. - Inter-plugin dependencies are declared in
composer.json("require"), not the manifest. - Autoload PSR-4 mappings are declared exclusively in
composer.json. The manifest no longer accepts anautoloadblock. fileoverride is gone — the loader instantiatesclassvia PSR-4 and throws on failure.
If you are maintaining an older plugin, see the PSR-4 entry-point quirk section in the Distribution chapter.
Entry-point class
The class named in the manifest class field must implement Spora\Plugins\PluginInterface. In practice, extend Spora\Plugins\AbstractPlugin (or, for new code that may one day move into an App, Spora\Extensions\AbstractExtension — both implement the same interface).
AbstractPlugin provides empty defaults for every hook, so you only override what you actually use. The two methods every plugin overrides are getName() (for UI display) and tools() (the whole point of the plugin).
<?php
declare(strict_types=1);
namespace Spora\Plugins\AcmeSearch;
use Spora\Plugins\AbstractPlugin;
use Spora\Plugins\AcmeSearch\Tools\AcmeSearchTool;
final class AcmeSearchPlugin extends AbstractPlugin
{
public function getName(): string
{
return 'Acme Search';
}
/** @return array<class-string<\Spora\Tools\ToolInterface>> */
public function tools(): array
{
return [
AcmeSearchTool::class,
];
}
}Available hooks
All hooks live on Spora\Plugins\PluginInterface (re-exported from Spora\Extensions\SporaExtensionInterface). Most plugins override one or two; the rest stay at their AbstractPlugin no-op defaults.
| Hook | Returns | Purpose |
|---|---|---|
getName() | string | Human-facing name shown in admin UIs. |
autoload() | array<string, string> (ns → path) | Additional PSR-4 namespace → path mappings registered at boot. |
tools() | class-string<ToolInterface>[] | Tools contributed to the tool registry. |
drivers() | string[] (id → FQCN) | LLM drivers contributed to the driver factory. |
recipePaths() | string[] | Absolute paths to recipe YAML directories or files. (Deprecated — use agentTemplatePaths() instead.) |
agentTemplatePaths() | string[] | Absolute paths to Agent template files (.json / .yaml / .yml). See Agent templates. |
apps() | class-string<AppInterface>[] | UI side-panels contributed to the App registry. |
migrationsPath() | ?string | Absolute path to plugin migrations directory, or null if no schema. |
schemaVersion() | int | Bump every time a new migration file is added. 0 if no schema. |
register(ContainerBuilder $builder) | void | Hook for arbitrary DI bindings, middleware, or services. |
boot() | void | Lifecycle hook fired once after DI container is built, before the first request. |
routes(MiddlewareRouteCollector $routes) | void | Register HTTP routes into the running middleware collector. |
For the new hook surface and why PluginInterface is now a marker (with SporaExtensionInterface carrying the contract), see the docblock on PluginInterface in the framework repo.
What’s next
- Tools — add the first callable surface
- LLM drivers — register a new driver with the factory (rare; tools are usually enough)
- Migrations — when your plugin needs its own tables
- Admin UI — when your plugin needs an operator-facing panel
- Distribution — when you have working code on
mainand want to ship it