Skip to content

Agent skills

A skill is a Markdown instruction body the agent loads when a question matches its one-sentence description. It says how a workflow works at this institution - the steps to drop a course, what to ask a new student, when to hand someone to a person - so the agent follows a written procedure instead of improvising one. This document says where a skill's text and its switches live, how the five shipped skills reach a deployment and how an engineer adds one, how the institution edits, publishes, imports and puts away a skill from the Settings page, who is offered which skill, how the agent reads and uses one at runtime, and what a turn leaves behind. It builds on agent-retrieval.md, which owns the agent loop the skills plug into.

The feature is off until an operator turns on the cc_agent_skills flag; with the flag off nothing in this document is visible to the model.

The rows and their owners

A skill is split across two tables in apps/main_app/models/skills.py, plus a third for the text being worked on, and the split is the ownership contract.

Skill holds what the deployment decides about a skill and nothing the author wrote: slug, origin (shipped or institution), load_mode (on_demand or always), is_enabled, include_guests, audience_roles, required_connectors, active_version, pending_shipped_version, is_retired, archived_at, and the updated_by / updated_at stamps. A shipped skill is created disabled with an empty guest switch, so shipping one never changes what a deployment's users see.

SkillVersion holds what an author wrote: name, description, body, its source (shipped or institution), a number unique per skill, a content_hash over the three text fields, and who published it when. A version is never edited after the insert; a change is a new version, and Skill.active_version names the one the agent reads. Skill.pending_shipped_version is where a newer shipped text waits when the institution has customized the skill and the sync must not overwrite their words.

SkillDraft is the text an admin is working on: one row per skill (a one-to-one), with the three text fields, the base_version it was opened from, a revision counter, and who saved it last. The draft is the only row the editor writes on a keystroke; nothing the agent reads changes until the draft is published.

Three writers exist. sync_shipped_skills creates and updates the shipped rows at boot and never touches the deployment fields on a row that already exists. SkillAdmin (apps/main_app/admin/skills.py, operators only, under /cc_admin/django/) edits the deployment fields and shows everything else read-only; it cannot add or delete a skill, and every save writes a skill_enabled, skill_disabled, or skill_updated audit row in the same transaction with the before-and-after values of what changed. services/skills/lifecycle.py is the institution's writer, behind the Settings page's typed API; every function in it takes the request, locks the skill row, writes one audit row in the same transaction, and returns the fresh skill. The three never overlap: the sync writes shipped versions and the pointers, the admin writes deployment fields, and the lifecycle service writes drafts, institution versions, the pointers an admin moves, and the deployment fields the page edits.

The limits are constants beside the models: a body is at most SKILL_BODY_MAX_LENGTH (12,000) characters, enforced by a database check constraint on both the version and the draft, and a name, description and slug are bounded the same way. SkillUse is the fourth table: one row per skill a completed turn used, described under the turn record below.

The composite foreign keys

Four pointers must name a version of the same skill: Skill.active_version, Skill.pending_shipped_version, SkillDraft.base_version, and SkillVersion.restored_from. Django cannot express that, so migration 0171 adds a unique index on main_app_skillversion (skill_id, id) and four composite foreign keys through RunSQL, each referencing that pair. They are immediate, not deferred: every writer sets a pointer after the version row exists, and an immediate check is what lets a test inside a transaction see the refusal. The sync's own invariant check stays as the readable error; the constraints are the floor under it.

The shipped registry

CampusCore ships five skills, declared in code the way roles and feature flags are: SKILL_DEFINITIONS in apps/main_app/skills/__init__.py, one ShippedSkill per skill, each reading its body from the Markdown file beside the module.

Slug Default audience Needs
hand-off-to-a-person every active role -
new-student-onboarding student -
registration-help student -
financial-aid-questions student -
it-help-request student the servicenow connector

Each body has the same sections - Purpose, When this applies, Steps, What to ask, Institution specifics, When to hand off, Do not - and the Institution specifics section opens with the template marker line (TEMPLATE_MARKER, "Template, needs your details.") that the lint warns about until the institution replaces the section with its own details.

