Skip to content
Thunderbolt

Skills

A skill is a named, reusable block of instructions stored as a row in the synced skills table. It reaches the model in one of two ways: the user types its /slug in the composer, or the model asks for it by name through the skill tool. The first path injects the instruction as a system message for that one send and persists nothing about the resolution; the second returns it as the tool’s result.

The reason the subsystem exists in this shape is prompt budget. The app has a lot of behaviour it would like to describe to the model — how to render a weather forecast, how to run a research pass, how to offer an integration connection — and inlining all of it would make every prompt pay for guidance most turns never use. So the prompt carries only a catalog (one name: description line per enabled skill) and the model pulls the body on demand. That is why every model-facing widget contract ships as a seeded skill rather than as prompt text, and why the description field is load-bearing: it is the only thing the model sees before deciding to load the skill.

The data model

skillsTable holds name, label, description, instruction, enabled, pinned_order, deleted_at, default_hash, user_id, plus a partial index on active rows.

name is the slug — the /token. It is validated against the AgentSkills spec by validateSkillName: 1–64 characters, lowercase a–z, digits and hyphens only, no leading, trailing or doubled hyphen. The leading / is a chat trigger added at display and parse time; it is never stored.

label is the free-text display name (“Daily Brief”). It is nullable, and rows created before the column existed still read as null. Reconciliation cannot backfill them — adding label to the content hash would make every legacy row look user-edited — so skillDisplayName title-cases the slug instead.

Deletes are soft: softDeleteSkill stamps deleted_at and nulls name, label, description, instruction and pinned_order, leaving (id, user_id, deleted_at) as a tombstone for PowerSync to propagate. Because the tombstone’s name is NULL, the uniqueness check in assertNameAvailable needs no extra deleted_at filter — a deleted skill’s slug is free again.

Pinning is capped at maxPinnedSkills = 10 (src/dal/skills.ts:14); pinned skills are the chips in the composer’s skills bar, ordered by pinned_order.

The table is part of the synced set and uses a composite primary key (id, user_id) so every account can hold the seeded defaults under the same ids — see composite-primary-keys-and-default-data.md. Rows are included in the data export (export-format.md) and are indexed by the command palette (search.md).

Progressive disclosure

shared/agent-core/skills.ts owns the two prompt shapes, and src/ai/prompt.ts picks between them on one signal:

const skillDisclosure = supportsTools ? buildSkillListing(skills) : buildFallbackSkillDisclosure(skills)

(src/ai/prompt.ts:114)

  • buildSkillListing emits the ## Skills catalog plus one line telling the model to use the skill tool before acting on a skill, and that a /name token in the conversation means those instructions are already loaded.
  • buildFallbackSkillDisclosure emits the same catalog followed by every instruction body inline. This is the path for models that cannot call tools, and for those models selectPromptSkillDefinitions narrows the set to widget contracts only — without tools there is no way to fetch a body later, so the inline cost is paid only for the skills that keep rendering correct.

The tool itself is createSkillTool: a one-argument AI SDK tool that resolves an enabled skill by bare name or /slug and returns its instruction, throwing when the name is unknown or the skill is disabled. It is registered only when the model supports tools (addSkillTool).

Loading a skill can also widen the turn’s web-tool budget. search and research are mapped to wider intents in src/ai/turn-web-budget.ts:8, and the onSkillLoaded callback promotes the budget when the model loads either of them mid-turn (src/ai/fetch.ts:498) — the same map the explicit /research token goes through. See chat-runtime.md for how that budget is keyed and spent.

Slash tokens

The token grammar lives in skillTokenRegex: / followed by [\w-]+, matched only when preceded by whitespace or start-of-input and terminated by whitespace or end-of-input. The lookbehind is the important half — without it docs/meeting-notes or example.com/meeting-notes would silently inject a skill the user never invoked. The trailing boundary excludes tokens followed by punctuation so a sentence-final period cannot eat the last character of a slug.

