Skip to content

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.


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
}

A skill reaches the model in one of two ways.

When at least one skill is registered as the agent is constructed, phpClaw does two things:

  1. 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
  2. It registers the load_skill tool.

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.

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.

  • 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 ev is injected for any message containing “relevant”.
  • Every skill costs prompt space on every request. Each registered skill adds its name, description and load_skill line 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_skill is still registered.

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


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


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 the system prompt lists the name alone
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

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.

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


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 names at least one skillmatched_skills, message_excerpt, matched_count
skill.not_matchedon a message that names 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 named; 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 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.