Coqui Toolkits
A toolkit is a group of related tools that extend Coqui's capabilities. Toolkits are Composer packages that implement ToolkitInterface — they are auto-discovered at boot, their tools are registered with the agent, and their guidelines are injected into the system prompt.
This guide covers everything you need to create, test, and distribute your own toolkits.
Table of Contents
- What Is a Toolkit?
- Quick Start
- Anatomy of a Toolkit
- Creating Your First Toolkit
- Adding Tools
- Parameter Types
- Tool Results
- Managing Dependencies
- Credential Management
- Auto-Discovery
- Testing
- Best Practices
- API Reference
- Examples
What Is a Toolkit?
A toolkit is a Composer package that:
- Implements
ToolkitInterfacefromcarmelosantana/php-agents - Declares itself in
composer.jsonviaextra.php-agents.toolkits - Returns one or more
Toolinstances from itstools()method - Provides usage guidelines via its
guidelines()method
When Coqui boots, it scans all installed packages for toolkit declarations. Discovered toolkits are instantiated, their tools are registered with the orchestrator agent, and their guidelines are appended to the system prompt. This happens automatically — no manual registration needed.
┌─────────────────────────────────────────────────────┐
│ Coqui Boot │
│ │
│ 1. Scan vendor/composer/installed.json │
│ 2. Find packages with extra.php-agents.toolkits │
│ 3. Instantiate toolkit classes │
│ 4. Wrap with CredentialGuardToolkit (if needed) │
│ 5. Register tools with OrchestratorAgent │
│ 6. Inject guidelines into system prompt │
└─────────────────────────────────────────────────────┘
Quick Start
The fastest way to create a toolkit is with Coqui's built-in generator:
You: Create a toolkit called "my-api" that integrates with the Example API
Coqui: toolkit_create(name: "my-api", description: "Example API integration",
dependencies: "guzzlehttp/guzzle:^7.0",
credentials: '{"EXAMPLE_API_KEY": "API key from https://example.com/keys"}')
This scaffolds the full package structure, generates composer.json with auto-discovery declarations, creates the main toolkit class with credential support, and runs composer install.
To add tools:
Coqui: toolkit_add_tool(toolkit_name: "my-api", tool_name: "fetch_items",
tool_description: "Fetch items from the Example API",
parameters: '[{"name": "query", "type": "string", "description": "Search query", "required": true}]')
Then install and activate:
Coqui: composer(action: "require", package: "coquibot/my-api", target: "workspace")
Coqui: restart_coqui(reason: "Activate my-api toolkit")
Anatomy of a Toolkit
A toolkit package has this structure:
my-toolkit/
├── composer.json # Package manifest with auto-discovery
├── README.md # Documentation
└── src/
└── MyToolkit.php # Main toolkit class
composer.json
The extra.php-agents section is what makes a package a toolkit:
{
"name": "coquibot/coqui-toolkit-my-toolkit",
"description": "My awesome toolkit for Coqui",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"CoquiBot\\Toolkits\\MyToolkit\\": "src/"
}
},
"require": {
"php": "^8.4",
"carmelosantana/php-agents": "^0.2 || @dev"
},
"extra": {
"php-agents": {
"toolkits": [
"CoquiBot\\Toolkits\\MyToolkit\\MyToolkit"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
Required fields in extra.php-agents:
| Field | Type | Description |
|---|---|---|
toolkits |
string[] |
Fully-qualified class names implementing ToolkitInterface |
Optional fields:
| Field | Type | Description |
|---|---|---|
credentials |
object |
Map of KEY_NAME → description for required credentials |
description |
string |
Human-readable description of the toolkit |
Toolkit Class
The toolkit class implements two methods:
<?php
declare(strict_types=1);
namespace CoquiBot\Toolkits\MyToolkit;
use CarmeloSantana\PHPAgents\Contract\ToolInterface;
use CarmeloSantana\PHPAgents\Contract\ToolkitInterface;
use CarmeloSantana\PHPAgents\Tool\Tool;
use CarmeloSantana\PHPAgents\Tool\ToolResult;
use CarmeloSantana\PHPAgents\Tool\Parameter\StringParameter;
final class MyToolkit implements ToolkitInterface
{
/**
* @return ToolInterface[]
*/
public function tools(): array
{
return [
$this->myTool(),
];
}
public function guidelines(): string
{
return <<<'GUIDELINES'
<MY-TOOLKIT-GUIDELINES>
- Use my_tool when the user asks to do something specific.
- Keep guidelines concise — every token counts.
</MY-TOOLKIT-GUIDELINES>
GUIDELINES;
}
private function myTool(): ToolInterface
{
return new Tool(
name: 'my_tool',
description: 'Does something useful.',
parameters: [
new StringParameter('input', 'The input to process'),
],
callback: function (array $input): ToolResult {
$value = $input['input'] ?? '';
return ToolResult::success("Processed: {$value}");
},
);
}
}
Creating Your First Toolkit
Option A: Using the Toolkit Generator
Ask Coqui to create a toolkit:
You: Create a toolkit called "datetime-utils" with tools for working with dates and times
Coqui uses toolkit_create to scaffold the package, then toolkit_add_tool to add specific tools.
Option B: Manual Creation
- Create the package directory:
mkdir -p .workspace/packages/my-toolkit/src
- Create
composer.json:
{
"name": "coquibot/coqui-toolkit-my-toolkit",
"description": "My custom toolkit",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"CoquiBot\\Toolkits\\MyToolkit\\": "src/"
}
},
"require": {
"php": "^8.4",
"carmelosantana/php-agents": "^0.2 || @dev"
},
"extra": {
"php-agents": {
"toolkits": [
"CoquiBot\\Toolkits\\MyToolkit\\MyToolkit"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
-
Create
src/MyToolkit.phpimplementingToolkitInterface -
Install and activate:
cd .workspace && composer require coquibot/coqui-toolkit-my-toolkit
Then restart Coqui for auto-discovery to pick up the new toolkit.
Adding Tools
Each tool is a Tool instance with four components:
| Component | Type | Description |
|---|---|---|
name |
string |
Unique snake_case identifier (e.g. fetch_data) |
description |
string |
What the tool does — the LLM reads this to decide when to use it |
parameters |
Parameter[] |
Typed parameter declarations |
callback |
Closure |
The function that executes when the tool is called |
private function fetchDataTool(): ToolInterface
{
return new Tool(
name: 'fetch_data',
description: 'Fetch data from the external API by query.',
parameters: [
new StringParameter(
'query',
'The search query to send to the API',
),
new NumberParameter(
'limit',
'Maximum number of results (1-100)',
required: false,
integer: true,
minimum: 1,
maximum: 100,
),
],
callback: function (array $input): ToolResult {
$query = $input['query'] ?? '';
if ($query === '') {
return ToolResult::error('Query parameter is required.');
}
$limit = (int) ($input['limit'] ?? 10);
// Your implementation here
$results = $this->api->search($query, $limit);
return ToolResult::success(json_encode($results, JSON_PRETTY_PRINT));
},
);
}
Tool Naming Conventions
- Use
snake_casefor tool names - Prefix with a namespace to avoid collisions (e.g.
brave_search,weather_lookup) - Keep names concise but descriptive
- Tool names must be unique across all installed toolkits
Tool Descriptions
Write descriptions for the LLM, not humans. The description is the primary signal the LLM uses to decide when to invoke a tool:
// Good — tells the LLM exactly when to use this tool
'Search the web using Brave Search. Returns titles, URLs, and descriptions.'
// Bad — too vague for the LLM to know when to use it
'Searches for things.'
Parameter Types
All parameter classes extend Parameter and live in CarmeloSantana\PHPAgents\Tool\Parameter\.
StringParameter
new StringParameter(
'name', // parameter name
'Description of the param', // description
required: true, // default: true
pattern: null, // optional regex pattern
maxLength: null, // optional max length
enum: null, // optional allowed values (string[])
)
NumberParameter
new NumberParameter(
'count',
'Number of results to return',
required: false,
integer: true, // emit "integer" type instead of "number"
minimum: 1, // optional minimum value
maximum: 100, // optional maximum value
)
BoolParameter
new BoolParameter(
'verbose',
'Enable verbose output',
required: false,
)
EnumParameter
new EnumParameter(
'format',
'Output format',
values: ['json', 'text', 'csv'], // allowed values
required: true,
)
ArrayParameter
new ArrayParameter(
'tags',
'List of tags to filter by',
required: false,
items: new StringParameter('tag', 'A single tag'), // optional item schema
)
ObjectParameter
new ObjectParameter(
'options',
'Configuration options',
required: false,
properties: [
new StringParameter('format', 'Output format'),
new BoolParameter('verbose', 'Enable verbose output'),
],
)
Tool Results
Every tool callback must return a ToolResult:
// Success — content is returned to the LLM
return ToolResult::success('Operation completed. Result: ...');
// Error — the LLM sees this as a tool error and can retry or inform the user
return ToolResult::error('Invalid input: query parameter is required.');
Guidelines for results:
- Keep success content concise — the LLM's context window is finite
- Use structured formats (JSON, markdown tables) for complex data
- Include actionable information the LLM can use in its response
- Error messages should explain what went wrong and how to fix it
- Never include secrets, credentials, or sensitive data in results
Managing Dependencies
Declaring Dependencies
Add dependencies to the require section of your toolkit's composer.json:
{
"require": {
"php": "^8.4",
"carmelosantana/php-agents": "^0.2 || @dev",
"guzzlehttp/guzzle": "^7.0",
"symfony/http-client": "^7.0"
}
}
Using the Toolkit Generator
Pass dependencies as a comma-separated string when creating a toolkit:
toolkit_create(
name: "my-api",
description: "API integration toolkit",
dependencies: "guzzlehttp/guzzle:^7.0,symfony/http-client:^7.0"
)
The generator writes them into composer.json and runs composer install automatically.
Using Packagist
Coqui has a built-in packagist tool for discovering packages before adding them:
packagist(action: "search", query: "http client")
packagist(action: "details", package: "guzzlehttp/guzzle")
packagist(action: "advisories", package: "guzzlehttp/guzzle")
Recommended workflow: search → details → advisories → add to toolkit dependencies.
Dependency Guidelines
- Use caret
^version constraints for stability - Prefer PSR interfaces over concrete implementations
- Never depend on full frameworks (Laravel, Symfony framework-bundle, etc.)
- Keep dependencies minimal — each one is a maintenance burden
- Check for security advisories before adding a package
Credential Management
Many toolkits require API keys or secrets. Coqui has a first-class credential system that handles this automatically.
Declaring Credentials
Add credential requirements to composer.json:
{
"extra": {
"php-agents": {
"toolkits": ["CoquiBot\\MyApi\\MyApiToolkit"],
"credentials": {
"MY_API_KEY": "API key for MyService — get one at https://myservice.com/keys",
"MY_API_SECRET": "API secret — found in your MyService dashboard"
}
}
}
}
How Credential Guards Work
When a toolkit declares credentials:
ToolkitDiscoveryreads the credential declarations fromcomposer.jsonCredentialGuardToolkitwraps the toolkit — each tool gets aCredentialGuardTooldecorator- When a tool is called with missing credentials, the guard intercepts and returns a structured error:
- Lists missing credential names and descriptions
- Provides the exact
credentials(action: "set", ...)call syntax - The inner tool is never invoked
- The LLM asks the user for the credential, saves it via the
credentialstool CredentialResolver::set()persists to.envAND callsputenv()for hot-reload- The next tool call succeeds immediately — no restart needed
Implementing Credential Support
Use lazy resolution in your toolkit so hot-reload works:
final class MyApiToolkit implements ToolkitInterface
{
private readonly string $apiKey;
public function __construct(
string $apiKey = '',
) {
$this->apiKey = $apiKey;
}
/**
* Factory method for ToolkitDiscovery.
*/
public static function fromEnv(): self
{
$apiKey = getenv('MY_API_KEY');
return new self(apiKey: $apiKey !== false ? $apiKey : '');
}
/**
* Resolve the API key lazily.
*
* Checks constructor value first, then process environment.
* This enables hot-reload: after CredentialTool saves a key
* via putenv(), the next call picks it up without restarting.
*/
private function resolveApiKey(): string
{
if ($this->apiKey !== '') {
return $this->apiKey;
}
$env = getenv('MY_API_KEY');
return $env !== false ? $env : '';
}
// ... tools use $this->resolveApiKey() ...
}
Key points:
- The
fromEnv()static factory is detected byToolkitDiscovery(Strategy 1) - Lazy resolution means credentials saved at runtime are picked up immediately
- The
CredentialGuardToolhandles the missing-credential UX — your toolkit does NOT need error messages for missing keys - Never expose credential values in tool results
Auto-Discovery
How It Works
ToolkitDiscovery scans installed packages at boot using three strategies (in order):
| # | Strategy | When Used |
|---|---|---|
| 1 | static fromEnv(): self |
Toolkit reads config from environment (preferred for credential-based toolkits) |
| 2 | No-arg constructor | No required constructor params (simplest toolkits) |
| 3 | First param is string |
Receives the workspace path (for file-aware toolkits) |
Discovery Sources
Toolkits are discovered from two locations:
- Project vendor:
vendor/composer/installed.json— packages installed in the main project - Workspace vendor:
.workspace/vendor/composer/installed.json— packages installed by the bot at runtime
The Registry
Discovered toolkits are persisted in .workspace/toolkits.json:
{
"coquibot/coqui-toolkit-brave-search": [
"CoquiBot\\Toolkits\\BraveSearch\\BraveSearchToolkit"
],
"coquibot/coqui-toolkit-hello": [
"CoquiBot\\Toolkits\\HelloToolkit\\HelloToolkit"
]
}
This registry is rebuilt on every boot from the actual installed packages.
Triggering Discovery
- Boot:
discoverAll()runs automatically - After
composer require:discover($packageName)runs for the new package - After
composer remove:unregister($packageName)removes the entry - Restart: use
restart_coquito trigger a full re-boot with fresh discovery
Testing
Use Pest 3.x for testing toolkits.
Basic Test Structure
<?php
declare(strict_types=1);
use CoquiBot\Toolkits\MyToolkit\MyToolkit;
test('toolkit provides expected tools', function () {
$toolkit = new MyToolkit();
$tools = $toolkit->tools();
expect($tools)->toHaveCount(2);
$names = array_map(fn($t) => $t->name(), $tools);
expect($names)->toBe(['my_search', 'my_fetch']);
});
test('guidelines contain XML tags', function () {
$toolkit = new MyToolkit();
expect($toolkit->guidelines())->toContain('<MY-TOOLKIT-GUIDELINES>');
});
test('search tool returns results', function () {
$toolkit = new MyToolkit(apiKey: 'test-key');
$tool = $toolkit->tools()[0];
$result = $tool->execute(['query' => 'test query']);
expect($result->status->value)->toBe('success');
expect($result->content)->toContain('results');
});
test('search tool rejects empty query', function () {
$toolkit = new MyToolkit();
$tool = $toolkit->tools()[0];
$result = $tool->execute(['query' => '']);
expect($result->status->value)->toBe('error');
});
test('tools produce valid function schemas', function () {
$toolkit = new MyToolkit();
foreach ($toolkit->tools() as $tool) {
$schema = $tool->toFunctionSchema();
expect($schema['type'])->toBe('function');
expect($schema['function']['name'])->toBe($tool->name());
expect($schema['function']['parameters']['type'])->toBe('object');
}
});
Mocking HTTP Clients
For toolkits that make HTTP requests, mock the client in tests:
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
test('handles API response', function () {
$mockResponse = new MockResponse(json_encode([
'results' => [['title' => 'Test', 'url' => 'https://example.com']],
]));
$httpClient = new MockHttpClient($mockResponse);
$toolkit = new MyApiToolkit(apiKey: 'test', httpClient: $httpClient);
$result = $toolkit->tools()[0]->execute(['query' => 'test']);
expect($result->status->value)->toBe('success');
});
Running Tests
# Run all tests
./vendor/bin/pest
# Run specific test file
./vendor/bin/pest tests/Unit/MyToolkitTest.php
# With static analysis
./vendor/bin/phpstan analyse
Best Practices
Code Style
- Mark toolkit classes
final— composition over inheritance - Use
readonlywherever state shouldn't change - Start every file with
declare(strict_types=1) - Follow PER-CS 2.0 coding style
- One class per file
Tool Design
- One purpose per tool. A tool should do one thing well.
- Validate early. Check inputs at the top of the callback and return
ToolResult::error()immediately. - Return structured data. Use JSON or markdown for complex results.
- Keep output concise. Truncate large outputs to prevent context overflow.
- Handle errors gracefully. Catch exceptions and return descriptive
ToolResult::error()messages.
Guidelines
- Wrap guidelines in XML-style tags:
<MY-TOOLKIT-GUIDELINES>...</MY-TOOLKIT-GUIDELINES> - Keep them concise — every token reduces available context
- Tell the LLM when to use each tool, not just how
- Include any constraints or important behaviors
Security
- Never hardcode secrets — use environment variables
- Never expose credential values in
ToolResultcontent - Validate all external input in tool callbacks
- Use
resolveApiKey()with lazygetenv()lookup for hot-reload support - Sanitize output that might contain user-controlled data
Dependencies
- Minimize external dependencies — prefer PHP built-ins
- Use PSR interfaces where they exist (PSR-7, PSR-17, PSR-18)
- Never depend on full frameworks
- Use caret
^version constraints
API Reference
ToolkitInterface
interface ToolkitInterface
{
/** @return ToolInterface[] */
public function tools(): array;
/** Usage guidelines injected into the system prompt. */
public function guidelines(): string;
}
ToolInterface
interface ToolInterface
{
/** Unique snake_case name. */
public function name(): string;
/** Human-readable description for the LLM. */
public function description(): string;
/** @return Parameter[] */
public function parameters(): array;
/** Execute the tool with given input. */
public function execute(array $input): ToolResult;
/** OpenAI-compatible function schema. */
public function toFunctionSchema(): array;
}
Tool (concrete class)
final class Tool implements ToolInterface
{
public function __construct(
string $name,
string $description,
array $parameters,
Closure $callback, // fn(array $input): ToolResult
int $maxTries = 3,
);
}
ToolResult
final readonly class ToolResult
{
public ToolResultStatus $status; // Success | Error | Timeout
public string $content;
public ?string $callId;
public static function success(string $content): self;
public static function error(string $message): self;
public function withCallId(string $callId): self;
}
Parameter (abstract base)
abstract readonly class Parameter
{
public function __construct(
public string $name,
public string $description,
public bool $required = true,
);
abstract public function toSchema(): array;
}
composer.json extra.php-agents Schema
{
"extra": {
"php-agents": {
"toolkits": ["Vendor\\Package\\ClassName"],
"credentials": {
"KEY_NAME": "Human-readable description of the credential"
},
"description": "Optional toolkit description"
}
}
}
Examples
Hello Toolkit
The simplest possible toolkit — two tools, no dependencies, no credentials.
Location: examples/hello-toolkit/
final class HelloToolkit implements ToolkitInterface
{
public function tools(): array
{
return [
$this->helloWorldTool(),
$this->helloTimeTool(),
];
}
// hello_world — returns a greeting
// hello_time — returns current time
}
See the Hello Toolkit README for full details.
Brave Search Toolkit
A production toolkit with credentials, HTTP client, and auto-discovery.
Package: coquibot/coqui-toolkit-brave-search
Key patterns demonstrated:
fromEnv()factory method for discoveryresolveApiKey()lazy credential resolution- Credential declarations in
composer.json - Symfony HTTP client for API requests
- Structured JSON output formatting
- Error handling with HTTP status codes
See the Brave Search README for full details.
Weather Toolkit
A toolkit with an HTTP dependency but no credentials (uses free APIs).
Location: .workspace/packages/weather-toolkit/
Key patterns demonstrated:
- Optional constructor dependency injection (
?Client $http) - No
fromEnv()needed (no credentials) - Multiple data source fallbacks (IP geolocation + ZIP code)
- Complex data transformation in tool callback