Plugin author guide — Tools
About 840 wordsAbout 3 min
A Spora plugin’s primary job is to ship tools that an agent can call. A tool is a class that extends Spora\Tools\AbstractTool and lives in your plugin’s src/Tools/{Name}Tool.php. The plugin’s entry-point references the tool’s FQCN from tools() and the orchestrator auto-prefixes it with the plugin slug (a tool named web_search in a plugin with slug: "acme-search" is exposed to the LLM as acme-search:web_search).
The full #[Tool] / #[ToolParameter] / #[ToolSetting] attribute surface is in Concepts → Tool system — read it before shipping a tool with operator-facing config.
Where the tool lives
The canonical base class is Spora\Tools\AbstractTool (in app/Tools/AbstractTool.php). It composes two opt-in traits:
HasOperations— per-operation dispatch + override resolution.HasParameterSchema— buildsgetParametersSchema()from#[ToolParameter]attributes and synthesises a discriminator enum from#[ToolOperation].
If your tool already extends a third-party base, you can use those traits directly instead.
Minimal example
<?php
declare(strict_types=1);
namespace Spora\Plugins\AcmeSearch\Tools;
use Spora\Services\PrincipalContext;
use Spora\Tools\AbstractTool;
use Spora\Tools\Attributes\Tool;
use Spora\Tools\Attributes\ToolParameter;
use Spora\Tools\ValueObjects\ToolResult;
#[Tool(name: 'web_search', description: 'Run a web query against the Acme Search API.')]
#[ToolParameter(name: 'query', type: 'string', required: true, description: 'Natural-language query.')]
final class AcmeSearchTool extends AbstractTool
{
public function __construct(
private readonly AcmeHttpClient $http,
private readonly AcmeSettings $settings,
) {}
public function execute(
array $arguments,
int $agentId,
?int $taskId = null,
?PrincipalContext $context = null,
): ToolResult {
$response = $this->http->search($arguments['query'], $this->settings->maxResults);
return ToolResult::ok(json_encode($response, JSON_THROW_ON_ERROR));
}
public function describeAction(array $arguments): string
{
return 'Search the web for: ' . ($arguments['query'] ?? '');
}
}execute() takes four parameters, and the signature above matches Spora\Tools\ToolInterface::execute() exactly. Only the first two are required — the rest default to null, so a direct call in a unit test can pass just the arguments and the agent id.
Note: the legacy
?int $userIdthird parameter was removed in spora-core PR #288. Getting the arity wrong is fatal at class-load, not at call time, and it fails in both directions — declaring four against the four-parameter interface is required, and declaring four against the still-five-parameter released v0.29.0 core is the same fatal. Core and every installed plugin must move together; there is no ordering in which one can go first.
AbstractTool implements only getParametersSchema() (via HasParameterSchema). Your class must still supply both execute() and describeAction(), or PHP fatals at class-load time with contains 1 abstract method and must therefore be declared abstract or implement the remaining method. describeAction() returns the one-line, markdown-safe summary the approval UI shows before the operator approves the call.
Note:
#[ToolParameter]goes on the class, never on a property. It is declared#[Attribute(Attribute::TARGET_CLASS)]and its first constructor argument (name) is required, so a property-level attribute is both illegal and ignored —ToolParameterSchemaBuilderreflects on theReflectionClass, so a property attribute is invisible and the tool ships with an emptypropertiesobject. Declare the argument in the schema, then read it out of$arguments['query']inexecute(); there is no typed property backing it.
| Parameter | Type | What it is for |
|---|---|---|
$arguments | array | Key-value pairs matching your #[ToolParameter] names — the ['query' => '...'] array, not the declared typed properties. |
$agentId | int | The agent executing the tool. Use it to scope per-agent state and per-agent credentials. |
$taskId | ?int | The current tick’s task id, so chat-level tools (sub_agent, summarize, archive) can reference the source Task without re-querying by user id. |
$context | ?PrincipalContext | The ownership signal. ownerUserId is the paying user or group owner and drives credential encryption, settings scope, and audit attribution; runnerUserId is whoever triggered this task. |
Note:
$context->ownerUserIdcarries the same user the removed$userIddid — the orchestrator sourced$userIdfrom$callingAgent->user_id, and that accessor delegates toPrincipalResolver::ownerUserId($agent->principal_id), which is exactly what fillsownerUserId. For a group-owned agent that is the group’s firstowner, not the member who clicked. What$contextadds is the rest of the bundle —principalId,type,runnerUserId, andisResolvable()— and the orchestrator always supplies it.
execute() must not throw — encode every failure in the returned ToolResult so the LLM can reason about it, with either ToolResult::ok($content) or ToolResult::fail($message).
The contract is Spora\Tools\ToolInterface. Read Concepts → Tool system for the full attribute surface and the settings-key convention.
Convention reminders
- Use
finalon the tool class. declare(strict_types=1);at the top of every PHP file.- FQCN must match the file path (PSR-4) —
Spora\Plugins\AcmeSearch\Tools\AcmeSearchToollives atsrc/Tools/AcmeSearchTool.php.
What’s next
- Skills — ship operator knowledge alongside the tool’s capability
- Migrations — when your tool needs its own tables
- Admin UI — when the operator needs a UI to manage the tool’s data