Building Agents
This guide is for developers building their own agents to distribute as ChargePanda modules.
Anatomy of an agent
An agent is a PHP class that extends AbstractAgent. It declares:
- What the input form looks like (
inputSchema) - What system prompt to send the LLM (
instructions) - How the output should be rendered (
outputFormat) - Which plan entitlement key gates access (
planKey)
Everything else — provider selection, API key injection, queuing, billing, status polling — is handled by AiAgentsCore.
Minimal example
<?php
namespace Modules\MyModule\Agents;
use Modules\AiAgentsCore\Support\AbstractAgent;
class BlogOutlineAgent extends AbstractAgent
{
public function slug(): string { return 'blog-outline'; }
public function displayName(): string { return 'Blog Outline Generator'; }
public function description(): string { return 'Generate a structured blog post outline from a topic.'; }
public function category(): string { return 'Content'; }
public function planKey(): string { return 'my_module'; } // matches PlanEntitlement key
public function inputSchema(): array
{
return [
[
'name' => 'topic',
'label' => 'Blog Topic',
'type' => 'text',
'required' => true,
'rules' => 'required|string|max:200',
],
[
'name' => 'tone',
'label' => 'Tone',
'type' => 'select',
'options' => ['Professional', 'Casual', 'Technical'],
'required' => false,
],
];
}
public function instructions(): string
{
return 'You are an expert content strategist. Generate a clear, structured blog post outline.';
}
}Registering the agent
In your module's ModuleServiceProvider::boot():
use Modules\MyModule\Agents\BlogOutlineAgent;
public function boot(): void
{
// ... other boot calls
$this->registerAgent(BlogOutlineAgent::class);
}registerAgent() is a helper on AbstractModuleServiceProvider — pass the fully-qualified class name, not an instance. The agent is now available to AiAgentsCore's runner, billing, and UI systems.
Input schema reference
Each field in inputSchema() is an array with these keys:
| Key | Required | Description |
|---|---|---|
name | Yes | Field name (snake_case). Becomes the key in input_data. |
label | Yes | Human-readable label shown in the form. |
type | Yes | One of: text, textarea, email, url, number, select, multiselect, toggle, hidden |
required | No | Adds a required indicator to the label. Defaults to false. |
rules | No | Laravel validation rules string (e.g. 'required|string|max:500') |
options | No | Array of options for select and multiselect types |
placeholder | No | Placeholder text for text inputs |
help | No | Help text shown below the field |
default | No | Default value pre-filled in the form |
Output formats
Override outputFormat() to control how the run result is rendered:
| Format | outputFormat() | How it works |
|---|---|---|
| Plain text | 'text' | Default. Output is displayed as pre-formatted text. |
| HTML | 'html' | Output is rendered as HTML in the result view. Sanitised before display. |
| Structured | 'structured' | LLM must return valid JSON. Parsed and stored as a PHP array. Rendered as a table or card by the view. |
For structured output, the LLM response should be a JSON object. AiAgentsCore strips markdown code fences automatically.
Custom prompt building
By default, buildPrompt() formats each input field as "Label: value\n". Override it for a more tailored prompt:
public function buildPrompt(array $inputs): string
{
return <<<PROMPT
Write a blog outline about: {$inputs['topic']}
Tone: {$inputs['tone'] ?? 'Professional'}
Return a numbered outline with 5-7 main sections and 3 bullet points each.
PROMPT;
}Cross-field validation with InputSchema
inputSchema() handles per-field validation via the rules key. For validation that spans multiple fields, use the InputSchema utility class inside buildPrompt() — it throws a ValidationException that the runner catches and returns to the user before a job is created.
use Modules\AiAgentsCore\Support\InputSchema;
public function buildPrompt(array $inputs): string
{
// Require at least one of two fields
InputSchema::requireAtLeastOne(
$inputs,
['url', 'content'],
'Provide a URL or paste the page content directly.'
);
// Require two fields to be different values
InputSchema::requireDistinct(
$inputs,
'your_url',
'competitor_url',
'Your URL and the competitor URL must be different.'
);
return "Analyse: {$inputs['url']} vs {$inputs['competitor_url']}";
}| Method | When to use |
|---|---|
InputSchema::requireAtLeastOne($inputs, $fields, $message) | User must fill in one of several optional fields (e.g. URL or pasted content) |
InputSchema::requireDistinct($inputs, $fieldA, $fieldB, $message) | Two fields must not be identical (e.g. comparing a URL to itself) |
Using tools
Agents can use tools — actions executed by the PHP side (not the LLM) to fetch data. Implement tools() to return tool instances recognised by the Laravel AI SDK:
public function tools(): iterable
{
return [
new \Modules\MyModule\Tools\FetchPageMetaTool(),
];
}When the LLM decides to call a tool, the SDK executes it and feeds the result back. AiAgentsCore records each tool call (name, input, output, duration) in the agent_tool_calls table.
See the Laravel AI SDK documentation for how to implement tool classes.
Plan entitlement key
planKey() returns the PlanEntitlement.key that gates access to this agent. All agents in the same module typically share one key (e.g. my_module) so a single plan entitlement covers the whole module.
If your module has agents at different access tiers (basic vs. premium), use different keys and seed separate entitlement rows.
Agent metadata
Override these methods to customise how your agent appears in the UI:
public function icon(): string { return 'ti-pencil'; } // Admin icon (Themify)
public function materialIcon(): string { return 'edit_note'; } // Account icon (Material Symbols)
public function estimatedSeconds(): int { return 15; } // Shown as estimated time
public function tags(): array { return ['content', 'seo']; } // Future filtering
public function runsPerUnit(): int { return 1; } // Cost weight (1 run = 1 unit)Seeding entitlements on activation
When your module activates, seed the plans for your product. Listen to ModuleActivated:
// ModuleServiceProvider::boot()
use App\Events\ModuleActivated;
use Modules\MyModule\Listeners\RunMyModuleSetupSeeder;
$this->app['events']->listen(ModuleActivated::class, RunMyModuleSetupSeeder::class);// RunMyModuleSetupSeeder::handle()
public function handle(ModuleActivated $event): void
{
if ($event->module->slug !== 'my-module') {
return;
}
// Guard against re-running
if (setting('my_module.setup_complete')) {
return;
}
// Create product, plans, entitlements...
// See SeoAgents module for a complete example.
setting(['my_module.setup_complete' => '1']);
}Events fired during a run
Listen to these in your module for side-effects (notifications, logging, webhooks):
| Event | When | Payload |
|---|---|---|
AgentRunDispatched | After job is queued | $run |
AgentRunStarted | Worker begins execution | $run |
AgentToolCalled | After each tool call | $run, $toolCallRecord |
AgentRunCompleted | Run finished successfully | $run |
AgentRunFailed | Run failed or timed out | $run, $exception |
use Modules\AiAgentsCore\Events\AgentRunCompleted;
$this->app['events']->listen(AgentRunCompleted::class, function ($event) {
// $event->run is a fully populated AgentRun model
});