April 2026
Skills don't have to be a vendor feature
I rebuilt a provider's native skills feature as request-time compilation so it runs on models with no skills API, and found the one piece that does not survive the translation
Someone picks a skill from a dropdown, then picks a model. The skill is a folder. It has a display title, a one-line description, a block of instructions in a file called SKILL.md, and a ZIP of supporting files: scripts, reference documents, whatever the author bundled. Then they pick a model from a provider that has never heard of the word “skill.”
One provider in the system ships a native skills API. You hand it a skill id and it does the wiring. Every other provider takes three things and nothing else: a list of messages, a system prompt, and a list of tools. So the question was narrow. When the model is one of the others, what do you actually send?
The easy answer is to gray out skills unless you pick the one provider that supports them. I didn’t want skills to be a property of which model you happened to choose. So I stored the skills myself, in a table. One row per skill: display title, description, the instructions, and the ZIP sitting in a file_content column as a blob.
Routing keys off the id. Every skill id carries a namespace prefix. The bare ids I forward to the native API untouched and let it do its own conversion into tools. A prefixed id, something like local:skill_<hash>, points at a row I wrote, and that row is where the work happens.
For those, I compile the skill at request time. I read the row, pull SKILL.md out of the instructions, and build two things. First, a block appended to the system prompt: a line that reads # Available Skills, then ## Skill: and the display title, then the description, then the full instructions verbatim. Second, a function tool named for the skill id, so the model has something concrete to call when the instructions tell it to use the skill.
That is the entire mechanism. A skill is not a capability the provider grants you. It is a packaging convention, and you can unpack it into the two inputs every chat model already accepts: text in the system prompt, and an entry in the tools array. The native API does exactly this unpacking, on its own servers, and keeps it hidden behind an id. The only difference in my version is where it runs: in my own request handler, from a row in a table, a few milliseconds before the call goes out.
Once you see skills this way, a lot of the “does provider X support skills” anxiety dissolves. Support is not the right axis. Any provider that accepts a system prompt and a tools list already supports skills. It just doesn’t call them that, and someone has to do the compilation step the native API was hiding.
The ZIP is where the translation breaks.
I store it. It sits in the same row as the metadata, and on the non-native path I never send it. There is nowhere to put a folder of scripts in a system prompt, and a function tool is a name and a JSON schema, not a filesystem.
The native feature does two things with those bundled files. It reveals them progressively: the model sees the instructions first and only pulls in a reference document or a helper script when it reaches the step that needs one, so the context stays small until it has to grow. And it runs them in a sandbox, so a skill can call out to a script and get a real answer back instead of guessing.
My compiled version does neither. It hands the model the instructions and nothing the instructions point at.
For a self-contained skill, where SKILL.md is just prose telling the model how to behave, this is identical to the native version, and it runs on every model in the system. For a skill whose instructions say “run the included parser on the uploaded file,” the model gets that sentence injected into its prompt and has no parser and no file. It does not raise an error. It reads the instruction, cannot act on it, and writes something plausible in its place.
So the skills that need the runtime most are the ones that fail most quietly. The author wrote a skill that shells out to code. The user picked a model on the wrong side of the prefix. The output comes back fluent and confident, and nothing in it says a file was supposed to be opened and never was.