v2.1.0 to v2.1.0

48 added, 27 removed. Audit A to A.

---
name: plugin-manual
description: >
Router for the `plugin` capability — the per-agent Agent Plugins
(agent-plugins.org, v1.0.0) catalog and registration snapshot. Covers what a
plugin is, the difference between a registered and a merely discovered one,
how installing and uninstalling actually work, and how to read a skipped
component.
Reach for this manual when:
- The human asks what plugins this agent has, or asks to install, add, or
remove an Agent Plugin.
- You want the current snapshot
(`plugin(action="info", input={}, reasoning="list plugins")`).
- You are authoring a `plugin.json` or `mcp.json` and need the required
fields, the `name` grammar, or the `./`-prefixed path containment rule.
- A plugin you expect is missing from `<registered_plugin>`, a skill you
- expect is missing from your skills catalog, or a server you expect is
+ expect is missing from the protected Plugin field, or a server you expect is
missing from `mcp_registry.jsonl`.
- A plugin is listed as `<mount>discovered</mount>` and you need to know why
nothing of it is usable.
Does NOT cover: the general MCP registration contract for hand-written servers
(that is the `mcp-manual` skill), authoring Agent Skills (that is the `skills`
capability's manual), or the Agent Plugins specification prose itself (fetch
https://agent-plugins.org/specification.md with web when you need normative
wording this router does not carry).
version: 2.1.0
- last_changed_at: 2026-08-08T00:00:00Z
+ last_changed_at: 2026-08-23T00:00:00Z
related_files:
- src/lingtai/tools/plugin/__init__.py
- src/lingtai/tools/plugin/ANATOMY.md
- src/lingtai/tools/plugin/CONTRACT.md
- src/lingtai/services/plugin_registry.py
- docs/examples/agent-plugins/hello-lingtai/plugin.json
maintenance: |
Tracks the capability and service it summarizes; update when the plugin tool
surface, the manifest validation rules, the registration/uninstall flow, or
the Agent Plugins version this kernel understands change. Keep the two-tier
mount contract (declared → registered, inherited → discovered) stated in this
body, the tool description, and the prompt preamble in lockstep — it is the
same contract in three places, and the boundary it draws is a security
boundary, not a presentation choice.
---
# Plugin Capability — How To Use It
The `plugin` capability is your view of **Agent Plugins** (the open standard at
https://agent-plugins.org, version 1.0.0). A plugin is a directory bundling
Agent Skills and MCP server configuration; this capability is how one gets
- mounted onto this agent, and how you see what did and did not mount.
+ registered for this agent, represented in the protected Plugin field, and how
+ you see what did and did not register.
- ## The one thing to internalize: declaration is what mounts
+ ## The one thing to internalize: declaration is what registers
Every plugin in `<registered_plugin>` carries a `<mount>` stamp, and it is the
- difference between "you can use this" and "this merely exists".
+ difference between "this was registered for the agent" and "this merely exists".
| `<mount>` | How it got there | Its skills | Its MCP servers |
|---|---|---|---|
- | `registered` | Declared in `init.json` `manifest.plugins` | **In your skills catalog**, located inside the plugin | **In `mcp_registry.jsonl`** with `source="plugin:<name>"` — registered, *not running* |
- | `discovered` | Only found on an inherited `manifest.capabilities.skills.paths` directory | Not in your catalog | Not in the registry |
+ | `registered` | Declared in `init.json` `manifest.plugins` | **Listed in the protected Plugin field** (`registered[].skills` / `<skill_names>`), with source inside the plugin; **not in the vanilla `skills` catalog** | **In `mcp_registry.jsonl`** with `source="plugin:<name>"` — registered, *not running* |
+ | `discovered` | Only found on an inherited `manifest.capabilities.skills.paths` directory | Metadata/count in the protected Plugin field only; not in the vanilla `skills` catalog | Not in the registry |
The asymmetry is deliberate and it is a security boundary. Dropping a directory
somewhere the skills capability happens to scan must never silently register a
third party's MCP server. Only an explicit declaration does that.
Two things registration still does **not** do, and both matter:
| What you might assume | What is actually true |
|---|---|
| A registered server is running | **No.** Registration is registry-level, exactly like `addons:[]`. To actually run one, add a matching entry under the top-level `mcp` key in `init.json` and `system(action="refresh")`. |
- | A registered skill was copied into `.library/` | **No.** Nothing is copied. Each validated skill directory inside the plugin is composed into the catalog scan, so the entry's `location` still points inside the plugin. That is why uninstall needs no file deletion. |
- | A registered plugin is trustworthy | **Not implied.** Registration reports what parses and mounts, not what is safe. A plugin is third-party code, and declaring one is the human's decision to make, not yours to make for them. |
+ | A registered skill was copied into `.library/` or injected into the vanilla catalog | **No.** Nothing is copied or injected. Its name/count is listed in the protected Plugin field and its source remains inside the plugin, so read it with `file` when needed. That is why uninstall needs no file deletion. |
+ | A registered plugin is trustworthy | **Not implied.** Registration reports what parses and is represented, not what is safe. A plugin is third-party code, and declaring one is the human's decision to make, not yours to make for them. |
## Installing a plugin
There is no install action. Installing is a declaration edit plus a refresh:
1. Read the plugin first. `plugin.json`, every `SKILL.md`, and especially
`mcp.json` — an `mcp.json` server is a command this agent will be configured
to run. Third-party code: read before you trust.
2. Add its directory to `manifest.plugins` in `init.json`:
```json
{
"manifest": {
"plugins": ["./plugins/hello-lingtai"]
}
}
```
Entries may be absolute, tilde-prefixed, or relative to the agent working
dir. An entry may be a single plugin directory (one carrying `plugin.json`)
or a *collection* directory whose immediate children are plugin roots.
3. `system(action="refresh")`. Registration runs before capability setup, so the
- catalog and the registry are in their mounted state by the first turn.
+ protected Plugin field and the registry reflect the registered state by the first turn.
4. `plugin(action="info", input={}, reasoning="confirm the mount")` and read
`registered` — including `skipped`, which is where anything that did not
mount says why.
### Uninstalling
The exact inverse, and equally mechanical:
1. Remove the entry from `manifest.plugins`.
2. `system(action="refresh")`.
Every `mcp_registry.jsonl` record stamped `source="plugin:<name>"` whose plugin
is no longer declared is pruned on that refresh, and the plugin's skills leave
the catalog because nothing scans its skill directories any more. Records the plugin
system does not own — hand-written ones, addon-decompressed ones — are never
touched. **Never delete the plugin directory as an uninstall step**; the
declaration is the installation, and the directory belongs to whoever put it
there.
The same pruning keeps a *still-declared* plugin honest: a server deleted from
its `mcp.json` loses its record, and a server whose spec changed has the stale
record replaced rather than duplicated.
### Canonical config key
`manifest.plugins` is the **canonical** declaration key.
`manifest.capabilities.plugin.paths` is a retained **alias** that means exactly
the same thing, for configs written before the canonical key existed. Prefer
`manifest.plugins` in anything you write. Declaring a directory under both is
harmless — it is de-duplicated and scanned once.
`manifest.capabilities.skills.paths` is neither: it is inherited for *discovery*
only, so a plugin dropped where skills live is at least visible.
## What a plugin is
A plugin is a directory:
```
my-plugin/
├── plugin.json # required
├── skills/ # optional — one Agent Skill per subdir with SKILL.md
│ └── some-skill/
│ └── SKILL.md
├── mcp.json # optional — MCP server configuration
├── com.example.client/ # optional — reverse-domain client extension namespace
└── LICENSE
```
A minimal working example ships with the kernel at
`docs/examples/agent-plugins/hello-lingtai/` — one skill, one stdio MCP server,
no dependencies. Read it when authoring your own.
### `plugin.json` — the required manifest
Two required fields, and this kernel rejects the plugin outright if either is
missing or malformed:
```json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin"
}
```
- **`$schema`** must be exactly the v1.0.0 URL above. It is compared as an
opaque version identifier — the kernel never fetches it. Any other value is
an unsupported version, not a warning.
- **`name`** is 1–64 characters, lowercase alphanumerics plus `.` and `-`, first
and last characters alphanumeric, no `--` and no `..`. Valid: `my-plugin`,
`acme.tools`, `lint3r`. Invalid: `My-Plugin`, `-leading`, `a--b`.
Optional and type-checked when present: `version`, `description`, `homepage`,
`repository`, `license` (strings), `keywords` (list of strings), `author` and
`extensions` (objects). `description` is what becomes `<summary>` in your
prompt, truncated at 200 characters.
- ### `skills/` — composed into your catalog
+ ### `skills/` — listed in the protected Plugin field, not the vanilla catalog
Each subdirectory of `skills/` containing a `SKILL.md` is one skill, per the
Agent Skills specification (https://agentskills.io/specification). For a
- **registered** plugin these become ordinary entries in your skills catalog, with
- their `location` pointing inside the plugin. For a **discovered** one they are
- counted and nothing more; to use one, read it from `<source>` with `file`.
+ **registered** plugin these names/counts are rendered in the protected Plugin
+ field, with their source inside the plugin; they do not become entries in the
+ vanilla `skills` catalog. For a **discovered** one they are counted in the
+ Plugin field and nothing more; to use one, read it from `<source>` with `file`.
Two details worth knowing when you read a plugin's `skill_count`:
- **Grouping directories are walked through.** A directory under `skills/` with
no `SKILL.md` of its own but only subdirectories is a grouping directory, so
`skills/group/nested/SKILL.md` is the skill `group/nested`. Reported and
- mounted are the same set: the count is what the catalog actually holds, not
- just the top level. A directory with loose files and no `SKILL.md` is
+ validated are the same set: the count is what the protected Plugin field
+ reports, not just the top level. A directory with loose files and no
+ `SKILL.md` is
corrupted, and is named in `skipped`.
- - **What is mounted is the validated skill list, not the `skills/` directory.**
- Each skill directory is contained-checked individually and composed into the
- catalog scan on its own. That is why a skipped skill is genuinely absent from
- your catalog rather than merely absent from the report.
+ - **What is listed is the validated skill list, not the `skills/` directory.**
+ Each skill directory is contained-checked individually and its name/count is
+ rendered in the protected Plugin field. The vanilla `skills` catalog never
+ receives Plugin paths, so a skipped skill is absent from the Plugin field
+ rather than merely absent from the report.
### `mcp.json` — translated into registry records
```json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"my-server": {
"type": "stdio",
"command": "python3",
"args": ["${PLUGIN_ROOT}/server.py"]
}
}
}
```
Transports: `stdio` (with `command`, optional `args`/`env`/`cwd`),
`streamable-http` and `sse` (with `url`, optional `headers`). `env` and `headers`
must map strings to strings or the server is skipped. The registry models two
transports, so `stdio` stays `stdio` and both URL-addressed transports become
`http`.
For a registered plugin each server becomes one `mcp_registry.jsonl` record,
carrying every field you declared — `env` and `cwd` for `stdio`, `headers` for
the URL-addressed transports — with `cwd` resolved to absolute:
```json
{"name": "my-server", "summary": "...", "transport": "stdio",
"source": "plugin:my-plugin", "command": "python3",
"args": ["/abs/path/to/plugin/server.py"],
"cwd": "/abs/path/to/plugin", "env": {"TOKEN": "..."}}
```
Three things to know when authoring one:
- **The server name is the registry name.** It must satisfy the registry's own
grammar (`^[a-z][a-z0-9_-]{0,30}$`) or the server is skipped with that reason
in `skipped` — it is never silently renamed, because the name is what you will
type.
- **Name collisions skip, never overwrite.** If a hand-written or
addon-decompressed record already owns the name, the plugin's server is
skipped and the existing record survives untouched. Same between two plugins:
first declared wins.
- **MCP tool names must stay unique within a daemon run.** A plugin's `mcp.json`
server is also injected into daemon tasks that select the plugin; if its
exposed tool name collides with a task-level `mcp` registration (or another
plugin/server), the daemon fails at dispatch with `duplicate MCP tool name` —
rename or dedupe one registration. This is expected: both injection paths are
live simultaneously.
- **`${PLUGIN_ROOT}` and `./` paths are resolved to absolute** in the record. The
record is a registration, not a launch spec: activating the server needs an
`init.json` top-level `mcp` entry, and the kernel spawns from *that* entry's
own config, matching the registry only by `name`. The resolved paths here are
informational — accurate, and not what gets executed.
## Path containment (§4.1) — why a component can be skipped
Every plugin-relative path a plugin declares **must start with `./`** (or the
equivalent `${PLUGIN_ROOT}/` form) and must still resolve inside the plugin
root. This covers `command`, `cwd`, and **every entry of `args`** — a path
smuggled through an argument is checked exactly like one in `command`. The
kernel enforces both halves, and containment is checked *after* symlinks are
followed, so `./link-to-elsewhere` is rejected exactly like `./../escape`.
Spelling is not a way around it. Any relative value carrying a `..` segment is
put through the identical gate whether or not it starts with `./`, so
`../../bin/sh` is rejected exactly like `./../../bin/sh`. A relative `..` that
stays inside (`bin/../bin/serve`) resolves and is rewritten absolute.
What genuinely is not a plugin-relative path passes through untouched, because
§4.1 has nothing to say about it, and the kernel cannot tell an executable name
from a relative file name:
- an absolute path (`/usr/bin/env`),
- an `${ENV_VAR}` the client is not asked to expand,
- a bare token with no `..` segment — `node`, `-c`, a literal argument.
So the gate constrains **where a declared path may point**, not what a server may
be. That is a boundary about the plugin directory, not a sandbox: registration is
registry-level only, nothing is spawned, and activation still requires an
operator-written `init.json` `mcp` entry.
Two failure boundaries, straight from the spec:
- **Whole-plugin:** an unreadable or invalid `plugin.json` rejects the plugin.
It does not appear at all, registers nothing, and the reason is in `problems`.
- **Per-component:** a single skill directory or MCP server whose path escapes
- is skipped, and the rest of the plugin still mounts. The skipped component is
+ is skipped, and the rest of the plugin remains listed. The skipped component is
named in `problems` and in that plugin's `skipped` list. Skipped means *not
- mounted*, on both halves: an escaping server never reaches
- `mcp_registry.jsonl`, and an escaping skill directory is never composed into
- the catalog — only the validated skill directories are, never their parent.
+ represented*, on both halves: an escaping server never reaches
+ `mcp_registry.jsonl`, and an escaping skill directory is absent from the
+ protected Plugin field — only validated skill directories are listed, never
+ the rejected parent.
- If a plugin you expect is missing, or a component of it did not mount, call
+ If a plugin you expect is missing, or a component of it was not represented, call
`plugin(action="info", ...)` and read `problems` and `registered[].skipped` —
every rejection carries the label, the offending path, and the reason.
## Tool surface
Two actions, called through the standard envelope
`plugin(action=..., input={}, reasoning="...")`. `action`, `input`, and
`reasoning` are all required; neither action takes any arguments, so `input` is
always the empty object `{}` — passing any field inside it is rejected before
the tool does anything. The optional root `summarize` boolean is presentation
only.
- `plugin(action="info", input={}, reasoning="...")` returns
`{status, declared, registered_count, registered, discovered_count,
discovered, mcp_appended, mcp_pruned, paths, problems}`.
- `registered` is the boot registration snapshot: per plugin, `skills`,
`skills_mounted`, `mcp_servers`, `mcp_registered`, and `skipped`
- (`{component, reason}` for everything that did not mount).
+ (`{component, reason}` for everything that was not represented).
+ `skills_mounted` is true only when validated plugin skills are present and
+ the skills capability is enabled; it does not mean that Plugin skills enter
+ the vanilla `skills` catalog. The protected Plugin field is their namespace.
- `discovered` is the discovery-only tier.
- `paths` is a per-configured-path report (`resolved`, `exists`, `plugins`).
- `plugin(action="manual", input={}, reasoning="...")` returns this manual body
on demand, without re-scanning.
**Both actions are read-only.** `info` re-scans and reports; it does not
- register. Mounting happens at boot, so a plugin you just declared needs
+ register. Registration happens at boot, so a plugin you just declared needs
`system(action="refresh")` before it appears as `registered`.
Every worked call in this manual is written in this full form; there is no
shorthand to expand.
+
+ ## Settings and result size
+
+ This capability owns **no** `<agent-dir>/settings/plugin.json` or
+ `settings/plugin.<action>.json` file. Plugin declarations are init.json
+ configuration, not tool settings: use `manifest.plugins` and refresh as described
+ above.
+
+ `info` is usually small, so leave root `summarize` absent or false when you need
+ exact plugin names, source paths, skipped-component reasons, or registry facts.
+ For a deliberately large catalog, `summarize=true` can make the visible result
+ shorter; raw output remains recorded by the host. Leave `summarize=false` for
+ `manual` so its installation and containment procedure is never summarized away.
## See also
- **The example plugin:** `docs/examples/agent-plugins/hello-lingtai/` — the
smallest thing that registers: one skill, one stdio MCP server, stdlib only.
- **The standard:** https://agent-plugins.org/specification.md — normative
wording, `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholder expansion, client
extension namespaces. Fetch it with `web` when you need spec text this router
does not carry.
- **`mcp-manual` skill** — the activation contract for actually running a
registered MCP server, including one a plugin registered.
- **`src/lingtai/tools/plugin/CONTRACT.md`** — the exact tool surface, result
shapes, and error envelopes.
## Cleanup / Footprint
The capability itself owns no state and writes no file. Registration writes in
exactly one place: `mcp_registry.jsonl`, and only records stamped
`source="plugin:<name>"`. Those are cleaned up by undeclaring the plugin and
refreshing — never by hand-editing the registry, and never by deleting the
plugin directory, which belongs to whoever put it there.