# `Phantom.Skill`
[🔗](https://github.com/dbernheisel/phantom_mcp/blob/main/lib/phantom/skill.ex#L1)

A skill served with the MCP Skills extension (`io.modelcontextprotocol/skills`).

A skill is a directory of files with a `SKILL.md` at its root, as defined by the
[Agent Skills specification](https://agentskills.io/specification). Route a skill
path to an action in your `Phantom.Router`, and return a `%Phantom.Skill{}` from it,
much like a Phoenix controller renders a template:

    defmodule MyApp.MCP.Router do
      use Phantom.Router, name: "MyApp", vsn: "1.0"

      # skill://git-workflow/SKILL.md → MyApp.MCP.Skills.git_workflow/2
      skill "git-workflow", MyApp.MCP.Skills

      skill "acme/billing/refunds", MyApp.MCP.Skills, :refunds
      skill "studies/:study_id/study-review", MyApp.MCP.Skills, :study_review
    end

The final segment of the path is the skill's name, and must equal the `name` in its
frontmatter. Earlier segments organize skills and may contain path params, except the
first. The router's `c:Phantom.Router.list_skills/2` decides which skills
`skills/list` returns; by default, every route without path params. Clients can get
any served skill by URI.

Each skill is a resource template named by its path, so skill paths share names with
resource templates, and `Phantom.Session.allow_resource_templates/2` limits skills too.
Skill routes are not listed in `resources/templates/list`.

## Actions

An action receives the path params and the session, and returns
`{:reply, %Phantom.Skill{}, session}`. Return `{:reply, nil, session}` when no skill
is served at that path. Actions are synchronous.

Phantom calls the same action for `skills/list`, `skills/get`, `resources/read`, and
`resources/directory/read`, and serves the part each method asks for. Digests are
computed from what the action returns, so the action must return the same files for
the same session. If it cannot, mark the skill with `dynamic/1`.

## Nested skills

A skill routed under another skill's path is nested in it. Each file belongs to the
deepest skill whose directory contains it: the parent's manifest and directory
listings take each nested skill's files from its own action, and leave out the
parent's own files in that directory. A nested action that returns `nil` or an error
serves no files; one that raises fails the parent's `skills/get` and is left out of
`skills/list`. A session allowed a skill may use every skill nested in it.

## Rendering

A file's contents are either a binary or a function that returns iodata. A function
is only called when Phantom needs that file: `resources/read` renders the one file
read, while `skills/list` and `skills/get` render every file for their digests.

## Caching

`skills/list` and `skills/get` results are private and uncached unless the action
calls `with_cache/2`. Only declare `scope: :public` when the skill is the same for
every user who can see it.

    {:reply, git_workflow(%{}) |> Phantom.Skill.with_cache(ttl_ms: 300_000, scope: :public),
     session}

## Embedding skills from files

`embed_skills/1` compiles skill directories into functions, the way
`Phoenix.Component.embed_templates/1` compiles templates. Files ending in `.eex` are
rendered with assigns; the frontmatter of `SKILL.md` is parsed at compile time and
must not contain EEx.

    defmodule MyApp.MCP.Skills do
      use Phantom.Skill

      # skills/refunds/SKILL.md.eex, skills/refunds/examples/email.md
      # → refunds(assigns)
      embed_skills "skills/*"

      def refunds(_params, session) do
        {:reply, refunds(%{user: session.assigns.user}), session}
      end

      def git_workflow(_params, session) do
        {:reply,
         Phantom.Skill.new(
           %{name: "git-workflow", description: "Follow this team's Git conventions"},
           %{"SKILL.md" => "# Git workflow\n\nBranch from `main`.\n"}
         ), session}
      end
    end

Parsing frontmatter requires the optional `:yamerl` dependency:

    {:yamerl, "~> 0.10"}

# `t`

```elixir
@type t() :: %Phantom.Skill{
  cache: nil | [ttl_ms: non_neg_integer(), scope: :public | :private],
  dynamic: boolean(),
  files: %{required(String.t()) =&gt; binary() | (-&gt; iodata())},
  frontmatter: %{required(String.t()) =&gt; term()}
}
```

# `contents`

```elixir
@spec contents(t()) :: %{required(String.t()) =&gt; binary()}
```

The skill's files as served, with the frontmatter written ahead of `SKILL.md`.
Renders every file.

# `dynamic`

```elixir
@spec dynamic(t()) :: t()
```

Mark a skill as generated such that its files can't have stable digests.

Clients receive `"resources": "dynamic"` instead of a manifest, and some
clients decline to load such skills.

# `embed_skills`
*macro* 

Compile every skill directory matching `pattern` into a function.

The pattern is relative to the current file. A directory with a `SKILL.md` or
`SKILL.md.eex` becomes a function named after the directory, with hyphens replaced
by underscores, that takes assigns and returns a `%Phantom.Skill{}`.

# `list`

```elixir
@spec list([String.t()], String.t() | nil) :: %{
  :skills =&gt; [String.t()],
  optional(:nextCursor) =&gt; String.t()
}
```

The result of `c:Phantom.Router.list_skills/2`: the `SKILL.md` URIs to list, and
the cursor of the next page, or `nil` on the last page.

# `new`

```elixir
@spec new(map(), %{required(String.t()) =&gt; iodata() | (-&gt; iodata())}) :: t()
```

Build a skill from its frontmatter and files.

`frontmatter` must contain `name` and `description`; every other field passes through
to clients as written. `files` maps paths relative to the skill's root to their
contents, and must include `"SKILL.md"` without its frontmatter. Contents are iodata,
or a function that returns iodata when the file is needed.

# `parse`

```elixir
@spec parse(String.t()) :: {:ok, map(), String.t()} | {:error, String.t()}
```

Split a `SKILL.md` into its parsed frontmatter and its body.

# `with_cache`

```elixir
@spec with_cache(t(), ttl_ms: non_neg_integer(), scope: :public | :private) :: t()
```

Let clients cache the skill's `skills/list` and `skills/get` entries.

Both `:ttl_ms` and `:scope` (`:public` or `:private`) are required, with the
meaning of `Phantom.Request.with_cache/2`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
