# Symposium plugin authoring guide > For AI coding agents: how to write a Symposium plugin (skills, hooks, MCP servers, and subcommands) for a Rust crate, and how to check that it works. Symposium (the `cargo agents` CLI, https://symposium.dev) gives AI coding agents (Claude Code, Codex, GitHub Copilot, Kiro, Antigravity, OpenCode, Goose) knowledge about the crates a Rust project directly depends on. A plugin carries that knowledge for one crate: - skills: `SKILL.md` guidance documents, installed into each agent's skills directory; - hooks: programs that run on agent events (before or after a tool call, when the user submits a prompt, when a session starts, when the agent finishes its turn); - MCP servers: registered into each agent's MCP configuration; - subcommands: the crate's own command-line tool, which agents and users run as `cargo agents `. Read the whole file. If your tool gave you a summary instead of the text (web-fetch tools often do), download it with `curl -fsSL https://symposium.dev/crate-authors/llms.txt`. Examples use the crate `widgetlib`; substitute your crate's name. Contents: 1. Minimum viable plugin: where it lives, the minimum files, how to check it, common failure modes. A plugin with only skills needs just this part. 2. Common patterns: recommendations entries, skills in another directory, gating. 3. Advanced features: hooks, MCP servers, subcommands. 4. Troubleshooting: the log, trying a hook or subcommand before publishing it, and per-feature checks. Each section ends with links to the full reference. ## 1. Minimum viable plugin ### 1.1 Where it lives - **You maintain the crate:** ship the plugin inside it (recommended). Its files go next to the crate's `Cargo.toml` (the package directory, not the workspace root) and must be part of the published package. Users who depend on the crate enable its plugin with `cargo agents use `, or when an interactive `cargo agents sync` asks them. - **You don't maintain the crate:** for the user's own projects, a workspace skill in `.agents/skills//SKILL.md`, or `~/.symposium/plugins//SKILL.md` with `depends-on: ` in its frontmatter to get it in every project that depends on the crate. To share it, an entry in the recommendations repository (2.1); ask the user before opening a pull request. ### 1.2 Minimum files: one skill A skill is a directory with a `SKILL.md` file: YAML frontmatter, then a markdown body with the guidance. In a crate, skills alone need no manifest: put each one in `skills//SKILL.md` next to `Cargo.toml`, for example `widgetlib/skills/widgetlib-basics/SKILL.md`. ```markdown --- name: widgetlib-basics description: Basics of the widgetlib crate. Use when building or configuring widgets with widgetlib. --- Prefer `Widget::builder()` over constructing widgets directly. Always call `.validate()` before passing widgets to the runtime. ``` - `name` (required): lowercase letters, digits, and hyphens. It becomes the name of the installed skill directory, so prefix it with the crate's name. - `description` (required, at most 1024 bytes): agents decide whether to load a skill from its description alone, so say what the skill covers and when to use it, naming the crate. ### 1.3 Check it Prerequisite: the CLI from the `symposium` crate (`cargo binstall symposium` or `cargo install symposium`), set up for the user's agent with `cargo agents init --add-agent ` (`claude`, `codex`, `copilot`, `kiro`, `antigravity`, `opencode`, or `goose`; repeat the flag for several). With no agent configured, `cargo agents use` installs nothing. Without `--add-agent`, `init` asks interactively, which needs a terminal. `init` writes the user's Symposium configuration and their agents' hook settings, so ask the user before running it. In a scratch project that depends on the crate by path: ```bash cargo new try-widgetlib && cd try-widgetlib cargo add widgetlib --path ../widgetlib cargo agents use widgetlib cargo agents status ``` `cargo agents status` should list `widgetlib` with `[plugins] use` as the reason, and its skills appear in each configured agent's skills directory in the project (`.claude/skills/` for Claude Code, `.kiro/skills/` for Kiro, `.agents/skills/` for the others), next to any skills other plugins install. A path dependency reads the source tree, so check what you will publish separately with `cargo package --list --allow-dirty` in the crate. When you have finished testing, including anything from parts 3 and 4, remove the test enablement with `cargo agents use widgetlib --remove`. ### 1.4 Common failure modes - Errors are not printed, even with `--verbose`: a mistake in the crate's `Symposium.toml`, `[package.metadata.symposium]`, or `SKILL.md` files skips the broken file, or the whole plugin, and the error goes to the newest log in `~/.symposium/logs/`. Read it whenever a skill, hook, MCP server, or subcommand is missing. - Every frontmatter value must be a string: lists, maps (including the agentskills.io `metadata` map), numbers, booleans, and empty values are rejected, so quote values such as `version: "1.0"`. Double-quote a value that starts with anything other than a letter or digit (a backtick, `*`, `[`, a quote; for example `depends-on: "*"`) or that contains `: ` or ` #`. Unquoted, YAML rejects most of these, and treats everything after ` #` as a comment, silently shortening the value. - Installed skills are copies: after changing the crate, run `cargo agents sync` again (a skill synced less than 5 seconds earlier is not copied again). - A file that `cargo package --list` does not show is not published, even though the path dependency found it; if `Cargo.toml` has an `include` list, add `skills/` (and `Symposium.toml`) to it. - A manifest must be named exactly `Symposium.toml`. On a case-sensitive filesystem (the usual case on Linux), another spelling such as `SYMPOSIUM.toml` is not read, so a misspelled file can work on macOS and still be skipped for users on Linux. - Symposium only looks at the direct dependencies of the workspace's members. Reference: https://symposium.dev/crate-authors/authoring-a-plugin.html, https://symposium.dev/reference/skill-definition.html ## 2. Common patterns ### 2.1 Recommendations entries For a crate you don't maintain, share the plugin as an entry: a top-level directory of https://github.com/symposium-dev/recommendations named after the crate (look at the repository's top-level directories first: one may already exist). It holds either: - a single `SKILL.md` whose frontmatter sets `depends-on: widgetlib` (without it, the skill stays off until someone enables it by name); - or a `Symposium.toml` that sets `name` and `depends-on` and declares its skills with a `[[skills]]` group (a registry entry gets no default `skills/` group): ```toml name = "widgetlib" depends-on = ["widgetlib"] [[skills]] source.path = "skills" ``` The repository does not accept `source.git` skill groups. Validate from the root of a checkout, passing the directory that contains your entry rather than the entry itself: ```bash cargo agents plugin validate . cargo agents plugin validate . --no-check-crates # skip the crates.io name check ``` To try the entry before opening the pull request, copy its directory into `~/.symposium/plugins/`, a registry Symposium reads by default (`mkdir -p ~/.symposium/plugins && cp -R widgetlib ~/.symposium/plugins/`), and run `cargo agents sync` in a project that depends on the crate. Afterwards, delete the copy and run `cargo agents sync` again. Once a shared entry is merged, remove any personal copy of the same skill, or both copies install. Reference: https://symposium.dev/reference/plugin-source.html ### 2.2 Skills in a directory other than `skills/` In a crate, declare a skill group; the default `skills/` group is kept unless you turn it off: ```toml # In Cargo.toml (or the same tables, without the prefix, in Symposium.toml) [[package.metadata.symposium.skills]] source.path = "docs/agent-skills" [package.metadata.symposium.defaults] skills = false # only if you don't want the default skills/ group ``` ### 2.3 Gating: `depends-on` and `predicates` - A dependency atom is a crate name with an optional version requirement, written without spaces: `serde`, `serde>=1.0`, `tokio>=1.30`. Unlike Cargo, `serde=1.0` means `^1.0`; write `serde==1.0.219` for an exact version. `*` matches every project. - `depends-on` is accepted on the plugin, `[[skills]]` groups, `[[mcp_servers]]`, `[[plugins]]` edges, `[subcommand.]`, and SKILL.md frontmatter. Several atoms match if any of them matches. - In SKILL.md frontmatter, `depends-on` narrows the skill to projects with certain dependencies, for example `depends-on: tokio>=1.30`. In a crate's own skills, leave it out unless you want to narrow further. - `predicates` (a list that must all hold) adds `depends-on(...)`, `shell()`, `path_exists()`, `env([=])`, `workspace-member()`, and `not(...)`, `any(...)`, `all(...)`. Arguments are taken verbatim: `shell(command -v rg)`, not `shell("command -v rg")`. - `predicates = ["not(workspace-member())"]` keeps a component for your users only: in your crate's own repository, its `Symposium.toml` and `skills/` are also active as a workspace plugin. References: https://symposium.dev/reference/depends-on.html, https://symposium.dev/reference/predicates.html ## 3. Advanced features Hooks, MCP servers, and subcommands are declared in a manifest. In a crate, that is a `Symposium.toml` next to `Cargo.toml`, or the same tables in `[package.metadata.symposium]` in `Cargo.toml` (same schema; when both exist they are merged and the file wins on conflicting values). Leave out the top-level `name` and `depends-on` in a crate's manifest: reaching the crate is already the gate. A recommendations entry declares them in its own `Symposium.toml`. To try a hook or subcommand before its binary is published, see part 4. ### 3.1 Hooks A hook runs a binary or script when an agent event fires. Its `command` names an `[[installations]]` entry (or declares one inline); the usual source is a binary crate published on crates.io, which Symposium installs the first time the hook runs. ```toml # Symposium.toml in your crate [[installations]] name = "widgetlib-hooks" source = "cargo" crate = "widgetlib-hooks" executable = "widgetlib-hooks" [[hooks]] name = "check-shell" event = "PreToolUse" matcher = "^(Bash|bash|run_command|execute_bash)$" command = "widgetlib-hooks" ``` - Events: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`. Symposium delivers `Stop` only on Claude Code and Antigravity, and Goose and OpenCode run no shell hooks at all (they still get skills and MCP servers). - `matcher` is an unanchored regex over the tool name, so anchor it with `^...$`. Agents name tools differently: the shell is `Bash` in Claude Code and Codex, `bash` in Copilot, `run_command` in Antigravity (its input uses `CommandLine` instead of `command`), and `execute_bash` in Kiro. Tool names reach the handler exactly as the agent sent them, and tool inputs keep the agent's own argument names. - A hook has no `depends-on` field; gate it with `predicates = ["depends-on(serde)"]`. - The handler reads one JSON event on stdin and writes its answer to stdout in Symposium's canonical format, which Symposium converts to and from each agent's format. Setting `format = "claude"` (or another agent) instead makes the hook fire only on that agent, with that agent's native JSON. Writing the handler in Rust with the SDK (a separate crate keeps the SDK out of your library's dependencies): ```bash cargo new widgetlib-hooks && cd widgetlib-hooks cargo add symposium-sdk anyhow ``` ```rust // src/main.rs use std::process::ExitCode; use symposium_sdk::hook::{HookHandler, PreToolUseInput, PreToolUseOutput, run}; const SHELL_TOOLS: &[&str] = &["Bash", "bash", "run_command", "execute_bash"]; struct WidgetHooks; impl HookHandler for WidgetHooks { async fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result { if !SHELL_TOOLS.contains(&event.tool_name.as_str()) { return Ok(PreToolUseOutput::default()); } let key = if event.tool_name == "run_command" { "CommandLine" } else { "command" }; let command = event.tool_input.get(key).and_then(|v| v.as_str()).unwrap_or_default(); if command.contains("cargo publish") { Ok(PreToolUseOutput::context("Run `cargo test --all-features` before publishing widgetlib.")) } else { Ok(PreToolUseOutput::default()) } } } fn main() -> ExitCode { run(WidgetHooks) } ``` - Override only the events you need: `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `session_start`, `stop`. - `PreToolUseOutput::context(text)` adds text to the agent's context, `PreToolUseOutput::deny(reason)` blocks the call, and `PreToolUseOutput::with_updated_input(value)` replaces the tool input. The other events return `context(text)` or `default()`. Agents differ in what they do with the output: Antigravity shows `PreToolUse` output only when the call is denied and nothing from `PostToolUse`, and no agent treats context returned on `Stop` as plain context (see the hook events reference). - Unit-test the binary by piping canonical JSON into it: ```bash cargo build echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"cargo publish"},"session_id":null,"cwd":"/tmp"}}' \ | ./target/debug/widgetlib-hooks ``` Publish the hook crate before the crate whose plugin uses it: users' agents install it from crates.io. References: https://symposium.dev/crate-authors/writing-a-hook-handler.html, https://symposium.dev/reference/hook-events.html, https://symposium.dev/reference/plugin-definition.html#hooks ### 3.2 MCP servers ```toml [[mcp_servers]] name = "widgetlib-tools" command = "widgetlib-mcp" args = ["--stdio"] env = [] ``` - `command` is written into each agent's MCP configuration as is: an absolute path, or a program on the user's `PATH`. Symposium does not install MCP servers (they don't use `[[installations]]`), so tell users how to install yours, for example in your README. - `args` and `env` are required, even when empty. `env` entries are `{ name = "...", value = "..." }`. - A remote server sets `type = "http"` (or `"sse"`), `url`, and `headers = []` instead of `command`, `args`, and `env`. - Servers are registered at the scope set by the user's `hook-scope` setting: the user-level configuration by default, or the project with `hook-scope = "project"` (agents without a project-level MCP file always use the user level). Reference: https://symposium.dev/reference/plugin-definition.html#mcp_servers ### 3.3 Subcommands A subcommand puts the crate's own command-line tool behind `cargo agents`, named after the crate by convention: an agent runs `cargo agents widgetlib --help` to discover the tool, then its commands. Symposium runs the installation with its `args` followed by everything typed after the name, `--help` included, and exits with the tool's exit code; the tool owns its commands, flags, and help text. ```toml [[installations]] name = "widgetlib-cli" source = "cargo" crate = "widgetlib-cli" executable = "widgetlib-cli" # `cargo agents widgetlib --help` runs `widgetlib-cli --help`; # `cargo agents widgetlib check --strict` runs `widgetlib-cli check --strict`. [subcommand.widgetlib] description = "Inspect and check widgetlib widget definitions" command = "widgetlib-cli" # the [[installations]] entry that provides the tool ``` - The tool is a crate with a binary that you publish, here `widgetlib-cli`. `crate` names the crates.io crate Symposium installs it from, so it must be yours, published before the crate whose plugin uses it. To try the subcommand before then, point the installation at your local build (part 4). - `description` is required (at most 1024 bytes) and is shown in `cargo agents --help`, which Symposium's session-start hook tells agents to run when a project has subcommands. - `audience` is `"agents"` (default) or `"humans"`. It only picks the `--help` section, but the session-start hint tells agents to use only the commands listed for agents unless the user asks. - The name may contain ASCII letters, digits, `-`, and `_`, and must not be the name of a built-in command (the reference lists them). If two active plugins define the same name, running it fails. Reference: https://symposium.dev/reference/plugin-definition.html#subcommandname ## 4. Troubleshooting Start with the newest log in `~/.symposium/logs/` (1.4), and check from the scratch project of 1.3, as a user would. `cargo agents sync --verbose` shows which plugins and skills Symposium considered. **Try a hook or subcommand before its binary is published.** Point its installation at the local build: drop `source` and `crate`, and set `executable` to the binary's absolute path. Restore the `source = "cargo"` installation before publishing. **Hook.** Send Symposium the payload an agent would send; this runs the same dispatch as the agent and prints what the agent would receive: ```bash echo '{"session_id":"test","cwd":"'"$PWD"'","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo publish"}}' \ | cargo agents hook claude pre-tool-use ``` Other agents send their own payload format; see https://symposium.dev/design/agent-details/index.html. If the hook does not fire, rerun with `--verbose` to see which plugins and hooks Symposium considered, read that log, and check the `matcher` against that agent's tool name (3.1). **Subcommand.** `cargo agents --help` lists it only while its plugin is active; otherwise running it fails with "no plugin defines subcommand". `cargo agents widgetlib --help` should print the tool's own help. **MCP server.** `name` is the server's key in each agent's MCP configuration; to check a registration, look for it there (for Claude Code, `~/.claude.json`, or `.mcp.json` in the project with project scope). Reference: https://symposium.dev/crate-authors/authoring-a-plugin.html#step-4-try-it-in-a-project