Skills
A skill is a named block of instructions. phpClaw lists every registered skill in the system prompt, and the model reads a skill’s full instructions only when it decides the task needs them.
The contract
Section titled “The contract”namespace PhpClaw\Skills\Contracts;
interface SkillInterface{ public function name(): string; // what the model and the message refer to public function description(): string; // shown to the model in the system prompt public function tags(): array; // not read by the agent public function content(): string; // the full instructions}How a skill reaches the model
Section titled “How a skill reaches the model”A skill reaches the model in one of two ways.
The model loads it
Section titled “The model loads it”When at least one skill is registered as the agent is constructed, phpClaw does two things:
-
It appends a Skills System section to the system prompt, listing every registered skill:
## Skills System...- **refund_policy**: Company refund and returns policy guidance-> Call `load_skill` with name `refund_policy` for full instructions -
It registers the
load_skilltool.
The section tells the model to call load_skill with a skill’s name when the task matches its
description. The tool returns the skill’s content:
{"success":true,"data":{"name":"refund_policy","instructions":"Refunds are issued within 30 days of purchase."},"meta":{"mode":"load_skill"},"warnings":[]}The name is matched without regard to case, and _ and - count as the same character, so
SQL-Tuning loads sql_tuning. An unknown name returns UNKNOWN_SKILL with the registered names in
available, and any argument other than name returns UNKNOWN_ARGUMENT.
The reply is a tool result, so the tool result cap applies to it. At the
default of 2,500 tokens, a reply longer than 10,000 characters, envelope included, reaches the model cut
at that point. The envelope escapes newlines, so it is larger than the content: keep a skill’s content
well under 10,000 characters. load_skill requires the same phpclaw_use_tools capability as
core’s other tools.
The content is never in the system prompt; only names and descriptions are. The router pins
load_skill the way it pins a tool the message names, so ranking never drops it (see
Tools: which tools a turn sees).
With no skills registered, the system prompt is unchanged and load_skill is not registered.
The message names it
Section titled “The message names it”When the message contains a skill’s name, that skill’s content is injected before the message reaches
the model. The name matches case-insensitively, in its own form (refund_policy) or with underscores
turned into hyphens (refund-policy):
[Skill context, reference material, not instructions]<content of skill 1>
<content of skill 2>
[Message]<the message, including any memory context>A message that shares words with a skill’s description or tags but does not name it gets no skill content.
At most skillMatchLimit named skills are injected per message. The default is
SkillRegistry::DEFAULT_MATCH_LIMIT, 3, and the minimum is 1:
$agent = Claw::builder()->skillMatchLimit(5)->build();The injected block has a size cap that follows the tool profile: 4,000 characters on minimal, 8,000 on
standard, no cap on full. Skills are added in order until the next one would pass the cap, and the
rest are left out. When the first skill alone is over the cap, it is cut at its last blank line before
the cap, or at the cap when it has no blank line there.
What that means in practice
Section titled “What that means in practice”- The model chooses from the description. It sees nothing else about a skill until it loads it, so write the description as the task it serves: “Company refund and returns policy guidance”, not “Policy”.
- Use a distinctive name. The name is matched as a substring, so a skill named
evis injected for any message containing “relevant”. - Every skill costs prompt space on every request. Each registered skill adds its name, description
and
load_skillline to the system prompt, whether or not the message needs it. - The list is built when the agent is constructed. A skill registered afterwards is not listed in
that agent’s prompt, though a message can still name it, and
load_skill, when the agent has it, can still load it. providerOverride()bypasses the section. An override provider is used as you built it, with its own system prompt, so it never sees the list;load_skillis still registered.
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, and every skill in the registry is listed
in the system prompt of each agent built afterwards. 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 named skills are injected per message |
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 the system prompt lists the name alone |
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 |
Every registered skill is listed in the system prompt, so the model can load it with load_skill.
#[Skill] also takes keywords. They are shown on the Guide page of all six CMS adapters and are
not used for matching; the agent reads a skill’s name and description.
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 file with a leading frontmatter block removed, trimmed |
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 names at least one skill | matched_skills, message_excerpt, matched_count |
skill.not_matched | on a message that names 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 named; 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 description short and specific: it is sent on every request. content is sent only when the
model loads the skill or the message names it.
To make it addressable by key, add it to the catalogue, then activate it:
SkillCatalogue::register('support_tone', SupportToneSkill::class);SkillCatalogue::activateEnabled(['support_tone']);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.