v0.1.0GitHubnpm
Concepts

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 Subagent tool. 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/
.skawld/
└── skills/
    ├── review-pr/
    │   └── SKILL.md
    └── changelog/
        ├── SKILL.md
        └── template.md

The 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 with name-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.

SKILL.md
---
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.
…
FieldTypeRequiredNotes
namestringNoSkill 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.
descriptionstringYesNon-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_usestringNoOptional usage hint appended to description in the listing as — <when_to_use>. Use it for trigger conditions the model should pattern-match against.
allowed_toolsstring[]NoAdditive 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.
argumentsstring[]NoNamed 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_hintstringNoOptional human-readable hint for the model, appended to the listing entry as (args: <hint>). Cosmetic; not parsed for shape.
modelstringNoPer-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.
versionstringNoFree-form. Recorded on the loaded Skill object; not used by the runtime.
disable_model_invocationbooleanNoWhen 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:

  1. 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 arguments by position. Unbound slots become the empty string.

  2. For each named slot foo, every occurrence of $foo in the body is replaced with the corresponding token.

  3. $ARGUMENTS is replaced with the raw, unsplit argument string. Use it when you want the model to see the original quoting.

  4. ${SKAWLD_SKILL_DIR} is replaced with the absolute path to the skill’s directory.

  5. ${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:

skill-tool.ts
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:

  1. Looks the skill up by name. Unknown name returns an error result containing Unknown skill: <name>.

  2. Rejects with an error if disable_model_invocation: true.

  3. Runs the substitution described in the body section against args.

  4. Appends an InvokedSkillRecord to the session so the body can be re-emitted after a compaction, and persists it via the session store.

  5. Stages a one-turn pendingSkillOverlay carrying allowed_tools and 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.

  6. 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

  1. Lazy load on first session. Nothing happens at new Agent() time. The first call to agent.session() awaits an internal connectSkills() which walks <configDir>/skills/, parses each SKILL.md, and populates the skill map on the agent.

  2. Memoized for the agent’s lifetime. Loading happens exactly once per Agent instance. Editing a SKILL.md while a session is running has no effect — the loaded skill set is held in memory. Create a new Agent to pick up disk changes.

  3. Skill tool registration. If the load found at least one skill, the Skill tool is registered with the tool registry and the system-prompt tool list is refreshed. When no skills are present, no Skill tool is exposed at all.

  4. Listing injection. A byte-stable skill_listing block 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.

  5. Skipped skills surface as warnings. A skills_loaded event 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.

  6. Per-invocation events. Each call emits skill_invoked followed by skill_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_tools nor a model override, 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_tools stages a one-turn overlay. When the skill is invoked, its allowed_tools list is attached to the session as currentTurnAllowedTools. 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 arg matches input.skill. Write rules against specific skills by setting arg. "*" matches any skill name.
skill-rules.ts
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-things with 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_use as the trigger surface. The model picks skills by reading the listing. A precise when_to_use — "invoked when the user asks to ship, deploy, or push to production" — beats a long description.
  • Reserve disable_model_invocation for 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 Bash or Read is 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:

.skawld/skills/post-deploy/SKILL.md
---
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.