Skills
A skill is a small Markdown file on disk that teaches the agent how to do a specific task — review a PR, write a changelog, run a release. Skills load lazily on the first session, and the model opts into them via a built-in Skill tool.
#What is a skill?
A skill is a directory containing a SKILL.md file: YAML frontmatter followed by a Markdown body. It is neither a piece of code nor an isolated worker — it is a prompt fragment the model can pull into its own context on demand. The body becomes the model’s instructions for handling whatever task the skill describes; the frontmatter controls how the skill is advertised, what tools it can use while it runs, and which arguments it accepts.
Skills sit between two adjacent concepts:
- A tool is executable TypeScript with a JSON schema. The model calls it, you run it, a result comes back. A skill, by contrast, ships no behavior of its own — when invoked, its rendered body is returned to the model as a tool result and the model decides what to do next, using the regular tool set.
- A subagent is a separate conversation with its own context, history, and tool registry, launched via the
Subagenttool. A skill never opens a new conversation; the body is spliced into the current one.
Operationally: a skill is one extra entry in a skill_listing system reminder the model sees every turn, plus an invocation path through the built-in Skill tool. Nothing else changes about the loop.
#Directory layout
Skills live under <configDir>/skills/<name>/SKILL.md. configDir comes from AgentOptions.configDir (resolved against cwd), and defaults to .skawld at the project root — so the conventional path is .skawld/skills/<name>/SKILL.md. Each skill is its own directory and can ship supporting templates or scripts alongside the prompt; the loader only reads SKILL.md itself, every other file is left alone and is available to the model at runtime via the ${SKAWLD_SKILL_DIR} substitution (see The skill body).
.skawld/
└── skills/
├── review-pr/
│ └── SKILL.md
└── changelog/
├── SKILL.md
└── template.mdThe loader walks the immediate children of <configDir>/skills/. Each child directory is considered one skill. The rules:
- Only directories. Loose files at the top level are skipped silently.
- Symlinks are not followed. A symlinked directory is recorded as skipped with reason
io-error. - Missing SKILL.md in an otherwise valid directory is skipped with reason
missing-skill-md. - Nested directories under the skill (templates, scripts, fixtures) are ignored by the loader but remain accessible from the body — the substituted body is prefixed with
Skill base directory: <abs path>so the model knows where to look. - Name collisions deny the later skill. If the resolved skill name collides with a built-in tool, the skill is skipped with reason
name-collision-tool. If two skills resolve to the same name, the second one to load loses withname-collision-skill. Skills are visited in directory order; the surviving result is sorted alphabetically for the listing.
A SKAWLD_CONFIG_DIR environment variable is read by the
example scripts in the SDK repo, but the Agent constructor
itself only honors the explicit configDir option — set it in
code if you need a non-default location.
#Frontmatter reference
The first block of a SKILL.md file is YAML frontmatter delimited by ---. The loader uses strict YAML, recognizes the fields below, and silently ignores anything it doesn’t recognize — that tolerance is intentional so skills authored against adjacent harnesses (e.g. Anthropic Skills carrying license: metadata) load without modification.
---
name: review-pr
description: Walk a pull request and post structured review comments.
allowed_tools: [Read, Grep, Bash]
---
# Review a PR
When the user asks you to review a pull request:
1. Read the diff with `gh pr diff`.
2. Look for tests covering the change.
…| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | Skill identifier. Must match /^[a-z0-9][a-z0-9_-]*$/i. Defaults to the containing directory name when omitted, which is the usual convention. Used as the value of input.skill when the model calls the Skill tool. |
description | string | Yes | Non-empty. Surfaced verbatim in the skill_listing the model sees. Keep it short — the listing caps each entry at 250 characters and clips with an ellipsis. |
when_to_use | string | No | Optional usage hint appended to description in the listing as — <when_to_use>. Use it for trigger conditions the model should pattern-match against. |
allowed_tools | string[] | No | Additive allow set for the next turn after the skill is invoked. Each entry must be a non-whitespace tool name (no schema enforcement on what names exist). See Permissions and skills. |
arguments | string[] | No | Named positional slots. Each must match /^[a-z_][a-z0-9_]*$/i, must be unique, and must not be the literal ARGUMENTS (reserved). Substituted into the body as $name. See The skill body. |
argument_hint | string | No | Optional human-readable hint for the model, appended to the listing entry as (args: <hint>). Cosmetic; not parsed for shape. |
model | string | No | Per-skill model override applied for the turn that consumes the skill body. If the session is running with a [1m] extended-context suffix and the override omits it, the suffix is carried forward automatically. |
version | string | No | Free-form. Recorded on the loaded Skill object; not used by the runtime. |
disable_model_invocation | boolean | No | When true, the skill is hidden from skill_listing and the Skill tool refuses to invoke it (returns is_error). Useful for keeping in-progress drafts on disk without exposing them to the model. |
A missing or non-string description rejects the skill with
reason invalid-frontmatter. The same goes for unparseable
YAML, a non-mapping document, or any of the typed fields above failing
its check. Rejections are returned to the caller via
loadSkillsFromDir’s skipped list but do
not throw.
#The skill body
Everything after the closing --- is the body. It is plain Markdown — the loader does not parse it. When the model invokes the skill, the body is substituted, wrapped, and returned to the model as the result of the Skill tool call. The substituted form is also stored on the session so a compaction-aware resume can re-emit the body as a <system-reminder> block.
Substitution is single-pass, in this order:
The raw argument string is split with a POSIX-lite tokenizer that honors single quotes, double quotes, and backslash escapes. Tokens are bound to the names listed in
argumentsby position. Unbound slots become the empty string.For each named slot
foo, every occurrence of$fooin the body is replaced with the corresponding token.$ARGUMENTSis replaced with the raw, unsplit argument string. Use it when you want the model to see the original quoting.${SKAWLD_SKILL_DIR}is replaced with the absolute path to the skill’s directory.${SKAWLD_SESSION_ID}is replaced with the current session id.
The final string handed to the model is prefixed with Skill base directory: <abs path> on its own line — keep this in mind when writing instructions that expect to be the very first content.
Substitution is plain string replacement, not template interpolation.
$foo inside a code fence or a quoted string will still be
substituted. There is no escape syntax — if you need a literal
$ARGUMENTS in the body, rename the token or use a
zero-width split.
#Invoking a skill
Skills do not fire on their own. When at least one skill is loaded, skawld registers a built-in Skill tool with this shape:
interface SkillInput { skill: string; // skill name; leading "/" is stripped args?: string; // optional raw argument string (shell-tokenized) }
The model picks a skill from the skill_listing system reminder injected at the head of the first user message of a session, and calls Skill with { skill: "post-deploy", args: "staging us-east-1" }. The tool:
Looks the skill up by name. Unknown name returns an error result containing
Unknown skill: <name>.Rejects with an error if
disable_model_invocation: true.Runs the substitution described in the body section against
args.Appends an
InvokedSkillRecordto the session so the body can be re-emitted after a compaction, and persists it via the session store.Stages a one-turn
pendingSkillOverlaycarryingallowed_toolsand the model override (if any). The loop reads and clears the overlay on the very next provider call — overlays do not persist beyond one turn.Returns the substituted body as a normal tool result. The next model turn sees it as a tool result, reads the instructions, and continues with the regular tool set.
Two session events bracket the call: skill_invoked with the skill name fires when the tool starts, and skill_completed fires when it finishes (carrying is_error). See Events.
#Lifecycle
Lazy load on first session. Nothing happens at
new Agent()time. The first call toagent.session()awaits an internalconnectSkills()which walks<configDir>/skills/, parses eachSKILL.md, and populates the skill map on the agent.Memoized for the agent’s lifetime. Loading happens exactly once per
Agentinstance. Editing aSKILL.mdwhile a session is running has no effect — the loaded skill set is held in memory. Create a newAgentto pick up disk changes.Skill tool registration. If the load found at least one skill, the
Skilltool is registered with the tool registry and the system-prompt tool list is refreshed. When no skills are present, noSkilltool is exposed at all.Listing injection. A byte-stable
skill_listingblock is built from the loaded set, sorted alphabetically, and prepended to the first user message of each new session as a<system-reminder>. The listing budget is ~1% of the context window in characters (or 8000 characters when the provider does not report one); entries truncate before the listing is dropped.Skipped skills surface as warnings. A
skills_loadedevent names every accepted skill; rejected ones are returned to internal state with their reason (invalid-frontmatter,missing-skill-md,name-collision-tool,name-collision-skill,io-error) but the load does not throw.Per-invocation events. Each call emits
skill_invokedfollowed byskill_completed. The substituted body is recorded on the session and re-emitted after compaction so resumed sessions retain the instructions even when older messages have been summarized away.
#Permissions and skills
Skill invocation is gated by the Skill tool, which is exec-scope and therefore ask by default. Two interactions matter:
- Auto-allow for informational skills. During load, for every skill that declares neither
allowed_toolsnor amodeloverride, skawld appends an auto-allow ToolRule of the form{ kind: "tool", tool: "Skill", arg: "<skill-name>", decision: "allow" }to the permission rules. User-supplied rules are kept in front of these — first-match-wins still holds. The net effect: pure prompt skills don’t prompt the user, but skills that expand the tool surface do. allowed_toolsstages a one-turn overlay. When the skill is invoked, itsallowed_toolslist is attached to the session ascurrentTurnAllowedTools. The permission engine unions this with the standing rules for the next provider turn only; the overlay is cleared as soon as the turn ends. The overlay is additive (allow-only): denies in your standing rules still apply.- ToolRule
argmatchesinput.skill. Write rules against specific skills by settingarg."*"matches any skill name.
const rules: PermissionRule[] = [ // Deny one specific skill outright (overrides the auto-allow). { kind: "tool", tool: "Skill", arg: "post-deploy", decision: "deny" }, // Allow every other skill without prompting. { kind: "tool", tool: "Skill", arg: "*", decision: "allow" }, ];
See Permissions for ToolRule semantics in full.
#Multiple skills
Loaded skills are kept in an alphabetically-sorted map keyed by name. There is no namespacing, no inheritance, and no precedence between skills — name collisions are rejected at load time rather than resolved at runtime.
- Prefer many small skills over one big one. The listing is the model’s catalog; a single skill named
do-thingswith a sprawling body buys you nothing. Per-entry descriptions are capped at 250 characters, so each skill should be discoverable from a sentence or two. - Use
when_to_useas the trigger surface. The model picks skills by reading the listing. A precisewhen_to_use— "invoked when the user asks to ship, deploy, or push to production" — beats a longdescription. - Reserve
disable_model_invocationfor drafts. A hidden skill is still loaded and validated, but neither shown to the model nor invokable. Use it to land WIP without exposing half-written prompts. - Name collisions with built-in tools lose. A skill named
BashorReadis rejected at load. Tool registration runs first, then skills are checked against the registered set.
#A worked example
A post-deploy verification skill that takes an environment and an AWS region as arguments, uses the auto-allow path for informational skills (no allowed_tools declared, so no extra prompting), and reads scripts living alongside the prompt:
---
name: post-deploy
description: Verify a deploy by hitting healthchecks, tailing logs, and reporting failures.
when_to_use: invoked when the user asks to verify, smoke-test, or sanity-check a recent deploy.
arguments: [environment, region]
argument_hint: "<environment> <region> e.g. staging us-east-1"
version: "1.2.0"
---
# Post-deploy verification
Verify the deploy to **$environment** in **$region**.
1. Run the healthcheck script:
```
${SKAWLD_SKILL_DIR}/check-health.sh $environment $region
```
2. If the healthcheck reports any non-200, fetch the last 5 minutes of
error logs with the team's standard query for that environment.
3. Summarise findings as a short bulleted report:
- Overall status (pass / partial / fail)
- Failing endpoints with status codes
- Notable log lines (max 5, with timestamps)
If everything passes, say "post-deploy ok for $environment ($region)"
and stop. Do not page anyone.
Session id for reference: ${SKAWLD_SESSION_ID}When the model calls Skill({ skill: "post-deploy", args: "staging us-east-1" }) the body is rendered with $environment bound to staging, $region to us-east-1, $ARGUMENTS to staging us-east-1, and the two ${...} variables to absolute paths. The rendered string is returned as the tool result; the model reads it and proceeds to run the healthcheck, fetch logs, and summarize — all using its standard tool registry, no special privileges granted because the skill declared no allowed_tools.
The model sees skills in a skill_listing system block and
decides whether to load one via the Skill tool. That
decision is visible to your UI as a skill_invoked event —
see Events.