Tool system
About 6711 wordsAbout 22 min
This document covers how to author a tool, the attribute system, naming conventions, and how tools reach the LLM.
Authoring a Tool
A tool is a final PHP class that extends AbstractTool and declares its identity, operations, parameters, and settings as PHP attributes. The base class composes HasOperations (operation dispatch) and HasParameterSchema (auto-generated JSON Schema), so a minimal tool is just execute() + describeAction().
use Spora\Tools\AbstractTool;
use Spora\Tools\Attributes\Tool;
use Spora\Tools\Attributes\ToolOperation;
use Spora\Tools\Attributes\ToolParameter;
use Spora\Tools\ValueObjects\ToolResult;
#[Tool(
name: 'web_search',
description: 'Search the web.',
displayName: 'Web Search',
category: 'research',
icon: 'search', // bundled icon key (optional; see below)
)]
#[ToolOperation(name: 'search', description: 'Run a search', enabledByDefault: true, requiresApprovalByDefault: false)]
#[ToolParameter(name: 'query', type: 'string', description: 'The search query.', required: true)]
final class MyWebSearchTool extends AbstractTool
{
public function execute(array $arguments, int $agentId, ?int $taskId = null, ?PrincipalContext $context = null): ToolResult
{
$query = trim((string) ($arguments['query'] ?? ''));
// ...
return new ToolResult(true, "Results for {$query}");
}
public function describeAction(array $arguments): string
{
return "Search the web for: '{$arguments['query']}'";
}
}That’s it — no hand-written getParametersSchema(). The ToolParameterSchemaBuilder reads the #[ToolOperation] and #[ToolParameter] attributes via reflection and produces the JSON Schema sent to the LLM.
Where ownership comes from
The $context passed into execute() is sourced by the Orchestrator from the calling Agent’s row (PrincipalResolver::resolveForToolExecute($agentId) at app/Agents/Orchestrator.php:533), not from the session. The dispatcher cannot thread a session-derived user id into a tool — the call site does not accept one. Tools therefore never need to treat ownership as “whoever is signed in”: $context->ownerUserId is “the owner of the agent that issued this call”. When the Orchestrator boots without a resolvable agent row, PrincipalContext::isResolvable() is false and ownerUserId is 0; the tool’s own getAgentByAgentId() fallback applies. See Concepts → Architecture → Orchestrator Loop for the structural guarantee.
There is no raw ?int $userId parameter any more — it was removed in spora-core PR #288, and getting the arity wrong is fatal at class-load in both directions, so core and every installed plugin move together.
The auto-synthesized action discriminator
When a tool declares two or more #[ToolOperation] attributes, the builder prepends a property to the schema (named after the first operation’s discriminatorKey, default 'action') whose enum lists every declared operation name. Do not also write #[ToolParameter(name: 'action', ...)] — the builder owns that property.
#[ToolOperation(name: 'list_events', description: 'Fetch upcoming events')]
#[ToolOperation(name: 'create_event', description: 'Create an event')]
// Auto-generated:
// properties.action = {type: string, enum: ['list_events', 'create_event'], description: '...'}
// required = ['action']Single-op tools skip discriminator synthesis — the LLM has no choice to make, and HasOperations::getOperationName() falls back to the one declared operation when the argument is absent.
To use a different discriminator key (e.g. 'operation' for parity with an external API), declare it on every #[ToolOperation]:
#[ToolOperation(name: 'search', ..., discriminatorKey: 'operation')]
#[ToolOperation(name: 'top_news', ..., discriminatorKey: 'operation')]Parameter declaration order is significant
The order in which #[ToolParameter] attributes appear on the class determines:
- The property order in the JSON Schema sent to the LLM.
- The render order of fields in the approval UI (the
parameter_schemafield ontool_callsAPI responses carries this order to the frontend).
Put the most important parameters first.
Inheritance
#[ToolParameter] declared on a parent class is inherited by subclasses for schema generation — ToolParameterSchemaBuilder walks the class hierarchy when collecting #[ToolParameter] attributes. Use this for shared parameter sets:
#[ToolParameter(name: 'name', type: 'string', description: '...', required: false)]
#[ToolParameter(name: 'content', type: 'string', description: '...', required: false)]
abstract class AbstractMemoryTool extends AbstractTool { /* shared CRUD */ }
#[Tool(name: 'memory', description: 'Agent-scoped memory.')]
#[ToolOperation(name: 'list', ...)] #[ToolOperation(name: 'get', ...)]
#[ToolOperation(name: 'save', ...)] #[ToolOperation(name: 'delete', ...)]
final class AgentMemoryTool extends AbstractMemoryTool { protected function getScope(): string { return 'agent'; } }The concrete AgentMemoryTool schema includes action, name, and content automatically.
Note on
#[ToolOperation]:HasOperationsreads operation attributes only from the concrete class — it does not walk parent classes. Always declare#[ToolOperation]on the concrete tool class, otherwise dispatch will fail to resolve operations advertised by an inherited schema.
#[Tool] reference
| Field | Type | Notes |
|---|---|---|
name | string | LLM-facing name — snake_case, /^[a-z][a-z0-9_]*$/. Plugin tools are auto-prefixed with <plugin-slug>: on the wire. |
description | string | Sent to the LLM. |
displayName | ?string | Operator-facing label; falls back to the class basename when omitted. |
category | string | One of 'general' (default), 'research', 'communication', 'productivity', 'data', 'system'. Drives the agent-tools UI grouping. |
icon | ?string | Bundled icon key (e.g. 'calendar', 'mail', 'search', 'globe'). Layer 1 of the icon resolution chain; falls back to the owning plugin’s plugin.json#icon and finally to 'puzzle'. |
recommendsSkills | ?array<string> | Optional list of skill slugs this tool bundles with. When the operator enables the tool, the agent-tools UI offers to also activate SkillTool and add these slugs to its allowed_skills. Slugs must match the agentskills.io pattern (lowercase, alphanumeric + hyphen, 1–64 chars, no leading/trailing hyphen, no --). Default []. See Bundled skills. |
#[ToolParameter] reference
| Field | Type | Notes |
|---|---|---|
name | string | Argument key the LLM sends. |
type | string | One of string, number, integer, boolean, array, object. |
description | string | Sent to the LLM. |
required | bool|list<string> (default true) | true/false keep the global behaviour; a non-empty list of operation names binds the parameter to those operations only — e.g. required: ['format'] makes the param required when the dispatcher is format. required: [] is coerced to true; use false for the truly optional case. |
default | mixed (default null) | Emitted as JSON Schema default. When set, the parameter is omitted from required[] regardless of the required flag. |
enum | list<string> | Value allowlist (string types). |
minimum / maximum | int|float|null | Numeric bounds. |
format | ?string | JSON Schema format hint (e.g. 'date', 'email'). |
items | ?array | Sub-schema for array types, e.g. ['type' => 'string']. |
Worked example: required: list<string>
TimeTool::epoch is shared across operations but required only by format, so it uses a per-operation binding:
#[ToolParameter(
name: 'epoch',
type: 'integer',
description: 'Unix timestamp to format.',
required: ['format'],
)]The generated schema requires epoch when the action dispatcher is format, while leaving it optional for other operations.
Plugin tools
Plugin tools can either extends AbstractTool like core tools, or — if they need to extend a third-party base class — opt in via:
use Spora\Tools\Traits\HasOperations;
use Spora\Tools\Traits\HasParameterSchema;
final class MyPluginTool extends ThirdPartyBase implements ToolInterface
{
use HasOperations;
use HasParameterSchema;
// ...
}The schema builder works on any FQCN via reflection — no path coupling.
Bundled skills
A tool can declare that it “bundles” one or more Skills — knowledge the operator would otherwise have to remember to activate separately. The recommendsSkills argument on #[Tool] takes a list of agentskills.io slug strings; the agent-tools UI then offers to also enable SkillTool and seed its allowed_skills with those slugs when the operator turns the bundling tool on, and asks whether to clean up the allowlist when the operator turns it off again.
#[Tool(
name: 'git_workflow',
description: 'Run git operations against the operator-configured repo.',
recommendsSkills: ['git', 'conventional-commits'],
)]The intent is to make implicit dependencies explicit. A git_workflow tool that needs the git and conventional-commits skills to produce useful output should declare that on its attribute so operators do not have to read every tool’s source to wire up the right allowlist.
Strict mode is on by default. Declaring a slug that does not exist on disk (under the framework, project, or plugin scan roots) makes GET /api/v1/tools return HTTP 500 with code TOOLS_RECOMMENDS_SKILLS_MISSING for the entire operator instance until the typo is fixed — there is no soft warning and no env-flag opt-out. The trade-off is deliberate: a misconfigured plugin (declared slug, no shipped skill) is a packaging bug operators must see, not a silently empty allowlist. Core tools are validated by tests/Unit/Tools/ToolRecommendsSkillsValidationCoreTest; plugin authors should mirror the same shape over their own scanner roots — see Validation in the plugin author guide.
The full operator flow — per-skill toggle list with title-cased names, the parent-tool disable cascade that strips unique slugs while leaving SkillTool enabled, and the status refresh that keeps the SkillTool card in sync — is documented under Skills → Bundled skills on the agent tools UI.
Tool naming
Every tool carries a unique LLM-facing name declared via the #[Tool(name:)] attribute:
#[Tool(
name: 'web_search', // snake_case, /^[a-z][a-z0-9_]*$/
description: 'Search the web.'
)]Names must match /^[a-z][a-z0-9_]*$/ (lowercase alphanumeric + underscore, starting with a letter). An InvalidArgumentException is thrown at class instantiation time if the name is invalid.
Core vs Plugin namespacing
- Core tools (built-in): sent to the LLM with their plain name, e.g.
web_search. - Plugin tools: prefixed with the plugin slug and a colon, e.g.
my-plugin:web_search.
This ensures global uniqueness — two plugins can never produce a tool name collision. The prefix is derived automatically from plugin.json and requires no changes to the plugin’s #[Tool] attribute.
Note: core tools intentionally do not use a
core:prefix. Adding it would change every tool name currently known to the LLM, breaking existing agents. Only plugin tools get the slug prefix.
Discovery from the LLM
The built-in AgentTool (app/Tools/AgentTool.php) exposes a get_available_tools operation that returns a compact, versioned JSON payload describing every registered tool on the agent. The LLM calls this operation to plan which tools to use, to identify the tool_class it needs for configure_tools, and to surface plugin_slug values for required_plugins (operator-upload path only).
Response contract (version 2)
{
"version": 2,
"count": 2,
"tools": [
{
"tool_class": "Spora\\Tools\\CalculatorTool",
"display_name": "Calculator",
"description": "Perform arithmetic calculations.",
"plugin_slug": null,
"enabled": true,
"ready_to_enable": true,
"missing_required": [],
"operations": [
{
"name": "calculate",
"description": "Evaluate an expression.",
"enabled": true,
"requires_approval": false
}
]
},
{
"tool_class": "Spora\\Plugins\\Tavily\\Tools\\TavilySearchTool",
"display_name": "Tavily Search",
"description": "Search the web via Tavily.",
"plugin_slug": "tavily",
"enabled": false,
"ready_to_enable": false,
"missing_required": ["api_key"],
"operations": [
{
"name": "search",
"description": "Run a search.",
"enabled": true,
"requires_approval": false
}
]
}
],
"skills": {
"allowed": ["time-arithmetic"],
"visible": [
{
"name": "time-arithmetic",
"description": "Answer questions about dates, times and durations.",
"active": true
},
{
"name": "agent-creation",
"description": "Create and configure a sub-agent end to end.",
"active": false
}
]
}
}Field semantics
tool_classis the FQCN the LLM passes verbatim intoconfigure_tools.tools[].tool_class. Don’t invent FQCNs — only registered classes can be enabled.plugin_slugisnullfor core tools, the plugin slug (e.g."tavily") for plugin-owned tools, and the plugin slug for tools registered by the operator’sapp/App.phpextension. Use it forrequired_plugins[]on the operator-upload path; never substitute the FQCN.display_nameanddescriptionare kept onget_available_toolsfor operator-facing browsing, but dropped from the slim per-tool manifest returned byread_agent/configure_tools/update_agent(the LLM already hastool_class— carrying descriptive metadata bloats every LLM turn). The slim shape is{ tool_class, icon, enabled, operations[] }.enabledmirrors the currentagent_toolsrow presence.ready_to_enableistruewhen no required settings are missing. A tool withenabled: false, ready_to_enable: falseneeds configuration before the operator can enable it; the agent cannot enable it on its own.missing_requiredlists only the required setting keys; no effective values are exposed to avoid leaking credentials.operations[]carries per-operationenabled/requires_approvalstate, resolved against the effective override (or the operation’senabledByDefault/requiresApprovalByDefaultwhen no override exists).skills.allowedis the agent’s ownallowed_skillslist — the names it may load right now.skills.visibleis every skill the executing principal can see, each{name, description, active}.activeistruewhen that name is inskills.allowed. The two lists are deliberately separate: folding them together would put names in the same array whether or not the agent holds them, and the model would read array membership as the answer to “may I load this?”.
Note:
skills.visibleis the legal name set for asettings.allowed_skillswrite.configure_toolsrefuses any skill name not present in it — the whole call, not just the offending entry, because a list that quietly shrank reads to the model as the whole list landing. Readskills.visiblefirst, then name from it. The check fails closed: a provider that scopes by principal sees nothing and every name is refused.
configure_tools — the tools[] entry shape
{
"agent_id": 42,
"tools": [
{
"tool_class": "Spora\\Tools\\SkillTool",
"enabled": true,
"settings": { "allowed_skills": ["time-arithmetic", "agent-creation"] },
"operations": [{ "name": "list", "enabled": true, "auto_approve": false }]
}
]
}settings is optional; so are enabled and operations. Omit a key and it changes nothing. The three semantics that are easy to get wrong:
- A
settingswrite replaces the value at that key wholesale — it does not merge and it does not append. Sendingallowed_skills: ["time-arithmetic"]over an existing["time-arithmetic", "agent-creation"]leaves you with exactly["time-arithmetic"]. Always send the complete list you want to end up with. (Other keys on the same tool are untouched — it is per-key replacement, not a whole-object overwrite.) - A
settingswrite lands at the AGENT level, and that breaks inheritance. The value is written toagent_tool_overrides, which is the last-wins layer of the cascade, so the agent stops inheriting whatever the group or user scope holds for that key. To go back to inheriting, sendnullor""for the key — an empty value drops the agent-level row entirely and the cascade resumes from the layer below. allowed_skillsisstring[]of skill names;allowed_target_agentsisint[]of agent ids. The shape follows the setting’sresolveAs:SkillTool::allowed_skillsdeclaresresolveAs: 'skill'and stores names, whileSubAgentTool::allowed_target_agentsuses the defaultresolveAs: 'agent'and stores integer ids. Send a string where an id is expected (or vice versa) and the entry is refused — readskills.visible[].namefor the first andGET /api/v1/agentsfor the second.
Two flags on a tool entry behave differently from the same flag on an operation:
enabledon a tool entry is tri-state.trueenables,falsedisables, and omitting the key leaves enablement alone — so a settings-only or operations-only entry cannot grant a tool as a side effect. A blank string is read as absent, not asfalse.enabledon an operation is not tri-state. Naming an operation turns it on;[{name: "now"}]is{name: "now", enabled: true}. To switch one off you must sendenabled: falseexplicitly — omitting it on an operation row is not “leave alone”, it is “turn on”.
Refusals are whole-call and happen before any write, so a rejected payload cannot have landed anything:
| Refused | Why |
|---|---|
An unknown settings key | Dropped silently, a misspelling like allowed_sklls would leave the model believing a list landed — and it would then read a skill it still cannot. |
A type: 'password' setting | A credential is the one thing a tool call must not be able to write: the value would land in the call’s own recorded arguments, so the agent could read back the key it just set. Credentials stay operator-only, in the settings panel. |
| An operation name the tool does not declare | A dead override row is invisible in the manifest and reads as “nothing landed”. The refusal names the available operations. |
A skill name outside skills.visible | Refuses the whole call — see the note above. |
Slim two-phase agent creation
The LLM-facing agent creation flow is two-phase — create_agent does NOT accept a tools[] block. The flow is:
create_agent— slim skeletal record (name,description,system_prompt,max_steps,allow_followup,retry_after_minutes,max_retries). Capture the returnedagent_id.configure_tools(agent_id: <id>, tools: [...])— apply the toolset. Each entry is{ tool_class, enabled?, settings?, operations: [{name, enabled?, auto_approve?}] }— see thetools[]entry shape for thesettingsandenabledsemantics.read_agent(agent_id: <id>)— verify the toolset is exactly what was wanted.
The full agent-template shape (id / version / nested agent{} / tools[] / required_plugins[]) is reserved for the operator-upload endpoint at POST /api/v1/agent-templates/import (see Agent template schema). create_agent rejects the nested-object shape with a literal “send X instead” example; see skills/agent-creation/SKILL.md for the full protocol.
/api/v1/tools response shape
GET /api/v1/tools is the admin-side registry endpoint that powers the agent-tools UI. The shape is built by ToolSchemaPresenter and includes every field the frontend needs to render a tool row plus the bundled-skill affordance:
| Field | Type | Notes |
|---|---|---|
tool_class | string | FQCN of the registered tool class. |
tool_name | string | The class basename (SubAgentTool → SubAgentTool). |
display_name | string | Operator-facing label, falls back to tool_name. |
description | string | Tool description, sent to the LLM. |
category | string | One of general / research / communication / productivity / data / system. Drives the agent-tools UI grouping. |
icon | string | null | Resolved icon key (3-layer chain — see Icon resolution). |
recommends_skills | string[] | Skill slugs this tool bundles. Default []. Powers the bundled-skill affordance. Strict-mode: if any entry does not resolve on disk, the entire endpoint short-circuits with HTTP 500 TOOLS_RECOMMENDS_SKILLS_MISSING — see Errors. |
operations | object[] | Per-operation { name, description, enabledByDefault, requiresApprovalByDefault, discriminatorKey }. |
Errors
| HTTP | Code | When |
|---|---|---|
| 500 | TOOLS_RECOMMENDS_SKILLS_MISSING | One or more recommends_skills entries do not resolve under the framework / project / plugin scan roots. The strict-mode check has no env-flag opt-out — the response shape carries the offenders under error.details.violations[] so operators can pinpoint the mismatch: |
{
"error": {
"code": "TOOLS_RECOMMENDS_SKILLS_MISSING",
"message": "2 tool(s) declare recommendsSkills slugs that are not on disk. See details for offenders.",
"details": {
"violations": [
{
"tool_class": "Spora\\Plugins\\AcmeSearch\\Tools\\AcmeSearchTool",
"tool_name": "AcmeSearchTool",
"missing": ["git", "conventional-commits"]
}
]
}
}
}The 500 affects only the list endpoint — per-tool settings (/api/v1/tools/{toolId}/settings, /user-settings) keep working, so operators can fix the underlying slug declaration without losing access to the rest of the admin UI.
Tool activation is operator-only
The agent-facing get_available_tools does not expose enable/disable operations. There is no enable_tool or disable_tool the LLM can call. To activate a tool:
- For sub-agents, call
create_agent(slim) and thenconfigure_tools(agent_id: <id>, tools: [...])— see Slim two-phase agent creation. - For the calling agent itself, the operator must enable the tool through the agent settings UI or the
POST /api/v1/agents/{id}/tools/{toolId}/enableendpoint. The{toolId}path segment is the tool’s#[Tool(name:)]value (e.g.tavily_searchorcalculator) — see Route definitions for the canonical mapping.
This split keeps tool activation on the calling agent under explicit operator control while still letting the agent self-compose a sub-agent when it needs capabilities beyond its current set.
Icon resolution
A tool may declare a ?string $icon argument on the #[Tool(...)] attribute — a kebab-case key from the bundled icon palette (e.g. 'calendar', 'mail', 'search', 'globe'). The icon is surfaced on the Agent resource (GET /api/v1/agents / GET /api/v1/agents/{id}) so the admin UI can render a matching tile for the tool, and on every ToolCall in tool_calls[] (REST + Mercure live-update) so the chat UI’s compact tool stream can render per-tool icons.
Resolution is a 3-layer chain, evaluated server-side:
- The
*Toolclass’s#[Tool(icon: ...)]argument (most specific — wins for multi-tool plugins). - The owning plugin’s
plugin.jsoniconfield (plugin-level identity — covers single-tool plugins automatically). null— the frontend<Icon>component falls back to'puzzle'.
Single-tool plugins don’t need to set #[Tool(icon: ...)] — their plugin.json icon field is enough via the layer-2 fallback. Multi-tool plugins should set both: plugin.json for the plugin’s overall identity and #[Tool(icon: ...)] per tool for the per-tool override.
Tool Settings Key Convention
Settings keys are the bare field name (e.g. api_key, http_timeout, host), scoped to the declaring tool class via its #[ToolSetting] attribute. The key is what the UI displays and what tool code reads via ToolConfigService::getEffectiveSettings(toolClass, ...).
There is no shared key namespace — keys are looked up per tool class through reflection on the #[ToolSetting] attributes on that class. Two different tool classes may declare the same key name (e.g. api_key) without colliding; each resolves to its own setting on its own tool.
Examples:
http_timeout(declared onReadUrlTool)api_key(declared on whatever tool needs it —TavilySearchTool,WeatherApiTool, etc.)
Plugin Tools
When a tool is contributed by a plugin, the same rule applies: the key is the bare field name. The plugin declares its tool class in a PSR-4-namespaced location and the #[ToolSetting] attributes on that class drive the same per-tool resolution. No plugin.* namespace prefix is used.
Architecture: Settings Live on the Tool
Settings are declared as #[ToolSetting] PHP attributes directly on the tool class that consumes them. There are no separate “Configuration” shell classes.
Example
#[Tool(
name: 'my_search',
description: 'Search a remote API.',
)]
#[ToolSetting(
key: 'api_key',
label: 'API Key',
type: 'password',
description: 'API key for the remote search service.',
required: true,
// `scope` defaults to `'any'` so the example stays focused on the
// most common case — the field renders in every settings panel.
// See [Setting render scope](#setting-render-scope) below for the
// narrow scopes available when a setting only makes sense under a
// specific principal or agent context.
scope: 'any',
)]
#[ToolOperation(name: 'search', description: 'Search', enabledByDefault: true, requiresApprovalByDefault: false)]
#[ToolParameter(name: 'query', type: 'string', description: 'The query.', required: true)]
final class MySearchTool extends AbstractTool
{
public function __construct(
private readonly ToolConfigService $configService,
private readonly HttpClientInterface $httpClient,
) {}
public function execute(array $arguments, int $agentId, ?int $taskId = null, ?PrincipalContext $context = null): ToolResult
{
$settings = $this->configService->getEffectiveSettings(static::class, $agentId, null, $context);
$apiKey = $settings['api_key'] ?? '';
// ...
}
public function describeAction(array $arguments): string { /* ... */ }
}Why?
- Discoverability: A developer reading a tool class can immediately see what settings it needs and what parameters the LLM sends it.
- No empty shell classes: Previously, settings lived on empty
*Configurationclasses that existed only to hold attributes. This was wasteful. - Self-documenting: The
ToolControllerscans#[ToolSetting]attributes via reflection. Since they now live on the tool class itself, the API endpointGET /api/v1/toolsautomatically returns both the tool schema and its settings schema in a single response.
Exception: LLM driver settings
LLM driver settings (OpenAI/Anthropic API keys, model, base URL, etc.) are stored as a JSON blob on the llm_driver_configurations table, scoped to a per-driver LLMDriverConfiguration record, and surfaced via LLMConfigService::decodeSettings(). The driver class itself declares its settings via #[ToolSetting] on the class (see app/Drivers/OpenAICompatibleDriver.php and app/Drivers/AnthropicCompatibleDriver.php), and DriverFactory reads them when constructing a driver for an agent.
Setting cascade: schema defaults → global → user → agent
ToolConfigService::getEffectiveSettings(toolClass, agentId, userId) resolves each setting in order:
- Schema defaults — the
default:value declared on the#[ToolSetting(...)]attribute, used when no row exists in any of the three tables below. - Global value (
tool_configurationstable, scoped to the tool class). - User-level override (
tool_user_settings, whenuserIdis provided). - Agent-level override (
agent_tool_overrides, when an entry exists foragentId + toolClass).
Later layers win; schema defaults fill in any keys that no layer has set. Tools never read tool_configurations directly — always go through ToolConfigService.
Per-Tool Key Scoping
Settings keys are scoped to the declaring tool class. Two tools that happen to declare a setting with the same key name (e.g. both declaring api_key) do not share values — each tool resolves its own settings independently via ToolConfigService::getEffectiveSettings(static::class, ...).
This means:
- A user configures
api_keyonce on theTavilySearchToolsettings panel — that value is only used whenTavilySearchToolruns. - A different tool that also declares
api_key(e.g.WeatherApiTool) reads its own separately-storedapi_keyvalue.
The ToolConfigService::getEffectiveSettings() method resolves settings by scanning the #[ToolSetting] attributes on the requested class, then looking up each key in the global and agent-override stores for that class.
LLM Exposure (exposeToLlm)
By default, tool settings are server-side only — they influence how a tool behaves at execution time but are never sent to the LLM.
The exposeToLlm parameter on #[ToolSetting] controls whether a setting’s resolved value is included in the tool definition the LLM receives. This lets the LLM make informed decisions based on its effective configuration.
#[ToolSetting(
key: 'allowed_domains',
label: 'Allowed Domains',
type: 'text',
description: 'Comma-separated list of domains the agent is allowed to query.',
exposeToLlm: true, // included in LLM tool definition
)]
#[ToolSetting(
key: 'api_key',
label: 'API Key',
type: 'password',
exposeToLlm: false, // NOT sent to LLM (credential)
)]Default behavior
exposeToLlm defaults to false because most settings are credentials or infrastructure (hosts, ports, timeouts). Only mark exposeToLlm: true for settings that directly affect what the LLM can do — e.g. allowed recipient lists, sender addresses, toggle-able capabilities.
How it reaches the LLM
ToolConfigService::getLlmToolSettings() returns the effective (cascaded) values for all exposeToLlm: true settings on a tool. The Orchestrator appends these to the tool’s description before sending it to the LLM:
[Effective Configuration]
- Allowed Domains: example.com, internal.example.org
- From Address: agent@spora.localUnconfigured settings are shown as (not configured) so the LLM knows a capability may be unavailable.
Setting render scope
Every #[ToolSetting] declares where it can be configured via the scope: argument. The settings panel reads this metadata and renders each field only in compatible contexts, so a setting that only makes sense under a specific principal or agent context is never offered for editing where the resulting value would be meaningless or rejected at runtime.
#[ToolSetting(
key: 'allowed_target_agents',
label: 'Allowed target agents',
type: 'multi-select',
required: true,
scope: 'principal', // see matrix below
exposeToLlm: true,
)]Scope values
| scope | Admin operator defaults | User scope (/settings/tools) | Group scope (/groups/{id}/tools) | Agent override (/agents/{id}/tools) |
|---|---|---|---|---|
'any' (default) | renders | renders | renders | renders |
'principal' | hidden | renders | renders | renders |
'agent' | hidden | hidden | hidden | renders |
The picker multi-select on scope: 'principal' settings is also scoped by principal_id when rendered. The settings panel derives the principal from its own mode:
mode='user'→ the caller’s user-principal (usePrincipalsStorelookup)mode='group'→useGroupDetailStore().group.principal_id- per-agent override (
AgentToolOverrideForm) → the agent’sprincipal_id mode='global'→ no principal (and the field is hidden anyway byscope: 'principal')
The picker URL becomes GET /api/v1/agents?select=id,name&principal_id={N} so the backend AgentController::index intersection (?principal_id= ∩ visiblePrincipalIds()) only returns same-principal agents. The runtime gates that protect intra-principal semantics — SubAgentTool::sharePrincipal(), HandoverService, SubAgentService, ToolConfigSchemaInspector::fetchAgentNameMap() — are independent of this UI hint and continue to defend against tampered or stale payloads.
When to pick each value
'any'— the default. Use when the setting’s value is meaningful regardless of who owns the agent. Credentials, hosts, timeouts, and toggle capabilities all live here.'principal'— use when the picker (or the value itself) is only coherent under a specific principal. Concrete example:SubAgentTool::allowed_target_agentsis a multi-select that lists agents; the runtime LLM-side filter only ever resolves names against the source agent’s principal (ToolConfigSchemaInspector::fetchAgentNameMap()), so the picker is meaningless without one. Future tools that hold an allowlist of same-principal resources follow the same pattern.'agent'— use when the setting is tied to a specific agent identity (e.g. a unique API token that’s minted per agent). Currently no#[ToolSetting]inspora-coredeclares this scope; it’s reserved for plugin-side or future-core settings.
Backward compatibility
Existing operator-defaults allowed_target_agents rows written before this change still cascade down to users without overrides. The runtime LLM-side filter in ToolConfigSchemaInspector::fetchAgentNameMap() (app/Services/ToolConfigSchemaInspector.php) restricts the LLM-visible list to the source agent’s principal, so stale foreign ids in a pre-existing global degrade to "#id" placeholders — the same as before. The change is purely UI-side: the operator can no longer see or edit the picker at admin-defaults scope, but the data still flows.
Quick Reference: All Tool Settings Keys
| Key | Type | Tool Class | Purpose | LLM Exposed |
|---|---|---|---|---|
http_timeout | text | ReadUrlTool | Read URL HTTP timeout (seconds) | — |
“LLM Exposed ✓” means exposeToLlm: true — the setting’s effective value is included in the tool definition sent to the LLM.
The remaining tools (Calculator, CurrentTime, AgentMemory, GlobalMemory, ReadUrl, UserInfo) declare no #[ToolSetting] attributes — they take their inputs entirely from the LLM’s arguments and have no infrastructure configuration to expose.
Sub-Agent tool
The sub_agent tool (app/Tools/SubAgentTool.php) is a single tool that declares two #[ToolOperation] entries on the op discriminator — handover (transfer + close) and sub_agent (spawn child + wait). Both ops share a single target_agent_id: int parameter and the allowed_target_agents multi-select on the agent’s Tools tab (/agents/:id/tools).
| Operation | Requires approval | Side-effect on source task |
|---|---|---|
handover | yes | Source task closes; a new task starts on the target agent. The closed source final_response becomes “Handed off to …” and data.handover.{target_task_id, target_agent_id, target_agent_name} records the lineage breadcrumb. |
sub_agent | yes | Source task flips to AWAITING_SUB_AGENTS; a child Task with parent_task_id is created on the target agent. After every spawned child reaches a terminal state the parent’s next tick resumes with each child’s output appended as a role:'tool' history row. The parent’s result_data.spawned_sub_task_ids: list<int> is always plural — same shape for one or many children. |
The LLM-facing schema declares op as the discriminator (enum: handover | sub_agent); single-op agents may omit op — OperationSchemaFilter strips the discriminator from required[] when only one op is allowed (back-compat path for agents created before the second op shipped).
allowed_target_agents declares scope: 'principal', so the picker is hidden at the admin operator-defaults page where no principal context exists. The same picker renders under Settings → Tools → Sub-Agent (per-user overrides), Groups → {name} → Tools → Sub-Agent (per-group overrides), and the agent’s Tools tab (per-agent override). Existing global rows continue to cascade down; the runtime LLM-side filter in ToolConfigSchemaInspector::fetchAgentNameMap() restricts the LLM-visible list to the source agent’s principal, so stale foreign ids in a pre-existing global degrade to "#id" placeholders. See Setting render scope for the full matrix.
Reading vs writing skills
Skills are read and written by different tools on purpose, and they live in different places.
skill (read) | manage_skill (write) | |
|---|---|---|
| Ships in | spora-core | spora-plugin-custom-skills |
| Operations | read, files | create, update, delete |
| Scope | Every visible skill — shipped and custom | The execution’s principal’s custom skills only |
| Gated by | The agent’s allowed_skills | Per-call operator approval |
| Approval | none | create / update / delete all requiresApprovalByDefault: true |
| Enabled by default | yes | create and update yes; delete no |
The asymmetry is the design. Reading a skill is a read of operator- or user-authored knowledge, gated by the allowlist the operator already curates. Writing one is a config change to the principal’s instruction set — principal-scoped, approval-gated, and absent from core entirely, because core has nothing to write to.
delete ships enabledByDefault: false for the same reason write_notes_overwrite does in AgentTool: a tool that creates and updates but cannot delete generates a support path immediately, but the destructive path must not ride on the safe default. Every write records provenance: agent and snapshots the previous state for a one-step restore, and an approved write goes live — there is no draft/published gate, because the approval card is the review.
One consequence worth knowing: a write is not a read. Writing a custom skill does not put it in any agent’s allowed_skills — that is still an operator action on the agent’s Tools tab (or a group/user default). The plugin’s admin panel lists which agents currently allowlist a skill precisely because the alternative is an author asking the agent to use a skill and getting only “Skill ‘x’ is not in the allowed_skills list for this agent.”
There is no LLM-visible way to bypass the allowlist: manage_skill writes, skill reads, and the read still checks the list. See Concepts → Skills → Custom skills for the visibility rules that apply to a custom skill once it exists.
Built-in tools
The tools below ship inside spora-core (not as plugins). Operators can configure their settings, but the implementations themselves are not pluggable — every operator gets the same behavior out of the box.
media — MediaTool
The media tool lets an agent search the media archive and mint public share URLs for individual assets. Its scope setting controls which media_assets rows the agent can see:
scope | What the agent sees |
|---|---|
agent | Assets where asset.agent_id = $agentId — only the calling agent’s own media. |
principal | Assets owned by the calling agent’s principal — direct uploads by the principal’s owner user, plus media attached to any agent of the principal. Works for user-principals AND group-principals. |
The deprecated scope=user value (pre-#221) is treated as a silent alias for principal so existing agent_tool_settings rows keep working without a DB migration. New operators see only agent and principal in the dropdown.
All eight operations are enabledByDefault: true. Exactly one, get_public_url, is requiresApprovalByDefault: true, because it is the only one that produces a URL which keeps resolving outside the operator’s session. Each call mints a 256-bit unguessable token (or reuses an existing one) and writes it to media_assets.public_access_token; the served URL has no auth — the token is the only protection. See PublicMediaController for the no-auth, token-gated serving endpoint.
The distinction that decides the gate is worth stating plainly, because it is the reason get_public_url is the odd one out. data.asset_url on any media operation is the /api/v1/assets/<uuid> route, gated on the requester being the asset’s owner or an admin — a handle for the agent’s own follow-up calls, useless to anyone else. get_public_url returns a different path, /api/v1/public/media/<id>?token=…, resolved by token match with no session at all. An operator approving that call is approving the creation of a durable public link, not a read.
Any operation can still be narrowed per agent from the dashboard, including by putting the approval back on an auto-approved operation. That is the supported way to bound write volume, and it needs no new configuration.
Operations
| Operation | Default enabled | Default approval | Purpose |
|---|---|---|---|
search | yes | auto | Run a media_assets query. Filters out derivative rows so the LLM only sees originals. This is also why a PDF’s markdown is not findable here — search is the agent’s only route to assets it was not handed, and derivative rows are excluded from it. Once a parent id is in hand, list_derivatives is how the agent finds the md render. |
get_media | yes | auto | Fetch one asset by id and render it. Echoes the markdown embed, the asset URL, and (for parent rows) the derivatives[] array; rows that are themselves a derivative also carry parent_id so the LLM can walk back up. |
get_embed_code | yes | auto | Return just the markdown embed string for an asset (no metadata). |
get_source | yes | auto | Read the source of an asset so the LLM can iterate on it (re-typeset a .typ source, re-read an extracted document). Text-shaped mimes (text/*, JSON, XML, YAML, SVG, CSV, x-typst) return the bytes inline, capped at GET_SOURCE_TEXT_MAX (5 MiB). Binary mimes do not return raw bytes — instead the asset’s md derivative is read and returned, truncated to GET_SOURCE_DERIVATIVE_PREVIEW_BYTES (64 KiB) and prefixed with a line naming the source asset and stating the bytes are its markdown derivative, so the LLM can tell which render it is holding. When no md derivative exists, fail with a hint pointing at create_derivative. It stays a pure read: it never mints the derivative, so the re-render remains the LLM’s explicit decision. External assets (storage_mode=external) have no Spora-side payload — fail with a get_media hint. Auto-approved because it reads a row the calling agent already owns under the same scope rules as get_media, so it grants no reach the agent did not already have. |
list_derivatives | yes | auto | Enumerate every derivative of a parent asset in the same wire shape the operator dashboard renders on the VersionsStrip (media_id, format, mime_type, asset_url, label, producer_plugin, producer_operation, created_at). Optional format argument narrows the list to one derivative kind (e.g. only PNG renders). This is the real discovery path for an md derivative: search filters derivative rows out, so this is the only way an agent learns a document has a text render to read. |
create_derivative | yes | auto | Generate a fresh derivative of a parent asset by calling the registered MediaDerivativeProducerInterface that matches the parent MIME and the requested format. Idempotent on (parent_id, format, producer_plugin, producer_operation) — re-renders with the same natural key return the same derivative id. Optional options carries producer-specific knobs (e.g. {"page": 0, "ppi": 144} for typst, {"longEdge": 1024} for image producers). Auto-approved: the derivative comes from a parent the agent already owns, it costs a render rather than granting access, and the row is operator-visible in the dashboard. Idempotency is what makes a blind retry the safe pattern. |
create_media | yes | auto | Store LLM-authored text (Markdown, plain text, CSV, JSON, XML, YAML, HTML) as a new source asset — the only media write that needs no existing parent. Takes content (required, 1 MiB cap), filename (required, sanitised, 255-char cap, extension inferred from mime_type when absent), mime_type (optional hint) and prompt (optional provenance → media_assets.prompt). The declared mime_type is a hint only: the byte ingest path always re-sniffs, and a row landing on a non-allowlisted MIME is deleted and the call rejected, so data.mime_type is the authoritative value. Not idempotent — ingest dedupes only on (tool_call_id, source_url), which an authored-text call has neither of, so a retry creates a second asset; reuse the returned asset_id. Auto-approved, and that is a deliberate trade-off worth stating plainly: this is the one write with no natural key, so a looping or prompt-injected agent can insert unbounded permanent rows, capped only per call at the 1 MiB content limit. It is deliberately not isTemporary, because an authored document is a deliverable and auto-deleting it on a retention count would be the worse bug; create_derivative carries the same unbounded-write posture for the same reason, so bounding one alone would not close it. To put a ceiling on it, set this operation’s approval per agent from the dashboard — that override already exists and needs no new configuration. |
get_public_url | yes | required | Mint (or reuse) an unguessable public-access token. See PublicMediaController. The only operation approval-gated by default. |
The derivatives[] enrichment on get_media and the create_derivative op both read through MediaAssetSerializer::derivativeRowsFor() so the operator dashboard and the LLM see the same row shape. See MediaDerivativeController for the underlying REST surface that operators use directly.
Text extraction from a binary document is an md derivative, not a column on the asset. A PDF or docx gets one minted at ingest, and again at attach time for any task that references it, so by the time an agent sees the attachment the md row usually exists. The rule the attachment path follows is: a text-ish source within the 512 KB inline budget is its own text; anything else gets an md derivative, and if it still doesn’t fit, the LLM is told where to read it — the metadata-only fallback block names get_source rather than reporting “no extractable text”. get_source reads the md derivative for binary mimes and does nothing else; create_derivative is how the LLM mints one when it is missing. See Media assets → 6. Derivatives for the MediaDerivativeService contract, the producer’s field-inheritance rules, and why list_derivatives rather than search is the discovery path for an md render.
Admin users bypass scope and see every asset.