spora-ai / spora-plugin-custom-skills
Principal-scoped custom skills — UI + manage_skill tool, served through core's skill tool.
Package info
github.com/spora-ai/spora-plugin-custom-skills
Type:spora-plugin
pkg:composer/spora-ai/spora-plugin-custom-skills
Requires
- php: ^8.4.1
- spora-ai/spora-core: >=0.30.0 <2.0.0
- spora-ai/spora-plugin-custom-skills-frontend: >=0.1.0 <2.0.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- larastan/larastan: ^3.9
- mockery/mockery: ^1.6
- pestphp/pest: ^5.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Principal-scoped custom skills — a two-pane admin panel for authoring skills by hand, plus the manage_skill tool so an agent can write them too.
Custom skills are not a second read tool. They are served by core's skill tool, provider-agnostically: a custom skill appears in the same allowed_skills multi-select as a shipped one, and the agent reads it with the same skill(action: "read", …) call. What this plugin adds is the write side, which core deliberately has no opinion about.
This plugin contributes the Custom Skills admin panel to the host's Apps dropdown at /apps/custom-skills. The panel is a pre-built Vue SPA delivered as a separate Composer package (spora-ai/spora-plugin-custom-skills-frontend, type spora-plugin-frontend). The two-package split lets the frontend evolve on its own release cadence and lets backend-only operators skip the bundle entirely.
Install
composer require spora-ai/spora-plugin-custom-skills
The PHP package's require block pulls the frontend package in transitively — operators don't need to require it separately. The PHP package ships the migration, the provider, the CRUD services, the controller, and the manage_skill tool; the frontend package ships the Vue IIFE bundle the host SPA lazy-loads at runtime.
Requires spora-ai/spora-core ≥ 0.30.0 — the release that ships Spora\Search\SearchProviderInterface, which CustomSkillSearchProvider implements. Neither that nor Spora\Skills\SkillProviderInterface is in any tagged core (v0.29.0 is the newest tag, and it has no app/Search/ at all), so 0.30.0 is the next version rather than a real one; the constraint names where the seam has to land. CustomSkillsPlugin throws PluginLoadFailedException at boot on a core without SkillProviderInterface rather than loading silently. That guard is load-bearing: PluginLoader::dispatchWithTolerance() swallows listener exceptions, so without it an old core would load the CRUD routes and admin panel happily while the agent could never see a single custom skill.
Mind the cost of that guard: PluginLoader::boot() has no per-plugin tolerance, so the throw is caught by Kernel, which skips plugin boot for the whole request. On an incompatible core this plugin therefore takes every installed plugin down with it — a warning in storage/spora.log, no plugins, no app tile. That is deliberate (a half-working skills store is worse than none), but an operator who hits it should expect to disable the plugin, not just this feature.
What it does
- Ships a skill provider (
Spora\Plugins\CustomSkills\Providers\CustomSkillProvider) that implements core'sSpora\Skills\SkillProviderInterfaceand is registered declaratively through theskillProviders()hook. It is a data hook, not a PSR-14 event, mirroringspeechToTextProviders(). - Ships the palette search provider (
Spora\Plugins\CustomSkills\Providers\CustomSkillSearchProvider), registered through thesearchProviders()hook, so ⌘K finds skills and every hit opens a real page. Core used to ship this provider and built its link from the skill's ownsource, returninghref: nullfor every skill core itself ships —AppRegistryonly ever holds plugin apps, so the whole shipped catalogue was unopenable. It lives here because this plugin is the thing that has a page to show a skill on. A skill this plugin owns links to the writable desk (/apps/custom-skills/skill/{name}), anything else to the read-only catalogue viewer (/apps/custom-skills/library/{name}); neither is ever null. - Ships one migration,
custom-skills_000001_create_custom_skills_tables.php(idempotenthasTableguard), andschemaVersion(): 1. Two tables:custom_skills(one row per skill,unique(principal_id, name)) andcustom_skill_files(sidecars, cascading on delete). TheSKILL.mdis synthesised on read from the frontmatter columns + body; there is no file on disk. - Ships the
manage_skilltool withcreate,update, anddeleteoperations.createandupdateareenabledByDefault: true;deleteisenabledByDefault: false— the destructive path does not ride on the safe default. All three arerequiresApprovalByDefault: true, and an approved write goes live: there is no draft/published workflow, because the approval card is the review. - Ships the Custom Skills admin panel: a read-only pane of pre-shipped skills (from the host's
GET /api/v1/skills, grouped bysource) and a "my skills" pane with CRUD, per-pane search, inlineValidationResulterrors, a warnings banner, a "last edited by agent" line, one-step restore, a "Duplicate" fork from any pre-shipped card, and a delete confirmation that names the agents whose allowlists will be scrubbed.
Visibility
Custom skills belong to exactly one principal, and the read and write gates are deliberately asymmetric:
| Caller relation to principal | Read | Write |
|---|---|---|
| Own user-principal | ✅ | ✅ |
| Group they belong to, any role | ✅ | ❌ |
| Group owner/admin | ✅ | ✅ |
| Unrelated principal | ❌ 404 |
❌ 403 |
Reading a group's custom skills needs only membership. Writing into a group's instruction set needs owner or admin. A member can read a group's skills but cannot author into them — a skill is instructions the model will follow, not shared notes.
Shipped skills sit outside this entirely: core's FilesystemSkillProvider ignores $principalId, because operator-authored content is identical for everyone and per-principal scoping would mean a copy per user of a file the operator already controls.
Identity and precedence
Identity is the frontmatter name, not a directory slug. The writer forces name === slug at write time, so the two cannot diverge.
SkillProviderRegistry dedupes first-provider-wins across providers, and core's FilesystemSkillProvider is first in the static class list — so a custom skill can never shadow a shipped one, and installing this plugin can never change what an existing agent's allowed_skills resolves to. A write that collides with a shipped name is rejected up front (409 SKILL_NAME_RESERVED) rather than creating a row the model could never resolve.
Deleting a custom skill
DELETE /api/v1/custom-skills/{name} also scrubs the name from every allowed_skills array for that principal, in the same transaction, and the response names the affected agents in scrubbed_agents. Without that, a deleted skill is silently dropped from the tool definition and every agent that used it loses a capability with no signal. The scrub is scoped to principal-owned custom skills — filesystem skills are not deletable through this plugin, so a shipped skill can never be removed from an agent's config.
Caps
Enforced as errors with named codes, not warnings. SkillValidator emits a soft SKILL_BODY_OVERSIZE warning and ValidationResult::isValid() checks errors only, so validator reuse alone would leave a user-authored body unbounded.
| Cap | Limit | Code |
|---|---|---|
| Skills per principal | 25 | SKILL_LIMIT_REACHED |
| Sidecar files per skill | 20 | TOO_MANY_FILES |
| Total bytes per skill | 200 000 | TOTAL_SIZE_EXCEEDED |
| Bytes per file | 50 000 | FILE_TOO_LARGE |
description length |
1024 chars | DESCRIPTION_TOO_LONG |
The per-file read cap is core's SkillProviderInterface::MAX_FILE_BYTES, re-asserted by SkillTool on what a provider returns. It is enforced on the write side, by this plugin rather than by the provider: sidecars are checked as they are validated, and the synthesised SKILL.md is checked by composing it, because that file is built from columns and so never passes through the sidecar loop. The provider's own getSkillFile() does not cap it.
API surface
After install, 9 endpoints appear under /api/v1/custom-skills*. All require AuthMiddleware + CsrfMiddleware, and every one honours ?principal_id=N to pick the acting principal.
GET /api/v1/custom-skills— list the acting principal's custom skills, ordered byname.GET /api/v1/custom-skills/{name}— one skill, full resource shape.GET /api/v1/custom-skills/{name}/files— the file listing (SKILL.mdfirst).GET /api/v1/custom-skills/{name}/files/{path}— one file's content (path percent-encoded).POST /api/v1/custom-skills— create.PUT /api/v1/custom-skills/{name}— update; a name change is rejected, andfilesfully replaces the sidecar set.DELETE /api/v1/custom-skills/{name}— delete, and scrub the allowlist.POST /api/v1/custom-skills/{name}/restore— restoreprevious_snapshotin one step (itself undoable).GET /api/v1/custom-skills/{name}/allowlist— which agents resolve this skill viaallowed_skills; the blast-radius preview behind the delete dialog. The tool'sdescribeActiondeliberately does not count agents, because only the model's arguments reach it and a count would be a cross-tenant disclosure.
Pre-shipped skills are not re-exposed here. They come from the host's GET /api/v1/skills.
Uninstalling
composer remove spora-ai/spora-plugin-custom-skills removes the admin-panel metadata from the App Registry, drops the 9 routes, and the navbar tile disappears cleanly. The custom_skills and custom_skill_files tables are preserved — uninstalling does not drop them. Reinstalling is a no-op on the schema. This is intentional: data persists across plugin uninstall/reinstall cycles, the same as spora-plugin-memories.
Note that a leftover custom skill whose provider is gone becomes unresolvable: allowed_skills entries pointing at it are reported as (unavailable: <name>) in the tool definition rather than silently dropped.
Reference
The canonical reference lives on the docs site:
- Concepts → Skills — discovery, the
allowed_skillsallowlist, and custom skills - Plugin author guide → Skills — shipping a directory vs. authoring a
SkillProviderInterfaceprovider - Tool system — the
skill(read) vs.manage_skill(write) split - REST API reference — the full endpoint list and the error registry
License
MIT — see LICENSE.