Skills
A skill is a block of instructions phpClaw adds to the prompt when it looks relevant to the message. If nothing matches, the message goes to the model unchanged.
The contract
Section titled “The contract”namespace PhpClaw\Skills\Contracts;
interface SkillInterface{ public function name(): string; // unique id public function description(): string; // matched public function tags(): array; // matched public function content(): string; // injected, never matched}How matching works
Section titled “How matching works”On every send() and stream(), SkillRegistry::match() scores each registered skill:
- The message is split into lowercase words of 3 letters or more.
- The skill’s description is split into words of 4 letters or more, and its tags into words of 3 letters or more.
- The score is the number of words in both, after dropping 79 common stopwords.
- Skills scoring 0 are dropped. The rest are ordered by score, then by name, and the top 3 are kept.
Change the limit per agent:
$agent = Claw::builder()->skillMatchLimit(5)->build();The default is the constant SkillRegistry::DEFAULT_MATCH_LIMIT, 3. The minimum is 1.
What that means in practice
Section titled “What that means in practice”These all follow directly from the rules above:
| Message | Skill with description “Generate customer invoices and billing”, tag billing | Why |
|---|---|---|
invoices for customers | matches | invoices is in the description |
I need billing | matches | billing is a tag |
make an invoice please | no match | invoice is not invoices; there is no stemming |
invoicing | no match | the skill’s name is never matched |
order 2024 | no match | digits are dropped from every word |
Rules of thumb:
- Name is not matched and neither is content. Put every word a user might type in
descriptionortags. - Matching is exact. Add singular and plural forms as tags when both are likely.
- Hyphens and underscores split words, so the tag
self-containedmatchesselforcontained. - Numbers carry no meaning. A tag like
php8matches onphp, and a tag that is only a number never matches.
What gets injected
Section titled “What gets injected”Each matched skill’s content() is prepended, above the message and any memory context already
added to it:
[Skill context]<content of skill 1>
<content of skill 2>
[Message]<the message, including any memory context>The registry is process-wide
Section titled “The registry is process-wide”SkillRegistry is static. A skill registered for one agent is available to every agent built later
in the same PHP process.
$a = Claw::builder()->addSkill($invoicing)->build();$b = Claw::builder()->addSkill($shipping)->build();
SkillRegistry::names(); // ['invoicing', 'shipping'], both apply to $bThis matters in long-running workers such as queue consumers. Registering a skill with a name that
already exists replaces it. SkillRegistry::reset() clears everything.
SkillRegistry also has has($name), all(), names() and count().
A plain Claw::builder()->build() registers no skills of its own.
Adding skills
Section titled “Adding skills”use PhpClaw\Claw;use PhpClaw\Skills\ArraySkill;
$agent = Claw::builder() ->addSkill(new ArraySkill( name: 'refund_policy', description: 'Company refund and returns policy guidance', tags: ['refund', 'refunds', 'return', 'returns'], content: 'Refunds are issued within 30 days of purchase. Ask for the order ID first.', )) ->build();| Builder method | Effect |
|---|---|
addSkill(SkillInterface $skill) | add one skill |
skills(array $skills) | replace the list |
skillMatchLimit(int $limit) | how many matched skills are injected |
withRemoteSkills(string $url) | load skills from a URL; call again for more |
Skills from the builder are registered when the agent is constructed. You can also call
SkillRegistry::register($skill) directly.
Built-in skills
Section titled “Built-in skills”Core ships three skill classes. Only PhpBestPracticesSkill carries a #[Skill] attribute, so it is
the only one the catalogue discovers. ArraySkill and FileSkill need constructor arguments, so you
register those yourself.
ArraySkill
Section titled “ArraySkill”A skill defined in PHP, with the four values passed to the constructor, as above.
FileSkill
Section titled “FileSkill”A skill loaded from a Markdown file with frontmatter.
use PhpClaw\Skills\FileSkill;
$agent = Claw::builder()->addSkill(new FileSkill('/var/www/app/skills/seo.md'))->build();---name: seodescription: On-page SEO guidance for product copytags: [seo, meta, keywords, search]---
Lead with the primary keyword in the first sentence...Everything after the frontmatter becomes the content.
| Field | If missing |
|---|---|
name | the file name without .md |
description | empty, so only tags can match |
tags | none; accepts [a, b] or a, b |
The frontmatter is read line by line, not by a YAML parser, so keep each field on one line.
A missing or unreadable file, or frontmatter that is absent or unclosed, throws
PhpClaw\Exceptions\SkillException.
PhpBestPracticesSkill
Section titled “PhpBestPracticesSkill”A PHP coding-standards checklist, named php_best_practices, with the tags php, code, review,
refactor, best, practices, clean, quality, standard, style, lint, analyse, class
and method.
Customise it without subclassing:
use PhpClaw\Skills\PhpBestPracticesSkill;
new PhpBestPracticesSkill(extraTags: ['psr', 'phpstan']);new PhpBestPracticesSkill(extraRules: ['Always use Carbon for dates.']);new PhpBestPracticesSkill(contentOverride: 'Custom rules...');Skills in each adapter
Section titled “Skills in each adapter”Every adapter registers every skill in SkillCatalogue at boot. The catalogue holds classes marked
with #[Skill] plus any declared by Composer packages.
| Adapter | Skills registered at boot |
|---|---|
| WordPress | php_best_practices, wp_plugin_creator |
| Joomla | php_best_practices, extension_scaffolder |
| Drupal, Magento, PrestaShop, OpenCart, Laravel, Symfony | php_best_practices |
Registering a skill only makes it available. Each message still has to match it.
#[Skill] also takes keywords. They are shown on the Magento and OpenCart admin pages and are
not used for matching; matching reads tags().
The catalogue
Section titled “The catalogue”use PhpClaw\Skills\SkillCatalogue;
SkillCatalogue::keys(); // discovered skill keysSkillCatalogue::activateDefaults(); // register all of themSkillCatalogue::activateEnabled(['php_best_practices']);SkillCatalogue::activateFromSettings() always returns an empty list and registers nothing. Skills
have no settings key; use activateEnabled() to choose a subset.
A Composer package can declare skills under extra.phpclaw.skills in its composer.json.
SkillCatalogue::boot(), which PhpClaw\AutoDiscovery\Bootstrap::boot() calls, adds them to the
catalogue; they are registered only once activated.
Remote skills
Section titled “Remote skills”$agent = Claw::builder() ->withRemoteSkills('https://example.com/skills/seo/SKILL.md') ->build();The format is detected from the body: anything starting with { is read as JSON, anything else as
Markdown.
JSON collection. Each entry needs name and content; description and tags are optional.
Entries missing either required key are skipped.
{ "skills": [ { "name": "seo", "description": "SEO guidance", "tags": ["seo"], "content": "..." } ] }A single Markdown file becomes one skill. Both formats register ArraySkill instances:
| Skill field | Taken from |
|---|---|
name | the file name in the URL; when it is SKILL, index, readme or empty, the parent directory name |
description | the first # or ## heading, followed by a frontmatter description if present |
tags | words of 4 letters or more from ## and ### headings and from bold text, minus stopwords, at most 25 |
content | the whole file |
Using the parent directory for a file named SKILL.md keeps two skills from different directories
from colliding under one name.
Fetching is constrained: HTTPS only, the host validated against SSRF and pinned, no redirects, an 8 second timeout, a 512 KB cap, and a 24 hour cache. A failure is logged and skipped, never thrown.
Resolving skills from config
Section titled “Resolving skills from config”SkillResolver::resolve() turns a list of arrays into skills. The first matching shape wins:
use PhpClaw\Skills\SkillResolver;
$skills = SkillResolver::resolve([ ['file' => '/var/www/app/skills/seo.md'], ['class' => SupportToneSkill::class], ['name' => 'refund_policy', 'description' => 'Refunds', 'content' => 'Within 30 days.', 'tags' => ['refund']],], onSkip: fn (mixed $entry, string $reason) => error_log("skill skipped: {$reason}"));An inline entry needs name, description and content. Anything unrecognised is skipped; pass
onSkip to hear about it.
Events
Section titled “Events”| Event | Fires | Context |
|---|---|---|
skill.registered | on SkillRegistry::register(), and on SkillCatalogue::register() | key, class, label |
skill.loaded | on every SkillRegistry::register(), together with skill.registered | skill_name, skill_class |
skill.matched | on a message that matched at least one skill | matched_skills, message_excerpt, matched_count |
skill.not_matched | on a message that matched none, including when no skills are registered | available_skills, message_excerpt |
message_excerpt is the first 200 characters of the message. See Hooks to listen:
use PhpClaw\Hooks\HookRegistry;use PhpClaw\Hooks\LifecycleEvent;
HookRegistry::on(LifecycleEvent::SkillNotMatched->value, function (array $context): void { error_log('no skill matched; available: '.implode(', ', $context['available_skills']));});Writing a skill
Section titled “Writing a skill”use PhpClaw\Skills\Contracts\SkillInterface;
final class SupportToneSkill implements SkillInterface{ public function name(): string { return 'support_tone'; }
public function description(): string { return 'Customer support tone, complaints and escalation guidance'; }
public function tags(): array { return ['support', 'customer', 'customers', 'ticket', 'tickets', 'refund', 'complaint']; }
public function content(): string { return 'Greet the customer by name. Acknowledge the issue before offering a fix. ' . 'Escalate refunds over $500 or any legal threat to a human.'; }}Keep content short. Every matched skill is sent with the message, up to the match limit.
To make it addressable by key, add it to the catalogue, then activate it:
SkillCatalogue::register('support_tone', SupportToneSkill::class);SkillCatalogue::activateEnabled(['support_tone']);Registering alone does not make the skill match. The catalogue constructs the class with no arguments.
Core reads the #[Skill] attribute only on classes in the PhpClaw\ namespace, so on your own class
it has no effect.