A skill is a named instruction packet plus a description of when it applies — your house style, editorial rules, the shape of a product description.
| Source | Scope | Who | Editable in the admin |
|---|---|---|---|
Code, on CopilotPlugin | Every workspace | An operator, at deploy | No — read-only |
Admin → Agents → Skills (/workspaces/:id/agents/skills) | One workspace | An Admin (copilot:skills:manage) | Yes |
Declare one in code
// apps/server/src/plugins.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
CopilotPlugin({
providers: copilotProviders(config),
skills: [
{
name: 'house-style',
title: 'House style',
description: 'How we write product copy: sentence case, second person.',
instructions: readFileSync(join(__dirname, 'skills/house-style.md'), 'utf8'),
mode: 'always' // omit for 'manual': only when someone attaches it
}
],
config: config.plugins.copilot
});<!-- skills/house-style.md -->
- Headlines in sentence case. No exclamation marks.
- Address the reader as "you".
- Standfirst: one sentence, under 25 words, no jargon.Code skills are validated at construction — a duplicate or malformed one fails boot.
| Field | Rule |
|---|---|
name | Lowercase letters, digits and hyphens (house-style); ≤ 64 |
title | Shown in the picker; ≤ 80 |
description | Written for the model — when to use it; ≤ 240 |
instructions | The body; ≤ 8,000 |
mode | manual (default) or always |
Code wins a name collision. The admin refuses a name code already holds, so a collision only happens when a deploy takes over a name.
Manage them over HTTP
Session-authenticated admin routes, scoped by X-Workspace-Id:
| Route | Needs | Does |
|---|---|---|
GET /api/copilot/skills | copilot:use | Skills available here, without instructions |
GET /api/copilot/skills/manage | copilot:skills:manage | Every skill, disabled and code-defined included |
GET /api/copilot/skills/:id | copilot:skills:manage | One skill, with instructions |
POST /api/copilot/skills | copilot:skills:manage | Create; 409 if either source holds the name |
PATCH /api/copilot/skills/:id | copilot:skills:manage | Edit, including enabled and mode |
DELETE /api/copilot/skills/:id | copilot:skills:manage | Delete (204); past turns keep their snapshot |
curl -b cookies.txt -X POST https://cms.example.com/api/copilot/skills \
-H 'X-Workspace-Id: 7f1c…' \
-H 'Content-Type: application/json' \
-d '{
"name": "product-copy",
"title": "Product copy",
"description": "Use when writing or editing a product description.",
"instructions": "Lead with the benefit. Three short paragraphs. End with the price.",
"mode": "manual"
}' curl -b cookies.txt -X PATCH https://cms.example.com/api/copilot/skills/9a3f… \
-H 'X-Workspace-Id: 7f1c…' \
-H 'Content-Type: application/json' \
-d '{"enabled": false}' curl -b cookies.txt https://cms.example.com/api/copilot/skills \
-H 'X-Workspace-Id: 7f1c…' // POST → 201
{
"id": "9a3f…",
"name": "product-copy",
"title": "Product copy",
"description": "Use when writing or editing a product description.",
"instructions": "Lead with the benefit. Three short paragraphs. End with the price.",
"mode": "manual",
"enabled": true,
"source": "cms",
"createdBy": "a51e…",
"createdAt": "2026-09-30T10:20:00.000Z",
"updatedAt": "2026-09-30T10:20:00.000Z"
}// GET /api/copilot/skills → 200
[
{ "id": null, "name": "house-style", "title": "House style", "description": "…",
"mode": "always", "source": "code", "enabled": true, "editable": false },
{ "id": "9a3f…", "name": "product-copy", "title": "Product copy", "description": "…",
"mode": "manual", "source": "cms", "enabled": true, "editable": true }
]// 409 — name taken
{ "message": "\"house-style\" is the name of a skill defined in this deployment's configuration. Choose another name.", "error": "Conflict", "statusCode": 409 }- An empty
PATCHbody is a400. - The Skills page lists each skill with what it Applies to — Every chat
(
always) or When turned on (manual) — and an Available switch on the row (enabled). A code skill says From code: its switch is drawn but cannot be changed, and it has no menu. New skill and Edit skill open a sheet over the list.
Three ways a skill reaches a run
All three are the system prompt.
| Channel | Effect |
|---|---|
mode: 'always' | In force for every run in the workspace, whatever the client sends |
| Attached for a turn | In force for that turn — up to 3 |
| Every other enabled skill | Advertised as - name — title: description, up to 25 |
Attaching one — what the composer sends when you pick a skill:
// POST /api/copilot/runs
{ "message": "Rewrite the Aurora lamp description.", "skills": [{ "name": "product-copy" }] }The third channel lets the model recommend a skill it wasn’t given (“want me to use product-copy?”) without its instructions costing context every run.
- Skill bodies sit after the security rules (which they cannot override) and before the style rules (which they refine).
- A run names skills; it never carries their text. Nobody with
copilot:usecan write their own system prompt. - Each turn records a snapshot of the skills it ran with.
No tool delivers a skill
A copilot_skill_read tool was considered and rejected. It would need an
exception in the untrusted-content fence, or instructions labelled as inert data.
Skills arrive through the system prompt or not at all.
Skills are instructions, not capabilities
A skill cannot add a tool, widen a permission, or reach data the caller could not. A skill saying “you may publish” changes nothing — there is no publish tool, and the permission check does not read the prompt.