Skip to main content

Skills & plugins

plugins:write · per-turn fields enable_plugins, plugins

Your domain knowledge had exactly one home: the project's system prompt, loaded on every turn whether relevant or not. So it grew until it was a liability, every behaviour change was a global change, and nobody could edit what the agent does without touching the prompt everything else depends on.

A skill is one procedure — how you handle a refund, how you triage a ticket — with its own instructions and files. Skills are shipped as Agent Plugins, the portable format, so what you write here works in any client that supports it.

What a plugin looks like

acme-support/
├── plugin.json # required: the manifest
├── mcp.json # optional: MCP servers this plugin brings
└── skills/
└── refunds/
├── SKILL.md # required: frontmatter + instructions
├── references/ # loaded only if SKILL.md says to
└── scripts/
plugin.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "acme-support",
"version": "1.2.0",
"description": "How Acme handles support."
}
skills/refunds/SKILL.md
---
name: refunds
description: How we issue a refund. Use whenever a customer asks for money back.
---

1. Check the order is under 90 days old.
2. Refund to the original payment method.

Publishing one

Upload it in the dashboard under Skills, or over the API. Three shapes are accepted and only the first is a packaged plugin.

The examples below use the data plane (api.oberik.com) with an end-user token carrying plugins:write. Publishing to the whole project from your server is a control-plane call with your project key — oberik.skills.upload(...), or POST https://oberik.com/api/projects/:id/skills with X-API-Key. A pk_… project key is not accepted on POST /plugins; it is a control-plane credential and the data plane answers 401.

# A packaged plugin — read as written.
curl -X POST "$BASE/plugins" -H "Authorization: Bearer $KEY" -F file=@acme-support.zip

# A single SKILL.md. This is what a first procedure actually looks like.
curl -X POST "$BASE/plugins" -H "Authorization: Bearer $KEY" -F file=@SKILL.md

# A folder of skills, each part named by its path inside the folder.
curl -X POST "$BASE/plugins" -H "Authorization: Bearer $KEY" \
-F "files=@refunds/SKILL.md;filename=refunds/SKILL.md" \
-F "files=@triage/SKILL.md;filename=triage/SKILL.md"

A manifest is written for you when there isn't one. Requiring a plugin.json — with a $schema URL and a name obeying rules nobody has read — in order to publish one file of instructions is a packaging exercise standing in front of the feature. The manifest is what makes a plugin portable, so it still gets written; it just gets written for you, and what is stored is a proper archive you can pull back out. Ship your own plugin.json and it is never overridden.

The name comes from the skill when there is exactly one, and from the folder or archive otherwise, cleaned into something the spec accepts.

A skill is named by its frontmatter, not by its folder. The spec wants the directory and the name: to match and we say so when they do not — but the skill is published under the name it declares, because that is the name the agent sees it by and the one a re-upload replaces. This was once a refusal, which meant a skill in any folder failed to publish: nobody names a folder after the skill inside it.

What was skipped comes back in warnings. A skill that would not parse, an MCP server that was refused, a file named nearly SKILL.md — the upload succeeds with the rest and names what it left out, rather than a 201 that quietly published less than you sent. Note that a second skill is a second directory: a file called anything other than exactly SKILL.md is an ordinary bundled file.

Re-uploading a name replaces it, so fixing a typo in a procedure is one upload rather than a delete and an upload.

There is no capability for USING the skills you publish. A project that publishes a procedure wants its agent to read it, so a toggle for that would be a switch that exists to be found rather than decided — and a customer whose plugin was silently ignored would have no way to discover why. Callers that want to skip or narrow them for one request have the per-turn fields below.

Why this is not just a longer prompt

Progressive disclosure. Each skill's name and description — about a hundred tokens — are in the system prompt from the first step, so the agent knows what exists. The instructions are loaded only when it decides a skill applies, and the bundled files only when the instructions say they are worth reading. A hundred procedures cost about what three do at rest.

That is why the description is the most important line you will write. It is all the agent has when deciding, so name the trigger, not the topic: "How we issue a refund. Use whenever a customer asks for money back" beats "Refund policy".

Who can upload

You, with a project key (pk_…)Published to the whole project. Always allowed — dashboard → Skills, oberik.skills.upload(...), or POST https://oberik.com/api/projects/:id/skills with X-API-Key.
Your end-users, with their own tokenOnly with plugins:write. Private to them, unioned on top of yours.
// From your app, with an end-user's token:
await ai.plugins.upload(file); // a .zip or a SKILL.md
await ai.plugins.uploadFolder( // a directory picker's files
[...input.files].map((f) => ({ path: f.webkitRelativePath, content: f })),
);
await ai.plugins.list(); // the project's, plus theirs

An end-user's plugins are visible exactly the way their documents are — the same rules, the same scoping — because a plugin is a thing a person uploaded and there is no reason it should behave differently. Only a project key can publish to everyone: one end-user changing how the agent behaves for the whole workspace is not a thing to leave to a capability toggle.

Choosing per turn

await ai.chat.send({ message: "...", plugins: ["acme-support"] });

Omit it and the agent gets everything the token can see. It narrows only, and a name that is not visible is simply absent rather than an error — a pinned list should not start failing turns the day somebody deletes a plugin.

MCP servers in a plugin

mcp.json is loaded and its servers join the project's own, through the same allow-list gate every other tool goes through.

stdio servers are refused. A stdio server is a command line, and a plugin may have come from a file an end-user uploaded — running one would be arbitrary execution on the host with none of the sandbox's isolation. The refusal is reported on the plugin rather than silent, because a plugin whose tools never appear and never says why is the failure people spend an afternoon on.

A skill's scripts/ are fine: they are files the agent may run in the sandbox, under the same capability and path rules as anything else it writes there.