Every rule a definition must satisfy is checked at import, so a malformed definition fails the test suite and the boot rather than one deployment's sync: the slug shape and length, the three text limits, a known load mode, every required connector registered in the connector registry, every default audience slug a SystemRole, and no two definitions sharing a slug or a renamed_from naming a live slug.

Adding or editing a shipped skill

  1. Write apps/main_app/skills/<slug>.md with the seven sections above, under 12,000 characters.
  2. Add a ShippedSkill(...) to SKILL_DEFINITIONS with the slug, a name, a one-sentence description the model decides from (say when it applies, not what it contains), body=_read_body("<slug>"), and a default_audience of SystemRole slugs or EVERY_ACTIVE_ROLE; add required_connectors when the steps depend on one.
  3. Nothing to do for the image: the *.md rule in .dockerignore is unanchored, so it matches only Markdown at the build-context root and the bodies under apps/main_app/skills/ reach both images; test_dockerignore_never_excludes_the_skill_bodies refuses a recursive **/*.md rule or one naming that directory.
  4. Run docker compose exec -T web python manage.py sync_skill_registry --dry-run and read the report, then docker compose restart web so the boot sequence applies it, or run the command without --dry-run.

Editing an existing body is step 1 and step 4: the sync sees the new content hash and publishes the next shipped version. Renaming a slug is step 2 with renamed_from=("<old slug>",), which re-keys the deployment's row so its enablement and audience follow the rename; without it the sync refuses, because a create and an orphan in one run is what a forgotten rename looks like.

The sync and its report

sync_shipped_skills (apps/main_app/services/skills/registry_sync.py) runs from run_boot_sequence after the connector policy sync, and by hand through sync_skill_registry. It decides one outcome per definition by comparing the definition's content hash with the newest version whose source is shipped, independent of which version is active:

  • no row: the Skill is created disabled, audience and connectors seeded from the definition, and version 1 published as active_version (+);
  • same hash: unchanged (~);
  • new hash on a skill whose active version is shipped: a new shipped version becomes active (*), with a skill_shipped_update_applied audit row;
  • new hash on a skill the institution customized: the new shipped version is parked in pending_shipped_version and the institution's text stays active (!);
  • a retired row whose slug is declared again: restored (^).

A readopted row that the institution had archived comes out of the archive (archived_at cleared) and is listed as unarchived on the report, because a shipped skill is disabled, not archived.

A shipped row whose slug left the registry and that no renamed_from claims is an orphan: retired if uncustomized (-, skill_retired), or released to the institution (/, skill_released_to_institution) if customized, which flips origin to institution so their text keeps working without a registry behind it. A released row keeps its shipped versions as history, so when its slug is declared again the sync re-adopts it (<, skill_readopted_from_institution): origin returns to shipped, enablement and audience stay the institution's, the institution's text stays active, and a shipped text whose hash differs from the newest shipped version is parked in pending_shipped_version, since the row is customized by construction. A declared rename is >.

The sync refuses the whole run, before writing anything, when: a renamed_from slug is claimed by more than one row (the remedy names the shell steps, because SkillAdmin refuses deletes and the version pointers are PROTECT); a declared slug is held by an institution-origin row with no shipped version at all, which is a skill the institution created whose slug collides; a run would both create a slug and orphan one with nothing linking them; a definition's default audience names a role the deployment does not have; a row already breaks a cross-table invariant (both version pointers must name versions of that skill, and an uncustomized skill cannot have a pending version); or applying the updates would push the always-on budget over its limit. Every refusal is printed on a dry run and raised as SkillSyncRefused on a real one, and the command exits non-zero either way, so a refused boot is visible in the deploy log with the fix spelled out. The dry run and the real run share one plan; the whole real run is one transaction.

The Settings page

Settings -> Agent Skills (web/src/components/settings/skills/, workspace admins) is where the institution works on its skills; every write goes through /api/workspace/skills (apis/workspace_skills_api.py) and from there through services/skills/lifecycle.py. The page opens with the cc_agent_skills switch, written through the same feature writer Feature Management uses, and then the list: name and description, origin (System, System (customized), or Custom), the status pills, and the audience. The pills are computed on the server, once, from the status flags (SkillStatusPill): one of Enabled, Disabled, Not published, Waiting on a connector, Retired, or Archived, then Draft changes and Update available as they apply. The template marker in a body is not a pill; it is a lint warning, and it blocks nothing. A row's menu, under the Actions column, offers Open, Enable or Disable, Export, and Archive or Unarchive. The audience is edited from the skill's own page rather than from the list, so the drawer has one home: the editor's header and its Audience panel both open it, and the panel above them shows what it currently holds - the role chips, public visitors, each required connector with whether this deployment offers it, and the load mode in words. The list and the Audience panel read the audience from SkillListItem.audience_role_slugs against the list's available_roles catalogue, and a slug the catalogue does not carry is a deactivated role, drawn as a muted " (deactivated)" chip: a dashed outline with mid-grey text on its own white ground, slate-900 in dark mode. The drawer's role list is the shared RoleChecklist (web/src/components/ui/RoleChecklist.tsx) that Feature Management and Workspace Connectors use: it locks a role nobody can hold only when it is new to the audience, flags a saved one as "No one can currently be assigned this role.", and lists a deactivated audience role muted until an administrator removes it. A long role name wraps onto more lines rather than being cut off, and the slug beside it takes at most 60% of the row. The audience save goes through resolve_scope_roles in apps/auth/services/scope_roles.py, the rule every role grant shares: a role deactivated after it joined the audience stays on it, and a newly added inactive or unreachable role is refused. An enabled skill needs at least one active audience role or public visitors, and a deactivated role does not count. The rule applies on every deployment save of an enabled skill, not only on switching it on, so a skill whose audience has become all-deactivated refuses any save, a load-mode change included, until it gains an active role or guests; the drawer holds Save back and says why.

The draft and its revision

Opening a skill shows its draft when one exists, else its current text. The editor autosaves after a pause, sending the draft's revision with every save; the server advances the revision on success and refuses a save whose revision is stale with a 409 draft_conflict naming who saved last. A save or a publish that names a revision of a draft that no longer exists, because another admin published or discarded it, is the same 409 with null details, and the editor's banner says so instead of naming anyone. The page shows that as a banner with a Reload button and stops autosaving, because the other admin's work is on the server and this page's is not. Discard draft deletes the row (skill_draft_discarded) and the editor returns to the current text.

Publishing and the lint

lint_skill (services/skills/lint.py) is a pure function over the three text fields, run on every draft save and again at publish. Blockers stop a publish: an empty name, description, or body; a string shaped like a secret (an AWS access key id, a private key block, a JWT, a credential assignment); and any of this deployment's own configured secret values (settings.SECRET_VALUES). Warnings are shown and do not block: the template marker still in the body, and a string shaped like a Social Security number. A finding names a line and never repeats the matched text.

Publish snapshots the draft into a new institution version, makes it active, deletes the draft, and writes skill_published with the version numbers. The publish carries the revision too, and when the current version moved while the draft was open (someone else published, a restore to the system default, a restore of an older version) the server refuses with a 409 live_version_changed carrying the diff; the page shows the diff and offers Publish anyway, which resubmits with acknowledge_live_change.

Versions, reset, and restore

Every version stays. The Versions panel lists them newest first with when each was published and by whom, time first, and marks the version the skill publishes as Current. System default, Restored from v<n> or Restored to the system default goes in front when that is how the version came to be, read from SkillVersion.source and SkillVersion.restored_from. A reset to the default names the admin who did it, while a version the registry sync wrote names nobody. The Compare picker's options read the same line behind the version number. Compare on a version opens the compare view in the page's wide column, against the version before it, or, on the oldest version, against the one after it: two pickers, From and To, over versions/<to>/diff?against=<from> (services/skills/diff.py, a line diff over the bodies), with how many lines the change adds and removes above the diff itself. A skill with a single version has nothing to compare with and offers no Compare. Long unchanged runs fold behind a count and each fold opens in place; View in full, beside the count of changed lines, opens every fold at once and turns into Changes only. The Versions panel marks the two versions being compared, and Back to the editor returns; the editor is hidden while comparing rather than unmounted, so an unsaved draft is still there. Restore republishes an older version's text as a new institution version (skill_version_restored); Restore to system default republishes the newest shipped text as a new shipped version (skill_reset_to_default), which makes the skill uncustomized again, so the next sync reports it unchanged. Both are held back while a draft is open.

Update available

When the sync parks a newer shipped text in pending_shipped_version, the editor shows a three-way panel: the system default the institution started from, the institution's changes to it, and the changes in the new system default. Take the update makes the pending version active and clears the pointer (skill_shipped_update_applied). Keep mine clears the pointer and keeps the institution's text; the version stays as history, and the next sync sees the same hash and reports unchanged (skill_shipped_update_dismissed). Open a draft with the update applied puts the new shipped text in the draft, to be merged by hand and published, which clears the pointer through the publish.

Export and import

Export downloads the current text as <slug>.md: a front-matter block with name and description, then the body (services/skills/transfer.py); it is refused while nothing is published or the current text has a blocker, and writes skill_exported. Both Export buttons, the one in the row menu and the one in the editor, are disabled only while nothing is published; a blocker in the current text is the server's to find, and its 422 is shown as a message beside the button. Import reads a file of the same shape into a new custom skill's draft, disabled and offered to every active role, and writes skill_imported; the file reaches it through the Create skill dialog's drop zone (see Creating a skill). A name whose slug is taken is refused with slug_taken, and the page asks for another name and sends the same file again. Every other refusal opens a dialog that stays until it is closed, because the admin has to go and fix the file: the server's sentence, then the fields a 422 named (a value over a column limit) or the lint findings behind lint_blocked, in the same rows the editor's Checks panel uses. Creating a skill without a body makes the same kind of row from a name and a description, with a body template that carries six headings (Purpose, When this applies, Steps, Institution specifics, When to hand off to a person, Do not) and the template marker.

Archived

Archive puts a custom skill away: disabled, hidden from the list and the catalog, its draft and versions kept, archived_at set (skill_archived); Unarchive brings it back disabled (skill_unarchived). A shipped skill is disabled, never archived, and the database refuses an archived row that is enabled. The list shows archived skills under Show archived.

The audit trail

Every write on the page is one row in the critical audit tier, resource_type = "Skill", with the slug and the version numbers in details:

Action Written by
skill_created, skill_imported, skill_exported create, import, export
skill_published, skill_draft_discarded publish, discard
skill_enabled, skill_disabled the page's switch and the operator admin
skill_updated the audience drawer and the operator admin, with the before-and-after of each changed field
skill_reset_to_default, skill_version_restored reset, restore
skill_shipped_update_applied, skill_shipped_update_dismissed take the update (also the sync's own apply), keep mine
skill_archived, skill_unarchived archive, unarchive
skill_retired, skill_released_to_institution, skill_readopted_from_institution the sync

Creating a skill

Create skill, in the page's header and on the empty state, opens one dialog with two ways in (web/src/components/settings/skills/CreateSkillDialog.tsx). A drop zone takes a SKILL.md file by drag and drop or a file picker, and hands its text to the same import described above, so a name collision still opens the rename dialog and any other refusal still opens the refused dialog. Create skill from scratch swaps the dialog to a form with Name, When the assistant should use it, and Instructions, and a Starting point box above them.

The Starting point is a brief in the administrator's own words, and Generate asks the assistant to write the first draft from it. POST /api/workspace/skills/assistant/draft streams the run: a progress event per step, then one draft event carrying the name, the description, the body and at most five assumptions, or a terminal error event. The dialog renders each progress line and replaces it by its step number when it completes, then fills the three fields, collapses the Starting point to the quoted brief with Generate again beside it, and lists what the assistant assumed. Every field stays typeable, and Create creates the skill from whatever they hold - the body when Instructions has one, the six-heading template when it is empty - and opens the editor, where the Checks panel runs the same lint as any other draft. Cancel during a generation aborts the request, which the server sees as a disconnect and unwinds.

What the generator reads, and how it answers

The generator is draft_skill in services/skills/assistant.py, a small loop of its own rather than an AgentCore run: the chat loop's iteration-budget fallback swaps in the prompt that demands HTML, and its attribution is hard-wired to feature="chat", and each of those would corrupt a typed answer. It builds a system prompt from prompts/skill_assistant_prompt.py over a SkillAssistantContext: the institution's name, abbreviation and website from AppConfig, the assistant's name, the tools the assistant can call, the connectors this deployment offers with each one's agent_hint, every skill that already exists, and the six headings of the body template. The existing-skills list has no audience or connector filter, because a skill this administrator cannot see in chat is still one the draft must not duplicate. The prompt states the rules of a good skill in plain sentences: the description says what the question looks like, steps name who and where and when, a price or a deadline or an office goes in only when a search result carries it, nothing the assistant cannot do, no personal data, and an assumption whenever the draft overlaps an existing skill.

The loop's two tools are search_knowledge, which runs under the administrator's own principal, and submit_skill, whose argument schema is the draft. Answering through a tool call means the arguments arrive parsed by the machinery every chat tool call already goes through, on both the chat-completions and the Responses endpoints. Neither provider path translates a named tool_choice, so the loop forces the answer by narrowing the tool list: on the last permitted turn, and on the turn after a text-only one, submit_skill is the only tool offered and the choice is required. A submission that fails validation is fed back as the tool's result and the model tries again, inside the same budget. The budget is six turns and the run is bounded at 180 seconds; spending the budget without a submission is no_draft and passing the bound is draft_timeout, each rendered to the dialog as a terminal error frame with a sentence the administrator can act on.

The model is SKILL_ASSISTANT_MODEL, and it must be in the pricing registry like any other model. When the setting is unset or empty, the generator runs on the agent's active model at its effort, and never on a person's override. Every call the run makes is metered under the feature skills:draft, so the analytics breakdown shows drafting apart from chat. The run opens its own OTel span, skill_assistant.draft, but does not enter an agent run, so it does not appear in the Agent Traces console: that console is built around chat runs, and a draft has no conversation to hang from.

A published skill is read by the assistant for every audience, so what a draft carries is the administrator's responsibility exactly as a hand-written skill's is. The generator writes from what the search returns; it does not check that a price or a deadline is current, which is what the assumptions list is for.

POST /api/workspace/skills/create takes an optional body and a drafted_by_assistant flag beside the name and the description; the flag without a body is refused, since the assistant never drafts the template. The create's skill_created audit row carries the flag, so how many of an institution's skills started as a draft is a question the audit log answers.

evals/offline/skill_draft_eval.py scores the generator against evals/data/skill_briefs.json: eight briefs, each with the headings its draft must carry, phrases it must not, a body length bound, and words its description must mention. It is manual, like every harness under evals/.

Who is offered which skill

load_skill_catalog(principal, user) in apps/main_app/services/skills/catalog.py is the single reader of the audience rules. It runs once at the top of every agent run, before the tools are wired, and returns a SkillCatalog of OfferedSkill entries ordered by slug; everything downstream reads that catalog, never the rows. A skill outside the catalog does not exist for that run.

The rules, in order:

  • the cc_agent_skills flag off means an empty catalog for everyone;
  • a skill that is disabled, retired, archived, or has no active version is withheld from everyone;
  • a skill whose required_connectors are not all offered on the deployment (Connector.objects.offered()) is withheld from everyone;
  • a public visitor (an Anonymous principal) is offered a skill only when include_guests is on;
  • a signed-in user is offered a skill when one of their active roles is in audience_roles; an empty audience is offered to nobody, the same fail-closed reading the feature service gives an empty role list.

The flag is deployment-scoped, so a guest's None user gets the deployment's answer. The public-access rules in public-access.md are unchanged: a guest's catalog holds only the skills an admin chose to show guests, and the guest tool registry gains use_skill and nothing else.

The two budgets

apps/main_app/services/skills/budget.py holds the two limits that bound what one caller's prompt can carry: at most OFFERED_SKILL_LIMIT (40) offered skills, and at most ALWAYS_ON_BODY_LIMIT (6,000) characters across the always-on bodies inlined into one prompt. Both are enforced where rows are written: the admin form refuses a save that would leave more than 40 enabled skills or push the enabled always-on bodies over 6,000 characters, counting every enabled skill (a superset of any one caller's catalog, so a save that passes leaves every caller under budget), and the sync refuses a run whose updates would break the second budget. The loader keeps the same check as a backstop and raises SkillCatalogOverBudget rather than truncating, because a silently cut instruction is worse than a loud failure an operator fixes by disabling a skill; the agent surfaces the raised error as an error frame.

At runtime

The Skills section

build_initial_messages appends render_skills_section(catalog) (prompts/prompt_builder.py, a pure function) to the volatile system message, after the connector-action outcomes and before the date, for guests and members alike. It sits outside the signed-in block because a guest catalog is the point of include_guests. The section opens with the authority order - the stable prompt and its guardrails outrank a skill, a skill outranks the agent's own assumptions about how a workflow goes. When the catalog holds an on-demand skill, the opening also tells the model to call use_skill("<slug>") before answering or acting on a matching question, and each on-demand skill follows as one line, slug - name: description; a catalog of always-on skills alone renders neither, since there is nothing to call. Always-on skills are rendered in full under ### Standing instructions, each as ## Skill: <name> followed by its body, and count as used the moment the run starts.

The cross-turn note [PRIOR TURN SKILLS] sits after the prior connector reads and reads Earlier turns of this conversation followed these skills: ..., naming what the last three completed turns followed, so a continuing workflow loads the same skill again instead of improvising its next step. It is built for guests too, and only from use_skill calls whose result succeeded, paired by tool_call_id, so a refused slug is never named as followed.

The tool

UseSkillTool (apps/main_app/services/agent/tools/use_skill.py, name use_skill, one slug argument) is registered in both tool registries and bound to the run's AgentState by wire_context_tools when the catalog offers anything; with an empty catalog it is dropped, so the model is never shown a tool that can only refuse. It carries no principal and never touches the database: it reads the catalog on the run state, which already has every audience rule applied, which is the get_current_date case in the principal contract. A slug outside the catalog returns success=False with text the model can act on (No skill named '...' is available to you. The skills you can use are: ...); this is also what stops a guest from naming a signed-in skill, because the guest's catalog never held it. A successful load appends a SkillLoad to AgentState.skills_used (once per slug per turn, stamped with the time of the load) and the model reads ## Skill: <name>, the body, and one closing line that the system prompt's rules still apply. The thinking line for the call reads Following the <slug> skill....

The turn record and observability

AgentState.skills_used holds both load modes and is the single source for three records of a completed turn: record_skill_uses (apps/main_app/services/skills/usage.py) writes one SkillUse row per load with the version the model actually read (the skill is the version's own, so a rename between the run and finalize still records the use), the conversation, the assistant message, the load mode and the load's own time, in its own try at turn finalize so a failure there can neither skip the outcome acknowledgement nor be hidden by it; audit_agent_turn puts the slugs in the agent_turn audit detail as skills_used on a turn the agent completed; and the root span carries campuscore.agent.skills_offered (set at setup) and campuscore.agent.skills_used (set at completion). The Agent Traces turn page shows both as a Skills offered ... used ... line above the prompt links.

The persisted turn trace (agent_metadata) is unchanged: a use_skill call is an ordinary tool_call event with its tool_result, which is what the prior-turn note reads.

The pruner

The run opens with two system messages (the stable prompt and the volatile block that names the skills), and compaction can add a third. _prune_old_iterations in services/agent/middleware/token_management.py re-emits every leading system message in order when it rebuilds the list; a system message placed after the conversation, which is where the search audit goes, stays with the iteration it was written for.

What comes next

  • A follow-up to #458 adds Review and Improve inside the editor: the assistant reads a draft and reports what is missing or wrong, and rewrites a passage on request.
  • 459 adds a try-it sandbox and the analytics rollup over SkillUse: which skills are used, by whom, and which enabled skills never fire.