A plugin is how Iva grows: a folder you bring from anywhere, install with one command and remove with another. It carries skills, code and MCP servers, so a new capability costs no edit to the shipped tree and no fork (ADR-0008). The format is Agent Plugins 1.0.0 — the same folder Codex, Cursor and Kiro read, so nothing here is Iva's own invention.
Only the owner installs a plugin, and only from the terminal. There is no Telegram command and no model tool by design: plugin code runs inside the agent's process with this installation's keys in its environment, so an injected message must never be able to install one (ADR-0009). What that means for you is at the end, under What you risk.
iva plugin add trace # a name from a Marketplace
iva plugin add smixs/iva-plugins/trace # owner/repo[/subdir][@ref]
iva plugin add smixs/iva-plugins/trace@v1.2 # pin a tag, a branch or a full sha
iva plugin add https://github.com/you/my-plugin.git
iva plugin add ./my-plugin # a folder on this machineadd prints what the folder carries before anything moves into place:
trace 0.1.0 — skills: trace; code: sh.iva; mcp: files
Before your first install it prints the accepted risk once. If the plugin brings stdio MCP
servers or its own services, it prints their commands and asks
Start these processes on this machine? [y/N] — --trust answers yes without asking, and a
non-interactive shell without --trust answers no.
What happens next depends on what the plugin carries:
- 📋 Skills only — they work from the next turn. No build, no restart.
- ⚙️ An eve Extension in
sh.iva/, or MCP servers you trust — their code and their connections live inside a version, so Iva builds a new one with the plugin in it and restarts the agent, on the same rails asiva update. A build that fails takes the install back and leaves the running version alone: installing a plugin cannot break the box (ADR-0003). - 🔌 Services only — a
sh.iva/withservices/and nopackage.jsonnever enters a version. Nothing is rebuilt and nothing restarts:trustwrites the units and starts them, and that is the whole installation.
| Command | What it does |
|---|---|
iva plugin add <name|source> [--trust] |
Install by name from a Marketplace, or from a folder, owner/repo[/subdir][@ref], https://… or git@… |
iva plugin list |
What is installed: version, sha, enabled/disabled, trusted/untrusted, components, source |
iva plugin list --available |
What your marketplaces offer, and which of it you already have |
iva plugin update [name] [--force] |
Pull the tracked ref, printing old → new; without a name, every plugin |
iva plugin enable <name> |
Turn a plugin back on — skills return on the next turn |
iva plugin disable <name> |
Turn it off without removing it |
iva plugin trust <name> |
Allow its MCP servers and services to run as processes on this machine |
iva plugin untrust <name> |
Stop those processes and remove their units; skills keep working |
iva plugin remove <name> |
Remove the plugin; its data in data/plugin-data/<name>/ is kept |
iva plugin sync |
Repair: rebuild plugins.json from the folders and reinstall whatever is missing |
iva plugin marketplace add|remove|list |
Your lists of plugins that install by name |
iva plugin with no arguments prints the same list. Every command that changes state takes
the same lock as iva update, so one run at a time: start a second while an update is in
flight and it says so instead of colliding with it.
Everything sits in the Custom layer or beside it, so iva update never touches it:
| Path | What it is |
|---|---|
data/custom/plugins/<name>/ |
the plugin itself — a git checkout pinned to one sha, or a copy of your folder |
data/custom/plugins/<name>.config.json |
your settings for the plugin's code, read at start and never baked into a build |
data/custom/plugins/<name>.env |
environment for the plugin's MCP servers, one KEY=value per line |
data/plugin-data/<name>/ |
the plugin's own data (PLUGIN_DATA) — survives an update of the plugin and even its removal |
data/custom/plugins.json |
one state file: name, source, ref, sha, digest, enabled, trusted, installedAt, plus your marketplaces and the ports handed out |
data/marketplace-cache/ |
the marketplace lists as last read |
plugins.json is the source of truth. Delete the folders, move the installation, restore an
old backup — iva plugin sync puts every plugin back at its recorded sha. A folder that is
there without an entry is taken back into the file; an entry whose folder is gone is
refetched. A sync that restores a plugin with code says so: the code reaches the running
version with the next iva update.
The config and env files live beside the plugin folder, not inside it, because an update overwrites the folder and your values have to survive that.
Two independent flags, and list prints both:
- enabled — the plugin's skills and code are loaded.
iva plugin disableswitches them off and keeps everything on disk. - trusted — the plugin's MCP servers and long-running services may run as processes on your machine. A fresh install is untrusted: text in a prompt and a process on the host are not one switch.
An untrusted plugin still works, just quieter: skills load, stdio MCP servers and services
stay down. iva plugin trust <name> starts them; untrust, disable and remove stop them
and remove their units. Trusting a plugin with MCP servers also rebuilds the version, because
its connections are generated into one; a plugin with only services just gets its units.
Skill names are global, so add refuses two plugins that offer the same skill name — one of
them would go dark without saying so. Your own skill in data/custom/agent/skills/ wins over
a plugin's skill of the same name, and a plugin's skill wins over a bundled one.
Every enabled plugin that carries code is rebuilt with the eve the new version runs. That is
why a plugin never ships dist/ and never falls behind an eve bump: the folder, its config,
its data and its trust flag stay exactly as they were, and only the build is redone.
A plugin whose code does not build against the new eve does not stop the update. The version is built without it, the plugin is switched off, and one Alert names it:
iva plugin update <name>
iva plugin enable <name>iva plugin update follows the ref recorded at install — a branch, a tag or a sha — and
prints old → new. It refuses to touch a folder you edited in place (the digest in
plugins.json says it changed) until you pass --force; that way local edits are never
overwritten in silence. An entry a Marketplace pinned to a commit has nothing to follow: to
move it, remove and add it again.
When an update changes a plugin's content, its units — services and MCP proxies — are
restarted onto the new code right there, so a plugin that only carries services never needs
iva update to run its new version.
A Marketplace is a list of plugins in a git repository — name to source, nothing more. No
central place, no moderation, nobody's approval to publish. Iva reads
.agents/plugins/marketplace.json, the convention of Codex, so the same list works for its
users too.
iva plugin list --available # what is on offer
iva plugin marketplace add you/your-plugins
iva plugin marketplace list
iva plugin marketplace remove your-pluginsThe default list is smixs/iva-plugins, and it stays in place when you add your own. Lists
are cached under data/marketplace-cache/; offline, the cache is used and marked as possibly
stale. A name offered by two lists is refused with an add <name>@<list> hint, and an entry
whose manifest calls the plugin something else is refused before anything is installed — the
list is a file nobody vetted.
The smallest plugin is a manifest and one skill:
my-plugin/
plugin.json
skills/
weather/
SKILL.md
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "0.1.0",
"description": "What it does, in one line."
}$schema and name are the only required fields. A name is 1–64 lowercase letters, digits,
hyphens and periods, starts and ends alphanumeric, and contains no -- or ... Iva
validates the manifest locally and never fetches the schema over the network.
Two limits worth knowing before you package anything: symlinks are refused outright, and a
plugin is capped at 2000 files, 50 MB and 16 levels of nesting. A broken component does not
take the others down — a plugin with an unusable mcp.json still gives you its skills, and
iva plugin list prints the diagnostic.
While you write, install the folder itself: iva plugin add ./my-plugin. It is copied into
the store rather than linked, and iva plugin update my-plugin copies it again from the same
path.
skills/<name>/SKILL.md, exactly one level deep, in the
Agent Skills format with name and description in the
frontmatter. Write the description as a trigger condition ("Use when…"): before the skill is
loaded, that line is all the model sees. Everything else about skills is in
extending.md — a plugin's skill is an ordinary skill that happens to arrive in
a folder.
sh.iva is Iva's namespace, and it holds two different things: an eve Extension, which is
sh.iva/package.json, and services, which are sh.iva/services/<svc>/service.json. Either
alone is fine. The manifest has to declare the namespace in both cases — a sh.iva/ no
manifest claims is ignored, with a diagnostic in iva plugin list.
For code, declare the key and put a real eve Extension package in the folder:
{ "extensions": { "sh.iva": {} } }my-plugin/
sh.iva/
package.json # eve.extension.{source,dist} — write it with: eve extension init
tsconfig.json # required: eve emits declarations with tsc and refuses without one
extension/extension.ts
Ship no dist/. Iva builds it inside the version: a copy of the plugin, npm ci without dev
and peer dependencies, eve extension build with the very eve that will run it, a generated
mount at agent/extensions/<ns>.ts, then the normal build, the probe, the flip and a restart.
<ns> is your plugin's name with . and - folded to _, and it is also the prefix on
every tool your extension contributes — my_plugin__forecast.
Owner settings come from data/custom/plugins/<name>.config.json. Declare their shape and
read the bound values through the handle; the generated mount passes the file's contents to it
at start, so a changed setting costs a restart, not a rebuild:
// sh.iva/extension/extension.ts
import { defineExtension } from "eve/extension";
import { z } from "zod";
export default defineExtension({ config: z.object({ city: z.string() }) });An extension cannot declare agent.ts, sandbox, schedules or nested extensions — those
belong to the agent that mounts it. A plugin has no schedules of its own for the same reason:
eve refuses them.
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"files": {
"type": "stdio",
"command": "node",
"args": ["server.mjs", "--root", "${PLUGIN_DATA}"],
"env": { "FILES_LOG": "${PLUGIN_DATA}/log" }
},
"search": {
"type": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${SEARCH_TOKEN}" }
}
}
}Both transports work, and both need trusted:
streamable-httpandssebecome a generated connection namedmcp-<name>--<server>— the fileagent/connections/mcp-<name>--<server>.tsinside the version. eve accepts lowercase letters, digits and dashes in a connection name, so both halves are folded into that alphabet: a server calledTrace.Viewerbecomestrace-viewer. The model finds its tools throughconnection_searchlike any other connection and calls themconnection__mcp-trace--viewer__<tool>.${VAR}in a header is filled at run time fromdata/custom/plugins/<name>.env, so no token is baked into a build. The URL must be absolutehttps://(plainhttp://only for loopback), with no user information and no fragment.stdioruns as a systemd unit,iva-mcp-<name>-<server>.service, with the MCP proxy in front of it: Iva's own service holds your server on its stdio and exposes it to the agent over streamable HTTP on127.0.0.1:<port>/mcpbehind a bearer. The token lives indata/plugin-data/<name>/mcp-<server>.tokenat mode0600;GET /healthanswers without it.Restart=on-failure, so a crashed server comes back.
command is a single token — a bare executable name or a ./-relative path inside the plugin
— never a shell string. ${PLUGIN_ROOT} and ${PLUGIN_DATA} are expanded once in args,
env and cwd; the working directory defaults to PLUGIN_ROOT.
What a stdio server sees in its environment is the point of the proxy: PATH, HOME,
PLUGIN_ROOT, PLUGIN_DATA, whatever mcp.json declares, and whatever you wrote in
<name>.env. Nothing else from the agent's environment reaches it — no Telegram token, no
provider key.
Ports are handed out at trust, from 8730 up, recorded in plugins.json and stable until the
plugin is removed.
A plugin that needs its own process — a viewer, a worker, anything that must outlive a restart
of the agent — declares it under sh.iva/, next to the same extensions key and with no
package.json needed:
my-plugin/
sh.iva/
services/
viewer/
service.json
server.mjs
{ "command": "node", "args": ["server.mjs"], "port": 8726 }Iva writes the unit iva-plugin-<name>-<svc>.service and starts it only while the plugin is
enabled and trusted, with Restart=on-failure. The working directory is the service's own
folder, and the environment is exactly four variables:
| Variable | What it holds |
|---|---|
IVA_SERVICE_PORT |
the port to listen on — bind 127.0.0.1 and nothing else |
IVA_DATA_DIR |
absolute path of Iva's data directory, for reading things like trace/ |
PLUGIN_ROOT |
the plugin folder in the store |
PLUGIN_DATA |
data/plugin-data/<name>/, yours to write in |
The plugins Iva ships in smixs/iva-plugins are TypeScript run by Node directly —
"command": "node", "args": ["server.ts"], with no build step — while a service of your own may
be any command at all: the contract is only service.json and those four variables.
port in service.json is a preference: Iva uses it when free and hands out the next free
port otherwise, which is why the number arrives through IVA_SERVICE_PORT rather than a
constant in your code. A plugin with services but no extension needs no version build — only
its units, and iva plugin update restarts them onto the code it just brought.
Units are Linux-only. On a machine without systemd the commands print no systemd and carry
on: state is still recorded, so the same installation on a server brings the processes up.
iva plugin list # per plugin: flags, components, source, diagnostics
iva doctor # the Plugins section
iva plugin sync # what doctor tells you to run when the two disagreeiva doctor reads the manifests, compares plugins.json with data/custom/plugins/, says
whether each code plugin is really built into the running version, and checks the expected
units — is-active for every one of them, plus GET /health for each MCP proxy.
Read this once, then decide per plugin.
A plugin's code runs inside the agent's process, and bash inside its skill runs with the
agent's environment. Both therefore see every key of this installation: the Telegram token,
the model provider, everything else in .env. Installing someone else's plugin means handing
it those keys. Checking the manifest does not help — plugin.json describes what is inside,
not what it does.
This is an accepted risk, not an oversight (ADR-0008): isolating secrets from the shell is a separate, deferred piece of work, and plugins do not bring it closer. What the rails do instead is keep the decision yours and make it visible:
- only the owner installs, only from the terminal, never the model and never from Telegram;
trustedis a second, explicit yes before any plugin process runs on your machine;- an MCP server gets less than the agent does: its own environment,
PLUGIN_ROOTandPLUGIN_DATA, and nothing more; - a plugin that fails to build never reaches the running version.
Your own plugin is a risk you can see. Someone else's is not — read the code before you install it, or pin it to a sha you have read.
The rest of the security picture is in security.md.