Your agent writes PHP. It writes generic PHP. It does not know your plugin header conventions, your text-domain rule, or that your team banned a particular function three years ago. That leaves three bad options: fine-tune a model, paste your conventions into every prompt by hand, or accept generic output.
phpClaw Skills is the fourth option. A skill is a named block of domain knowledge carrying a description, registered once and injected into the prompt when it is relevant. No training run. No per-request copy-paste.
There are four ways to register one — a Markdown file, a PHP class, an inline array, or an HTTPS URL — and the same four wire identically on all 8 adapters.
Four mechanisms, not three
Worth being blunt about this, because it is the kind of thing that gets restated wrong and then repeated: the common summary of phpClaw Skills says three injection paths. Source says four. SkillResolver resolves all of these:
- FileSkill — a Markdown file with YAML frontmatter carrying the skill’s name, description, and tags. The Markdown body is the knowledge.
- Class-based skill — a PHP class implementing
SkillInterface, registered by fully-qualified class name. - Inline
ArraySkill— name, description, content, and optional tags declared directly in config, no separate file. - Remote URL — fetched by
RemoteSkillLoader. HTTPS only, SSRF-guarded viaSsrfValidator. The remote endpoint serves either a JSON skill collection or a raw Markdown single skill.
In config the four look like this — one array, four entry shapes, SkillResolver::resolve() dispatches on shape:
'skills' => [
// 1. FileSkill — path to a Markdown file with YAML frontmatter
__DIR__ . '/skills/acme-plugin-conventions.md',
// 2. Class-based — FQCN of a class implementing SkillInterface
\Acme\Skills\PluginConventionsSkill::class,
// 3. Inline ArraySkill — declared right here
[
'name' => 'acme_commit_style',
'description' => 'ACME commit message format. Use when writing or reviewing a commit message.',
'content' => "Conventional Commits. Subject <= 72 chars, imperative mood.\nNo ticket ID in the subject; put it in the trailer.",
'tags' => ['git', 'commit', 'convention'],
],
// 4. Remote — HTTPS only, SSRF-validated, cached 24h
'https://skills.acme.dev/php-house-style.md',
],
Answer the precedence question first
Before anyone writes their second skill, they hit this: two sources register a skill under the same name and the instructions disagree. Which one wins?
Last registered wins. There is no priority mechanism. SkillRegistry::register() overwrites silently on name collision — no exception, no warning, no merge, no ordering hint you can set.
This matters most in the exact situation you would want it to be loudest: a host application registers acme_plugin_conventions from a local file, and a plugin registers a skill of the same name later in the boot sequence via the platform extension event. The plugin’s version is what reaches the model. Nothing tells you the first one was discarded.
The practical rule: namespace your skill names. acme_plugin_conventions, not plugin_conventions. Collision resistance is your job, because the registry will not do it for you.
SkillRegistry and SkillCatalogue are not the same class
Two distinct classes, easy to conflate, worth separating:
SkillRegistryhandles registration — the name-keyed store where last write wins.SkillCataloguehandles attribute-discovered defaults.SkillCatalogue::activateDefaults()is what turns on the skills a package ships as enabled-by-default, without anyone naming them in config.
Both are part of the same wiring on every adapter: SkillResolver::resolve() plus RemoteSkillLoader plus SkillCatalogue::activateDefaults(). That trio is identical across all 8 adapters — the platform-specific part is only how third parties get their skills into that pipeline, which is the table further down.
The remote cache, precisely
The remote path is the only one that touches the network, so it is the only one with a cache. The numbers, exactly:
- TTL:
RemoteSkillLoader::CACHE_TTL = 86400— 24 hours. - Cache key:
md5($url). One file per URL. - Path:
{PHPCLAW_CACHE_DIR:-/tmp}/phpclaw-skill-cache/.
That last one is worth stating carefully, because the imprecise version of this claim circulates: it is not hardcoded to /tmp/phpclaw-skill-cache. /tmp is the fallback when the PHPCLAW_CACHE_DIR environment variable is unset. Set PHPCLAW_CACHE_DIR and the cache moves with it — which you want on any host where /tmp is cleared between requests or shared across tenants.
Invalidation is purely time-based. There is no content-hash check. The loader compares the cache entry’s age against the 24-hour TTL and refetches only when it has expired. It does not ask the remote whether the content changed.
The debugging consequence, stated plainly so you do not lose an afternoon to it: if you publish an updated remote skill and the agent keeps behaving the old way, that is almost certainly the cache, not a bug. The old content is valid for up to 24 hours from the last fetch. Delete the md5($url) file under the cache directory, or wait it out. A remote skill update is not instant and was never designed to be.
Note also what the SSRF guard costs you: HTTPS only, and SsrfValidator will reject an internal host. You cannot serve skills from http://localhost:9000/skills.json during development. Use a local file path for that — which is what the FileSkill path is for.
All 8 adapters
The four core mechanisms are identical everywhere. What differs is the one platform-native extension event each adapter adds, so third-party code can push skills into the pipeline at boot without editing anyone’s config.
| Adapter | Bundled skill | Extension event |
|---|---|---|
| WordPress | WPPluginSkill (wp_plugin_creator) | apply_filters('phpclaw_extra_skills', []) |
| Joomla (5/6, one registration) | JoomlaBuilderSkill (joomla_extension_creator) | onPhpClawExtraSkills |
| Laravel | none bundled | phpclaw.booting event → PhpClawExtensions::$skills |
| Symfony | none bundled | phpclaw.booting Symfony event |
| Drupal | none bundled | phpclaw.skill-tagged services |
| Magento | none bundled | phpclaw_extra_skills observer |
| OpenCart | none bundled | phpclaw/extra/skills OC event |
| PrestaShop | none bundled | phpclaw/extra/skills PS hook (actionPhpclawExtraSkills) |
Two things to read out of that table.
First, only WordPress and Joomla ship a bundled skill. The other six start empty — that is not a gap, it is that there is no universal domain knowledge to ship for a Laravel app or a Magento store. Yours is the first skill they will have.
Second, these extension events are a different mechanism from the lifecycle event bridge. Registering a skill at phpclaw.booting is boot-time extension. The 40 lifecycle events that HookEventBridge fires onto your platform’s native event system are a separate, one-directional runtime stream. Do not expect one to do the other’s job — see phpClaw Hooks.
A real skill with a real boundary
JoomlaBuilderSkill (joomla_extension_creator) is the honest example here, because its scope is narrow and documented: it covers Joomla plugins only. It does not cover components or modules.
That is a genuinely useful thing to see in a shipped skill, because it is the shape every good skill has. A skill is not “everything the model should know about Joomla.” It is a bounded instruction set with a stated trigger condition, and the description field is what tells the model when it applies. If you ask that agent to scaffold a component, you are outside the skill’s boundary and you get generic output — which is the correct failure, not a bug.
The same pairing applies on WordPress: WPPluginSkill (wp_plugin_creator) teaches the agent the shape of a plugin; the wp_zip_plugin tool is what actually validates and builds the package. Skills supply the how; Tools do the work. Neither ZIP tool installs the result into the running site — both hand back a real, validated ZIP and an upload instruction. That division is covered in phpClaw Tools.
Writing your own, end to end
Here is a complete skill, from scratch, injected two different ways.
Path 1 — FileSkill
skills/acme-plugin-conventions.md:
---
name: acme_plugin_conventions
description: ACME house rules for generating WordPress plugin scaffolding. Use whenever creating or editing plugin files, headers, or text domains.
tags: [wordpress, plugin, conventions]
---
# ACME plugin conventions
## Plugin header
Every main plugin file starts with a header block containing, in this order:
Plugin Name, Description, Version, Requires PHP, Text Domain, License.
Version is always three-part semver. Never omit Requires PHP.
## Text domain
The text domain always equals the plugin directory slug. Never hardcode a
different string in `__()` or `esc_html__()`. Never call `load_plugin_textdomain()`
for plugins targeting WP 4.6+.
## File layout
- `acme-{feature}.php` — main file, header + bootstrap only, no business logic
- `includes/` — classes, one class per file, prefixed `Acme_`
- `admin/` — anything that only loads in wp-admin
## Never generate
- `extract()`, `eval()`, or variable variables
- direct `$wpdb->query()` with interpolated values — use `$wpdb->prepare()`
- a `readme.txt` unless explicitly asked
Register it in config, or push it in from a plugin without touching config:
add_filter('phpclaw_extra_skills', function (array $skills): array {
$skills[] = plugin_dir_path(__FILE__) . 'skills/acme-plugin-conventions.md';
return $skills;
});
Resulting behavior: ask the agent “add a settings page to our ACME Reports plugin” and it emits a header with Requires PHP, puts the class in includes/class-acme-reports-settings.php, uses the directory slug as the text domain, and reaches for $wpdb->prepare() unprompted. Ask it something unrelated to plugin files and the skill does not fire.
Path 2 — the same knowledge, inline, on Laravel
Same content, no file, registered at boot:
use Illuminate\Support\Facades\Event;
use PhpClaw\Laravel\PhpClawExtensions;
Event::listen('phpclaw.booting', function (): void {
PhpClawExtensions::$skills[] = [
'name' => 'acme_api_conventions',
'description' => 'ACME house rules for writing API controllers. Use when creating or editing anything under app/Http/Controllers/Api.',
'tags' => ['laravel', 'api', 'conventions'],
'content' => <<<'MD'
# ACME API conventions
Every API controller method returns a JsonResource or ResourceCollection.
Never return an Eloquent model or array directly.
Validation goes in a FormRequest under app/Http/Requests/Api.
Never call $request->validate() inline in a controller.
Errors use the problem+json shape: type, title, status, detail.
Never return a bare {"error": "..."} string.
MD,
];
});
Path 3 — class-based, when the content is computed
Use a class when the knowledge is not static — when it needs to read the installed plugin list, the current schema, or a feature flag. The class supplies the same four pieces the inline form does; it just computes them at call time instead of hardcoding them.
namespace Acme\Skills;
use PhpClaw\Skills\Contracts\SkillInterface;
final class SchemaConventionsSkill implements SkillInterface
{
public function __construct(private readonly SchemaReader $schema)
{
}
public function name(): string
{
return 'acme_schema_conventions';
}
public function description(): string
{
return 'ACME database conventions and the current live table list. Use when writing any query or migration.';
}
public function tags(): array
{
return ['database', 'schema', 'conventions'];
}
public function content(): string
{
$tables = implode(', ', $this->schema->tableNames());
return "# ACME schema conventions\n\n"
. "Live tables: {$tables}\n\n"
. "All primary keys are ULID CHAR(26). Never AUTO_INCREMENT.\n"
. "Timestamps are INT UNSIGNED Unix, never DATETIME.\n";
}
}
Register the FQCN in config, or via your platform’s extension event from the table above.
Path 4 — remote, for a skill shared across many sites
Point the config entry at an HTTPS URL and every site that boots picks the same skill up, cached for 24 hours per site. This is the right path when the same conventions govern thirty client installs and you want one place to edit them. It is the wrong path when you need an edit to land immediately, or when the endpoint is internal-only — SsrfValidator will reject it.
Limitations
Honest list. All of these are real and none of them are hypothetical.
Last-write-wins is silent, and you cannot see what it ate. No exception, no log line, no ordering control. Two sources registering the same skill name means one of them is gone and nothing tells you which. This is a genuine debugging trap: an agent that stops following a convention it followed last week may simply have had that skill overwritten by a newly-installed plugin. Namespace your names and audit what your extension event actually pushes.
A remote skill update is not instant. 24 hours, time-based, no content-hash check. If you edit the hosted file and nothing changes, that is the cache doing exactly what it was built to do. Clear the md5($url) entry under {PHPCLAW_CACHE_DIR:-/tmp}/phpclaw-skill-cache/ or wait for the TTL.
There is no token-cost guardrail on the file, class, or inline paths. Skill content goes into the prompt. A 4,000-line Markdown file is injected in full every time it matches. Nothing in the mechanism truncates it, samples it, or warns you. Compare this to memory, where MessageAugmenter caps injection at 3 keyword-scored entries per turn — skills have no equivalent ceiling, and skill content and memory entries are competing for the same prompt. If you are watching cost, watch skill size first; see phpClaw Memory. Keep a skill to the rules that actually change output, not a manual.
Skills have narrow, real boundaries, and shipped ones are no exception. JoomlaBuilderSkill covers plugins and not components or modules. That boundary is not written on the tin at call time — the model gets the description and decides. Write descriptions that state the trigger condition precisely, or the model will apply a skill where it does not belong.
The remote path is HTTPS-only and SSRF-guarded. Correct security default, but it means no http://, and no internal hosts. Local development uses the file path instead.
What to read next
- phpClaw Tools: How the Agent Calls Code — skills say how, tools do it. Start here if your skill is about generating or modifying something.
- phpClaw Memory: What the Agent Remembers, and What It Costs — the other thing filling your prompt, and the one with an actual injection cap.
- phpClaw Hooks: The Agent Lifecycle You Can Intervene In — the 40 lifecycle events, and why the boot-time extension events used here are a different mechanism.
- phpClaw Guard: What Stops a Bad Tool Call — a skill can tell the model what not to generate; Guard is what enforces when it does anyway.
- phpClaw Providers: Choosing and Configuring the Model Backend — which model reads your skill, and what its context budget is.