# Agent Skills Support

Design plan for adding SKILL.md-based agent skills to swival, following the open standard from [agentskills.io/specification](https://agentskills.io/specification).

## What are skills?

Skills are portable Markdown-based knowledge packages that tell the agent *how* to perform domain-specific tasks. Unlike tools (which are executable functions), skills encode procedural expertise — multi-step workflows, best practices, edge case handling — as natural language instructions the LLM interprets at runtime.

A skill is a directory containing a `SKILL.md` file with YAML frontmatter (name + description) and a Markdown body with detailed instructions. Optional subdirectories (`scripts/`, `references/`, `assets/`) can hold supporting files.

```
pdf/
  SKILL.md            # Required: frontmatter + instructions
  scripts/extract.py  # Optional: helpers the agent can run
  references/FORMS.md # Optional: additional docs loaded on demand
```

The key architectural insight is **progressive disclosure**: at startup, only the name and description (~100 tokens each) are loaded into context. The full instructions are loaded only when the agent decides a skill is relevant to the current task — keeping context costs low even with many skills available.

## Current architecture (relevant parts)

**Instruction loading** (`agent.py:167-189`): `load_instructions()` scans `base_dir` for `CLAUDE.md` and `AGENT.md`, wraps each in XML tags, and appends them to the system prompt. Capped at 10K chars per file.

**System prompt assembly** (`agent.py:517-538`): Default prompt loaded from `system_prompt.txt`, then instructions appended, then optional `run_command` docs appended. Final system content goes into `messages[0]`.

**Tool dispatch** (`tools.py`): `dispatch(name, args, base_dir, **kwargs)` routes tool calls. Tools are defined as OpenAI function-calling schemas in `TOOLS` list. The `run_command` tool is conditionally appended.

**Context management** (`agent.py`): `estimate_tokens()`, `compact_messages()`, `drop_middle_turns()` handle token pressure. System message is never compacted or dropped.

## Design

### Skill discovery

At startup, scan for skills in `base_dir/skills/` only. Global skills (`~/.swival/skills/` or arbitrary paths) are opt-in via `--skills-dir`. This avoids surprise behavior from files outside the project and keeps the default surface area small.

Scan order when `--skills-dir` is provided:

1. `base_dir/skills/` — project-local skills
2. Each `--skills-dir` path — in the order specified on the command line

Each subdirectory containing a `SKILL.md` is a skill candidate. Parse the YAML frontmatter to extract `name` and `description`. Validate:
- `name`: 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens, must match directory name
- `description`: 1-1024 chars, non-empty

On duplicate names: project-local skills take precedence over `--skills-dir` skills. Among multiple `--skills-dir` paths, the first occurrence wins. A warning is logged to stderr for every shadowed duplicate so behavior is deterministic and observable.

Invalid skills are logged to stderr and skipped (never crash).

### Progressive disclosure (three tiers)

**Tier 1 — Discovery (startup):** Build a skills catalog from frontmatter. Format a compact summary block and append it to the system prompt:

```
<available-skills>
The following skills are available. To activate a skill, call the `use_skill` tool with its name.

- pdf: Extract text and tables from PDF files, fill forms, merge documents.
- code-review: Review code changes for bugs, style issues, and security problems.
</available-skills>
```

Cost: ~50-100 tokens per skill. 20 skills ≈ 1000-2000 tokens.

**Tier 2 — Activation (on demand):** When the agent calls `use_skill`, load the full `SKILL.md` body and inject it as a system-like message. The agent then follows the instructions using existing tools.

**Tier 3 — Resources (on demand):** The agent reads supporting files (`references/`, `scripts/`, etc.) using the existing `read_file` tool. For project-local skills (under `base_dir`), `safe_resolve()` already handles path resolution — no changes needed. For `--skills-dir` skills outside `base_dir`, the `use_skill` response tells the agent the skill directory path, but the agent cannot read those files through `read_file` (sandbox prevents it). See "Handling skills outside base_dir" below for how this is resolved.

### New tool: `use_skill`

A new tool added to the `TOOLS` list (conditionally, only when skills are discovered):

```python
USE_SKILL_TOOL = {
    "type": "function",
    "function": {
        "name": "use_skill",
        "description": "Activate a skill to get detailed instructions for a specific task.",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "The skill name to activate."
                }
            },
            "required": ["name"]
        }
    }
}
```

When called, `dispatch()` routes to a handler that:
1. Looks up the skill by name in the catalog
2. Reads the full `SKILL.md` body (capped at 20K chars to prevent context blowout)
3. Returns the instructions as the tool result, wrapped for clarity:

```
[Skill: pdf activated]

<skill-instructions>
# PDF Processing Guide
...full SKILL.md body...
</skill-instructions>

Skill directory: /home/user/.swival/skills/pdf
To access supporting files, use read_file with absolute paths under this directory
(e.g. "/home/user/.swival/skills/pdf/scripts/extract.py").
```

The agent receives this as a normal tool result and follows the instructions using existing tools (`read_file`, `write_file`, `edit_file`, `run_command`, etc.). For external skills, `read_file` access to the skill directory is enabled by the allowlist mechanism described below.

**Path resolution contract:** `read_file` always resolves paths the same way — relative to `base_dir`. There is no special "relative to skill root" resolution. The `use_skill` response provides the skill's absolute path so the agent can construct absolute paths to supporting files. `safe_resolve()` with `extra_read_roots` then permits read access to those absolute paths if they fall under an activated skill root. This avoids any ambiguity about which root a relative path resolves against.

### Why a tool and not a system message injection?

Alternatives considered:

| Approach | Pros | Cons |
|----------|------|------|
| **System message injection** | Simple, always visible | Permanent context cost, can't unload, grows system message unboundedly |
| **Dedicated tool (chosen)** | On-demand loading, context-efficient, compactable | Extra tool call, instructions may be compacted away in long sessions |
| **Auto-activation (LLM picks from catalog)** | Zero friction | No explicit signal, can't track which skills are active, harder to test |

The tool approach wins because:
- Instructions arrive as tool results, which `compact_messages()` can truncate if context pressure grows — unlike the system message which is never compacted.
- The explicit tool call gives the agent (and logs) a clear signal of skill activation.
- Follows the existing pattern of `dispatch()` routing.

### Handling skills outside `base_dir`

Skills loaded via `--skills-dir` may live outside the file tool sandbox (`base_dir`). The agent cannot use `read_file` to access their supporting files.

**Approach: Allowlisted read-only access via `safe_resolve()`.**

`safe_resolve()` currently checks `is_relative_to(base_dir)`. We extend it to accept an optional list of additional read-only roots. When `use_skill` is called for an external skill, the handler adds that skill's resolved directory to a per-session allowlist. Subsequent `read_file` calls can then access files under that skill root (read-only — `write_file` and `edit_file` remain `base_dir`-only).

Implementation:
- `safe_resolve()` gains an optional `extra_read_roots: list[Path]` parameter (default empty).
- `dispatch()` receives the allowlist via `**kwargs` (key: `skill_read_roots`).
- `use_skill` appends the activated skill's resolved root to the list.
- `_read_file` passes the list through to `safe_resolve()`.
- Write/edit tools ignore the list entirely — they only accept `base_dir`.

Security constraints on the allowlist:
- Each entry is the resolved (symlink-free) absolute path of a skill directory that was explicitly passed via `--skills-dir`.
- The allowlist is append-only within a session (no removal, no modification).
- Deduplicated: before appending, check if the path is already in the list (O(n) scan is fine — the list is tiny). Re-activating the same skill is a no-op on the allowlist.
- Capped: the list cannot exceed the number of discovered external skills (bounded by the catalog size at startup). No artificial cap needed beyond that — if you have 20 external skills and activate all 20, the list has 20 entries. The containment check iterates at most `len(skill_read_roots)` entries per `safe_resolve()` call.
- Skill directories are resolved and canonicalized at discovery time, not at access time.
- Path traversal within a skill (`../` escaping the skill root) is caught by `safe_resolve()` the same way it catches escapes from `base_dir`.

Alternatives rejected:
- **Inline file content in tool result**: Fragile (requires detecting references in Markdown, which is heuristic and can be gamed with `../` paths). Unbounded — a skill could reference many large files.
- **Extend base_dir**: Would allow writes to skill directories, which is wrong.
- **No access at all**: Makes external skills much less useful (scripts and references can't be read).

### CLI interface

New arguments:

```
--skills-dir PATH     Additional directory to scan for skills (can be repeated)
--no-skills           Don't load or discover any skills
```

Default behavior: scan only `base_dir/skills/`. `--skills-dir` adds extra directories (e.g. `--skills-dir ~/.swival/skills`). `--no-skills` disables everything (like `--no-instructions` for CLAUDE.md).

Interaction with prompt flags:
- `--no-skills`: Disables discovery, catalog, and `use_skill` tool entirely.
- `--no-system-prompt`: No system message, so no catalog block — but `use_skill` tool is still in the tools list if skills were discovered. The LLM can still call `use_skill` if it somehow knows a skill name (e.g. from user input).
- `--system-prompt "custom"`: Same behavior — no catalog injected, but `use_skill` tool available. This is an intentional tradeoff: a custom system prompt user has opted out of the default prompt assembly, so we don't inject our catalog format into their prompt. The tool remains available because the user may embed their own skill references in their custom prompt, or the user's question may name a skill directly. In practice, skills are hard to use without the catalog since skill names are not discoverable — but that's the expected consequence of overriding the system prompt.

### Context budget

Skills consume context in two places:

1. **Catalog in system prompt** (~100 tokens/skill): Acceptable. 20 skills = ~2000 tokens, well within budget.
2. **Activated skill instructions** (tool result): Subject to normal compaction. If a skill's instructions are large (>5000 tokens), they may be truncated by `compact_messages()` in later turns. This is acceptable — the agent should extract what it needs early and use `think` notes for persistence.

The 20K char cap on SKILL.md body loading prevents a single skill from blowing the context.

### Data flow: discovery → dispatch

The skill catalog is built in `agent.py` at startup and must be accessible to `dispatch()` in `tools.py` at runtime. This follows the existing pattern used by `thinking_state` and `resolved_commands`: pass the catalog through `**kwargs`.

Concrete flow:

1. `agent.py:main()` calls `discover_skills(base_dir, extra_dirs, verbose)` → returns `dict[str, SkillInfo]`.
2. The catalog dict and a mutable `skill_read_roots: list[Path]` are created in `main()`.
3. `handle_tool_call()` passes both via `**kwargs` to `dispatch()`:
   ```python
   handle_tool_call(tc, base_dir, thinking_state, verbose,
                    resolved_commands=resolved_commands,
                    skills_catalog=skills_catalog,
                    skill_read_roots=skill_read_roots)
   ```
4. `dispatch()` adds a new `elif name == "use_skill":` branch that:
   - Reads `kwargs["skills_catalog"]` to find the skill
   - Reads `kwargs["skill_read_roots"]` to potentially append a new root
   - Calls `_activate_skill(name, catalog, read_roots)` which loads the body and updates the allowlist
5. `_read_file()` receives `skill_read_roots` from `dispatch()` and passes it to `safe_resolve()`.

This is the same `**kwargs`-threading approach used for `thinking_state` and `resolved_commands`, so no architectural change is needed.

### File changes

| File | Changes |
|------|---------|
| `agent.py` | New `discover_skills()` function, catalog formatting, `--skills-dir` and `--no-skills` args, skill catalog appended to system prompt, `use_skill` tool conditionally added, `skill_read_roots` list created and passed to `handle_tool_call()` |
| `tools.py` | `USE_SKILL_TOOL` schema definition, `use_skill` branch in `dispatch()`, `_activate_skill()` handler, `safe_resolve()` extended with `extra_read_roots` parameter, `_read_file()` threads `skill_read_roots` through |
| `skills.py` | New file: `parse_frontmatter()`, `validate_skill_name()`, `SkillInfo` dataclass — keeps skill-specific logic separate from tool dispatch |
| `system_prompt.txt` | No change (skill docs appended dynamically to system_content, like run_command) |
| `tests/test_skills.py` | New test file (see test plan below) |

No changes to `edit.py` or `thinking.py`. `safe_resolve()` changes are additive (new optional parameter, existing callers unaffected).

### Dependencies

No new dependencies. We parse a deliberately restricted subset of YAML frontmatter.

**Supported frontmatter subset (v1):**

Only two fields are extracted: `name` and `description`. All other fields (`metadata`, `license`, `compatibility`, `allowed-tools`) are ignored — their lines are consumed but not parsed.

The parser handles these YAML constructs and nothing else:
- **Plain scalar values**: `name: my-skill` — value is everything after `: ` to end of line, stripped.
- **Quoted scalar values**: `name: "my-skill"` or `name: 'my-skill'` — outer quotes stripped. Inner escaped quotes (`\"`, `\'`) are unescaped.
- **Multiline folded descriptions**: Lines indented deeper than the key are continuation lines, joined with spaces (YAML folded style). Example:
  ```yaml
  description: This is a long
    description that spans
    multiple lines.
  ```
- **Multiline literal descriptions**: `description: |` followed by indented block — lines joined with newlines preserved.

The parser rejects (returns an error, does not crash):
- Missing `---` delimiters (file must start with `---` line, frontmatter must end with `---` line)
- Missing `name` field
- Missing `description` field
- `name` or `description` that fail validation after parsing

Unknown keys at the top level are silently skipped (line starts with `key:` where key is not `name` or `description` — consume until next unindented key or closing `---`).

This subset covers every SKILL.md in the Anthropic skills repository and should handle well-formed skills from other sources. Malformed YAML that falls outside this subset produces a clear error: `"error: failed to parse SKILL.md frontmatter in {dir}: {reason}"`, and the skill is skipped.

### Implementation steps

1. **New file `skills.py`**: `SkillInfo` dataclass, `parse_frontmatter(text) -> dict`, `validate_skill_name(name, dir_name) -> str|None` (returns error string or None), `discover_skills(base_dir, extra_dirs, verbose) -> dict[str, SkillInfo]`, `activate_skill(skill, read_roots) -> str`, `format_skill_catalog(catalog) -> str`.

2. **Extend `safe_resolve()`** (`tools.py`): Add `extra_read_roots: list[Path] = ()` parameter. Resolution logic: resolve the path (relative paths still resolve against `base_dir` as today), canonicalize via `Path.resolve()`, then check containment against `base_dir` first, then each `extra_read_roots` entry. First match wins. The agent uses absolute paths for external skill files (provided in the `use_skill` response), so relative-to-skill-root resolution is not needed. Thread `extra_read_roots` through `_read_file()` → `safe_resolve()`. No changes to write/edit paths — they never receive `extra_read_roots`.

3. **Wire `use_skill` into `dispatch()`** (`tools.py`): New `elif name == "use_skill":` branch. Extract `skills_catalog` and `skill_read_roots` from `**kwargs`. Call `activate_skill()`. `USE_SKILL_TOOL` schema defined in `tools.py`.

4. **CLI args and main() changes** (`agent.py`): `--skills-dir` (action=append), `--no-skills` (store_true). Call `discover_skills()` after resolving `base_dir`. Build catalog string, append to `system_content`. Conditionally add `USE_SKILL_TOOL` to tools list. Create `skill_read_roots: list[Path] = []` and pass to `handle_tool_call()`.

5. **Tests** (`tests/test_skills.py`):

   **Frontmatter parsing:**
   - Valid: plain scalars, quoted scalars (single and double), multiline folded, multiline literal
   - Quoted edge cases: colon in description value (`description: "Deploy to https://example.com"`), quotes within quotes
   - Missing fields: no name, no description, empty name, empty description
   - Malformed: no opening `---`, no closing `---`, garbage between delimiters, binary content
   - Unknown fields: silently skipped, don't affect name/description extraction

   **Name validation:**
   - Valid: `pdf`, `code-review`, `a`, 64-char name
   - Invalid: uppercase (`PDF`), leading hyphen (`-pdf`), trailing hyphen (`pdf-`), consecutive hyphens (`pdf--review`), 65-char name, special chars (`pdf_review`, `pdf.review`), empty string
   - Directory mismatch: frontmatter name doesn't match directory name

   **Discovery:**
   - No skills dir exists (no error, empty catalog)
   - Empty skills dir (empty catalog)
   - Valid skills found and cataloged
   - Invalid skills skipped with stderr warning
   - Multiple `--skills-dir` paths scanned in order
   - Deduplication: project-local beats `--skills-dir`, first `--skills-dir` beats later ones, warning logged for each shadow
   - Non-existent `--skills-dir` path (warning, not crash)
   - `--skills-dir` path is a file, not a directory (warning, not crash)

   **Activation (`use_skill` handler):**
   - Successful load: body returned with correct formatting, absolute skill path included in response
   - Unknown skill name: `"error: unknown skill: ..."` returned
   - Body truncation: SKILL.md over 20K chars truncated with marker
   - Skill root appended to `skill_read_roots` for external skills
   - Skill root NOT appended for project-local skills (already in sandbox)
   - Re-activating the same external skill: no duplicate entry in `skill_read_roots`

   **safe_resolve with extra_read_roots:**
   - Read access to file under skill root: allowed
   - Read access to file outside skill root via `../`: blocked
   - Read access to symlink escaping skill root: blocked
   - Write/edit to file under skill root: blocked (only base_dir for writes)
   - Empty extra_read_roots list: behaves identically to current code (regression test)
   - Multiple roots: access allowed under any of them

   **Integration:**
   - Catalog appears in system prompt between instructions and command docs
   - `--no-skills` suppresses discovery and catalog
   - `use_skill` tool conditionally added to tools list only when catalog is non-empty
   - `--no-system-prompt` disables catalog (no system message to append to) but `use_skill` tool still in tools list
   - `--system-prompt "custom"` skips catalog in prompt but `use_skill` tool still in tools list (intentional: skills are undiscoverable but callable if user names one explicitly)
   - `read_file` with absolute path under activated external skill root: allowed
   - `read_file` with relative path that resolves under `base_dir` (not skill root): resolves against `base_dir` as usual, no skill-root resolution

### Data structures

```python
@dataclass
class SkillInfo:
    name: str           # validated name from frontmatter
    description: str    # description from frontmatter
    path: Path          # resolved absolute path to skill directory
    is_local: bool      # True if under base_dir (no allowlist entry needed)

MAX_SKILL_BODY_CHARS = 20_000
MAX_SKILL_DESCRIPTION_CHARS = 1024
MAX_SKILL_NAME_CHARS = 64
```

Skills are discovered at startup and stored in a `dict[str, SkillInfo]` keyed by name. The body is loaded lazily (only when `use_skill` is called) to keep startup fast.

`is_local` is set during discovery by checking `skill.path.is_relative_to(Path(base_dir).resolve())`. When `use_skill` activates a non-local skill, the handler appends `skill.path` to the `skill_read_roots` list. For local skills, no allowlist update is needed (already inside `base_dir`).

### Interaction with existing features

- **Context management**: Skill catalog is part of the system message (never compacted). Activated skill instructions are tool results (compactable). This is the right behavior.
- **CLAUDE.md/AGENT.md**: Independent. Skills supplement project instructions, they don't replace them. A project could have both `CLAUDE.md` for general guidance and `skills/` for domain procedures.
- **run_command**: Skills that reference scripts assume `run_command` is available. If `--allowed-commands` doesn't include `python3` or `bash`, those scripts can't run. The skill instructions should handle this gracefully (the LLM will see the error and adapt). No special integration needed.
- **think tool**: Skills can suggest using `think` for complex reasoning steps. No integration needed.

### Future extensions (not in v1)

- **Skill chaining**: Skills referencing other skills (load one skill's output as input to another).
- **allowed-tools field**: Pre-approve specific tools for a skill (security boundary).
- **Remote skill registries**: Fetch skills from URLs or package managers.
- **Skill templates**: A `skill-creator` meta-skill that generates new skills.
- **Full YAML parsing**: Support `metadata`, `compatibility`, `license` fields.
- **Skill deactivation**: Explicit tool to unload a skill's instructions from context.

### Security considerations

- Skills from `base_dir/skills/` are as trusted as `CLAUDE.md` — same provenance, same risk model.
- `--skills-dir` paths are user-specified on the command line, trusted.
- Skill instructions are LLM-interpreted, not executed directly. The agent still uses sandboxed tools for all actions.
- `--no-skills` provides an opt-out for untrusted repositories (like `--no-instructions`).
- `safe_resolve()` with `extra_read_roots` enforces containment per root. A skill at `/home/user/.swival/skills/deploy/` can only access files under that directory. Path traversal (`../`) and symlink escapes are caught by the same canonicalization logic used for `base_dir`.
- Write/edit operations are never allowed outside `base_dir`, regardless of `extra_read_roots`.
- No network access during skill discovery or activation.
- Skill body loading is capped at 20K chars to prevent context flooding.

### Example usage

Given `project/skills/deploy/SKILL.md`:

```yaml
---
name: deploy
description: Deploy the application to staging or production. Use when the user mentions deploying, shipping, or releasing.
---

# Deployment Procedure

1. Run tests: `python3 -m pytest tests/ -v`
2. Check git status — all changes must be committed
3. Read `deploy.yaml` for environment-specific config
4. Build: `python3 scripts/build.py --env <target>`
5. Deploy: `python3 scripts/deploy.py --env <target>`
6. Verify: check the health endpoint at the URL from deploy.yaml

## Common issues
- If tests fail, fix them before deploying
- If build fails with "missing env", check deploy.yaml has the target environment
```

Agent session:
```
$ uv run agent "deploy to staging" --base-dir ./project --allowed-commands "python3,git"

[stderr] Discovered 1 skill(s): deploy
[stderr] --- Turn 1/50 ---
# LLM sees catalog in system prompt, calls use_skill(name="deploy")
[stderr] Tool: use_skill(name=deploy) -> [loaded 847 chars]
[stderr] --- Turn 2/50 ---
# LLM follows the deployment procedure using run_command, read_file, etc.
```
