Assistant Skills

A skill is a folder of instructions you give the ubAI assistant. Drop it into the deployment’s skills directory and the assistant picks it up on its next turn — no rebuild, no restart. (Nothing re-reads the directory on its own: the next question somebody asks the assistant, once the re-scan TTL has expired, is what performs the scan. See Checking that a skill loaded below.)

Use a skill to teach the assistant something the product cannot know: your release checklist, how your team names requirements, which report answers which question, the phrasing your auditors expect.

Warning

Anything written to the skills directory changes what the assistant does. Treat write access to it exactly as you treat write access to the deployment’s configuration.

Where skills come from

The assistant reads two directories, in this order:

Directory

What it holds

/app/skills

Your own skills. This is the one you add folders to.

/app/skills-bundled

Skills that ship with ubTrace. Part of the product, read-only, nothing to install.

The first directory wins a name collision, so your own skills take precedence. Give a folder the same name as a shipped skill and yours is the one that loads, which is how you replace a shipped skill whose wording does not suit your project — you cannot edit the shipped copy, but you can override it. The copy that lost is reported as skipped on the health endpoint (see Checking that a skill loaded), with the reason naming the directory that won, so an accidental collision is visible rather than silent.

Both directories are mounted read-only into the assistant’s container: it reads skills and never writes them.

Folder layout

One folder per skill, named exactly as the skill is named, containing a SKILL.md:

skills/
  release-checklist/
    SKILL.md
    references/
      rollback.md

SKILL.md carries the whole skill. The other files are optional: the assistant reads them only when the skill points it at one, which keeps SKILL.md short while the detail stays available.

Vocabulary in a skill

Whatever a skill’s text says reaches the assistant on whatever project the deployment serves — the same wording is in front of the assistant no matter which project a question is about. A skill that ships with ubTrace therefore names no concrete need or link type: it binds roles to the live schema of the project being asked about instead, and the bundled traceability skill is the pattern to copy. A skill you write in your own skills/ folder is different: it may name your project’s types freely — that is exactly what it is for. If a shipped skill’s wording does not suit your project, give your own folder the same name and yours is the one that loads (see Where skills come from above).

A complete example

skills/release-checklist/SKILL.md:

---
name: release-checklist
description: The checks a release must pass before it ships
when_to_use: when the user asks about releasing, shipping, or a release gate
agents: all
license: proprietary
---

Before calling a release ready, confirm all four:

1. Every requirement in the milestone has a passing test.
2. No requirement is left in state `draft`.
3. The safety chapter's requirements are covered end to end.
4. The changelog names the release.

Report the four as a table, one row per check.

For the rollback procedure, read `references/rollback.md`.

Header fields

The header is YAML between two --- lines at the very top of the file. Keys this harness does not recognise are ignored, so a skill written for another tool still loads.

Field

Required

Meaning

name

yes

The skill’s name. Must equal the folder name, and may contain only lowercase letters, digits and single dashes (release-checklist).

description

yes

One line saying what the skill does. The assistant reads this to decide whether to open the skill, so make it concrete.

when_to_use

no

When the skill applies, in your own words. Set it to the single word always to load the skill on every turn instead (see below).

agents

no

Which agents may see the skill. One value or a list. Defaults to all.

disable-model-invocation

no

true (or yes) stops the assistant from opening the skill on its own. Combine with when_to_use: always for a rule that is always in force, or with /name invocation for one the user must ask for.

user-invocable

no

false removes the /name command for this skill. Defaults to true. It governs the user path only: the skill stays listed to the assistant and the assistant may still open it. To keep a skill out of the assistant’s reach, use disable-model-invocation.

license

no

Free text recording the skill’s licence. Recorded, never interpreted.

when_to_use: always

when_to_use is normally prose that helps the assistant decide. The single value always (any capitalisation) means something different: the skill’s full text is placed in front of every agent it is scoped to, on every turn, and it is not offered as something to open — it is already there.

Use always for house rules. Use prose for anything situational: a skill the assistant opens only when it applies costs nothing on the turns where it does not.

