Skip to content

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.


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
}

On every send() and stream(), SkillRegistry::match() scores each registered skill:

  1. The message is split into lowercase words of 3 letters or more.
  2. The skill’s description is split into words of 4 letters or more, and its tags into words of 3 letters or more.
  3. The score is the number of words in both, after dropping 79 common stopwords.
  4. 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.

These all follow directly from the rules above:

MessageSkill with description “Generate customer invoices and billing”, tag billingWhy
invoices for customersmatchesinvoices is in the description
I need billingmatchesbilling is a tag
make an invoice pleaseno matchinvoice is not invoices; there is no stemming
invoicingno matchthe skill’s name is never matched
order 2024no matchdigits are dropped from every word

Rules of thumb:

  • Name is not matched and neither is content. Put every word a user might type in description or tags.
  • Matching is exact. Add singular and plural forms as tags when both are likely.
  • Hyphens and underscores split words, so the tag self-contained matches self or contained.
  • Numbers carry no meaning. A tag like php8 matches on php, and a tag that is only a number never matches.

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>

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 $b

This 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.


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 methodEffect
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.


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.

A skill defined in PHP, with the four values passed to the constructor, as above.

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: seo
description: On-page SEO guidance for product copy
tags: [seo, meta, keywords, search]
---
Lead with the primary keyword in the first sentence...

Everything after the frontmatter becomes the content.

FieldIf missing
namethe file name without .md
descriptionempty, so only tags can match
tagsnone; 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.

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...');

Every adapter registers every skill in SkillCatalogue at boot. The catalogue holds classes marked with #[Skill] plus any declared by Composer packages.

AdapterSkills registered at boot
WordPressphp_best_practices, wp_plugin_creator
Joomlaphp_best_practices, extension_scaffolder
Drupal, Magento, PrestaShop, OpenCart, Laravel, Symfonyphp_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().

use PhpClaw\Skills\SkillCatalogue;
SkillCatalogue::keys(); // discovered skill keys
SkillCatalogue::activateDefaults(); // register all of them
SkillCatalogue::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.


$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 fieldTaken from
namethe file name in the URL; when it is SKILL, index, readme or empty, the parent directory name
descriptionthe first # or ## heading, followed by a frontmatter description if present
tagswords of 4 letters or more from ## and ### headings and from bold text, minus stopwords, at most 25
contentthe 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.


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.


EventFiresContext
skill.registeredon SkillRegistry::register(), and on SkillCatalogue::register()key, class, label
skill.loadedon every SkillRegistry::register(), together with skill.registeredskill_name, skill_class
skill.matchedon a message that matched at least one skillmatched_skills, message_excerpt, matched_count
skill.not_matchedon a message that matched none, including when no skills are registeredavailable_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']));
});

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.