Two token shapes exist, because the composer inserts human titles while the model only ever sees slugs. findSkillTokens matches display tokens (/Daily Brief, resolved longest-first through a display-name → slug map) as well as hand-typed slug tokens, and normalizeSkillTokensToSlugs rewrites the former to the latter at send time. Display names are free text and therefore not unique, so buildDisplayNameToSlug drops any name claimed by two skills: an unresolvable token degrading to plain text beats sending the wrong skill’s instructions.

Resolution runs on every send and regenerate and is not persisted — the user’s text carries the tokens forward, and a replay re-resolves against whatever the library looks like then. resolveSkillTokenInstructions is the single entry point, deliberately shared by two surfaces that must not drift: chat-prompt-input.tsx sums the resolved instructions into the token estimate behind the context-overflow modal, and the send path prepends them as system messages. Every agent path calls it — src/ai/fetch.ts for the classic built-in pipeline, src/acp/built-in-adapter.ts for the Pi harness, and src/chats/chat-instance.ts for external ACP agents, which have no system channel of their own.

In the built-in pipeline the resolved instructions land in buildVolatileSystemNotes, after the date/time note and the voice notes — the per-send half of the prompt, kept out of the cacheable stable half.

The composer paints tokens through an overlay (renderHighlightedSkillTokens) that classifies each committed token as enabled, disabled or unknown and offers an Enable or Create action for the latter two via SkillTokenPopover. The / glyph is rendered transparent rather than removed, so caret alignment against the textarea underneath holds.

Widget contracts are skills

Five of the nine seeded defaults are not editable user content but model-facing rendering contracts whose instruction is imported straight from a widget’s instructions.ts (src/defaults/skills.ts:7). The set of their ids is widgetSkillIds, exposed as isWidgetSkillId, and the DAL refuses to let the user break them: updateSkill rejects any patch other than enabled, softDeleteSkill refuses outright, and setPinned and reorderPins refuse to pin or move one (unpinning is allowed).

The subtler half is how their toggle state interacts with reconciliation. hashSkill hashes only [name, label, description, instruction] for a widget id, while an editable default also hashes enabled, pinnedOrder and deletedAt:

const contentFields = [skill.name, skill.label, skill.description, skill.instruction]
return hashValues(
isWidgetSkillId(skill.id) ? contentFields : [...contentFields, skill.enabled, skill.pinnedOrder, skill.deletedAt],
)

Reconciliation treats a hash mismatch as “the user edited this row, leave it alone”. For an editable skill that is exactly right. For a widget contract it would mean that a user who disabled the Weather skill once could never receive a corrected weather tag contract again. Excluding the state fields from the hash keeps contract updates flowing; the user’s choice is then preserved on the other side by frozenFields, which tells the reconciler to keep the existing enabled and pinned_order when it rewrites the row (src/lib/reconcile-defaults.ts:642). The two mechanisms are complementary — dropping either one loses a property.

The end-to-end recipe for exposing a new widget to the model is in widgets.md; this section is the reason behind its steps.

The seeded defaults

src/defaults/skills.ts ships nine rows, seeded per user by reconcileDefaults during app initialization (app-initialization.md).

Slug Label Kind Ships enabled Ships pinned
daily-brief Daily Brief editable yes no
important-emails Important Emails editable no no
search Search editable yes position 0
research Research editable yes position 1
weather Weather widget contract yes position 2
link-preview Link Preview widget contract yes no
connect-integration Connect Integration widget contract yes no
ask Ask widget contract yes no
map Map widget contract yes no

Changing any of these requires bumping defaultSkillsVersion. It is the ordering signal reconciliation uses to decide which device owns the newest defaults, so an unbumped change silently breaks convergence across a sync group. The colocated snapshot test (src/defaults/skills.test.ts:43) pins the version against a per-row hash and fails on any content change without a matching bump. The algorithm is documented in reconciled-defaults.md; the short rule, and the other tables that carry a version constant, are in AGENTS.md.