Running a skill by hand

Start a message with /<name> — for example /release-checklist is 2.1 ready? — and that skill is loaded for the turn as if it were always, for every agent it is scoped to. A / that names no skill is left alone and answered as an ordinary question, so /usr/local/bin and /2 still work.

Which agents can see a skill

A turn is answered by several agents. agents: decides which of them a skill reaches.

An id names an agent by its role — its position in the turn — and, where a role runs once per subject area, by its domain.

Role

Domains

What it does

planner

—

Decides what the turn needs and which agents to send.

research

needs, docs_qa

Queries the traceability graph and reads documentation.

display-composition

needs, docs_qa

Reshapes results for display. Reserved — see the note below.

render

—

Writes the answer the user reads. Reserved — see the note below.

Write one of these five forms:

Write

Meaning

all

Every agent that has skills enabled. The default.

none

No agent. Turns the skill off without deleting it — and it wins over every other value in the same list.

planner

A role, whatever domain it runs under. research reaches both the needs and the docs_qa research agents.

needs

A domain, whatever role. Reaches every agent working on needs.

research:needs

Exactly one agent.

As a list:

agents:
  - planner
  - research:needs

An unrecognised value is an error and the skill is skipped, with the reason reported on the health endpoint — a silently ignored selector would produce a skill that loads and is never seen.

Note

display-composition, render and the reports domain are accepted ids, but no agent behind them currently reads skills — the reports agent runs no model at all. A skill scoped only to those loads and does nothing. Scope skills to planner, research, needs or docs_qa.

Installing a skill

Docker Compose

Put the folder in skills/ beside your compose file (docker-compose.yml, or docker-compose-prod.yml in an offline bundle, which ships the directory with a README.md in it):

skills/release-checklist/SKILL.md

The directory is bind-mounted read-only into the ubtrace-copilot container at /app/skills. No restart is needed; the next assistant turn after the re-scan TTL re-reads it. The skills that ship with ubTrace are already inside the container at /app/skills-bundled — you never install those, and you cannot edit them.

Both paths are named by COPILOT_SKILLS_PATH, a :-separated list in which the earlier directory wins a name collision — your own directory is listed first, so a skill you write overrides a shipped skill of the same name. Adding a further directory means adding a mount and naming it in that list; a single path is still perfectly valid.

Kubernetes

The chart provisions a PersistentVolumeClaim (<release>-skills) and mounts it read-only at /app/skills in the copilot pod. The shipped skills need no volume — they come with the image at /app/skills-bundled.

Because the copilot’s mount is read-only, kubectl cp into the copilot pod is refused (Read-only file system). Write into the claim from a short-lived helper pod instead, the same pattern Kubernetes Deployment uses for the artifact claims. The helper is pinned to the copilot’s node: the default claim is ReadWriteOnce, which only pods on one node can attach.

NAMESPACE=ubtrace
CLAIM=$(kubectl get pvc -n "$NAMESPACE" -o name | grep -- '-skills$' | cut -d/ -f2)
echo "$CLAIM"                                   # e.g. ubtrace-skills

kubectl run ubtrace-skills-import --image=busybox --restart=Never -n "$NAMESPACE" \
  --overrides="{
    \"spec\": {
      \"affinity\": {\"podAffinity\": {\"requiredDuringSchedulingIgnoredDuringExecution\": [{
        \"labelSelector\": {\"matchLabels\": {\"app.kubernetes.io/component\": \"copilot\"}},
        \"topologyKey\": \"kubernetes.io/hostname\"
      }]}},
      \"containers\": [{
        \"name\": \"import\", \"image\": \"busybox\", \"command\": [\"sleep\", \"3600\"],
        \"volumeMounts\": [{\"name\": \"skills\", \"mountPath\": \"/skills\"}]
      }],
      \"volumes\": [{\"name\": \"skills\", \"persistentVolumeClaim\": {\"claimName\": \"$CLAIM\"}}]
    }
  }"
