Skills
A skill is a procedure: instructions, a whitelist of tools, and one turn of the loop. Where a tool is one thing the model may call, a skill is a job you want done a particular way, every time.
use EduLazaro\Laragents\Skills\BaseSkill; use EduLazaro\Laragents\Skills\SkillContext; class DraftViewingReport extends BaseSkill { public function name(): string { return 'draft_viewing_report'; } protected function procedure(): string { return implode("\n", [ 'Write the report of a viewing, in this order:', '1. Read the viewing with get_viewing.', '2. Read the property with get_property.', '3. Write three paragraphs: what was shown, what was asked, what to do next.', 'Never invent a figure that is not in what the tools returned.', ]); } public function tools(): array { return ['get_viewing', 'get_property']; } }
The procedure is injected as the system message. The whitelist is enforced by the registry, so the skill genuinely cannot reach a tool it did not ask for, however the model phrases the request.
Running one
use EduLazaro\Laragents\Skills\SkillContext; $result = app(DraftViewingReport::class)->run(new SkillContext( session: $session, context: ToolContext::make(['office' => $office]), args: ['viewing_id' => $viewing->id], model: 'gpt-4.1-mini', persist: false, )); $result->content; // the deliverable $result->totalTokens(); $result->meta; // ['skill' => 'draft_viewing_report']
The session is required because the loop writes its turns to it. To run a skill from a button rather than from a conversation, make a session for it and pass that.
persist: false runs it headless, writing nothing, which is what a skill invoked from inside a conversation wants: the outer conversation owns the history, and the skill's internal turns are plumbing.
The prompt that starts it
By default the skill is kicked off with Run the 'draft_viewing_report' procedure. plus the JSON of its arguments. Override prompt() to phrase it properly, which is usually worth doing:
protected function prompt(SkillContext $context): string { return "Write the report for viewing #{$context->arg('viewing_id')}."; }
House rules in one place
abstract class AgencySkill extends BaseSkill { protected function preamble(): string { return implode("\n", [ 'You work for an estate agency. Non-negotiable:', '- Never invent a figure, a date or a reference.', '- Say what is missing rather than filling it in.', '- Write in Spanish, with correct accents.', ]); } }
preamble() goes before every procedure and is empty in the package on purpose. Yours are not generic and not in English; put them in your own base class rather than repeating them in every procedure().
More than one turn
run() is a normal method. A skill that needs several steps, or deterministic orchestration between them, overrides it entirely: call the loop more than once, parse in between, decide what happens next.
public function run(SkillContext $context): SkillResult { $found = $this->loop()->run(/* the search turn */); $picked = $this->pick(json_decode($found->content, true)); return SkillResult::from($this->loop()->run(/* the writing turn */), [ 'skill' => $this->name(), 'picked' => $picked, ]); }
The base class covers the common case, which is instructions plus a whitelist plus one turn. It does not pretend to be a workflow engine, because the moment a procedure needs branching you want PHP, not a prompt.
Letting the model run them
Laragents::skills([DraftViewingReport::class]); Laragents::tools([RunSkillTool::class]);
RunSkillTool exposes the catalogue as a single tool, so the model can invoke a procedure by name in the middle of a conversation. Register the skills without the tool and they stay yours to call from code, which is the more predictable arrangement.
The registry holds class names and resolves them lazily, so booting does not instantiate every skill and everything behind it. It is the mirror of ToolRegistry, which holds instances instead: a tool is cheap to keep around, a skill is a procedure with dependencies behind it.