Skip to content

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:

NamespaceLocationRole
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-insembedded in the binaryShipped, read-only defaults

When you reference an agent (or pipeline) by a bare name, muaz resolves it across namespaces in tiers, most-specific first:

  1. local workspace — wins outright if it defines the name.
  2. installed plugins (in priority order).
  3. 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.

Terminal window
muaz chat --agent coding # bare name → local, else plugins, else built-in
muaz chat --agent @web-research/researcher # qualified → that plugin only
  • The built-in agents (starter, codebase, and the code-review step agents) live in the embedded preset plugin. 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’s agents edit and the UI’s “Fork” both do this for you).
  • The coding agent is not built in — it comes from the auto-sde plugin 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.
~/.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 declares

Agents reference their co-located files with relative paths (system_prompt: ./../prompts/researcher.md or ./researcher.md), so a plugin is fully portable.

Manifests are version 2manifest_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 # required
name: Web Research # human-facing name; the directory is the canonical id
version: 0.2.0
description: Research agents + browsing skills
author: you
muaz: ">=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.md
Terminal window
muaz plugins list # installed + built-ins, with enabled/trusted state
muaz plugins show web-research # manifest + contents
muaz plugins install ./web-research.zip # install from a local .zip
muaz plugins install web-research # install by name from the registry
muaz plugins install web-research@0.2.0 # pin a version
muaz plugins enable web-research # re-enable a disabled plugin
muaz plugins disable web-research # keep installed, but hide from resolution
muaz plugins uninstall web-research # delete it
muaz plugins trust web-research # review its requests, then grant trust
muaz plugins untrust web-research # revoke trust (the plugin keeps working)
muaz plugins search browsing # search the remote registry
muaz plugins update [id] [--check] # apply (or just list) registry updates

The same operations are available in the browser UI’s Plugins page.

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 PATH

This 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 runnable
import json, sys

Without 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-tools are 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:

  1. revokes trust — the plugin stays installed and usable, but its skill pre-approvals prompt again, and
  2. skips its post_update hook — 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.

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.0

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.

~/.muaz/config.yaml
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 control

Each 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.

FieldMeaning
nameUsed by --registry <name>, and recorded in the plugin’s source
index_urlThe JSON index (https:// unless allow_plaintext)
trusted_keysminisign public keys trusted for this registry; empty ⇒ muaz’s
require_signedRefuse unsigned plugins from this registry (default true)
allow_plaintextPermit 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.

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). Pass muaz plugins install <name> --allow-unsigned to 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.

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:

Terminal window
minisign -S -s publisher.key -m index.json # writes index.json.minisig

muaz 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.

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).

  • serial is 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.
Terminal window
$ muaz plugins registries
acme-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:

Terminal window
muaz plugins forget-registry acme-internal

Treat 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.

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.

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:

Terminal window
$ muaz plugins update --check
warning: plugin 'acme-tools' 1.2.0 was yanked by its publisher — update it, or
uninstall it if no replacement is offered

That notice is reported separately from registries that couldn’t be reached, so “we checked and found something” never reads like “we couldn’t check”.

  1. Create a directory with a manifest.yaml and any agents/, skills/, pipelines/, prompts/, commands/, mcp.json you want to ship.

  2. 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.

  3. Pack it and install it:

    Terminal window
    muaz plugins pack ./your-plugin # → your-plugin-1.0.0.zip
    muaz plugins install ./your-plugin-1.0.0.zip

A complete, copy-pasteable sample plugin is in the Examples page.

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.

Terminal window
$ muaz plugins pack ./acme-tools
Packed 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):

Terminal window
$ muaz plugins verify acme-tools-1.2.0.zip --key publisher.pub
acme-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.

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.