kubectl wait --for=condition=Ready pod/ubtrace-skills-import -n "$NAMESPACE" --timeout=60s

kubectl cp ./release-checklist "$NAMESPACE/ubtrace-skills-import:/skills/release-checklist"
kubectl exec ubtrace-skills-import -n "$NAMESPACE" -- ls /skills   # release-checklist

kubectl delete pod ubtrace-skills-import -n "$NAMESPACE"

The copilot picks the folder up on its next scan — the first assistant turn after the re-scan TTL has expired; no restart is needed. Point persistence.skills.existingClaim at your own claim to populate the volume some other way — for instance a ReadWriteMany claim your CI writes to.

Checking that a skill loaded

GET /api/v1/copilot/health reports every skill the assistant loaded and every folder it skipped. Send an access token with the request: the skills report names the directory on disk and the reason each folder was skipped, so it is returned to an authenticated caller only. Called without credentials the endpoint answers 200 with the bare liveness status and nothing else — it is also the container’s health probe, and it must stay reachable without a login (and without a licence) so a healthy service is never restarted for want of one:

{ "status": "ok" }

Note

The endpoint reports the catalogue as of the last scan, and it never reads the directory itself — it is also the container’s liveness probe, and a probe that walked a network mount would take the service down with the mount. A scan happens at startup and then only when an assistant turn asks for the catalogue and the last scan is older than COPILOT_SKILLS_RESCAN_TTL_MS (default 30 s). That TTL is an expiry consulted on the next use, not a timer: on an idle deployment nothing re-reads the directory at all. So after adding a skill, ask the assistant anything, then read the endpoint.

A folder you cannot find in loaded is one of two different things, and the payload tells you which: a folder in rejected was scanned and refused (the reason says why); a folder in neither list has not been scanned yet — ask the assistant a question and read the endpoint again. If it is in neither list after a turn, the copilot cannot see the directory: check the mount and COPILOT_SKILLS_PATH. The copilot log carries the same verdict (Skill catalogue loaded: N skill(s), M rejected., plus one line per rejected folder) for every scan that found a change.

With a valid access token the full report is returned:

{
  "status": "ok",
  "skills": {
    "rootPath": "/app/skills",
    "rootPaths": ["/app/skills", "/app/skills-bundled"],
    "loaded": [
      { "name": "release-checklist", "agents": ["all"], "alwaysLoad": false }
    ],
    "rejected": [
      {
        "directoryName": "house-style",
        "reason": "frontmatter \"name\" (style) must equal the directory name \"house-style\"."
      }
    ]
  }
}

rootPaths lists every skills directory in the order they are read — the first entry wins a name collision, and your own directory is first. rootPath repeats the first of them and is kept so a reader written for a single directory still works.

A skill with a problem is skipped and named, with the reason; the rest keep working and the service stays healthy. Common reasons:

  • the name in the header does not match the folder name;

  • name uses characters outside a-z, 0-9 and -;

  • description is missing;

  • the header is not valid YAML;

  • agents names something that is not an id;

  • the folder, or its SKILL.md, resolves outside the skills directory — a symbolic link pointing out of the mount is refused, not followed;

  • a file is a hard link, or is larger than 4 MB (see Limits);

  • the skill can never be reached — not always, not model-invocable and not user-invocable;

  • a skill of the same name is provided by an earlier directory, which wins — the reason names that directory.

Limits

  • A skill’s text is used up to about 20 000 characters; longer text is cut and marked.

  • Always-loaded skills share a budget of about 40 000 characters per agent. Past it, whole skills are left out rather than half-included, and the ones left out are named in the prompt.

  • Everything a skill is made of must live inside its own skills directory: its folder, its SKILL.md, and every file it references. Anything that resolves outside — including through a symbolic link, and including into the other skills directory — is refused rather than read. A skill’s referenced files are always read from the directory that skill came from. A symbolic link that stays inside the directory is fine; a hard link is always refused, because its content is shared with a file the directory does not contain. Copy the file in instead.

  • No single file may exceed 4 MB. A larger one is refused by name and the rest of the skill still loads.