For the same reason, label and description on seeded rows are not localized: they are reconciled by content hash, so translating them would make every row read as user-edited on a device running another language.

Skills across agent types

The built-in pipeline reads the library directly. The other two paths have to carry skills over a wire, and both live on the same shared module.

External ACP agents. Thunderbolt advertises and detects a custom capability under the thunderbird.net/thunderbolt ACP extension namespace (thunderboltAcpMetaKey). If the agent’s initialize response declares { skills: true } in its capability _meta (supportsWireSkills, surfaced onto the UI capability shape at src/acp/acp-adapter.ts:110), the full skill definitions ride along on session/new and session/resume as buildWireSkillsMeta metadata (src/acp/acp-adapter.ts:557); disclosing them to its model is then the agent’s own business — the CLI harness below reads them back with readWireSkills. An agent that does not declare the capability gets buildFallbackSkillDisclosure injected into the session instead (src/acp/acp-adapter.ts:536) — every body inline, because there is no channel to fetch one later.

The CLI. cli/src/acp/harness-agent.ts is on the receiving end of that same protocol: it declares skillsCapabilityMeta in its initialize response (cli/src/acp/harness-agent.ts:244) and reads the delivered definitions with readWireSkills on both session/new and session/resume. It then builds its own catalog with buildSkillListing and registers its own skill tool (cli/src/agent/system-prompt.ts:69, cli/src/agent/skill-tool.ts:19). The standalone CLI, with no client to send it skills, gets an empty list and no ## Skills section at all.

The slash menu in the composer merges skills with the connected agent’s own ACP commands into one list (SlashItem); commands insert their literal name while skills insert their display title.

Managing skills

Settings → Skills (/settings/skills, lazily loaded at src/app.tsx:277) renders SkillsView, whose state machine is factored out into skills-view-state.ts — a list plus a detail panel that doubles as a create/edit form, with confirmation for discarding a dirty form.

Deletion and disabling both run a dependents check first (skills-view.tsx:78 and :140). findDependents scans every other skill’s description and instruction for a /target reference, with a (?![a-z0-9-]) lookahead so /foo does not match /foo-bar. One skill’s instructions may compose another by naming its token, and the model is told that a /name token means those instructions are loaded — so removing the referenced skill leaves the referrer pointing at something that resolves to nothing. The dialog names the referrers and lets the user decide rather than breaking them silently.

Telemetry is in src/skills/telemetry.ts. Every skill_* event carries sha256(user_id + ':' + skill.id) truncated to 16 hex characters and never any authored content — salting with the user id means the analytics pipeline cannot correlate the same skill across accounts.

Invariants worth knowing

  • Slugs are stored bare. Any code that compares a token to a stored name must strip the / first — resolveSkill does this for both skill tools, the app’s and the CLI harness’s.
  • Token resolution is never persisted. If you need a skill’s effect to survive a replay, it has to come from the user’s text or the library, not from turn state.
  • Both resolution surfaces share one helper. A change to matching semantics that only lands in one of them makes the context-overflow estimate disagree with what is actually sent.
  • A new injected system section needs both the built-in and the ACP paths, or it vanishes when the user switches agents — see “What must stay in step when you change send behaviour” in chat-runtime.md.
  • Widget-contract ids are locked in the DAL, not just the UI. Reach for updateSkill on one and it throws.
  • Changing a default without bumping defaultSkillsVersion breaks multi-device convergence.

Tests

Terminal window
bun test src/skills src/defaults/skills.test.ts shared/agent-core/skills.test.ts --timeout 5000

parse-skill-tokens.test.ts covers the token grammar and the display/slug normalization, find-dependents.test.ts the reference scan, display.test.ts the label fallback and ambiguity rules, skill-tool.test.ts the tool’s resolution and error paths, skills-view-state.test.ts the settings state machine, src/defaults/skills.test.ts the version snapshot and the widget-skill carve-out, and shared/agent-core/skills.test.ts the prompt builders and the ACP wire format.