← Back to Blog

Porting Claude Agent Skills to Any LLM Agent

How we ran Claude-style Skills across every model Elvean supports, by leaning on our existing tool-use infrastructure instead of building a new subsystem.

We build Elvean, a chat app that talks to a lot of models: Claude, Gemini, GPT, Grok, and local models through Ollama and LM Studio. When Anthropic shipped Agent Skills, the interesting question for us was not “how do we use this with Claude.” It was “can we give the same Skills to every model we support, and can we reuse the Skills people already wrote for Claude Code?”

The answer to both turned out to be yes, and the reason is that a Skill is not really a model feature. It is a folder of Markdown and a loading convention. Once we saw that, porting it became a question of plumbing, not research. This post is about the plumbing, and the handful of architecture decisions we made along the way.

What a Skill actually is

The clever part of Agent Skills is a loading strategy called progressive disclosure. Instead of stuffing every instruction into the system prompt and paying for it on every turn, a Skill is split into layers, and each layer loads only when the agent needs it.

flowchart TB
    A["Tier 1: Metadata<br/>name and description<br/>always in context"]
    B["Tier 2: SKILL.md<br/>the real instructions<br/>loaded on demand"]
    C["Tier 3: Resources<br/>reference.md, examples.md, scripts<br/>read only when needed"]
    A --> B --> C

The reason this matters is boring but real. Context is expensive and attention is finite. Loading a 4,000 token skill on every turn, whether or not you need it, is waste. Loading it on the one turn where it helps is not.

The nice thing for us is that none of those three tiers needs a Claude-specific API. Loading a Skill is reading a file. Using its resources is reading more files. So any agent that can read a file on request can run a Skill, regardless of the model behind it.

The tool layer we already had

Elvean already dispatches tools in a provider-neutral way. Every tool is described with an OpenAI-style function schema, every model returns a call in a normalized shape, and every result comes back in the same envelope. A single switch routes a call to its handler by name.

That normalization is the whole reason Skills port cleanly. Claude, OpenRouter, and Ollama each format tool calls differently on the wire, but by the time a call reaches our dispatcher it looks the same. So Skills did not need a per-provider path. We defined two tools once, load_skill and read_skill_file, and got them on every model.

flowchart LR
    M["Any model<br/>Claude, Gemini, GPT, Ollama"] --> TC["Normalized tool call"]
    TC --> D["Single dispatch switch"]
    D --> S["SkillFileService"]
    S --> R1[("~/Documents/Elvean/Skills")]
    S --> R2[("~/.claude/skills")]

Mapping the three tiers onto two tools

We did not invent a metadata channel or a manifest format. We mapped each tier of progressive disclosure onto something the tool layer already does.

Tier 1, the metadata, is the tool description. When we offer the load_skill tool, we build its description on the fly from the list of Skills on disk, one line each with the name and a short summary. That list is the only Skill content that sits in context all the time, and it is small. It is enough for the model to know a Skill exists and roughly when to reach for it.

Tier 2, the instructions, is load_skill. When the model decides a task is relevant, it calls load_skill with a name, and we return that Skill’s SKILL.md. We cap the returned text at around 24,000 characters so a very large Skill cannot quietly blow the context budget, and we tell the model when we truncated.

Tier 3, the resources, is read_skill_file. If the instructions point at a supporting file, the model asks for it by name. Because that filename comes from the model, we treat it as untrusted. We reduce it to its last path component and reject anything that starts with an underscore or a dot, so a Skill cannot read its way out of its own directory. Two tools, three tiers, no new subsystem.

Reusing the Skills you already have

Here is the decision we are happiest about. Elvean reads Skills from two roots:

  1. ~/Documents/Elvean/Skills/ for Skills you create in the app. These are mutable, and we seed a couple of starters (a code-review skill and a writing skill) on first run.
  2. ~/.claude/skills/ for the Skills you already wrote for Claude Code. These are read-only, and opt in.

If a name appears in both places, the Elvean-native one wins. The result is that if you have already built a library of Skills for Claude Code, Elvean picks them up as they are. We read only the name and description from each Skill’s YAML frontmatter and deliberately ignore the Claude-specific fields like allowed-tools and agent, so a Claude Skill loads without us having to honor semantics that do not apply outside Claude.

The sandbox reality

Elvean ships on the App Store, which means it runs in a sandbox and cannot just reach into ~/.claude/skills/ on its own. So reading that folder is an explicit, user-granted decision. The user points us at the folder once, and we keep a security-scoped bookmark that is read-only by construction.

Read-only is not a nice-to-have here. Claude Skills are the user’s own files living outside our container, and we have no business writing to them. The bookmark encodes that at the OS level, and we handle the stale-bookmark case by asking the user to grant access again rather than failing silently.

Reading a Skill is not running a Skill

A lot of Skills reference scripts. The obvious move is to give the agent a shell tool and let it run them inline. We chose to keep those two things separate.

The Skill tools only ever read text. They hand the model knowledge, not the ability to execute anything. Execution lives in a completely different tool, our sandboxed run_command, which is gated far more tightly. It is compiled only into direct (non App Store) builds, only on macOS, and only runs on macOS 26 and later, inside an OS-level sandbox.

So on an App Store build, a Skill can teach the model exactly how to do something, and the model still cannot run arbitrary code, because the execution tool is not present at all. On a direct build, execution is available but boxed in. Splitting knowledge from execution let us ship Skills widely while keeping the risky part behind a separate, much stricter door.

sequenceDiagram
    participant U as User
    participant A as Agent (any model)
    participant FS as SkillFileService
    participant SB as Sandbox (run_command)

    U->>A: Review this pull request
    A->>FS: load_skill("code-review")
    FS-->>A: SKILL.md (capped at ~24k chars)
    A->>FS: read_skill_file("code-review", "checklist.md")
    FS-->>A: checklist.md
    Note over A,SB: Execution is a SEPARATE, tightly gated tool
    A->>SB: run_command (direct builds, macOS 26+ only)
    SB-->>A: result
    A-->>U: Findings with severity and line numbers

Where it plugs in

Skills are a Pro feature and are off unless the user turns them on, so we only add the two tools to a request when three things are true: the user has Pro access, the Skills toggle is on, and at least one Skill actually exists on disk. If there are no Skills, we add nothing, so we never spend a single token describing a feature the user is not using. That is the same instinct as progressive disclosure itself, applied one level up.

What we took away from it

A few things stood out once it was running:

  • The hard part was already done. Because our tool dispatch was provider-neutral, Skills worked on every model the day we added the two tools. We did not write a Claude path, a Gemini path, and an Ollama path. We wrote one.
  • Reuse beats reinvention. Reading ~/.claude/skills/ directly means a user’s existing Skills just show up. No import step, no conversion.
  • Separating knowledge from execution paid off. It let us offer Skills on the App Store build, where arbitrary code execution is off the table, without watering the feature down.
  • Everything stays visible. Each step is a tool call you can see in the transcript: which Skill loaded, which file it read, and whether it ran anything. There is no hidden behavior baked into a prompt.

If you already keep Skills for Claude Code, point Elvean at the folder and they will be there, driving whichever model you happen to be talking to that day. That portability was the whole goal, and it fell out of treating Skills as what they are: files, plus a good convention for when to read them.

Try this workflow yourself — download Elvean for Mac.