Plugins
A plugin is a self-contained, installable bundle of authored content: agents, skills, pipelines, prompts, custom commands, and optionally its own MCP servers. Plugins make it easy to share and reuse a set of agents as a unit.
Everything you author or install lives under one of three namespaces:
| Namespace | Location | Role |
|---|---|---|
| local | ~/.muaz/plugins/local/ | Your own workspace — where agents create and edits land |
| plugins | ~/.muaz/plugins/<id>/ | Installed plugins you manage (enable/disable/uninstall) |
| built-ins | embedded in the binary | Shipped, read-only defaults |
Name resolution
Section titled “Name resolution”When you reference an agent (or pipeline) by a bare name, muaz resolves it across namespaces in tiers, most-specific first:
- local workspace — wins outright if it defines the name.
- installed plugins (in priority order).
- built-ins.
If two or more plugins define the same name and your local workspace does not,
the name is ambiguous: muaz lists every match and asks you to use a
fully-qualified @plugin/name. A fully-qualified name (e.g. @web-research/researcher,
or @local/coding) always targets exactly one namespace.
muaz chat --agent coding # bare name → local, else plugins, else built-inmuaz chat --agent @web-research/researcher # qualified → that plugin onlyBuilt-ins and the local workspace
Section titled “Built-ins and the local workspace”- The built-in agents (
starter,codebase, and thecode-reviewstep agents) live in the embeddedpresetplugin. They are compiled into the binary, so an upgrade always ships the current version by virtue of being a new binary — nothing is written to keep it current. Built-ins are read-only — to customise one, shadow it with a same-named agent in your local workspace (the CLI’sagents editand the UI’s “Fork” both do this for you). - The
codingagent is not built in — it comes from theauto-sdeplugin in the registry (muaz plugins install auto-sde). muaz installs nothing on your behalf, so every plugin you have is one you chose and approved at a trust prompt. - Your local workspace (
~/.muaz/plugins/local/) is just another namespace, but it always wins resolution and can’t be uninstalled.
Plugin layout
Section titled “Plugin layout”~/.muaz/plugins/web-research/├── manifest.yaml # required — plugin metadata├── agents/│ └── researcher.yaml├── skills/│ └── web-digger/│ └── SKILL.md├── pipelines/│ └── research-and-write.yaml├── prompts/│ └── researcher.md├── commands/│ └── crawl.yaml└── mcp.json # optional — MCP servers this plugin declaresAgents reference their co-located files with relative paths
(system_prompt: ./../prompts/researcher.md or ./researcher.md), so a plugin is
fully portable.
manifest.yaml
Section titled “manifest.yaml”Manifests are version 2 — manifest_version: 2 is required (there is no v1
fallback). Beyond metadata, a manifest declares everything privileged the plugin
wants, all surfaced verbatim at the trust prompt.
manifest_version: 2 # requiredname: Web Research # human-facing name; the directory is the canonical idversion: 0.2.0description: Research agents + browsing skillsauthor: youmuaz: ">=0.2" # semver constraint, enforced at install and by doctor
# Typed settings → an auto-generated form on the Plugins page. Non-secret values# are stored in plugins/<id>/config.yaml; `secret` values go to the OS keychain.# Reference any of them as ${plugin.<key>} inside the plugin's agents/mcp.json/tools.config: index_url: type: string # string | int | bool | enum | path | secret description: "Base URL to crawl" required: true api_token: type: secret # never written to disk; keychain only
# What the plugin is allowed to do (replaces the old implicit folder-scan).permissions: mcp: [crawler] # MCP servers (from the plugin's mcp.json) it attaches network: true # may its tools/servers reach the network tools: [fetch_webpage] # built-in tools its agents rely on
# Lifecycle hooks (argv arrays) — run only for trusted plugins, shown verbatim# at trust time, and audit-logged.hooks: post_install: ["./setup.sh"]
# Command-backed custom tools, registered as plugin_<id>__<name>. The command# receives the tool input as JSON on stdin; stdout is the result.tools: - name: crawl description: "Crawl a URL and return extracted text" command: ["./crawl.py"] parameters: { type: object, properties: { url: { type: string } } } timeout_secs: 60
# RAG source declarations (surfaced on the plugin card; backend deferred).knowledge: - name: handbook source: ./docs/handbook.mdManaging plugins
Section titled “Managing plugins”muaz plugins list # installed + built-ins, with enabled/trusted statemuaz plugins show web-research # manifest + contentsmuaz plugins install ./web-research.zip # install from a local .zipmuaz plugins install web-research # install by name from the registrymuaz plugins install web-research@0.2.0 # pin a versionmuaz plugins enable web-research # re-enable a disabled pluginmuaz plugins disable web-research # keep installed, but hide from resolutionmuaz plugins uninstall web-research # delete itmuaz plugins trust web-research # review its requests, then grant trustmuaz plugins untrust web-research # revoke trust (the plugin keeps working)muaz plugins search browsing # search the remote registrymuaz plugins update [id] [--check] # apply (or just list) registry updatesThe same operations are available in the browser UI’s Plugins page.
Shipping files a plugin runs
Section titled “Shipping files a plugin runs”A plugin’s tools:, its lifecycle hooks:, and any stdio server in its
mcp.json name programs as argv arrays. Two rules govern how those are
interpreted, and they are the same on a laptop and in a hosted deployment.
Paths are resolved against the plugin, not against your shell. Any argument
beginning with ./ is rewritten to an absolute path inside the plugin, as is the
program itself when written as a relative path with a separator:
tools: - name: crawl command: ["./crawl.py"] # → <plugin>/crawl.py - name: serve command: ["node", "./server/index.js"] # → node <plugin>/server/index.js - name: fmt command: ["ruff", "format"] # bare name — resolved on PATHThis matters because a plugin cannot know where it lives. When muaz’s state is
in a database rather than on the local disk, a plugin’s files are materialized
into a content-addressed directory whose path changes whenever its contents do.
An argument that climbs out with ../ is refused and the command is dropped —
outside the plugin there is nothing meaningful to point at.
Whether a file may be executed is decided by its content. Modes recorded in
the .zip are discarded outright (an archive that picks its own permissions can
pick setuid). A file whose first two bytes are #! is written executable; every
other file is written read-only data:
#!/usr/bin/env python3 # ← this line is what makes ./crawl.py runnableimport json, sysWithout the shebang, name the interpreter yourself: command: ["python3", "./crawl.py"] works with no execute bit at all.
Installing a plugin runs none of its code, but its skills can request tool
pre-approvals (allowed-tools) and it can declare MCP servers (which launch
processes). On install, muaz shows exactly what a plugin requests and asks whether
to trust it:
- Trusted — the plugin’s skill
allowed-toolsare honoured silently. - Untrusted (default) — those tools simply prompt on use, like any other tool.
A plugin installed without trust is fully usable; trust only governs silent
pre-approval. Change your mind later with muaz plugins trust <id> /
muaz plugins untrust <id>, or the Plugins page in the UI.
Trust does not survive an escalating update
Section titled “Trust does not survive an escalating update”Trust is granted against what you were shown, not against the plugin’s name.
When you trust a plugin, muaz records a digest of everything it requested
(declared permissions, hook argv, custom tools, skill grants) as trusted_digest
in plugins.yaml.
On muaz plugins update, the new version’s requests are re-derived and compared.
If it asks for anything additional, muaz:
- revokes trust — the plugin stays installed and usable, but its skill pre-approvals prompt again, and
- skips its
post_updatehook — hooks are arbitrary commands, and an update that quietly adds one is precisely the case that must not run unattended.
The CLI and the Plugins page list exactly what was added, so you can review and re-trust. Dropping a capability is not an escalation and never revokes trust.
Archive restrictions
Section titled “Archive restrictions”Plugin archives are extracted before you are asked to trust anything, so
extraction is treated as fully untrusted input. muaz rejects an archive that
contains symlinks, paths escaping the plugin directory, more than 2,000
entries, or more than 64 MiB of uncompressed content. Unix permissions in the
archive are ignored — files are written 0644 and directories 0755.
The registry overlay (~/.muaz/plugins.yaml)
Section titled “The registry overlay (~/.muaz/plugins.yaml)”plugins.yaml records per-plugin state — it never duplicates manifest content.
Plugins on disk without an entry default to enabled at priority 0.
web-research: enabled: true priority: 10 # higher wins a bare-name tie between plugins trusted: true trusted_digest: 9f2c… # what you approved; re-checked on update source: ./web-research.zip version: 0.2.0Remote registries
Section titled “Remote registries”muaz plugins install <name> / search fetch a JSON index describing
available plugins. muaz can be pointed at several registries, declared in
config.yaml and consulted in list order — the first one carrying a plugin
wins. Omit the block entirely to use the official muaz registry.
plugin_registries: - name: muaz index_url: "https://raw.githubusercontent.com/shigar-dev/plugins-muaz/registry/index.json" trusted_keys: [] # empty ⇒ muaz's embedded publisher keys require_signed: true - name: acme-internal index_url: "https://plugins.acme.internal/index.json" trusted_keys: ["RW<your minisign public key>"] require_signed: true allow_plaintext: false # true only for an http:// intranet host you controlEach registry carries its own trust anchors. A company signs its internal
plugins with its own minisign key, lists that key under trusted_keys, and gets
verified-tier installs from its own registry — without that key becoming trusted
for the public registry, or muaz’s keys being trusted for theirs. A registry that
declares no keys inherits muaz’s embedded publisher keys, which is right for the
official registry and wrong for anybody else’s.
| Field | Meaning |
|---|---|
name | Used by --registry <name>, and recorded in the plugin’s source |
index_url | The JSON index (https:// unless allow_plaintext) |
trusted_keys | minisign public keys trusted for this registry; empty ⇒ muaz’s |
require_signed | Refuse unsigned plugins from this registry (default true) |
allow_plaintext | Permit plain http:// — declared, never inferred from the URL |
The whole block is validated at config load: a duplicate name, a non-http(s)
URL, an http:// index without allow_plaintext, or a malformed key in
trusted_keys is an error at startup rather than a mystery at install time.
$MUAZ_PLUGIN_REGISTRY overrides the first registry’s index_url and
nothing else — it can redirect muaz to a mirror, but it cannot hand that mirror a
different key set or relax require_signed.
Where updates come from
Section titled “Where updates come from”A plugin’s source records the registry it came from (registry:acme:widget@1.2.0),
and muaz plugins update only ever re-fetches it from that registry. A
plugin installed from your internal registry is never silently replaced by a
same-named entry in the public one. If its home registry is no longer configured,
muaz skips it and says so instead of sourcing it elsewhere.
{ "serial": 42, "published_at": "2026-08-13T10:00:00Z", "plugins": [ { "name": "web-research", "version": "0.2.0", "description": "Research agents + browsing skills", "url": "https://example.com/web-research-0.2.0.zip", "sha256": "<hex>", "minisign_sig": "untrusted comment: …\nRW…\ntrusted comment: …\n…", "publisher_key_id": "<id of the key that signed it>", "muaz": ">=0.2" }, { "name": "web-research", "version": "0.1.9", "url": "https://example.com/web-research-0.1.9.zip", "sha256": "<hex>", "yanked": true } ]}sha256 is verified for integrity on download. minisign_sig adds
authenticity: it is checked against the hosting registry’s trusted keys, and
the result sets the install tier —
- valid signature → verified: installed with a verified banner.
- no signature → refused under
require_signed(the default). Passmuaz plugins install <name> --allow-unsignedto take it anyway as community tier. That flag is CLI-only: the browser UI never offers it, since a browser session is exactly where someone could be talked into one click too many. - present but invalid → hard error: the download is rejected as tampered.
This holds regardless of
require_signed— allowing unsigned plugins says “unsigned is acceptable here”, not “forgeries are”.
The muaz hint mirrors the manifest’s compatibility constraint (the manifest’s
muaz: is the hard gate, checked at install and re-checked by muaz doctor).
Without an explicit @version, muaz installs the highest semver in the index
— not whichever entry happens to be listed last.
Registry traffic is fetched over HTTPS with bounded timeouts, a redirect cap, and
a response size limit; a redirect that downgrades to plain HTTP is refused. An
http:// registry URL is honoured only when you configure it explicitly, for
intranet hosts.
The index is signed too
Section titled “The index is signed too”Signing only the zips would leave the document that says which zip to fetch
unauthenticated. Strip a minisign_sig, swap a url and sha256, and a
verified install quietly becomes a community-tier one.
So a registry publishes <index_url>.minisig beside its index — a detached
minisign signature over the exact index bytes:
minisign -S -s publisher.key -m index.json # writes index.json.minisigmuaz fetches both, verifies the signature against that registry’s trusted_keys,
and under require_signed (the default) refuses an index that has none. This
is not something --allow-unsigned can waive: that flag is about an individual
plugin, and an unsigned index can redirect every plugin at once.
Once a registry has served a signed index, muaz will not accept an unsigned one
from it again, even if require_signed is off — dropping a signature is how a
downgrade starts.
Rollback and freeze
Section titled “Rollback and freeze”A signature proves who published an index, never when. Anyone who can choose which bytes you receive can serve an old but genuinely signed index and hold you on a version whose vulnerability is already fixed (rollback), or keep re-serving today’s index so a security update never arrives (freeze).
serialis a counter you increment on every publish. muaz records the highest it has accepted per registry, in~/.muaz/registry-state.json(chmod 600), and refuses an index that goes backwards — or that drops the field, which would make it replayable again. It is recorded only after the index passes its signature check, so a forged index cannot poison the mark.published_at(RFC 3339) is advisory: an index older than 30 days draws a warning, since a freeze is not something a client can prove from one fetch.
$ muaz plugins registriesacme-internal https://plugins.acme.internal/index.json keys: 1 own key(s) policy: signed only serial: 42 (an older index will be refused) index: signed (unsigned will be refused)If a registry legitimately resets its serial or stops signing, clear its record deliberately:
muaz plugins forget-registry acme-internalTreat that as a security decision, not routine maintenance — the next index muaz fetches becomes the new baseline, whatever it contains. It is exactly the command a rollback attack needs you to run.
Signed plugins stay signed
Section titled “Signed plugins stay signed”Independently of any registry setting, a plugin that was installed from a
verified download can never be replaced by an unsigned one. The pin is
recorded per plugin in plugins.yaml and has no override — not require_signed: false, not --allow-unsigned. Uninstall the plugin first if you genuinely mean
to switch to an unsigned build.
This is what stops a single relaxed setting, or one hurried --allow-unsigned,
from letting an unsigned build take over a plugin already established as signed —
a substitution that otherwise looks exactly like an ordinary upgrade.
Withdrawn versions
Section titled “Withdrawn versions”A publisher retracts a bad version by marking it yanked: true in the index,
never by deleting the release. Pinned installs (name@version) and every
recorded sha256 depend on those bytes staying reachable — deleting them breaks
honest users and does nothing at all to anyone who already has the plugin.
The flag is what carries the message. muaz skips a yanked version when resolving the newest, refuses it if you pin it explicitly, leaves it out of search results, and — the part that matters — tells you when a version you have installed has been withdrawn:
$ muaz plugins update --checkwarning: plugin 'acme-tools' 1.2.0 was yanked by its publisher — update it, or uninstall it if no replacement is offeredThat notice is reported separately from registries that couldn’t be reached, so “we checked and found something” never reads like “we couldn’t check”.
Authoring a plugin
Section titled “Authoring a plugin”-
Create a directory with a
manifest.yamland anyagents/,skills/,pipelines/,prompts/,commands/,mcp.jsonyou want to ship. -
Agents are provider-agnostic, so a shipped agent automatically adopts the installing user’s default binding — or a binding they set for it in
models.yaml. -
Pack it and install it:
Terminal window muaz plugins pack ./your-plugin # → your-plugin-1.0.0.zipmuaz plugins install ./your-plugin-1.0.0.zip
A complete, copy-pasteable sample plugin is in the Examples page.
Packing and verifying
Section titled “Packing and verifying”muaz plugins pack <dir> validates the manifest the way an install does, then
writes the archive a registry publishes. The output is reproducible: entries
sorted, timestamps pinned to the zip epoch, modes fixed at 0644. The same
sources always produce the same bytes, so the sha256 it prints is the one CI
publishes and the one every client verifies.
$ muaz plugins pack ./acme-toolsPacked acme-tools v1.2.0 archive acme-tools-1.2.0.zip contents 14 file(s), 23.4 KiB sha256 3f2a…It skips .git/, node_modules/, .DS_Store, and a root config.yaml (that
file holds one install’s configured values, including anything you set locally).
A symlink is a hard error rather than a silent omission, since installs reject
symlink entries outright — see Archive restrictions.
--json prints the same metadata for a publish pipeline to consume, so an index
builder never has to re-parse the manifest with a second YAML implementation
that might disagree with muaz’s.
muaz plugins verify <zip> reads an archive back the way a client will — hash,
manifest, and detached signature (<zip>.minisig by default, or --sig):
$ muaz plugins verify acme-tools-1.2.0.zip --key publisher.pubacme-tools v1.2.0 (acme-tools) contents 14 entr(ies), 23.4 KiB sha256 3f2a… signature ✓ valid against the supplied key(s) (acme-tools-1.2.0.zip.minisig)--key takes a minisign key or a .pub file and repeats; with none given it
checks against the publisher keys built into muaz.
Signing is deliberately not in the binary. muaz verifies signatures and never holds a signing key — that lives in your publish pipeline, or offline.
Publishing to a registry
Section titled “Publishing to a registry”To run a registry of your own — for a team, or for plugins that must not leave
your network — see
Run an internal plugin registry.
The public registry,
shigar-dev/plugins-muaz, is the
reference implementation to copy from: publish workflow, index builder, and
CODEOWNERS.