docs/craft/product/skills.md
Skills are reusable capability bundles that extend what the Craft AI coworker can do. A skill is a self-describing directory containing a SKILL.md instruction file plus any mix of supporting assets — Python scripts, bash helpers, executables, schemas, fonts, fixture data, additional markdown — that the agent reads, executes, or references on demand when the skill's description matches the user's task.
Skills come from two sources in V1:
docker/skills/<slug>/ and are registered at app boot in the in-memory BuiltinSkillRegistry./admin/skills. Each custom skill is one row in the skill table plus a bundle blob in the file store, scoped to the tenant.Bundles are validated synchronously on upload; on success they're stored, indexed, and immediately pushed into every sandbox that should see them.
Skills live in the sandbox at the user level, not per session. Each user's sandbox keeps a directory of the skills they currently have access to at /workspace/managed/skills/<slug>/ (the agent reads them via the .opencode/skills/ symlink). The backend keeps that directory in sync as bundles, grants, and availability change by pushing to running pods through SandboxManager.push_to_sandboxes. Sessions running inside the sandbox see the live skill set — no need to wait for a new session to pick up a granted, replaced, or revoked skill.
The agent automatically gets every skill the running user has access to — no per-session picking, no manual selection. Visible surfaces are: an admin skills page (/admin/skills) for org-wide governance, and a read-only "what's available" panel inside Craft sessions for transparency.
The full engineering plan lives at ../features/skills/ — read skills-requirements.md, skills-db-layer-status.md, and skills-api-plan.md in that order. This document is the product spec.
These are the endpoints the product behaviors in this doc rely on (see ../features/skills/skills-api-plan.md for the full plan):
| Endpoint | Purpose |
|---|---|
GET /admin/skills | Admin: list built-ins (with availability) + customs (with grants, including disabled) |
POST /admin/skills/custom | Admin: upload a new custom skill (multipart bundle + metadata) |
PATCH /admin/skills/custom/{id} | Admin: edit is_public, enabled |
PUT /admin/skills/custom/{id}/bundle | Admin: replace the bundle bytes |
PUT /admin/skills/custom/{id}/grants | Admin: atomic replace of group grants |
DELETE /admin/skills/custom/{id} | Admin: hard-delete (no soft-delete in V1) |
GET /skills | User: list available built-ins + accessible customs |
Mutations push bundle bytes to running sandboxes via SandboxManager.push_to_sandboxes after commit; the FileStore blob is still written for persistence and cold-start hydration on next session start.
A skill has a name, a description, and a body. The description is what the agent reads when deciding whether to invoke the skill. The body is whatever instructions, scripts, or assets the skill needs.
A skill is a directory, not a single file. Bundles can include any mix of:
SKILL.md (required) and other markdown documentation.python … / bash … from inside the sandbox.The runtime treats the bundle as an opaque directory the agent can read, execute from, and reference by path. There is no whitelist of allowed file types — only a per-file size cap (25 MiB) and a total bundle cap (100 MiB), both enforced by validate_custom_bundle in backend/onyx/skills/bundle.py. The existing pptx built-in is the canonical example: it ships SKILL.md, several supporting *.md guides, a scripts/ directory of Python helpers, and template .pptx assets.
Skills are reached by the agent, not invoked by the user. Users don't pick skills from a menu before sending a prompt. The agent matches the user's intent against available skill descriptions and reaches for the right one.
Skills are scoped to the user. The set of skills the agent has access to in a session is exactly the set the running user has access to — no more, no less.
pptx), image generation (image-generation), and permissioned company search (company-search). New built-ins ship via deploy, not configuration. They are registered in BuiltinSkillRegistry at app boot, not stored in the database.is_available(db_session) -> bool callable. If image-generation requires a Gemini key and the deploy doesn't have one configured, the skill is unavailable. Admins don't toggle built-ins on/off — wiring up the dependency is the toggle.BuiltinSkill.is_available and unavailable_reason.POST /admin/skills/custom) and the management page (/admin/skills) are admin-gated. User-authored skills are a future enhancement (see below).SKILL.md at the root with frontmatter name + description, plus any supporting files (additional markdown, Python / bash / Node scripts, executables, schemas, fixtures, fonts, images, etc.) — and upload it through /admin/skills. Re-uploading via PUT /admin/skills/custom/{id}/bundle replaces the bundle atomically.Custom skills have one of three V1 visibility states:
is_public = false with no group grants. Only admins can see it via /admin/skills; no end users have access. Useful as a staging state before sharing.is_public = false plus one or more entries in skill__user_group. Users in any granted group have access; others don't.is_public = true. Every user in the tenant has access.Visibility is set on create and editable via PATCH /admin/skills/custom/{id} (is_public flag) and PUT /admin/skills/custom/{id}/grants (atomic replace of group grants). Direct user grants ("share with user X specifically") are out of scope for V1 — group membership is the only per-user access mechanism.
Admins manage every custom skill. Authorship is recorded (author_user_id) for audit, but in V1 the author is always an admin and every admin can edit every custom skill. Curator-or-admin access controls the admin router.
Custom skills can be disabled and re-enabled. Setting enabled = false via PATCH immediately removes the skill from every sandbox that had it, without losing the bundle, metadata, or grants. Useful as a kill-switch or as a way to take a skill out of circulation while reworking it. Disabled skills remain visible to admins in /admin/skills; users do not see them.
SKILL.md, slug collision with a built-in or another custom skill, path traversal, symlinks, oversize files, forbidden template files) surface inline with a clear OnyxError and reason. Nothing partially persists.PUT /admin/skills/custom/{id}/bundle atomically replaces the prior bundle with a new fingerprint, pushes to affected sandboxes, then deletes the old blob inline (best-effort, logged on failure). Slug is immutable post-create unless explicitly changed via PATCH; the rest of the metadata (name, description, visibility, enabled state) is editable inline without re-uploading.DELETE /admin/skills/custom/{id} removes the row, removes the skill from every sandbox immediately, and cleans up the bundle blob inline. There is no soft-delete in V1 — slug reuse works because the row is gone. If you need to take something out of circulation without losing it, disable it instead.is_public, replacing a bundle, disabling, enabling, or deleting — every change is reflected in the affected sandboxes as soon as the request commits. The skills feature computes the set of affected users from the before-and-after state, resolves their sandbox_ids, builds a per-user file set, and pushes through SandboxManager.push_to_sandboxes(mount_path="/workspace/managed/skills", sandbox_files=...). Sessions in flight see the new state without restarting. Push failures are logged inside SandboxManager and recorded in PushResult; they don't surface as request errors — cold-start hydration on the next session covers the long tail.skills.push_to_pod(sandbox_id, user, db_session) materializes the user's accessible skill set from the DB + FileStore and pushes via SandboxManager.push_to_sandbox. After bring-up, all changes flow through the per-mutation push path described in #17./workspace/managed/skills/<slug>/<original-path> exactly as authored. File modes are preserved so executable bits survive — the agent can run a bundled scripts/foo.sh directly. Built-ins additionally support template rendering of SKILL.md.template against the user's SkillRenderContext at materialization time; custom skills do not..opencode/skills/<slug>/SKILL.md (a symlink to the managed mount); the agent enumerates and reads them as needed. The session's AGENTS.md lists either every skill inline (when the count is small) or the built-ins inline plus a discovery instruction to enumerate the rest. Because the agent re-reads the directory on demand, additions during a session are discoverable; descriptions the agent has already pulled into context, however, persist until that context is cleared./admin/skills) for org-wide governance. Two sections: built-ins (read-only with availability) and customs (every custom skill in the tenant, with replace / patch / grants / disable / delete actions). The page is not nested under Craft because skills are a cross-surface primitive. Backed by GET /admin/skills.GET /skills — the same access query as the materializer. This is for transparency; users don't act on it./ opens a skill picker in any prompt input. Typing / in the Craft session chat input — or in a scheduled trigger's prompt field — opens a popover listing every skill the user has access to (same set as GET /skills), with name and description. Continued typing filters the list by slug, name, and description. Arrow keys move the selection; Enter or Tab inserts /<skill-slug> into the input (replacing the / token) so the user can continue writing. Pressing Escape or clicking outside dismisses without inserting. This is a hint — the agent still uses description-based matching — but it gives the user a way to nudge the agent toward a specific skill when they already know which one they want. For scheduled triggers the available skills are scoped to the trigger owner's accessible set (same access query as GET /skills).pip install). Onyx does not provide a per-skill image-extension mechanism in v1.backend/onyx/db/skill.py, backend/onyx/skills/, backend/onyx/server/features/skill/). Craft is the v1 consumer; Personas, Chat, or other surfaces can adopt skills later without touching the universal layer.These are intentionally deferred. Listed with the reason so we can revisit when the constraint changes.
POST /skills/custom endpoint for regular users, and no /skills upload UI.
Why: The admin-only path covers the most common case (a tenant admin distributing skills to their org) and avoids designing share, fork, and visibility-bounding flows before we see how skills are actually used. The DB schema already tracks author_user_id, so user-authoring is a clean follow-up — add a user-scoped router, default new uploads to is_public=false with no grants, and bound group sharing by membership.is_public), group-scoped (via skill__user_group), or private — there's no "share with user@" picker, and no skill__user junction table.
Why: Group membership is the existing access primitive in the rest of the product; reusing it keeps the surface small. A direct-grant table is a backwards-compatible add when the demand surfaces.is_public = true directly when they want org-wide reach. Worth revisiting when user-authoring lands.DELETE /admin/skills/custom/{id} is destructive — the row is gone, the blob is cleaned up inline, and the slug becomes available for reuse. There's no deleted_at column and no recovery path.
Why: Disable (enabled = false) is the kill-switch that preserves the bundle, metadata, and grants. Soft-delete adds a column, a filter on every read, and a slug-uniqueness wrinkle without buying anything disable doesn't already provide.SKILL.md, no file tree, no inline script editor, no drag-and-drop bundle assembler.
Why: The bundle format and lifecycle should settle on uploaded zips before we invest in editing UX. Most early authors already keep their skill content in git or a local folder; a zip is a thin packaging step on top of that. Once we see how authors actually maintain skills, we'll know what to optimize for.*.template files are rejected at upload by validate_custom_bundle.
Why: The render context shape (SkillRenderContext) is still evolving for built-ins. Locking it in publicly via custom uploads would create a compatibility surface we'd have to support indefinitely.POST /skills/custom exists for regular users.is_available callable. If you don't want image-generation, don't configure the provider. Adding a per-tenant override creates a state-vs-config divergence we don't want to debug.GET /skills/{slug_or_id} single-skill endpoint. Listing returns enough metadata; there's no detail-fetch endpoint.
Why: The list payload already includes every field the UI needs. Deferred until a concrete UI flow demands it./admin/skills (backed by GET /admin/skills).BuiltinSkill.is_available(db_session) and unavailable_reason.Implicit. Admin configures the underlying dependency (GEMINI_API_KEY, provider row, feature flag) elsewhere. The built-in flips to Available on the next page load. Built-ins re-converge into sandboxes on the next session start / wakeup (no push trigger for built-in availability flips in V1 — see skills-api-plan.md §2).
/admin/skills.is_public flag, group IDs (JSON-encoded list). The slug is derived from the zip filename, and name + description are parsed from SKILL.md frontmatter — they are not separate form fields.SKILL.md at the root + supporting files (markdown, scripts in any language, executables, schemas, fixtures, fonts, images).POST /admin/skills/custom runs validate_custom_bundle synchronously.
OnyxError reason (e.g. "SKILL.md is missing", "Bundle contains SKILL.md.template (templates aren't supported in custom skills)", "Slug reserved by a built-in skill"). Nothing persists.skill row created, group grants inserted, commit, push to affected sandboxes, modal closes, list refreshes./admin/skills, admin opens any custom skill.PUT /admin/skills/custom/{id}/bundle: backend validates and atomically writes the new blob → updates the skill row (new bundle_file_id, new bundle_sha256) → commits → pushes to affected sandboxes → deletes the old blob inline. Updated last-updated and fingerprint shown.If the admin wants no sandbox to use the skill while reworking it, they disable the skill before replacing and re-enable after the new bundle is in place.
PATCH /admin/skills/custom/{id} accepts a SkillPatchRequest covering is_public and enabled. Slug, name, and description are derived from the bundle (filename + SKILL.md frontmatter) and are only mutated via Replace (A4). Bundle content is not editable here — bundle changes go through Replace (A4).
The endpoint pushes to affected sandboxes when visibility-affecting fields change (is_public, enabled).
Two endpoints cover visibility:
PATCH /admin/skills/custom/{id} with is_public: true/false.PUT /admin/skills/custom/{id}/grants with {"group_ids": [...]}. Atomic replace: the new list is the full set of granted groups.The drawer in the UI presents both controls together. Save replaces grants atomically; combined is_public + group state determines who sees the skill.
PATCH /admin/skills/custom/{id} with enabled: false. Skill stays in the catalog; immediately removed from every sandbox that had it.DELETE /admin/skills/custom/{id}. Hard-delete — the row is removed, the bundle blob is cleaned up inline, the skill is removed from every sandbox. Idempotent (404 on second call is acceptable).The detail page shows: slug, name, description, frontmatter, file tree (relative paths, sizes, "executable" badge for files with the executable bit set), total uncompressed size, sha256 fingerprint, author, last-updated timestamp, and visibility summary (org-wide flag + granted group IDs). Downloading the bundle as a zip is a v1 affordance for re-upload-as-rollback.
skills.push_to_pod provisions the skills directory as part of bring-up; otherwise the session just sees the live state.SKILL.md, follows it.The user does not have to select a skill — the agent reaches for the right one. If the user already knows which skill they want, U3 lets them nudge the agent toward it via the / picker.
GET /skills and lists each available skill with name and description.A future enhancement may distinguish source (built-in vs. custom), but v1 just shows the flat list.
/ picker/.GET /skills payload as the Skills available panel), each row showing slug, name, and description. The first row is selected by default./ token is replaced with /<skill-slug> followed by a space, with the cursor positioned after it so the user can keep typing their prompt.The inserted /<skill-slug> is a hint, not a hard invocation — the agent still chooses based on descriptions. The picker exists for cases where the user already knows which skill they want and wants to save the agent the matching step.
Same as U1, but the session is started by the trigger system. The trigger attaches to the trigger owner's sandbox, so it sees that user's live skill set. The trigger config does not carry per-skill toggles in v1.
When authoring or editing a scheduled trigger, the prompt field supports the same / picker as U3: typing / opens a popover listing the trigger owner's accessible skills (sourced from GET /skills), filtering as the user types, with Enter or Tab inserting /<skill-slug> into the prompt. The inserted slug is stored verbatim in the trigger's prompt text — at run time it's just a hint to the agent, same semantics as in an interactive session. If the trigger owner later loses access to a referenced skill (group revoked, skill disabled or deleted), the prompt text doesn't change; the agent simply won't find the skill in the sandbox and falls back to description-based matching across whatever is still available.
If the dependency that powered a built-in is removed (key unset, provider row deleted), is_available(db_session) returns false. The built-in flips to Unavailable in GET /admin/skills and GET /skills. Active sandboxes still hold the prior files until the next session start / wakeup (no push trigger for built-in availability flips in V1).
Adding the user to a group that's granted a custom skill puts the skill in their sandbox on the next mutation that triggers a push, or at the next session start / wakeup. Removing them takes it out on the same triggers. V1's push triggers are skill mutations (create / patch / bundle replace / grants replace / delete); group-membership changes are not currently a push trigger — they re-converge through session bring-up. (Adding a group-membership push trigger is a low-risk extension once the push API is stable.)
Every active sandbox that had the prior bundle is updated in place through push_to_sandboxes. The agent picks up the new version the next time it reads from the skill's directory. Already-running scripts inside the agent finish against whatever was on disk when they started; new invocations see the new bundle.
If the author wants to be sure no agent reaches for the skill mid-replace, they disable the skill before replacing and re-enable after.
Push failures are recorded in PushResult from SandboxManager.push_to_sandboxes and logged inside the push API. The HTTP request still returns success (the DB commit happened; the source of truth is consistent). The affected sandbox re-converges to the correct state on the next mutation or cold-start hydration. Acceptable for V1; if partial failures become common, the synchronous fan-out can move to a background task with retry.
SKILL.md into context, then the bundle changesThe skill's files on disk are updated; the agent's in-memory copy of the prior SKILL.md (if any) persists until the conversation context is cleared. This is a property of how LLMs handle context, not a sync gap — the next read against /workspace/managed/skills/<slug>/SKILL.md returns the new content. For most skills the agent re-reads on demand, so the practical impact is small.
These do not block v1 but are worth flagging for product follow-up:
list_skills_for_user(user, db_session) helper.SkillsList already separates builtins from customs.author_user_id, updated_at, and bundle_sha256 are all already in the schema.is_public = true flip in a large tenant rebuilds the per-user file dict for every user with an active sandbox. Capped by the push API at 100 MiB total, but the skills-side rebuild cost is unmeasured. Acceptable for v1; revisit if it shows up in admin-mutation latency.Things explicitly considered for a future version, captured here so they don't get rediscovered every quarter:
POST /skills/custom, PATCH /skills/custom/{id}, PUT /skills/custom/{id}/bundle, PUT /skills/custom/{id}/grants, DELETE /skills/custom/{id}) that mirrors the admin endpoints but scopes mutations to the author's own skills, bounds group-sharing by the author's group membership, and defaults new uploads to is_public = false. Add a /skills page where users upload, share, and manage their own skills. The schema already supports this (author_user_id is in place); the work is API + UI + access checks.skill__user junction table for "share with user X specifically", plus a PUT /admin/skills/custom/{id}/user-grants (and user equivalent) endpoint. Visibility filter becomes is_public OR user-granted OR group-granted./admin/skills, a demote path that restores the author's prior visibility setting, and a notice on the author's /skills page when admins disable, delete, demote, or promote their skills.SKILL.md with frontmatter helpers, file tree for the bundle, inline editing for text files (scripts, additional markdown, schemas), drag-and-drop for binary assets, executable-bit toggle, pre-save preview. Same validator, same artifact, same lifecycle as today's zip upload — just a different production path.deleted_at and a bundle-history table; let authors and admins roll back without re-uploading.GET /skills/{slug_or_id} single-skill detail. Currently the listing endpoints return all fields the UI needs; add a detail-fetch endpoint when a concrete flow demands it.