Tools
A tool is a class of yours the model can call. The package brings the abstract class, the catalogue, the tag filter and the loop that runs them; the tools themselves talk about your domain, so they are yours.
Writing one
use EduLazaro\Laragents\Tools\Tool; use EduLazaro\Laragents\Tools\ToolContext; class SendViewingConfirmation extends Tool { public function name(): string { return 'send_viewing_confirmation'; } public function description(): string { return 'Email the client confirming a viewing that is already booked. ' . 'Does not book anything: use schedule_viewing for that.'; } public function execute(array $args, ToolContext $context): array { // ... return ['sent' => true]; } }
extends Tool, one word. This was an interface plus a base class for a while, which meant extends BaseTool implements Tool written at the top of every tool and two names to learn for one idea. The cost of merging them is PHP's single inheritance, and a tool that needs to share code with others puts it in a trait.
The class is deliberately thin. Its predecessor grew a helper for resolving files scoped to a case, and from then on every tool in the application inherited a method about a concept only four of them used, and the class could never leave that application. Convenience shared by some of your tools belongs in a trait of yours, not in the base class.
The description is the API
It is the main thing the model uses to pick between twenty tools, and a vague one gets picked wrongly or not at all. Write what it does, when to use it, and what it is not:
Email the client confirming a viewing that is already booked. Does not book anything: use
schedule_viewingfor that.
That last sentence is worth more than the rest. Models pick neighbouring tools when the boundary is unstated.
Arguments
Override schema() and the JSON Schema is built for you, using Illuminate\JsonSchema, which ships with the framework:
use Illuminate\JsonSchema\JsonSchema; protected function schema(): array { return [ 'role' => JsonSchema::string()->enum(['admin', 'member'])->description('...'), 'limit' => JsonSchema::integer()->description('Max results, default 20.'), ]; }
You can override parameters() and write the array by hand instead, and the reason not to is that nothing keeps a hand-written schema in step with what execute() actually reads. Add a filter to the body, forget the schema, and the model never learns the filter exists. That failure is invisible: the tool works, it just quietly never gets used the way you built it.
A tool with no arguments returns ['type' => 'object', 'properties' => new \stdClass(), 'required' => []] on its own. An empty PHP array would serialise to [], and a provider expecting an object rejects the entire request rather than that one tool.
Two kinds of input
public function execute(array $args, ToolContext $context): array
$args is what the model asked for. Untrusted: it is text a language model produced, possibly steered by whatever the user pasted into the chat. Validate it, and never take identity or scope from it.
$context is what your application put there. Trusted. The organisation, the current user, the office, whatever your tools need.
$office = $context->require('office'); // throws if the caller forgot $user = $context->get('user'); // null if absent $session = $context->get('session'); // the loop always puts this one there
The loop adds session and model to every context before the tools run, so a tool can always tell which conversation it is in and which tier it was called on. Leaving that to callers means the memory tools work in agent runs and quietly fail in the chat, depending on who remembered.
Why a bag and not parameters
The application this came from declared its tools as:
execute(array $args, ?CaseModel $case, Organization $org): array
Two application models nailed into an interface implemented by 95 tools. Adding a third piece of context meant editing all 95, and no package could ever host that signature: a package cannot know what a case is. One app's context is a case, another's is a property, another's is nothing at all.
Registering them
Laragents::tools([ListProperties::class, SendViewingConfirmation::class]); Laragents::tools(new AlreadyBuilt($dependency));
Class names are resolved through the container, so a tool can ask for what it needs in its constructor.
Tags
Tags are strings you invent and the registry only compares:
use EduLazaro\Laragents\Attributes\Tags; #[Tags(['billing', 'admin-only'])] class IssueRefund extends Tool { }
$tools->definitions(tags: ['billing']); // just the billing ones $tools->definitions(whitelist: ['list_properties']); // exactly these, whatever their tags
A tool tagged '*', which is the default, always passes the tag filter. Override tags() instead of using the attribute when a tool's tags depend on something it can only know at runtime; the method wins over the attribute.
The predecessor filtered on requiresCase() and a workspace enum, two application concepts hardcoded into a supposedly generic API, and by the end one was being derived from the other. Strings you compare are enough.
Actions
public function isAction(): bool { return true; }
A flag, and nothing in the package acts on it. It says "this one changes something", and it is there so your UI can require a confirmation before a tool sends an email or issues a refund. The loop will happily run it either way: gating is a product decision and belongs where the user is.
The two that ship
save_memory and search_memories, both registered for you. See Memory. Turn them off with 'memory' => ['tools' => false] if you would rather only the distiller writes.