Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Symposium makes Rust dependencies actionable for AI agents. It discovers crate-matched plugins and wires in skills, hooks, and MCP servers so your agent can work with project-specific context.

Getting started

cargo binstall symposium # or: cargo install symposium
cargo agents init

After initialization, start your agent in a Rust project as usual.

Recent posts

What is Symposium?

Symposium is a one-stop shop to help agents write great Rust code. It connects you to best-in-class tools and workflows but it also serves up skills and extensions tailored to the crates you are using, all authored by the people who know those crates best – the crate authors.

init and go

Getting started with Symposium is easy:

#![allow(unused)]
fn main() {
cargo binstall symposium       # or `cargo install` if you prefer
cargo agents init
}

The init command will guide you through picking your personal agent. It will then configure the agent to use Symposium (e.g., by installing hooks). This will immediately give you some benefits, such as introducing Rust guidance and reducing token usage with the rtk project.

Leveraging the wisdom of crates.io

To truly get the most out of Symposium, you also want to install it into your project. When you run cargo agents init in a project directory, it will scan your dependencies and create customized skills, tools, and other improvements. These extensions are source either from our central recommendations repository. In the future, we plan to enable crate authors to embed extensions within their crates themselves and skip the central repo altogether.

Everybody picks their own agent

Work on an open-source project or a team where people use different agents? No problem. Your Symposium configuration is agent agnostic, and the cargo agents tool adapts it to the agent that each person is using. You can also specify the agent centrally if you prefer.

Staying synchronized

By default, Symposium is setup to synchronize itself. It’ll source the latest skills automatically and add them to your project. If you prefer, you can disable auto-updates and run cargo agents sync manually.

For crate authors

If you maintain a Rust crate, you can publish skills for Symposium so that every AI-assisted user of your library gets your best practices built in. See Supporting your crate for how to get started.

Blog

Welcome to the Symposium blog.

Announcing Symposium - AI The Rust Way

Authored By: Jack Huey

Are you using an AI agent to write Rust code (or curious to try it)? If so, GREAT NEWS! We’d like to share with you Symposium - a Rust-focused interoperability layer that connects AI agents to crate-authored skills, tools, and workflows.

(If you’ve read Niko’s previous blog posts talking about Symposium, this is pretty different! The tool we’re announcing today is the result of many iterations of figuring out what exactly the “thing we want” is. So please, read on!)

Also, this announcement comes with exciting news: Symposium has joined the Rust Foundation’s Rust Innovation Lab (RIL)! Be sure to check out the Foundation’s blog post.

What is Symposium?

There are really two answers to that question. The first one is that Symposium is a tool that examines what crates your project depends on and uses that to automatically install new skills, MCP servers, or other extensions. These extensions help your AI agent to write better code, avoid common footguns and pitfalls, and even leverage ecosystem tools like the Rust Token Killer (RTK) to save you tokens.

The second one is that Symposium is an organization dedicated to one goal, “AI the Rust way”, meaning reliable, efficient, and extensible. We are focused on interoperable, vendor-neutral, and community-oriented ways to make agents more reliable and efficient.

Getting started

You interact with Symposium through the cargo agents CLI command. If you want to try it, do this:

#![allow(unused)]
fn main() {
cargo binstall symposium # or `cargo install`
cargo agents init
}

The init command will prompt you to select what agents you want to use and a few other things. Based on that we install hooks that will cause Symposium to be invoked automatically. The next time you start an agent on a Rust project, Symposium will check if there are available skills or other extensions for the crates you use and set them up automatically. You shouldn’t have to do anything else.

Symposium helps your agent write better code and use fewer tokens

You may be familiar with various extensions that agents can work with, such has MCP servers, Skills, or Hooks. You may also know that different agents have different levels of support for these, and even different takes on them (Hooks, for example, are not as well-standardized as MCP servers and Skills). However, that doesn’t diminish the fact that many people have built many tools around these extension systems. We want you to easily use these ecosystem tools.

You may also have run into cases where a model is “outdated” compared to either the Rust language itself (e.g., there may be a newer language feature that is more idiomatic) or was trained on an older versions of a crate that you are using. It’s generally not hard to get models to follow newer conventions, but they need to be told to do so. We want to make that easier and more automated.

Finally, we want writing code with agents to be more efficient and reliable. Some of this comes from the above two goals, but part of it also comes from making sure that agents write code the way you would write it. For example, when you finish writing Rust code, you likely run cargo check, run your tests, or format your code - and we think that you should expect your agent to do the same. Simulatenously, efficiency also means that we want these tools to use as few tokens as possible.

Symposium Plugins

A Symposium plugin defines a set of extensions (mcp servers, skills, hooks, etc) and the conditions in which they should be used (currently: when a given version of a given crate is in the project’s dependencies). Plugins are hosted on repositories called a “plugin source”; we define a central repository with our globally recommended plugins, but you can additional plugin sources of your own if you like.

Skills

Agent Skills are a lightweight format for defining specialized knowledge or workflows for agents to use. Most agents have a pre-defined list of places that they look for skills, but don’t currently have a way to dynamically make them available.

In Symposium, we automatically discover skills from plugins applicable to the current crate. By default, we automatically sync them to the current project’s directory so they can be used by your agent (either .agent/skills or .claude/skills). This is done through a custom hook (if your agent supports it), but can be disabled or manually synced with cargo agents sync.

Hooks

Unlike skills which are dynamically loaded by agents, hooks are dispatched on certain events such as on agent start, after a user prompt, or prior to a tool use. Symposium has a small number of hooks it installs (when available) that it uses to ensure that plugins are discovered and loaded for an agent to use.

Additionally, today, hooks defined by plugins are also dispatched through Symposium. This allows, for example, dispatching hooks written for one agent when using a different agent (to the extent that we’ve implemented support). The list of supported hooks is fairly small, but we’re far from done with expanding hooks support.

MCP servers

MCP servers were one of the first extensions made available by agents. They expose a set of tools, either local or remote, that agents can call. MCP servers defined by a Symposium plugin get installed into your agent’s settings for use.

What’s next?

As we said in the beginning, the Symposium org is focused on “AI the Rust way” – so what does that mean? We’re starting with a minimal, usual product for users to experiment with and hopefully find use from. But, we’re far from done. We have a number of really interesting ideas to make Symposium even more useful.

We want to continue to expand the set of agent features that Symposium supports. When an agent supports a tool or similar, we want it to be a minimal process to be able to recommend that users of your crate also use that tool. Often, this means that we should “just install” those tools into project-local agent settings; but, we want to make sure that this is done correctly and supports the agents that our users use. However, we also want to support (when possible) more dynamic loading - such as by dispatching hooks through Symposium itself, or having Symposium register a transparent MCP server layer. There are lots of things we can do here, and we’re excited to hear what people want and need first.

We currently have pretty minimal support for how to run hooks or MCP servers - really just a command to run. We already have in-progress work to support declarative dependencies, which in turns allows both auto-installation and auto-updates. Using a symposium plugin should “just work”.

The work we’re presenting today is focused mainly around Rust crates, but our vision also includes better recommendations around the Rust language. We’ve already seen a few ecosystem-driven projects with this goal - we plan to review these and find what works best for Symposium users and make it the default for the best experience possible when writing Rust code. Similarly, we plan to write our own plugins that help your agent format and test Rust code that it has written, before you even look at it.

Symposium previously was focused around the Agent Client Protocol (ACP), which provides a programmatic way to interact with and extend agent capabilities. We still love this vision, but our current focus is on an ecosystem-first approach of meeting agents where they are today. We do expect that as ACP adoption continues to increase and we have a solid foundation with the work we’ve presented today, that we will again focus on ACP to further increase the interoperability and extensibility we provide for users of Symposium.

Finally, although our initial work is focused around Rust, we think this idea - discoverability and use of plugins defined by dependencies - is applicable and useful for other language ecosystems too. We would love to expand this to other languages.

In all, we’re really excited for people to use Symposium. We hope that what we’ve shared today gets you excited about building better Rust with AI, and we think that this is only the beginning. If you have thoughts or questions, either open an issue on Github or join the Symposium Zulip; we’d love to hear your thoughts!

A Maturing Symposium

Authored By: Jack Huey

We announced an initial MVP release of Symposium just about two months ago, and figured it would be good to give an update on what we’ve added since!

Tl;dr we’ve added a number of different features that allow Symposium to do more things for different workflows, and to keep your plugins updated!

More powerful plugins

The first set of changes to discuss all cover essentially the things that you can do with plugins.

Unified predicate system. In the MVP, a plugin only had a single crates field to filter when it was activated. We’ve added a more robust and more general predicates field that supports more complex logic for plugin (and e.g. skills) activation. We currently support builtin crate, shell, path_exists, and env predicates; as well as not/any/all combinators. So, you can, for example gate the activation of a plugin based on if a program is installed, or if a workspace env var is set. The existing crates field desugars into any(crate(..), ..). Additionally, plugins can register custom predicates, available globally across plugins. This is particularly useful to gate plugin use on if something like battery packs is enabled or if async is used.

Plugin-vended subcommands. Skills, hooks, and MCP servers cover a lot, but sometimes a plugin just wants to offer a command that you, or your agent, can run directly. Now plugins can contribute their own cargo agents <name> subcommands, and they only appear when the surrounding project makes them relevant.

Hooks 2.0. One thing we really want is the ability to just use the ecosystem - so if a Claude Code hook exists, you should just be able to use that in a plugin. But, we also want to support the ability to “write once, run anywhere”. We’ve added support for a common hook infrastructure, and hook dispatch will fall back to that if a native hook doesn’t exist. We eventually want to potentially extend that to custom hook events too, to enable something like “post cargo test run”.

Of course, the schema has changed a couple times since the initial MVP. But, we try to validate plugin definitions to ensure that what you wrote makes sense.

More control of your plugin sources and where they are installed

In addition to making your plugins more powerful, we also have given you more control of where your plugins are sourced and where they are installed.

Global install + env vars. By default, plugin dependencies (installations) are installed into the ~/.symposium directory. However, we’ve added the ability to install globally. Additionally, we’ve added env vars that are set to the installation paths and binaries, for more control.

Crate-sourced skills. Crates can now ship agent skills directly. Skills can use source = "crate". You can use either the current crate, or a separate crate. Additionally, you can decide what skills are active when developing the current crate, and which skills are active when using the current crate. Skills written to ./skills are used for crate use; skills written to .agents/skills are for crate development. When you write a skill to .agents/skills , they will be synced to .claude/skills or other agent skills directories, if needed. Automatically synced skills are git-ignored (as are all symposium-installed skills), so you don’t need to worry about your git workspace getting cluttered!

Keeping Symposium and your plugins up to date

We all want our software to be up-to-date. Similarly, when a crate author publishes a newer version of a skill, we want to ensure that the latest version is used.

Plugin and Symposium Auto-Update. We’ve added a auto-update config option (on by default) that will automatically update Symposium. This can be manually triggered with cargo agents self-update. Plugin hooks and dependencies now also automatically update; skills already updated automatically.

Smarter, cheaper sync. Auto-sync of skills now skips when Cargo.lock is unchanged, has a configurable debounce (sync-debounce-secs, default 5s), is change-aware (no disk churn when nothing differs), and dedups/disambiguates installed skill dirs by origin rather than name.

Helping agents (and humans) use Symposium better

Although we want Symposium to be mostly “hands off” once set up, we recognize that sometimes we (humans and agents) need to interact with it. We’ve added a couple things to help with this.

Audience-grouped --help. cargo agents --help now renders two sections: “Commands for humans” and “Commands for agents”. It also lists workspace-applicable plugin subcommands alongside built-ins. SessionStart also nudges the agent to run --help when relevant subcommands exist.

Structured output (--verbose / --json). A new report layer renders command output three ways: human-readable (default), verbose decision trace to stderr, or a machine-readable JSON array. This is intended in large part for debugging or machine/tooling consumption.

Upcoming opt-in telemetry to help us ensure Symposium is effective

We’ve added the basic infrastructure to support anonymous, local, opt-in telemetry. Nothing is collected, yet. But, we want to be able to eventually let users help us make Symposium better: we want to know what’s working and what isn’t. Telemetry status and information can be checked with cargo agents telemetry status and cargo agents telemetry show.

Conclusions

In general, we’re proud of the progress we’ve made. But, we still have more that we want to do! We’re looking into supporting additional language ecosystems (e.g. npm or pypi) and we want to be flexible to new standards as they are developed. We want Symposium to empower you to do things you otherwise couldn’t or wouldn’t due to complexity or cognitive overhead, while also meeting the ecosystem where it is.

Getting Started

Install

The fastest way to install Symposium is with cargo binstall:

cargo binstall symposium

If you prefer to build from source, use cargo install instead:

cargo install symposium

Initialization

Once you have installed Symposium, you need to run the init command:

cargo agents init

Select your agents

This will prompt you to select the agents you use (Claude Code, Copilot, Gemini, etc.) — you can pick more than one:

Which agents do you use? (space to select, enter to confirm):
> [ ] Claude Code
  [x] Codex CLI
  [ ] GitHub Copilot
  [ ] Gemini CLI
  [ ] Goose
  [x] Kiro
  [x] OpenCode

Global vs project hook registration

Next, Symposium will ask you whether you want to register hooks globally or per-project:

  • global registration means Symposium will activate automatically for all Rust projects.
  • project registration means Symposium only activates in projects once you run cargo agents sync to add hooks to that project.

We recommend global registration for maximum convenience.

Tweaking other settings

You may wish to browse the configuration page to learn about other settings, such as how to disable auto-sync.

After setup

Symposium will now install skills, MCP servers, and other extensions based on your dependencies automatically.

Currently all the plugins installed by Symposium can be found in the central recommendations repository. We expect eventually to allow crates to define their own plugins without any central repository, but not yet. If you have a crate and would like to add a plugin for it to symposium, see the Supporting your crate page.

If you have private crates or would like to install plugins for your own use, you can consider adding a custom plugin source.

Workspace skills

In addition to adding skills based on your dependencies, Symposium will also install skills your workspace defines for itself: skills found in skills/ or .agents/skills in the workspace root or any member crate install into the directory appropriate for your configured agent(s).

This allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users will have the skills synced to .claude/skills).

The default skill location therefore varies depending on the intended audience:

Skills intended for…Go into…
Maintaining your crate.agents/skills
Using your crateskills/

Workspace plugins

The workspace root and every member crate directory can define a workspace plugin: add a SYMPOSIUM.toml manifest (see the plugin definition), or just a bare skills/ directory — a directory with skills and no manifest counts as a plugin whose only content is those skills.

Workspace plugins are always active while you work in that workspace — no plugin source configuration or depends-on gate is needed. A skills/ directory in a member crate serves double duty: it installs for everyone working in the workspace and, once published, for projects that depend on the crate.

Every workspace plugin gets two default skill groups (unless disabled with [defaults] skills = false):

[[skills]]
source.path = "skills"

[[skills]]
predicates = ["workspace-member()"]
source.path = ".agents/skills"

The second group is how the .agents/skills convention works: it is gated by the workspace-member() predicate, so maintainer skills apply while working in the workspace but never install for dependents of a published crate. (The group can also be turned off globally with agents-syncing = false in the user config.)

Two details specific to workspace manifests:

  • name may be omitted; it defaults to the directory name.
  • No top-level depends-on is required — workspace membership is the gate, so a workspace plugin never goes dormant the way a gateless registry plugin does.

Components that should apply only to people developing the workspace (not to dependents of a published crate) can be gated with the workspace-member() predicate.

Informal skills

Workspace skills are your own notes, so the skill frontmatter requirements are relaxed: the name and description fields — and the frontmatter block itself — are optional. A SKILL.md that is just plain markdown works; its name defaults to the directory that contains it. Skills distributed through a registry or a published crate still require the full frontmatter.

We recommend you commit your .agents/skills or skills/ into the repository. Symposium installs a .gitignore file into every skill that it creates, so automatically copied and installed skills should not dirty your git status.

Pre-existing files

Symposium never touches skills in .claude/skills/, .kiro/skills/ etc. that it did not put there itself. If you previously hand-wrote a skill with the same name as one in .agents/skills/, your existing directory stays in place and the workspace skill installs under a suffixed name (<name>-<hash>) alongside it.

Custom plugin sources

Custom plugin sources let you define your own sets of plugins without putting them in the central recommendations repository.

Custom plugin sources are useful for:

  • Company-specific plugins — internal tools and guidelines for your organization
  • Development plugins — local plugins you’re working on

Custom plugins in your home directory

plugin definitions or standalone skills added to the ~/.symposium/plugins directory will be registered by default and propagated appropriately to your other projects.

Adding your own custom sources

You can also define a custom plugin source in a git repository or at another path on your system. Each one is a [[registry]] entry ([[plugin-source]] is the retired spelling of the same table, still accepted).

Git repository

Add a remote Git repository as a registry:

# In ~/.symposium/config.toml
[[registry]]
name = "my-company"
git = "https://github.com/mycompany/symposium-plugins"
auto-update = true

We recommend creating a CI tool that runs cargo agents plugin validate on your repository with every PR to ensure it is properly formatted.

Local directory

Add a local directory as a registry:

[[registry]]
name = "local-dev"
path = "./my-plugins"
auto-update = false

Structure of a plugin source

See the reference section for details on what a plugin source looks like.

Managing sources

The cargo agents plugin command allows you to perform operatons on the installed plugin sources, like synchronizing their contents or validating their structure.

Supporting your crate

If you maintain a Rust crate, you can extend Symposium with skills, MCP servers, or other extensions that will teach agents the best way to use your crate.

Embed skills in your crate

The recommended approach is to ship skills directly in your crate’s source tree. Add a skills/ directory with SKILL.md files, then add a small plugin manifest to our central recommendations repository. Users will get guidance that matches the exact version of your crate they’re using.

See Authoring a plugin for the full walkthrough.

Skill layout metadata

By default Symposium looks in your crate’s skills/ directory. To customize the layout — a different subdirectory, named groups, a git source, or delegating to another crate — you describe your crate’s plugin inline under [package.metadata.symposium] in Cargo.toml. This block uses the exact same schema as a SYMPOSIUM.toml plugin manifest — it is just that manifest embedded in Cargo.toml:

# Optional — absence means "look in skills/ by default"
[[package.metadata.symposium.skills]]
source.path = "guidance"   # custom subdirectory for skills

Because it is a plugin manifest, the same rules apply as for a crate-embedded SYMPOSIUM.toml: name defaults to the crate, a top-level depends-on is unnecessary (the reference that reached your crate is the gate), and the default skills/ group is appended unless you opt out with [package.metadata.symposium.defaults] skills = false.

If you ship both a [package.metadata.symposium] block and a SYMPOSIUM.toml, they are combined — list entries (skill groups, chained references, …) from both are kept; where the two set the same scalar, the SYMPOSIUM.toml file wins.

Resolution rules

  1. No metadata section and no SYMPOSIUM.toml — Symposium uses the default skills/ subdirectory.
  2. Opt out of the default group[package.metadata.symposium.defaults] skills = false and declare no skill groups → no skills from this crate.
  3. source.path groups — look in that subdirectory of your crate’s source.
  4. [[package.metadata.symposium.plugins]] chained references — load another crate’s plugin (see Delegating to another crate).

Delegating to another crate

A [[package.metadata.symposium.plugins]] chained reference lets your crate delegate skill hosting to another crate — the replacement for the old crate = {..} redirect. This is useful when:

  • Your main crate is small but skills live in a larger companion package.
  • Multiple crates in a workspace want to share a single set of skills.
  • You want to version skills separately from the library.
# In dial9-tokio-telemetry/Cargo.toml
[[package.metadata.symposium.plugins]]
source.cargo = "dial9-viewer"   # or { name = "dial9-viewer", version = ">=1.0" }

A chained reference can target any crate, not just workspace dependencies. The referenced crate is itself resolved as a plugin (its own metadata / SYMPOSIUM.toml / default skills/), so delegation composes transitively.

Cycle detection prevents infinite loops (A → B → A stops and warns). Chains are capped at 10 hops. Crate name comparison is hyphen/underscore-insensitive (my-crate and my_crate are the same crate).

Edge cases

  • Malformed metadata — If [package.metadata.symposium] is present but doesn’t parse as a valid manifest (wrong types, unknown fields), Symposium logs a warning and ignores that layer, still resolving the remaining layers (a SYMPOSIUM.toml, at minimum the default skills/ group). Fix any warnings to ensure your intended layout is respected.
  • Missing path directory — If a source.path group references a directory that doesn’t exist, Symposium silently produces zero skills for that group. Other groups are still processed.
  • Diamond references — If multiple crates all delegate to the same target crate, the target’s skills are installed once (deduplication is based on crate name + version, not who referenced it).

Want to write a skill for someone else’s crate?

We prefer crates to ship their own skills, but some crates may not want to or may not be actively maintained. We also accept skills for those crates to help bootstrap the ecosystem. External skills must be uploaded directly into our central recommendations repository so that we can vet them.

See Authoring a plugin for the details.

Moar power!

Beyond skills, there are two more extension types you can publish through a plugin:

  • Hooks — checks and transformations that run when the AI performs certain actions, like writing code or running commands.
  • MCP servers — tools and resources exposed to agents via the Model Context Protocol.

See Authoring a plugin for how to set one up.

Authoring a plugin

Symposium lets you ship skills, hooks, and MCP servers that are automatically loaded when a user’s project depends on your crate. This page walks through how to create a plugin and configure each extension type.

Step 1. Create a SYMPOSIUM.toml manifest

Every plugin starts with a SYMPOSIUM.toml manifest uploaded to the central recommendations repository. The manifest declares your plugin’s name, which crates it applies to, and what extensions it provides.

# `my-crate/SYMPOSIUM.toml` on the symposium-dev/recommendations repository
name = "my-crate"
depends-on = ["my-crate"]

The depends-on field controls when the plugin is active — it will only load for projects that depend on the listed crates. Use ["*"] to apply to all projects.

See the plugin definition reference for the full manifest schema.

Why is the central repository required?

We currently require an entry in our central recommendations repository before Symposium will install a plugin. This protects against malicious plugins (e.g., from typosquatting crates) and lets us centrally yank a plugin that proves problematic. Once Symposium has reached a steady state and we have established security protocols we are comfortable with, we expect to lift this requirement.

Step 2. Add skills, hooks, and/or MCP servers

With your manifest in place, you can add any combination of the extension types below.

Skills

Skills are guidance documents that teach AI assistants how to use a crate. Each skill is a directory containing a SKILL.md file with YAML frontmatter and a markdown body:

---
name: my-crate-basics
description: Basic guidance for my-crate usage
---

Prefer using `Widget::builder()` over constructing widgets directly.
Always call `.validate()` before passing widgets to the runtime.

See the Skill definition reference for the full format and the agentskills.io quickstart for writing effective skills.

If you maintain the crate, we recommend shipping skills directly in your source tree. This way users always get skills matching the exact version they have installed.

1. Put skills in your crate sources under skills/
my-crate/
    Cargo.toml
    src/
        lib.rs
    skills/
        basics/
            SKILL.md
        advanced-patterns/
            SKILL.md
2. Reference your crate with a chained plugin
# `my-crate/SYMPOSIUM.toml` on the symposium-dev/recommendations repository
name = "my-crate"
depends-on = ["my-crate"]

[[plugins]]
source.cargo = "my-crate"

When my-crate is a dependency, this loads its plugin: Symposium fetches the crate source (from the local cargo cache or crates.io) and discovers skills in the skills/ directory.

Prefer a directory other than skills/?

Add [package.metadata.symposium] to your crate’s Cargo.toml to specify a custom path. This block uses the same schema as a SYMPOSIUM.toml plugin manifest:

# In your crate's Cargo.toml
[[package.metadata.symposium.skills]]
source.path = "docs/agent-skills"

When no metadata section is present, Symposium defaults to the skills/ directory. See Supporting your crate for the full schema including chained references to other crates.

Ship a full SYMPOSIUM.toml in your crate

For more than a single custom directory — named skill groups, per-group predicates, or a git skill source — put a SYMPOSIUM.toml at your crate root. When the crate is reached through a [[plugins]] source.cargo reference, that manifest is loaded as a first-class plugin:

# `my-crate/SYMPOSIUM.toml` (in your crate's source tree)
[[skills]]
source.path = "docs/agent-skills"

[[skills]]
depends-on = ["tokio"]
source.path = "docs/async-skills"

Because the chained reference is already the gate, a crate manifest doesn’t need name (it defaults to the crate) or a top-level depends-on. The default skills/ group is still appended unless you opt out with [defaults] skills = false. The [package.metadata.symposium] block and a SYMPOSIUM.toml file are the same manifest schema and are combined when both are present (defaults → Cargo.toml → SYMPOSIUM.toml) — use whichever is convenient.

Hooks, MCP servers, and subcommands declared in a crate SYMPOSIUM.toml are parsed but not yet dispatched — only its skills load today. Declare those in a recommendations-repo manifest for now.

Standalone skills (on the recommendations repo)

You can also upload skills directly to the recommendations repo — without embedding them in the crate source. This is the right approach when you’re writing skills for a crate you don’t maintain.

Place skill directories alongside your SYMPOSIUM.toml:

my-crate/
    SYMPOSIUM.toml
    basics/
        SKILL.md
    advanced-patterns/
        SKILL.md

And point the manifest at the local directory:

name = "my-crate"
depends-on = ["my-crate"]

[[skills]]
source.path = "."

Standalone skills must include depends-on in their frontmatter so Symposium knows which crate they apply to:

---
name: widgetlib-basics
description: Basic guidance for widgetlib usage
depends-on: widgetlib=1.0
---

Guidance body here.

Skills from a git repository

Symposium also supports fetching skills from a GitHub URL:

[[skills]]
source.git = "https://github.com/org/my-crate/tree/main/symposium/skills"

This is useful for hosting skills in a dedicated repository or a subdirectory of a monorepo. Note that the central recommendations repository does not currently accept source.git entries by policy — use a [[plugins]] source.cargo chained reference or source.path for submissions there.

Installing auxiliary tools

An installation tells symposium how to obtain a binary that your hooks or MCP servers will run. The recommended approach is a cargo installation, which installs a crate binary from crates.io:

[[installations]]
name = "my-crate-hooks"
source = "cargo"
crate = "my-crate-hooks"
executable = "my-crate-hooks"

Symposium caches the binary under ~/.symposium/cache/. Binaries are updated automatically when new versions are available on crates.io.

See the plugin definition reference for other installation sources (GitHub repositories, local paths) and advanced options like install_commands.

Hooks

Hooks run when the AI performs certain actions — invoking a tool, starting a session, or submitting a prompt. They receive JSON on stdin describing the event and can return guidance, inject context, or block the action.

Every agent varies in the specifics of what hooks it offers and how those hooks are configured. Symposium allows you to provide agent-specific hook handlers, but we recommend instead using a Symposium hook handler, which is portable across all agents.

Symposium hooks (portable across agents)

To define a Symposium hook handler you add a [[hooks]] section. This defines the command to run as well as the events it expects and other filters.

[[hooks]]
name = "check-usage"
event = "PreToolUse"
matcher = "Bash"
command = "my-crate-hook-command"

The command field references the name of an installation defined in the [[installations]] section described previously. For example:

[[installations]]
name = "my-crate-hook-command"
source = "cargo"
crate = "my-crate-hooks"
executable = "my-crate-hooks"

The hook binary receives symposium canonical JSON on stdin and writes symposium canonical JSON to stdout. Symposium handles converting to and from each agent’s wire format, so a single implementation works across all supported agents. See Writing a hook handler for how to implement the binary using the symposium-hook crate, and Symposium hook events for input/output JSON schemas.

Agent-specific hooks

You can also provide hooks specialized for a particular agent by setting format to an agent name. The handler receives that agent’s native wire format on stdin — giving you access to agent-specific features (e.g., Claude Code’s updatedInput, Copilot’s modifiedArgs). Symposium still intermediates; it just delivers in the declared format instead of converting to canonical. On agents without a matching hook, symposium falls back to delivering any symposium-format hook the plugin declares.

[[hooks]]
name = "check-usage-claude"
event = "PreToolUse"
format = "claude"
command = "my-crate-hooks"
args = ["--claude"]

On Claude, check-usage-claude fires (receives Claude’s native JSON). On other agents, check-usage fires (receives symposium canonical JSON). See the plugin definition reference for the full [[hooks]] manifest syntax.

MCP servers

MCP servers expose tools and resources to agents via the Model Context Protocol. Symposium registers them into each agent’s configuration during sync — you declare the server once and it works across all agents.

An MCP server typically uses the same installation as your hooks:

[[installations]]
name = "my-crate-mcp"
source = "cargo"
crate = "my-crate-mcp"
executable = "my-crate-mcp"

[[mcp_servers]]
name = "my-crate-tools"
command = "my-crate-mcp"
args = ["--stdio"]

See the plugin definition reference for HTTP and SSE transports, crate filtering, and registration details.

Step 3. Validate your plugin

Before submitting a PR, validate your plugin or skill directory to catch errors early — missing fields, bad crate predicates, unreachable skill paths, and crate names that don’t exist on crates.io. You can run this on your local checkout of the recommendations repo once you’ve prepared your changes:

# Validate a plugin manifest
cargo agents plugin validate path/to/SYMPOSIUM.toml

# Validate a directory of standalone skills
cargo agents plugin validate path/to/skill-directory/

# Skip the crates.io name check (e.g., for private crates)
cargo agents plugin validate path/to/SYMPOSIUM.toml --no-check-crates

Writing a hook handler

This guide walks through writing a symposium hook handler in Rust using the symposium-hook crate.

Step 1. Create a new binary crate

Create your new crate:

cargo new my-hook-handler
cd my-hook-handler

And then add symposium-hook to your dependencies:

cargo add symposium-hook

Step 2. Write the handler

A hook handler is a program that reads a JSON event on stdin and writes a JSON response to stdout. The symposium-hook crate provides a HookHandler trait and a run() harness that handles the plumbing.

Implement HookHandler and override the methods for the events you care about:

// src/main.rs
use std::process::ExitCode;
use symposium_hook::{HookHandler, PreToolUseInput, PreToolUseOutput, run};

struct MyHook;

impl HookHandler for MyHook {
    fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result<PreToolUseOutput> {
        if event.tool_name == "Bash" {
            Ok(PreToolUseOutput::context("Remember: prefer non-destructive commands"))
        } else {
            Ok(PreToolUseOutput::default())
        }
    }
}

fn main() -> ExitCode {
    run(MyHook)
}

The run() function:

  1. Reads symposium canonical JSON from stdin.
  2. Deserializes it into an Input event.
  3. Calls handler.handle_event(), which dispatches to the appropriate method.
  4. Serializes the output to stdout.

You only need to override the methods you care about — unimplemented methods return the default (empty) output for their event type.

Step 3. Register it in your plugin manifest

In your SYMPOSIUM.toml, reference the built binary as a hook command:

name = "my-crate"
depends-on = ["my-crate"]

[[hooks]]
name = "check-usage"
event = "PreToolUse"
command = { source = "cargo", crate = "my-hook-handler", executable = "my-hook-handler" }

Output types

Each handler method returns its event-specific output type:

MethodReturn typeKey fields
pre_tool_usePreToolUseOutputadditional_context, updated_input
post_tool_usePostToolUseOutputadditional_context
user_prompt_submitUserPromptSubmitOutputadditional_context
session_startSessionStartOutputadditional_context

Each output type has convenience constructors:

  • ::default() — empty output, no-op.
  • ::context("...") — inject text into the agent’s context.
  • PreToolUseOutput::with_updated_input(value) — replace the tool input.
  • PreToolUseOutput::deny("reason") — block the tool call with a reason.

Return Err(...) from any method to report an error (exit code 1, message on stderr).

The HookHandler trait

#![allow(unused)]
fn main() {
pub trait HookHandler {
    fn handle_event(&self, input: &Input) -> anyhow::Result<Output> { /* dispatches */ }
    fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result<PreToolUseOutput> { /* default */ }
    fn post_tool_use(&self, event: &PostToolUseInput) -> anyhow::Result<PostToolUseOutput> { /* default */ }
    fn user_prompt_submit(&self, event: &UserPromptSubmitInput) -> anyhow::Result<UserPromptSubmitOutput> { /* default */ }
    fn session_start(&self, event: &SessionStartInput) -> anyhow::Result<SessionStartOutput> { /* default */ }
}
}

Override handle_event only if you need custom dispatch logic (e.g., shared state across events). Otherwise, just override the per-event methods.

Testing locally

You can test your handler by piping JSON directly:

cargo build
echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":null,"cwd":"/tmp"}}' \
  | ./target/debug/my-hook-handler

Or via the symposium CLI:

echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":null,"cwd":"/tmp"}}' \
  | cargo agents hook symposium pre-tool-use

Example: blocking destructive commands

#![allow(unused)]
fn main() {
{{#include ../../symposium-hook/examples/block_destructive.rs}}
}

Example: injecting context on session start

#![allow(unused)]
fn main() {
{{#include ../../symposium-hook/examples/inject_context.rs}}
}

Reference

The reference defines the behavior of the Symposium system in detail.

The cargo agents command

cargo agents manages agent extensions for Rust projects. It discovers skills based on your project’s dependencies and configures your AI agent to use them.

Subcommands

CommandDescription
cargo agents initSet up user-wide configuration
cargo agents syncSynchronize skills with workspace dependencies
cargo agents searchSearch configured registries for plugins
cargo agents useEnable a plugin by name (--remove to disable)
cargo agents statusShow which plugins are enabled for this workspace, and why
cargo agents pluginManage plugin sources
cargo agents self-updateUpdate symposium to the latest version
cargo agents crate-infoFind crate sources (agent-facing)

Global options

FlagDescription
-v, --verbosePrint detailed decision trace (which plugins matched, which skills were considered, etc.)
--jsonOutput structured JSON report to stdout; suppresses human-readable output. Combine with -v to include the full decision trace.
--update <LEVEL>Plugin source update behavior: none (default), check, fetch
-q, --quietSuppress status output
--helpPrint help
--versionPrint version

The -v and --json flags work with sync, plugin list, and plugin validate. During hook dispatch, decision events are emitted at debug level and appear in verbose output when testing hooks.

cargo agents init

Set up Symposium for the current user.

Usage

cargo agents init [OPTIONS]

Behavior

Prompts for which agents you use (e.g., Claude Code, Copilot, Gemini) and where to install hooks, writes ~/.symposium/config.toml, and registers hooks for each selected agent.

If a user config already exists, init updates it (preserving existing settings not affected by the flags).

Options

FlagDescription
--add-agent <name>Add an agent (e.g., claude, copilot, gemini). Repeatable. Skips the interactive prompt.
--remove-agent <name>Remove an agent. Repeatable.
--hook-scope <scope>Where to install hooks: global (default, writes to ~/) or project (writes to the project directory).

Examples

Interactive setup:

cargo agents init

Non-interactive, specifying agents directly:

cargo agents init --add-agent claude --add-agent gemini

Adding an agent to an existing config:

cargo agents init --add-agent copilot

Removing an agent:

cargo agents init --remove-agent gemini

cargo agents sync

Synchronize skills with workspace dependencies.

Usage

cargo agents sync

With the global -v flag, sync additionally shows each plugin, skill group, and skill that was evaluated and why each was included or skipped. With --json, stdout receives a JSON array of structured event objects (see global options).

Behavior

Must be run from within a Rust workspace. Performs the following steps:

  1. Find workspace root — runs cargo metadata to locate the workspace.

  2. Scan dependencies — reads the full dependency graph from the workspace.

  3. Discover applicable skills — loads plugin sources (from user config) and matches skill predicates against workspace dependencies.

  4. Install skills — for each configured agent, copies applicable SKILL.md files into the agent’s expected skill directory (e.g., .claude/skills/ for Claude Code, .agents/skills/ for Copilot/Gemini/Codex). A .gitignore containing * is written into every new skill directory (and its skills/ parent if new), and an empty .symposium marker file is dropped into each installed skill directory.

  5. Mirror workspace skills — if agents-syncing is enabled (default), user-authored skills in <workspace>/.agents/skills/ are propagated into the skill directories of any configured agent that doesn’t natively use .agents/skills/ (e.g., .claude/skills/, .kiro/skills/). See Workspace skills.

  6. Clean up stale skills — scans every agent’s skills parent directory and removes any subdirectory containing the .symposium marker that wasn’t installed (or propagated) this sync. Directories without the marker (user-managed) are left untouched.

  7. Register hooks — ensures hooks and MCP servers are registered for all configured agents. Registers both global hooks (for all projects) and project-specific hooks (for the current project). Unregisters hooks for agents no longer in the config.

Before syncing, an interactive cargo agents sync asks about each dependency whose source embeds an agent plugin that you have not decided about yet. Depending on a crate means compiling its code, not letting its author inject agent context, so these stay off until you say otherwise. Three answers:

  • Ask me later (the default) — records nothing; you are asked again next time.
  • Enable — recorded in [plugins] auto-enable, and installed by this same sync.
  • No — don’t ask again — recorded in [plugins] disable.

Only explicit answers are recorded, so hitting Enter through the prompt never permanently declines anything. Escape leaves the remaining questions undecided.

The prompt only runs in a real terminal session. The automatic sync below — and anything else an agent triggers — never prompts; there, pending candidates are named in the SessionStart context instead, and cargo agents status lists them as candidate.

Automatic sync

By default (auto-sync = true), cargo agents sync runs automatically during hook invocations. This keeps skills in sync with workspace dependencies without manual intervention. Set auto-sync = false in the user config to disable this and sync manually.

Example

# One-time setup
cargo agents init --add-agent claude

# Sync skills for the current workspace
cargo agents sync

cargo agents search

Find plugins across every configured registry.

Usage

cargo agents search <QUERY>

Options

FlagDescription
<QUERY>Name, or name fragment, to look for

Behavior

The query is a case-insensitive substring match — the same looseness cargo search has. Results come from two arms and are printed grouped by the instance each hit came from:

  1. Already loaded — plugin names in the plugin registry (a bare SKILL.md is loaded as a plugin, so it appears here too). A configured registry is a trust root, so a hit here is available now, with no use needed (unless the plugin is dormant, which is noted).
  2. Offered by a package manager — each configured registry’s package manager is searched in turn.

A package manager without a searchable registry contributes nothing rather than failing, and an instance that errors outright (an offline registry, say) is skipped — so search degrades to the results it can get instead of failing the command.

With --json, each hit is emitted as a search_match event carrying its origin, name, and — where the registry provides them — version and description.

Example

$ cargo agents search widget
ℹ️  from user-plugins:
  widget-guidance
      Guidance for working with widgets
ℹ️  from symposium-recommendations:
  widget-skills 1.2.3
      Skills for widget

Pass a name from the output to cargo agents use to enable it.

cargo agents use

Enable a plugin by name, and sync it into the workspace immediately.

Usage

cargo agents use <NAME> [--global]
cargo agents use <NAME> --remove [--global]

Options

FlagDescription
<NAME>Plugin or crate name to enable
--globalEnable for every workspace instead of just the current one
--removeDrop a previously recorded enablement instead of adding one

Behavior

use is the durable, by-name form of consent. It records a use entry in the [plugins] section of the user config:

[plugins]
use = [
  "everywhere-plugin",                                    # --global
  { name = "crate-a", workspace = "/home/me/my-project" }, # default
]

Then it runs a sync, so the plugin’s skills install right away rather than waiting for the next one.

Two things a use entry can enable:

  • A dependency’s embedded plugin. Depending on a crate means compiling its code, not letting its author inject agent context, so a dependency is not a trust root — its embedded plugin stays off until you say otherwise. use is the by-name way to say so ([plugins] auto-enable is the ahead-of-time way).
  • A dormant registry plugin. A plugin whose manifest names no dependency has nothing to gate it on, so it loads dormant. A use entry naming it is what wakes it.

Anything a configured registry already offers under a depends-on gate is enabled by configuration — pointing config at a registry is the act of trusting its curation — so use-ing it is a no-op and reports as such.

Enablement is not activation: use only adds to what may run. The plugin’s own predicates still decide when it applies.

The name must resolve to something before it is recorded — a dormant registry plugin, a workspace dependency, or a registry search hit — otherwise the command errors and writes nothing. Use cargo agents search to find the right name.

--remove

Removes the entry in the matching scope: without --global the entry recorded for the current workspace, with it the unscoped one. A scope mismatch (or no entry at all) is an error rather than a silent success. The sync that follows reaps the plugin’s installed skills.

Example

cargo agents search widget      # find it
cargo agents use widget-skills  # enable it here
cargo agents status             # confirm why it is on
cargo agents use widget-skills --remove

cargo agents status

Show which plugins are enabled for this workspace, and why.

Usage

cargo agents status

Must be run from within a Rust workspace.

Behavior

Symposium separates two questions. Enablement asks whether a plugin may run at all; activation predicates ask when it applies. status reports both, one line per plugin, each naming its enablement root — so it answers “why is this here?” with “enabled via serde”.

Each line is in one of four states:

StateMeaning
activeEnabled and its predicates hold here. The root names the trust root: workspace membership, a configured registry, [plugins] auto-enable, or a [plugins] use entry.
dormantLoaded but contributing nothing: a registry plugin awaiting cargo agents use, or one whose predicates don’t currently hold.
candidateDiscovered in a dependency and awaiting consent. These are exactly what an interactive cargo agents sync asks about.
declinedRecorded in [plugins] disable — the record of pruned plugins and declined discoveries.

Discovery is cache-only, so a dependency whose source has not been fetched yet is simply not listed as a candidate. Enabling it by name still works.

With --json, each line is emitted as a plugin_status event carrying name, state, root, and — for a discovered dependency plugin — the resolved version.

Example

$ cargo agents status
✅ my-tool — workspace member
✅ serde-skills 1.0.0 — `[plugins] use`
💤 team-conventions — registry `user-plugins` (dormant: awaiting `cargo agents use`)
❓ widget-lib 0.3.1 — found via dependency `widget-lib`, awaiting consent (`cargo agents use widget-lib`)
➖ noisy-crate — declined (`[plugins] disable`)

cargo agents self-update

Update symposium to the latest version.

Usage

cargo agents self-update

Behavior

  1. Check for updates — runs cargo search against the configured registry to find the latest published version of symposium. If the installed version is already current, prints a message and exits.

  2. Install — runs cargo install symposium --force to build and install the latest version.

Configuration

The auto-update key in ~/.symposium/config.toml controls update behavior. It is also configurable during cargo agents init.

auto-update

ValueBehavior
"on" (default)Check the registry at most once per 24 hours. When a newer version is found, automatically install it and re-execute the current command with the new binary.
"warn"Check the registry at most once per 24 hours. Print a message when a newer version is available. During hook invocations, the nudge is included in the session-start hook’s additionalContext.
"off"Never check for updates.

The 24-hour throttle is tracked in ~/.symposium/state.toml. The check is skipped for self-update itself (which always checks unconditionally).

State file

~/.symposium/state.toml tracks:

  • version — the semver of the binary that last ran. Updated on every invocation. Future versions can use a version mismatch to trigger migrations.
  • last-update-check — timestamp of the last registry query. Used to throttle checks to once per 24 hours.

Examples

Manual update:

cargo agents self-update

Disable all update checks:

# ~/.symposium/config.toml
auto-update = "off"

Warn instead of auto-updating:

# ~/.symposium/config.toml
auto-update = "warn"

cargo agents plugin

Manage plugin sources.

Usage

cargo agents plugin <SUBCOMMAND>

Subcommands

cargo agents plugin sync

cargo agents plugin sync [PROVIDER]

Fetch or update git-based plugin sources. If a provider name is given, syncs only that provider (ignoring auto-update settings). If omitted, syncs all providers that have auto-update = true.

cargo agents plugin list

cargo agents plugin list

List all configured plugin sources and the plugins they provide.

cargo agents plugin show

cargo agents plugin show <PLUGIN>

Show details for a specific plugin, including its TOML configuration and source file path.

cargo agents plugin validate

cargo agents plugin validate <PATH> [--no-check-crates]

Validate a plugin source directory or a single TOML manifest file. Useful when authoring plugins.

FlagDescription
<PATH>Path to a directory or a single .toml file
--no-check-cratesSkip checking that crate names in predicates exist on crates.io

cargo agents crate-info

Find crate sources and guidance.

This is an agent-facing command, listed under “Commands for agents” in cargo agents --help. Its output format and exit codes may change in future releases.

Usage

cargo agents crate-info <NAME> [--version <VERSION>]

Behavior

Fetches the crate’s source code and returns:

  • Path to the extracted crate source
  • Available skills for the crate

Options

FlagDescription
<NAME>Crate name to get guidance for
--version <VERSION>Version constraint (e.g., 1.0.3, ^1.0). Defaults to the workspace version or latest.

cargo agents telemetry

Manage opt-in, per-user usage telemetry. See the telemetry design chapter for the event format and the [telemetry] configuration for the underlying config key.

Telemetry is off by default, local-first, and never uploaded automatically — you share it yourself. The preference is also offered during cargo agents init.

Usage

cargo agents telemetry [status]      # whether enabled, where data lives, how much is stored (default)
cargo agents telemetry enable        # turn on collection (writes [telemetry] enabled = true)
cargo agents telemetry disable       # turn it off
cargo agents telemetry show [--count N]   # print recent events (JSON lines) for inspection

Where the data lives

When recording is wired in, anonymous events will be appended as JSON lines to per-day files under ~/.symposium/telemetry/ (e.g. events-2026-06-23.jsonl), with files older than 30 days rolled off automatically. Events record counts and coarse metadata only (session starts, prompts, tool names) — no prompt text, command lines, or file paths.

Unstable agent commands

The commands in this section are invoked by AI agents, not by users directly. They are hidden from cargo agents --help, and their arguments, output format, and exit codes may change in future releases without notice.

Currently this is cargo agents hook, the hook protocol entry point. (crate-info is also agent-facing but is no longer hidden — it appears under “Commands for agents” in cargo agents --help.)

cargo agents hook

Entry point invoked by your agent’s hook system. This is an internal command — you generally don’t need to run it yourself.

Usage

cargo agents hook <AGENT> <EVENT>

Behavior

When your agent triggers a hook event, it calls cargo agents hook with the agent name and event type. The hook does two things:

  1. Auto-sync (if enabled) — when auto-sync = true in the user config, runs cargo agents sync to ensure skills are current for the workspace. The workspace root is resolved from the hook payload’s cwd field; if the payload does not include a working directory, the process’s current working directory is used as a fallback. Failures are logged but don’t block hook dispatch.

  2. Dispatches to plugin hooks — runs any hook handlers defined by plugins for the given event.

Events

The specific events depend on which agent you are using. cargo agents init configures the hook registration appropriate for your agents.

When is the hook invoked?

The hook is registered globally during cargo agents init. It runs automatically when your agent triggers supported events (e.g., session start, tool use).

Supported agents

Symposium supports seven AI coding agents. Each agent gets skill installation; hook support varies by agent.

Claude Code

Config name: claude

Skills

ScopePath
Project.claude/skills/<name>/SKILL.md
Global~/.claude/skills/<name>/SKILL.md

Claude Code does not support the vendor-neutral .agents/skills/ path.

Hooks

Symposium merges hook entries into Claude Code’s settings.json.

ScopeFile
Project.claude/settings.json
Global~/.claude/settings.json

Events registered: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart (PascalCase).

Output format: JSON with hookSpecificOutput wrapper. Exit code 2 blocks tool use.

MCP servers

ScopeFileKey
Project.claude/settings.jsonmcpServers.<name>
Global~/.claude/settings.jsonmcpServers.<name>

GitHub Copilot

Config name: copilot

Skills

ScopePath
Project.agents/skills/<name>/SKILL.md
Global(none)

Copilot has no global skills path.

Hooks

Symposium creates a symposium.json file in the project hooks directory, and merges entries into the global config.

ScopeFile
Project.github/hooks/symposium.json
Global~/.copilot/config.json

Events registered: preToolUse, postToolUse, userPromptSubmitted, sessionStart (camelCase).

Output format: JSON. Uses "bash" key instead of "command" for platform-specific dispatch. Any non-zero exit code denies (not just exit 2).

MCP servers

ScopeFileKey
Project.vscode/mcp.json<name> (top-level)
Global~/.copilot/mcp-config.json<name> (top-level)

Gemini CLI

Config name: gemini

Skills

ScopePath
Project.agents/skills/<name>/SKILL.md
Global~/.gemini/skills/<name>/SKILL.md

Hooks

Symposium merges hook entries into Gemini’s settings.json.

ScopeFile
Project.gemini/settings.json
Global~/.gemini/settings.json

Events registered: BeforeTool, AfterTool, BeforeAgent, SessionStart (Gemini’s own naming).

Output format: JSON with nested matcher groups. Timeouts in milliseconds.

MCP servers

ScopeFileKey
Project.gemini/settings.jsonmcpServers.<name>
Global~/.gemini/settings.jsonmcpServers.<name>

Codex CLI

Config name: codex

Skills

ScopePath
Project.agents/skills/<name>/SKILL.md
Global~/.agents/skills/<name>/SKILL.md

Hooks

Symposium merges hook entries into Codex’s hooks.json.

ScopeFile
Project.codex/hooks.json
Global~/.codex/hooks.json

Events registered: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart (PascalCase).

Output format: JSON. Exit code 2 blocks tool use.

Caveat: Codex hooks are experimental and disabled by default. To enable, add to ~/.codex/config.toml:

[features]
codex_hooks = true

MCP servers

ScopeFileKey
Project.codex/config.toml[mcp_servers.<name>]
Global~/.codex/config.toml[mcp_servers.<name>]

Kiro

Config name: kiro

Skills

ScopePath
Project.kiro/skills/<name>/SKILL.md
Global~/.kiro/skills/<name>/SKILL.md

Kiro uses its own skill path, not the vendor-neutral .agents/skills/.

Hooks

Kiro requires hooks to be registered with a named agent. We create a symposium agent by creating a symposium.json agent definition file in .kiro/agents/. This registers Symposium as a Kiro agent with hooks attached. If you use a different agent, you won’t benefit from hook-based features like token reduction unless you manually add the hooks into your agent definition.

ScopeFile
Project.kiro/agents/symposium.json
Global~/.kiro/agents/symposium.json

Events registered: preToolUse, postToolUse, userPromptSubmit, agentSpawn (camelCase; agentSpawn maps to session-start internally).

Output format: plain text on stdout (not JSON). Exit code 2 blocks preToolUse only.

The generated agent file includes "tools": ["*"] (all tools available) and "resources": ["skill://.kiro/skills/**/SKILL.md"] (auto-discover skills). Without tools, a Kiro custom agent has zero tools.

Caveat: Kiro uses a flat hook entry format ({ "command": "..." }) unlike the nested format used by Claude/Gemini/Codex. Unregistration deletes the symposium.json file entirely.

MCP servers

ScopeFileKey
Project.kiro/settings/mcp.jsonmcpServers.<name>
Global~/.kiro/settings/mcp.jsonmcpServers.<name>

OpenCode

Config name: opencode

Skills

ScopePath
Project.agents/skills/<name>/SKILL.md
Global~/.agents/skills/<name>/SKILL.md

Hooks

OpenCode does not support shell-command hooks. Its extensibility is based on TypeScript/JavaScript plugins. Symposium cannot register hooks for OpenCode.

OpenCode is supported as a skills-only agent — cargo agents sync will install skill files, but no hooks are registered.

MCP servers

ScopeFileKey
Projectopencode.jsonmcp.<name>
Global~/.config/opencode/opencode.jsonmcp.<name>

Goose

Config name: goose

Skills

ScopePath
Project.agents/skills/<name>/SKILL.md
Global~/.agents/skills/<name>/SKILL.md

Hooks

Not supported. Goose has no hook system. It uses MCP extensions for extensibility.

Skill files are installed but cargo agents hook will never be called by this agent.

MCP servers

ScopeFileKey
Project.goose/config.yamlextensions.<name>
Global~/.config/goose/config.yamlextensions.<name>

Configuration

cargo agents uses a single user-wide configuration file at ~/.symposium/config.toml. Created by cargo agents init.

Full example

auto-sync = true
agents-syncing = true
hook-scope = "global"
auto-update = "on"

[[agent]]
name = "claude"

[[agent]]
name = "gemini"

[logging]
level = "info"

[defaults]
symposium-recommendations = true
user-plugins = true

[[registry]]
name = "my-org"
git = "https://github.com/my-org/symposium-plugins"

[[registry]]
name = "local-dev"
path = "my-plugins"

Top-level keys

KeyTypeDefaultDescription
auto-syncbooltrueAutomatically run cargo agents sync during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention.
agents-syncingbooltrueInclude each workspace plugin’s .agents/skills/ default skill group, so skills you author there install into every configured agent’s skill directory (such as .claude/skills/ or .kiro/skills/). Skills that symposium itself installed — identified by the .symposium marker file — are never treated as sources. See Workspace skills for the user-guide overview, or Agents syncing below for details.
hook-scopestring"global"Where agent hooks are installed. "global" writes to the user’s home directory (e.g., ~/). "project" writes to the project directory, keeping hooks local to the workspace.
auto-updatestring"on"Controls automatic update behavior. "off" disables update checks entirely. "warn" checks the registry (at most once per 24 hours) and prints a message when a newer version is available. "on" automatically installs the update via cargo install and re-executes the command with the new binary.

Agents syncing: mirror user-authored skills

Agents such as Copilot, Gemini, Codex, Goose, and OpenCode all read skills from the vendor-neutral .agents/skills/ directory, but Claude Code and Kiro use their own paths (.claude/skills/ and .kiro/skills/). When agents-syncing is enabled, every workspace plugin — the workspace root and each member directory — carries a second default skill group, gated by the workspace-member() predicate:

[[skills]]
predicates = ["workspace-member()"]
source.path = ".agents/skills"

Skills you author in .agents/skills/ therefore flow through the same pipeline as every other skill and install into each configured agent’s own skill directory, so a single authored copy is visible to every agent. The workspace-member() gate is what keeps these maintainer skills from installing for dependents of a published crate — they apply only while working in the workspace itself.

Two .symposium-marker rules keep sources and copies distinct (symposium never writes a marker into a source, only into directories it installs):

  • Skill discovery skips marker-bearing directories, so copies symposium installed into .agents/skills/ (for agents that read it natively) are never re-discovered as sources.
  • For an agent whose skill directory is .agents/skills/, a skill whose source already sits at its install slot is left in place — nothing is copied.

Installed copies receive the same marker and * .gitignore that plugin-installed skills get, which means: updates to the source are re-copied on each sync; removing the source removes the copies on the next sync (the normal stale-skill reap); disabling agents-syncing = false does the same; and a pre-existing user-managed directory in a target is never overwritten (the skill installs under a suffixed name instead).

Because these are real skills now, SKILL.md frontmatter must carry name and description like any other skill definition.

Hook scope: control whether Symposium activates in all projects or only those you select

Registering hooks globally ensures that Symposium activates whenever you use the selected agent, which means that it will work in any Rust project automatically.

Registering hooks at the project level requires you to run cargo agents sync within each project at least once to create the hooks. After that, the auto-sync feature will keep you up-to-date.

[[agent]]

Each [[agent]] entry identifies an agent you use. You can configure multiple agents.

KeyTypeDefaultDescription
namestring(required)Agent name: claude, codex, copilot, gemini, goose, kiro, or opencode.

[logging]

KeyTypeDefaultDescription
levelstring"info"Minimum log level. One of: trace, debug, info, warn, error.

[telemetry]

Opt-in, per-user usage telemetry. Off by default. When enabled, Symposium appends anonymous events as JSON lines to a local, per-day log under ~/.symposium/telemetry/. Nothing is uploaded automatically — you inspect and share the data yourself with cargo agents telemetry show. The preference is also collected during cargo agents init. See the telemetry design chapter for the event format.

KeyTypeDefaultDescription
enabledboolfalseRecord anonymous usage events (session starts, prompts, tool usage — counts and metadata only, no prompt or command content). Toggle with cargo agents telemetry enable / disable.
[telemetry]
enabled = true

[defaults]

Controls the two built-in registries. Both are enabled by default.

KeyTypeDefaultDescription
symposium-recommendationsbooltrueFetch plugins from the symposium-dev/recommendations repository.
user-pluginsbooltrueScan ~/.symposium/plugins/ for user-defined plugins.

[[registry]]

Defines additional registries — directories or repositories offering plugins. Each entry must have exactly one of git or path. [[plugin-source]] is the retired spelling of this table and is still accepted.

KeyTypeDefaultDescription
namestring(required)A name for this registry. Used in logs and cache paths, and to attribute the plugins loaded from it.
gitstringRepository URL. Fetched and cached under ~/.symposium/cache/plugin-sources/, then read as a local directory.
pathstringLocal directory containing plugins. Relative paths are resolved from ~/.symposium/.
auto-updatebooltrueCheck for updates on startup. Only applies to git registries.

[plugins]

Enablement: which plugins are allowed to run at all, as distinct from the predicates that decide when an enabled plugin applies.

Symposium trusts two things without asking: the workspace you are in, and the registries it is configured with. A registry exists to curate plugins, so enabling one is the act of accepting its curation. Both built-in registries count here and are on by default — user-plugins is your own directory, while symposium-recommendations is a list curated by the Symposium project and trusted until you turn it off in [defaults].

Your dependency list is deliberately not a trust root. Depending on a crate means compiling its code; it should not silently let the crate’s author add instructions to your agent. So a plugin embedded in a dependency runs only once you say so, and a registry plugin that names no dependency anywhere is dormant — loaded and listed, but inactive — until you enable it by name.

Trust follows whoever supplies the content, not the package the content is about: a registry entry recommending a plugin for serde is the registry’s own content and is trusted, while serde’s embedded plugin is not. One consequence is worth knowing: a trusted plugin may name a crate with a [[plugins]] chained reference, and that crate’s plugin content then loads without a [plugins] entry of its own — the registry is vouching for it.

KeyTypeDefaultDescription
auto-enablearray of strings[]Dependency names whose embedded plugins load without being asked about. "*" pre-consents to every dependency.
usearray[]Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace) or { name = "...", workspace = "/path" } (enabled only while working in that workspace root).
disablearray of strings[]Names that must never be enabled. Takes precedence over auto-enable, including over "*".

Names are matched hyphen/underscore-insensitively, like crate names: widget-lib and widget_lib are the same entry.

[plugins]
auto-enable = ["widget-lib"]
disable = ["noisy-crate"]
use = ["standalone-plugin", { name = "team-tools", workspace = "/home/me/work/service" }]

use is what wakes a dormant plugin, and it also enables a plugin whether or not any dependency references it. auto-enable is narrower: it is consent for what a dependency you already have carries with it.

You rarely edit this section by hand. cargo agents use writes and removes use entries; the consent prompt in an interactive cargo agents sync writes auto-enable and disable; and cargo agents status reports what the section currently decides.

Directory resolution

User-wide data lives under ~/.symposium/ by default. Override with environment variables:

ConfigCacheLogs
SYMPOSIUM_HOME$SYMPOSIUM_HOME/$SYMPOSIUM_HOME/cache/$SYMPOSIUM_HOME/logs/
XDG$XDG_CONFIG_HOME/symposium/$XDG_CACHE_HOME/symposium/$XDG_STATE_HOME/symposium/logs/
Default~/.symposium/~/.symposium/cache/~/.symposium/logs/

SYMPOSIUM_HOME takes precedence over XDG variables.

File locations

PathPurpose
~/.symposium/config.tomlUser configuration
~/.symposium/state.tomlPersistent state (binary version stamp, last update check)
~/.symposium/telemetry/Telemetry event log, one JSONL file per day (created when [telemetry] enabled = true and events are recorded)
~/.symposium/plugins/User-defined plugins
~/.symposium/cache/Cache directory (crate sources, plugin sources)
~/.symposium/logs/Log files (one per invocation, timestamped)

Plugin sources

A plugin source is a directory or repository containing plugins and standalone skills that Symposium discovers automatically. Plugin sources can be local directories or remote Git repositories, and Symposium searches them recursively to find all available extensions.

Discovery rules

Symposium scans a plugin source recursively to find plugins and standalone skills:

  • A plugin is a directory that contains a SYMPOSIUM.toml file;
  • A directory that contains a SKILL.md and no SYMPOSIUM.toml is loaded as a plugin with default values — named for the skill’s frontmatter name, with the skill’s depends-on acting as the plugin’s activation gate. depends-on is optional: a skill that names no dependency loads dormant (it activates once you enable it by name with cargo agents use), exactly like a gateless SYMPOSIUM.toml plugin.

We do not allow these entries to be nested within one another. When we find a directory that is either a plugin or a skill, we do not search its contents any further.

Example structure

plugin-source/
  my-plugin/
    SYMPOSIUM.toml        # ✓ Plugin
    skills/               # ✗ Not searched (parent claimed)
      basic/
        SKILL.md
  serde-skill/
    SKILL.md              # ✓ Standalone skill
  nested/
    deep/
      tokio-skill/
        SKILL.md          # ✓ Standalone skill (found recursively)
  mixed/
    SYMPOSIUM.toml        # ✓ Treated as plugin
    SKILL.md              # ✗ Ignored (plugin takes precedence)

Configuration

Plugin sources are configured in your config.toml file. See the Configuration reference for details on setting up local directories, Git repositories, and built-in sources.

Validation

You can validate a plugin source directory:

# Validate all plugins and skills in a directory
cargo agents plugin validate path/to/plugin-source/

# Also verify that crate names exist on crates.io (on by default; use --no-check-crates to skip)
cargo agents plugin validate path/to/plugin-source/ --no-check-crates

This scans the directory, attempts to load all plugins and skills, and reports any errors found.

Plugin definitions

A symposium plugin collects together all the extensions offered for a particular crate. Plugins are directories containing a SYMPOSIUM.toml manifest file that references skills, hooks, MCP servers, and other resources relevant to your crate. These extensions can be packaged within the plugin directory or the plugin can contain pointers to external repositories.

Plugins enable capabilities beyond standalone skills — they’re needed when you want to add hooks or MCP servers. For simple skill publishing, see Authoring a plugin instead.

Example: a plugin definition with inline skills

You could define a plugin definition with inline skills by having a directory struct like this:

myplugin/
  SYMPOSIUM.toml
  skills/
    skill-a/
      SKILL.md
    skill-b/
      SKILL.md

where myplugin/SYMPOSIUM.toml is as follows:

name = "example"
depends-on = ["*"]

[[skills]]
source.path = "skills"

Top-level fields

FieldTypeRequiredDescription
namestringyesPlugin name. Used in logs and CLI output.
depends-onstring or arraynoWhich crates this plugin applies to. Use ["*"] for all crates. See Plugin-level filtering.
predicatesarray of stringsnoPredicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the plugin to apply. See Predicates.
installationsarray of tablesnoNamed installation declarations ([[installations]]). Hooks reference these by name. See Installations.
skillsarray of tablesnoSkill groups ([[skills]]).
hooksarray of tablesnoHooks ([[hooks]]).
predicatearray of tablesnoCustom predicate definitions ([[predicate]]). See Custom predicates.
mcp_serversarray of tablesnoMCP server registrations ([[mcp_servers]]).

Note: A plugin that references no dependency anywhere — at the plugin level, in [[skills]] groups, [[mcp_servers]] entries, or [[plugins]] entries — via a depends-on list or a depends-on(...) predicate is dormant: it loads, but it never activates until the user enables it by name in the [plugins] use config. Use depends-on = ["*"] for a plugin that should always be active. (Workspace plugins are unaffected: membership in the active workspace is itself the gate.)

Plugin-level filtering

The top-level depends-on field controls when the entire plugin is active:

name = "my-plugin"
depends-on = ["serde", "tokio"]  # Only active in projects using serde OR tokio

# OR use wildcard to always apply
depends-on = ["*"]

Plugin-level filtering is combined with skill group filtering using AND logic — both must match for skills to be available.

[[skills]] groups

Each [[skills]] entry declares a group of skills.

FieldTypeDescription
depends-onstring or arrayWhich crates this group advises on. Accepts a single string ("serde") or array (["serde", "tokio>=1.0"]). See Crate predicates for syntax.
predicatesarray of stringsPredicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the group to install. See Predicates.
source.pathstringLocal directory containing skill subdirectories. Resolved relative to the manifest file.
source.gitstringGitHub URL pointing to a directory in a repository (e.g., https://github.com/org/repo/tree/main/skills). Symposium downloads the tarball, extracts the subdirectory, and caches it.

A skill group must have exactly one of source.path or source.git. A crate is no longer a skill-group source; to load a crate’s own skills, name it in a chained plugin.

Chained plugins

A [[plugins]] entry names another plugin that loads whenever this plugin is active — the “a package is a plugin” edge. Today the referenced plugin is a crate, which always loads as a first-class plugin built from its manifest sources (see Crate-embedded manifest below). This is the recommended path for crate authors to ship skills alongside their crate — see Supporting your crate.

FieldTypeDescription
source.cargostring or tableThe crate carrying the plugin. A dependency-atom string ("serde", "serde>=1") or a { name = "...", version = "..." } table.
depends-onstring or arrayGate for this edge — the referenced plugin loads only when these hold (in addition to the owning plugin’s own gate).
predicatesarray of stringsAdditional gate for this edge. See Predicates.
name = "serde-plugin"

# When serde is a dependency, load serde's plugin (its skills).
[[plugins]]
depends-on = ["serde"]
source.cargo = "serde"

The edge’s depends-on decides whether to load the referenced crate; the crate name in source.cargo decides which crate. (This replaces the retired source = "crate" skill-group form, where one depends-on predicate did both jobs.) List several [[plugins]] entries to load several crates.

Only source.cargo is supported today; source.git / source.path chained plugins are reserved and rejected with a clear error.

Crate-embedded manifest

A referenced crate describes its plugin with the ordinary plugin-manifest schema, from two interchangeable sources: a SYMPOSIUM.toml at its source root, and/or a [package.metadata.symposium] table in its Cargo.toml. Both are honored the same as a registry manifest — named [[skills]] groups, per-group predicates, source.path / source.git sources, and further [[plugins]] chained references. The crate’s effective manifest is the two sources merged over the crate defaults (merge order defaults → [package.metadata.symposium]SYMPOSIUM.toml): list entries from both are kept; where the two set the same scalar, the file wins. Each source is parsed leniently — a malformed layer is logged and dropped, and the crate still resolves through the remaining layers (at minimum the default skills/ group).

Because the chained reference is already the gate, a crate manifest may omit name (defaults to the crate) and a top-level depends-on; the default skills/ group is appended unless [defaults] skills = false. A crate with no manifest sources at all still resolves as a plugin whose only content is that default skills/ group.

The opt-out belongs to the referenced crate, not the referrer. The edge decides only whether to load the crate (via its depends-on / predicates); it cannot toggle the crate’s defaults. So for an active edge to crate foo:

  • foo ships nothing → its skills/ directory loads.
  • foo declares [[skills]] source.path = "guidance" → both guidance/ and skills/ load (combined with defaults).
  • foo declares [defaults] skills = false plus a custom group → only the custom group loads.
  • foo declares [defaults] skills = false and nothing else → nothing loads.
  • foo carries its own [[plugins]] source.cargo = "bar"bar resolves the same way, recursively.

Hooks, MCP servers, and subcommands declared in a crate manifest are parsed and validated but not yet dispatched — its skills and further chained references load today.

Delegating to another crate

A crate can delegate to another crate with a [[plugins]] chained reference of its own — the replacement for the retired crate = {..} metadata redirect:

# In the referenced crate's Cargo.toml (or its SYMPOSIUM.toml)
[[package.metadata.symposium.plugins]]
source.cargo = "companion-crate"

Chained references are expanded recursively, with cycle detection (hyphen/underscore-insensitive) and a depth limit of 10. When multiple crates delegate to the same target, its skills install once (dedup by crate name + version). See Supporting your crate for the full crate-author walkthrough.

Installations

An installation describes how to obtain (and optionally pre-configure) something a hook will run. Hooks then reference an installation as their command — either by name (command = "rtk") or inline at the use site (command = { script = "scripts/x.sh" }).

A [[installations]] entry has a name plus any of:

FieldTypeDescription
sourcestringOptional. How to acquire bits onto disk. One of cargo, github, binary (see below). When omitted, no acquisition step runs.
install_commandsarray of stringsOptional. Shell commands run (in order) after the source step. Useful for post-install setup such as aliasing, or when only have manual commands. Each command must exit zero.
requirementsarrayOptional. Other installations to acquire whenever this one is referenced. Strings name [[installations]] entries; tables are inline declarations.
executablestringOptional. Path to a binary to run. For cargo, the binary name (looked up in the install’s bin/ dir). For github / binary, a path inside the acquired tree. With no source, a path on disk.
scriptstringOptional. Same resolution rules as executable, but invoked as sh <path> <args>.
argsarray of stringsOptional. Default invocation arguments.

executable and script are mutually exclusive — pick one. The hook layer applies the same rule, and at most one of executable / script may be set across the hook AND the installation it references. An installation may have neither (then it’s pure setup — useful as a requirements entry). For a hook to run, the chosen layer pair must end up with exactly one runnable.

Inline installations (used as command or as a requirement entry) accept the same fields, including requirements.

Installation sources

cargo

[[installations]]
name = "rg"
source = "cargo"
crate = "ripgrep"
version = "13.0.0"     # optional; defaults to latest stable
executable = "rg"      # the binary to run; if omitted and the crate has a single binary, that one is used
args = ["--version"]   # optional default args

Symposium attempts cargo binstall first, falls back to cargo install, and caches the result under ~/.symposium/cache/binaries/<crate>/<version>/bin/ (passing --root so the install doesn’t pollute ~/.cargo/bin). The chosen executable resolves to <cache>/bin/<executable>. Hooks that depend on this installation get <cache>/bin/ prepended to $PATH, so scripts can invoke the binary by name.

To install from a git repo instead of crates.io, set git:

[[installations]]
name = "tool"
source = "cargo"
crate = "tool"
git = "https://github.com/example/tool"
executable = "tool"   # required for git sources (crates.io is not consulted)

To install into the user’s global cargo location (~/.cargo/bin) instead of a symposium-managed cache, set global = true. No --root is passed; $PATH is not augmented (the binary is expected to already be on $PATH). This can be useful if you are using scripts which require globally-installed programs, or if you want to use tools separately in a CLI.

[[installations]]
name = "rg"
source = "cargo"
crate = "ripgrep"
executable = "rg"
global = true

github

[[installations]]
name = "rtk-hooks"
source = "github"
url = "https://github.com/example/rtk-hooks"
script = "hooks/claude/rtk-rewrite.sh"   # optional; see below
args = ["--format"]

Acquires the repo (or a subtree, if url points at …/tree/<ref>/<path>) into a local cache. The chosen executable / script resolves to a file inside the cached tree.

executable/script may be set on the installation or on the hook (but not both, in any combination). Setting it on the installation pins this entry to a specific file; omitting it lets multiple hooks each pick a different file.

no source

Omit source entirely when you just need to point at a path on disk (or rely on install_commands to put one there):

[[installations]]
name = "tool"
executable = "/usr/local/bin/tool"

Or “shell-only” installations — useful as side-effect requirements:

[[installations]]
name = "setup"
install_commands = [
    "ln -sf $HOME/.cache/foo $HOME/.local/bin/foo",
]

[[hooks]]

Each [[hooks]] entry declares a hook that responds to agent events. For the JSON schemas that symposium-format hooks receive and produce, see Symposium hook events.

FieldTypeDescription
namestringDescriptive name for the hook (used in logs).
eventstringEvent type to match (e.g., PreToolUse).
matcherstring (optional)Which tool invocations to match (e.g., Bash). Omit to match all.
commandstring or tableWhat to run. A string names a [[installations]] entry; a table is an inline installation (promoted to a synthetic entry named after the hook).
executablestring (optional)Path to a binary inside (or relative to) the installation. At most one of executable/script set across hook + installation.
scriptstring (optional)Path to a shell script to run via sh. Same exclusivity rule as executable.
argsarray (optional)Invocation arguments. Forbidden when the installation also declares args.
requirementsarray (optional)Installations to acquire before running. Same shape as command (string name or inline declaration).
agentstring (optional)Restrict the hook to a specific agent (claude, copilot, gemini, kiro, …).
formatstringWire format the handler expects on stdin. symposium (default): symposium converts the agent’s event to its canonical format before delivering. Any agent name (claude, codex, copilot, gemini, kiro): the handler receives that agent’s native wire format. Symposium always intermediates — it never registers plugin hooks directly into agent configs. See Hooks.
predicatesarray (optional)Predicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the hook to dispatch. Evaluated per-dispatch. See Predicates.

Examples

Run a cargo-installed binary as the hook:

[[installations]]
name = "rg"
source = "cargo"
crate = "ripgrep"
executable = "rg"

[[hooks]]
name = "rg-version"
event = "PreToolUse"
command = "rg"
args = ["--version"]

Install rtk as a side requirement and run a hook script from a separate github source:

[[installations]]
name = "rtk"
source = "cargo"
crate = "rtk"

[[installations]]
name = "rtk-hooks"
source = "github"
url = "https://github.com/example/rtk-hooks"

[[hooks]]
name = "rewrite"
event = "PreToolUse"
requirements = ["rtk"]
command = "rtk-hooks"
script = "hooks/claude/rtk-rewrite.sh"
args = ["--format"]

Inline a one-off cargo install directly:

[[hooks]]
name = "rg-test"
event = "PreToolUse"
command = { source = "cargo", crate = "ripgrep", executable = "rg" }
args = ["--version"]

Run a script file on disk (no source):

[[hooks]]
name = "check"
event = "PreToolUse"
command = { script = "scripts/check.sh", args = ["--strict"] }

A cargo install with a post-install step (e.g. to symlink a wrapper script):

[[installations]]
name = "rtk"
source = "cargo"
crate = "rtk"
install_commands = [
    "ln -sf $HOME/.symposium/cache/binaries/rtk/*/bin/rtk $HOME/.local/bin/rtk",
]

[[hooks]]
name = "rtk-rewrite"
event = "PreToolUse"
command = "rtk"
args = ["rewrite"]

Agent-specific hooks

An agent-specific hook expects a particular agent’s native wire format on stdin. Use this when you need full access to an agent’s event schema. Symposium still intermediates — it delivers the input in the declared format (passing through unmodified when the current agent matches, or converting when it doesn’t).

A plugin with a Claude-specific hook and a symposium fallback:

[[hooks]]
name = "check-claude"
event = "PreToolUse"
format = "claude"
command = "my-hook-binary"

[[hooks]]
name = "check-portable"
event = "PreToolUse"
format = "symposium"
command = "my-hook-binary"
args = ["--symposium"]

On Claude, check-claude fires (receives Claude’s native JSON). On other agents, check-portable fires (receives symposium canonical JSON). Only one hook per plugin fires for a given event — symposium picks the best match by format priority.

Requirements

requirements ensures other installations are acquired before the hook runs. Useful when the hook’s command relies on something else being on disk (or eventually on $PATH).

[[hooks]]
name = "uses-rtk-via-script"
event = "PreToolUse"
requirements = ["rtk", { source = "cargo", crate = "ripgrep" }]
command = { script = "scripts/uses-rtk.sh" }

Requirements may also be declared on an [[installations]] entry. Whenever that installation is referenced — as a hook’s command or in another requirements list — its declared requirements are appended (one level, prerequisites first):

[[installations]]
name = "rtk"
source = "cargo"
crate = "rtk"

[[installations]]
name = "rtk-hooks"
source = "github"
url = "https://github.com/example/rtk-hooks"
requirements = ["rtk"]   # rtk gets installed whenever rtk-hooks is used

[[hooks]]
name = "rewrite"
event = "PreToolUse"
command = "rtk-hooks"
script = "hooks/claude/rtk-rewrite.sh"

Requirement installation is best-effort: failures are logged and dispatch continues.

Hook environment

Hooks are spawned with the following extras on top of the parent environment:

VariableWhen setValue
$SYMPOSIUM_DIR_<name>Installation has a symposium-managed cache (scoped cargo, github)Absolute path to the cache / clone directory.
$SYMPOSIUM_<name>Installation resolves to a runnable with an absolute pathAbsolute path to the resolved executable / script.
$PATHOne or more dependencies contribute a runnable with an absolute pathEach runnable’s parent dir is prepended, with the hook’s command first.

<name> is the installation name with non-alphanumeric characters replaced by _ (e.g. rtk-hooksSYMPOSIUM_DIR_rtk_hooks). Both the hook’s command installation and every requirement (recursively, one level via installation-level requirements) contribute.

Global cargo installs (global = true) don’t set $SYMPOSIUM_DIR_<name> or augment $PATH — the binary is expected to already be on the user’s $PATH via ~/.cargo/bin.

install_commands runs before env vars are set. The $SYMPOSIUM_* vars and the augmented $PATH are only available to the hook’s spawned process. install_commands runs earlier, inside the symposium dispatch process, so it cannot reference its own (or any other) installation’s env vars. Use absolute paths in install_commands instead.

Supported hook events

Hook eventDescriptionCLI usage
PreToolUseBefore a tool (e.g., Bash) is invoked by the agent.pre-tool-use
PostToolUseAfter a tool completes.post-tool-use
UserPromptSubmitWhen the user submits a prompt.user-prompt-submit
SessionStartWhen an agent session starts.session-start

Agent → hook name mapping

Tool / EventClaude (claude)Copilot (copilot)Gemini (gemini)
PreToolUsePreToolUsePreToolUseBeforeTool

Hook semantics

  • Exit codes:

    • 0 — success: the hook’s stdout is parsed as JSON and merged into the overall hook result.
    • 2 (or no reported exit code) — treated as a failure: dispatch stops immediately and the hook’s stderr is returned to the caller.
    • any other non-zero code — treated as success for dispatching purposes; stdout is still parsed and merged when possible.
  • Stdout handling: Hooks should write a JSON object to stdout to contribute structured data back to the caller. Valid JSON objects are merged together across successful hooks; keys from later hooks overwrite earlier keys.

  • Stderr handling: If a hook exits with code 2 (or no exit code), dispatch returns immediately with the hook’s stderr as the error message. Otherwise stderr is captured but not returned on success.

Testing hooks

Use the CLI to test a hook with sample input:

echo '{"tool": "Bash", "input": "cargo test"}' | cargo agents hook claude pre-tool-use

You can also use copilot, gemini, codex, or kiro as the agent name.

[[predicate]]

Each [[predicate]] entry defines a custom predicate function that can be used in predicates expressions anywhere a predicate is accepted. Custom predicates extend the built-in predicate language with plugin-specific checks.

FieldTypeDescription
namestringThe predicate name. Must be a valid identifier ([a-zA-Z][a-zA-Z0-9_]*) and must not collide with builtins (depends-on, crate, shell, path_exists, env, workspace-member, not, any, all).
commandstring or tableThe installation to run. Same shape as hook command (a string naming a [[installations]] entry or an inline table).
argsarray of stringsOptional. Static arguments passed to the command before the dynamic argument.

How custom predicates work

Custom predicates are registered globally — a predicate defined in one plugin can be used by any other plugin’s predicates expressions. Registration is unconditional: even if the defining plugin’s own crate predicates don’t match the current workspace, its [[predicate]] entries are still available.

When a predicate expression uses a function name that isn’t a builtin, Symposium looks it up in the custom predicate registry. If found, it spawns the declared command with the static args followed by the raw argument text from the expression.

[[installations]]
name = "cargo-bp-install"
source = "cargo"
crate = "cargo-bp"
executable = "cargo-bp"

[[predicate]]
name = "battery_pack"
command = "cargo-bp-install"
args = ["bp", "status", "--check"]

Usage in a predicates expression:

predicates = ["battery_pack(cli>=0.3)"]

This evaluates as:

cargo-bp bp status --check cli>=0.3

Exit 0 means the predicate passes; non-zero means it fails.

The argument is trimmed of leading/trailing whitespace before being passed. An empty argument — battery_pack() or battery_pack( ) — does not append anything to the command (only the static args are passed).

A custom predicate is a boolean gate only: it passes iff the command exits 0. Its stdout is ignored (the former selectedCrates witness output is retired along with source = "crate").

Collisions

If two plugins define the same predicate name, both definitions are skipped and a warning is emitted. Skills referencing the collided name evaluate as false.

Caching

Results are cached by (predicate_name, raw_arg) for the duration of a single sync run. The same predicate called with the same argument is only spawned once.

[[mcp_servers]]

Each [[mcp_servers]] entry declares an MCP server that Symposium registers into the agent’s configuration during sync --agent.

There are multiple MCP transports:

Stdio

[[mcp_servers]]
name = "my-server"
command = "/usr/local/bin/my-server"
args = ["--stdio"]
env = []
FieldTypeDescription
namestringServer name as it appears in the agent’s MCP config.
depends-onstring or arrayWhich crates this server applies to. Optional if plugin has top-level depends-on.
predicatesarray of stringsPredicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the server to register. See Predicates.
commandstringPath to the server binary.
argsarray of stringsArguments passed to the binary.
envarray of objectsEnvironment variables to set when launching the server.

Stdio entries do not need a type field.

HTTP

[[mcp_servers]]
type = "http"
name = "my-server"
url = "http://localhost:8080/mcp"
headers = []
FieldTypeDescription
typestringMust be "http".
namestringServer name as it appears in the agent’s MCP config.
depends-onstring or arrayWhich crates this server applies to. Optional if plugin has top-level depends-on.
urlstringHTTP endpoint URL.
headersarray of objectsHTTP headers to set when making requests.

SSE

[[mcp_servers]]
type = "sse"
name = "my-server"
url = "http://localhost:8080/sse"
headers = []
FieldTypeDescription
typestringMust be "sse".
namestringServer name as it appears in the agent’s MCP config.
depends-onstring or arrayWhich crates this server applies to. Optional if plugin has top-level depends-on.
urlstringSSE endpoint URL.
headersarray of objectsHTTP headers to set when making requests.

How registration works

During cargo agents sync --agent, each MCP server entry is written into the agent’s config file in the format that agent expects. Registration is idempotent — existing entries with correct values are left untouched, stale entries are updated in place.

When a user runs cargo agents sync (or the hook triggers it automatically), Symposium:

  1. Collects [[mcp_servers]] entries from all enabled plugins.
  2. Writes each server into the agent’s MCP configuration file.

All supported agents have MCP server configuration. Symposium handles the format differences — you declare the server once and it works across agents.

AgentConfig locationKey
Claude Code.claude/settings.jsonmcpServers.<name>
GitHub Copilot.vscode/mcp.json<name> (top-level)
Gemini CLI.gemini/settings.jsonmcpServers.<name>
Codex CLI.codex/config.toml[mcp_servers.<name>]
Kiro.kiro/settings/mcp.jsonmcpServers.<name>
OpenCodeopencode.jsonmcp.<name>
Goose~/.config/goose/config.yamlextensions.<name>

Example: full manifest

name = "widgetlib"
depends-on = ["widgetlib"]

# Skills shipped inside the widgetlib crate source (in skills/)
[[plugins]]
source.cargo = "widgetlib"

# Additional skills hosted in a git repo
[[skills]]
depends-on = ["widgetlib=1.0"]
source.git = "https://github.com/org/widgetlib/tree/main/symposium/serde-skills"

[[hooks]]
name = "check-widget-usage"
event = "PreToolUse"
matcher = "Bash"
command = { source = "local", command = "./scripts/check-widget.sh" }

[[mcp_servers]]
name = "widgetlib-mcp"
command = "/usr/local/bin/widgetlib-mcp"
args = ["--stdio"]
env = []

Validation

cargo agents plugin validate path/to/symposium.toml

This parses the manifest and reports any errors. Crate name checking against crates.io is on by default; use --no-check-crates to skip it.

Symposium hook events

This page documents the JSON schemas for symposium-format hooks — the input your hook receives on stdin and the output it should write to stdout. Symposium converts to and from each agent’s native wire format, so you only need to handle these canonical types.

Events

EventDescription
PreToolUseBefore the agent invokes a tool. Can inject context or modify the tool input.
PostToolUseAfter a tool completes. Can inject context.
UserPromptSubmitWhen the user submits a prompt. Can inject context.
SessionStartWhen an agent session begins. Can inject context.
StopWhen an agent session/turn ends.

Input schemas

Your hook receives one of the following JSON objects on stdin, depending on which event it is registered for.

PreToolUse

{
  "PreToolUse": {
    "tool_name": "Bash",
    "tool_input": { "command": "cargo test" },
    "session_id": "abc-123",
    "cwd": "/home/user/project"
  }
}
FieldTypeDescription
tool_namestringName of the tool being invoked.
tool_inputobjectArguments the agent is passing to the tool.
session_idstring or nullAgent session identifier, if available.
cwdstring or nullWorking directory of the agent.

PostToolUse

{
  "PostToolUse": {
    "tool_name": "Bash",
    "tool_input": { "command": "cargo test" },
    "tool_response": { "stdout": "test result: ok" },
    "session_id": "abc-123",
    "cwd": "/home/user/project"
  }
}
FieldTypeDescription
tool_namestringName of the tool that was invoked.
tool_inputobjectArguments passed to the tool.
tool_responseobjectThe tool’s response/output.
session_idstring or nullAgent session identifier, if available.
cwdstring or nullWorking directory of the agent.

UserPromptSubmit

{
  "UserPromptSubmit": {
    "prompt": "Fix the failing test in src/lib.rs",
    "session_id": "abc-123",
    "cwd": "/home/user/project"
  }
}
FieldTypeDescription
promptstringThe text the user submitted.
session_idstring or nullAgent session identifier, if available.
cwdstring or nullWorking directory of the agent.

SessionStart

{
  "SessionStart": {
    "session_id": "abc-123",
    "cwd": "/home/user/project"
  }
}
FieldTypeDescription
session_idstring or nullAgent session identifier, if available.
cwdstring or nullWorking directory of the agent.

Stop

{
  "Stop": {
    "session_id": "abc-123",
    "cwd": "/home/user/project"
  }
}
FieldTypeDescription
session_idstring or nullAgent session identifier, if available.
cwdstring or nullWorking directory of the agent.

Output schemas

Your hook writes a JSON object to stdout. The object is wrapped in an enum tag matching the event, just like the input.

PreToolUse output

{
  "PreToolUse": {
    "additionalContext": "Remember to use --release for benchmarks",
    "updatedInput": { "command": "cargo test --release" }
  }
}
FieldTypeDescription
decision"allow" or "deny"Whether to allow or block the tool call. Defaults to "allow" and may be omitted.
additionalContextstring or nullText injected into the agent’s context for this tool call.
updatedInputobject or nullReplacement tool input. If set, overrides the original tool_input.

PostToolUse output

{
  "PostToolUse": {
    "additionalContext": "Note: 3 tests were skipped due to missing fixtures"
  }
}
FieldTypeDescription
additionalContextstring or nullText injected into the agent’s context after the tool result.

UserPromptSubmit output

{
  "UserPromptSubmit": {
    "additionalContext": "Relevant context: this project uses tokio 1.x"
  }
}
FieldTypeDescription
additionalContextstring or nullText injected into the agent’s context for this prompt.

SessionStart output

{
  "SessionStart": {
    "additionalContext": "symposium 0.5.0 is available (current: 0.4.2). Run `cargo agents self-update` to upgrade."
  }
}
FieldTypeDescription
additionalContextstring or nullText injected into the agent’s context at session start.

Stop output

{
  "Stop": {
    "additionalContext": "Things look good!"
  }
}
FieldTypeDescription
additionalContextstring or nullText injected into the agent’s context when the session/turn ends.

Exit codes

CodeMeaning
0Success. Stdout is parsed as JSON and merged into the hook result.
2Block. The action is blocked and stderr is returned to the agent as the reason.
Other non-zeroWarning. The hook is considered to have succeeded for dispatch purposes; stdout is still parsed if possible.

Matcher

The matcher field on a hook entry is a regex matched against tool_name for PreToolUse and PostToolUse events. For UserPromptSubmit, SessionStart, and Stop, the matcher is ignored (all hooks fire). Use "*" to match all tools.

Testing

You can test a symposium-format hook directly from the command line:

echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":null,"cwd":"/tmp"}}' \
  | ./scripts/check.sh

Or via the cargo agents hook CLI with the symposium format:

echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"cargo test"},"session_id":null,"cwd":"/tmp"}}' \
  | cargo agents hook symposium pre-tool-use

Skill definitions

A skill is a SKILL.md file inside a skill directory. Skills follow the agentskills.io format.

Skills can be supplied by a plugin or by adding skills into the .agents/skills directory within the workspace.

Directory layout

skills/
  my-skill/
    SKILL.md
    scripts/       # optional
    resources/     # optional

SKILL.md format

A SKILL.md file has YAML frontmatter followed by a markdown body:

---
name: serde-basics
description: Basic guidance for serde usage
depends-on: serde
---

Prefer deriving `Serialize` and `Deserialize` on data types.

Frontmatter fields

FieldTypeRequiredDescription
namestringyesSkill identifier.
descriptionstringyesShort description shown in skill listings.
depends-onstringnoComma-separated dependency atoms this skill is about (e.g., depends-on: serde, tokio>=1.0). Narrows the enclosing [[skills]] group scope — cannot widen it.
predicatesstringnoComma-separated predicates (depends-on, shell, path_exists, env, workspace-member, not, any, all); all must hold for the skill to activate. ANDed with plugin- and group-level predicates. See Predicates.

Crate atoms

Crate atoms specify a crate name with an optional version constraint:

  • serde — any version
  • tokio>=1.40 — 1.40 or newer
  • tokio>1.40 — strictly above 1.40
  • regex<2.0 — below 2.0
  • regex<=2.0 — 2.0 or below
  • serde^1.0 — compatible with 1.0 (same as =1.0)
  • serde~1.2 — patch-level changes only (>=1.2.0, <1.3.0)
  • serde=1.0 — compatible-with-1.0 (equivalent to ^1.0)
  • serde==1.0.219 — exact version

See Crate predicates for the full syntax.

Scope composition

depends-on can be declared at the [[skills]] group level (in the plugin TOML) and at the individual skill level (in SKILL.md frontmatter). They compose as AND: both layers must match for a skill to activate. A skill-level depends-on narrows the group’s scope — it does not widen it.

Dependency predicates (depends-on)

Dependency predicates control when plugins, skill groups, and individual skills are active. A predicate matches against a workspace’s direct dependency set — not against individual packages in isolation. Today the dependency set is the workspace’s cargo dependency graph; a depends-on atom matches a direct dependency by name.

The depends-on field is shorthand: depends-on = ["serde", "tokio"] lowers to a single any(depends-on(serde), depends-on(tokio)) predicate and is merged into the same list as the predicates field (ANDed together). Everything below describes the dependency-atom syntax depends-on accepts; the equivalent depends-on(<atom>) predicate is also usable directly in predicates.

Predicate syntax

A dependency atom is a package name with an optional version requirement.

Examples:

  • serde
  • serde>=1.0
  • tokio^1.40
  • regex<2.0
  • serde=1.0
  • serde==1.0.219
  • *

Semantics:

  • bare name: matches if the workspace has this package as a direct dependency (any version)
  • >=, <=, >, <, ^, ~: standard semver operators applied to the workspace’s version of the package
  • =1.0: compatible-version matching, equivalent to ^1.0
  • ==1.0.219: exact-version matching
  • *: wildcard — always matches, even a workspace with zero dependencies

Predicates match against direct workspace dependencies only, not transitive ones.

Usage in different contexts

Plugin manifests (TOML)

The depends-on field accepts an array of atom strings:

  • depends-on = ["serde"]
  • depends-on = ["serde", "tokio>=1.40"]
  • depends-on = ["*"] (wildcard — always active)

Skill frontmatter (YAML)

The depends-on field uses comma-separated values:

  • depends-on: serde
  • depends-on: serde, tokio>=1.40

Matching behavior

A depends-on list matches if at least one atom in the list matches the workspace. The wildcard * always matches — even a workspace with zero dependencies.

If there are multiple depends-on declarations in scope, all of them must match (AND composition). For example with skills, depends-on predicates can appear at three distinct levels:

  • If a plugin defines depends-on at the top-level, it must match before any other plugin contents will be considered.
  • If a skill-group within a plugin defines depends-on, that predicate must match before the skills themselves will be fetched.
  • If the skills define depends-on in their front-matter, those dependencies must match before the skills will be added to the project.

depends-on is purely a gate — it decides whether an item activates, not which crate to fetch. To load a crate’s own skills, name that crate explicitly in a [[plugins]] chained reference (source.cargo = "..."), gating the edge with depends-on as usual.

Migration from crates

depends-on replaces the former crates field and crate(...) predicate (renamed as part of the registry-centric plugin distribution RFD, which generalizes dependency matching beyond cargo). The old spellings are rejected at parse time with a migration hint — the atom syntax itself is unchanged, so migrating is a mechanical rename.

Predicates

A predicate decides whether a plugin, skill group, skill, hook, MCP server, or subcommand is active, evaluated against the workspace’s dependency graph and the live environment. There is one predicate model, written two ways:

  • The depends-on field uses dependency-atom syntax (see dependency predicates) and is sugar: depends-on = ["serde", "tokio"] lowers to a single any(depends-on(serde), depends-on(tokio)) predicate.
  • The predicates field uses the function-call syntax below.

Both fields are merged into one list that is ANDed together, so depends-on and predicates compose with AND. A depends-on(...) predicate is available in predicates too — the field just makes the common case terse.

The available predicate functions are:

PredicateHolds when
depends-on(<name>) / depends-on(<name><req>)A workspace dependency named <name> is present (and its version satisfies <req>, e.g. depends-on(serde>=1.0)).
depends-on(*)Any workspace matches (even one with zero dependencies). The lowered form of *.
shell(<command>)<command> run via sh -c exits 0. Any other exit (including spawn failure) fails.
path_exists(<arg>)<arg> resolves to an existing path. An argument with a path separator is checked on the filesystem (cwd-relative or absolute). A bare name with no separator is checked against the cwd and then searched on $PATH, so it matches either a local entry (path_exists(.git)) or an installed binary (path_exists(rg)).
env(<name>)The environment variable <name> is set (to any value).
env(<name>=<value>)<name> is set and equals <value> exactly. Only the first = separates name from value, so env(KEY=a=b) matches the value a=b.
workspace-member()The plugin this predicate belongs to is defined by a member of the active workspace (a workspace plugin). Takes no argument.
not(<predicate>)The inner predicate does not hold. The only way to express absence.
any(<p>, <p>, …)At least one inner predicate holds (logical OR).
all(<p>, <p>, …)Every inner predicate holds (logical AND).

Predicates compose with AND semantics within a list: every entry must hold. any(...) gives OR within a single entry, all(...) gives an explicit AND group, and not(...) gives negation — together they form full boolean logic. They also compose with AND across levels (plugin ∧ group ∧ skill).

The argument of a leaf predicate (depends-on, shell, path_exists, env) is taken verbatim between the parentheses — it is not quoted. shell(command -v rg) runs command -v rg; do not wrap the argument in quotes (they would become part of the command). An inner ) is fine as long as parentheses balance, so shell(echo $(date)) works. The combinators not, any, and all take nested predicates as their arguments and may be nested arbitrarily, e.g. not(any(env(CI), path_exists(.skip))).

crate(...) is the retired spelling of depends-on(...) and is rejected at parse time with a migration hint.

Loading a crate’s skills

A predicate is purely a boolean gate — it decides whether an item activates, not which crate to fetch. To load a crate’s own skills, name that crate in a [[plugins]] chained reference (source.cargo = "...") and gate the edge as you like:

[[plugins]]
depends-on = ["serde"]      # only when serde is a dependency
source.cargo = "serde"      # load serde's plugin (its skills)

When predicates are evaluated

Predicates are evaluated at the same point the workspace’s dependency predicates are evaluated for that item:

LevelEvaluated
Plugin predicatesAt sync (gates skills & MCP) and at every hook dispatch
Skill group predicatesAt sync, before any git/crates source is fetched
Skill frontmatter predicatesAt sync, after the skill loads
Hook predicatesAt hook dispatch, after the matcher passes
MCP server predicatesAt sync, when collecting servers to register

Hook-level predicates run at dispatch (not sync) so they observe live state — e.g. a hook gated on path_exists(jq) will silently disable itself if jq was uninstalled since the last sync, without forcing a re-sync.

Tip: keep predicates fast and side-effect free (path_exists(rg), path_exists(.git), shell(test -f Cargo.toml)). Plugin- and hook-level predicates fire on every hook dispatch.

Usage

Plugin manifests (TOML)

name = "my-plugin"
depends-on = ["*"]
predicates = ["path_exists(rg)", "shell(test -f Cargo.toml)"]

[[skills]]
depends-on = ["serde"]
predicates = ["path_exists(jq)"]
source.path = "skills"

[[hooks]]
name = "h"
event = "PreToolUse"
command = { script = "scripts/x.sh" }
predicates = ["path_exists(.git)"]

[[mcp_servers]]
name = "tool"
command = "/usr/local/bin/tool"
args = []
env = []
predicates = ["path_exists(tool)"]

Skill frontmatter (YAML)

Like depends-on, predicates is comma-separated on a single line in SKILL.md frontmatter. Commas inside (...) are not treated as separators, so a shell(...) command may itself contain commas:

---
name: my-skill
description: Skill that depends on ripgrep
depends-on: serde
predicates: path_exists(rg), shell(test -f Cargo.toml)
---

Example: gating a plugin on tool availability

name = "uses-jq"
depends-on = ["*"]
predicates = ["path_exists(jq)"]

[[hooks]]
name = "format-json"
event = "PreToolUse"
command = { script = "scripts/format.sh" }

The hook here only registers if jq is on the user’s $PATH. No error, no warning — symposium just skips this plugin’s contributions while jq is missing.

Combining predicates

depends-on, env, not, any, and all cover the cases plain depends-on lists can’t:

# Opt-in: only when a flag is set.
predicates = ["env(SYMPOSIUM_EXPERIMENTAL)"]

# Opt-out / escape hatch: skip when a marker file is present, or in CI.
predicates = ["not(path_exists(.skip-hooks))", "not(env(CI))"]

# Tool packaged under different names across distros.
predicates = ["any(path_exists(fd), path_exists(fdfind))"]

# A dependency gate that also requires an env flag (vs. the bare `depends-on = ["serde"]`).
predicates = ["all(depends-on(serde), env(USE_SERDE))"]

# Apply only when a dependency is absent (impossible with `depends-on`).
predicates = ["not(depends-on(legacy-thing))"]

These are equivalent — depends-on is just the terse form for the common case:

depends-on = ["serde", "tokio"]
# is exactly
predicates = ["any(depends-on(serde), depends-on(tokio))"]

Contributing to Symposium

Welcome! This section is for people who want to work on Symposium itself. If you’re a crate author who wants to publish skills or hooks for your library, see Supporting your crate instead.

Building and testing

Symposium is a standard Cargo project with both a library and a binary:

cargo check              # type-check
cargo test               # run the test suite
cargo run -- crate-info tokio # run locally: crate-specific guidance

Tests use snapshot assertions via the expect-test crate. If a snapshot changes, run with UPDATE_EXPECT=1 to update it:

UPDATE_EXPECT=1 cargo test

Logging and debugging

Symposium uses tracing for structured logging. Each invocation writes a timestamped log file to ~/.symposium/logs/.

The default log level is info. To get more detail, set the level in ~/.symposium/config.toml:

[logging]
level = "debug"   # or "trace" for maximum detail

Log files are named symposium-YYYYMMDD-HHMMSS.log. When debugging an issue, the log file from the relevant invocation is usually the best place to start.

Tenets

Design principles that guide symposium’s architecture. When in doubt, these break ties.

Unobtrusive

Symposium should never be a reason to opt out. Using it should be non-disruptive to existing workflows:

  • Existing projects should be able to adopt symposium without restructuring.
  • Never dirty the user’s repo with unexpected files or diffs or require users to manually edit .gitignore.
  • Avoid adding “symposium-specific” files or modifications in project repositories (when possible).

Prefer existing standards over our own

When there’s an existing mechanism that works, use it rather than inventing a new one. Adopt agent conventions, standard file layouts, and community norms wherever possible. Symposium’s own canonical format exists only where no cross-agent standard exists.

Union, not least-common-denominator

Symposium’s plugins should be able to take full advantage of what agents can do. We aim for interoperability but we also let plugin authors opt into agent-specific formats or capabilities.

Vendor neutral, interoperable

Plugins and repository provide functionality; users pick their agent. Symposium provides the bridge, exposing plugin functionality in whatever way is requested by an individual user.

Safety

Avoid exposing users to fresh risk. Plugins run code on the user’s machine — symposium should make it easy to audit, constrain, and revoke. The central repository requirement exists to prevent supply-chain attacks until we have better decentralized trust mechanisms.

Empower the ecosystem

Crate authors should be able to ship agent extensions independently, without waiting for central approval or coordination beyond the initial plugin registration. Once registered, updates flow through normal crate publishing. The symposium project’s role is infrastructure, not gatekeeping.

Key repositories

All repositories live under the symposium-dev GitHub organization.

symposium

The main repository. Contains the Symposium CLI/library (Rust), the mdbook documentation, and integration tests.

symposium-claude-code-plugin

The Claude Code plugin that connects Symposium to Claude Code. Contains a static skill, hook registrations (PreToolUse, PostToolUse, UserPromptSubmit), and a bootstrap script that finds or downloads the Symposium binary.

recommendations

The central plugin repository. Crate authors submit skills and plugin manifests here. Symposium fetches this as a registry by default.

Key modules

Symposium is a Rust crate with both a library (src/lib.rs) and a binary (src/bin/cargo-agents.rs). The library re-exports all modules so that integration tests can access internals.

config.rs — application context

Everything hangs off the Symposium struct, which wraps the parsed Config with resolved paths for config, cache, and log directories. Two constructors: from_environment() for production and from_dir() for tests.

Defines the user-wide Config (stored at ~/.symposium/config.toml) with [[agent]] entries, logging, [[registry]] entries ([[plugin-source]] is the retired spelling, still accepted), defaults, auto-update (off/warn/on, default on), and the [plugins] enablement section. User config is deserialized through RawConfig and validated into the runtime Config; runtime code does not deserialize Config directly. Provides registry_instances() to build the effective registry PmInstances directly (the builtin recommendations entry, the builtin user-plugins entry, then the configured ones): a git [[registry]] entry becomes a GitPm, a path entry a PathPm, each a trust root named for its registry (that name is what its plugins are attributed to). There is no ResolvedRegistry/content_dir intermediate — a git registry’s cache directory and its refresh live on the GitPm itself. package_managers(deps) prepends the fixed cargo transport (a CargoPm built over the shared deps resolver) to those to make the active PmRegistry. detached_managers() is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs cargo metadata. The workspace_deps(cwd) factory is the standard way to create a WorkspaceDeps — it wires in cargo_override and cache_dir so callers get both the SYMPOSIUM_CARGO override and cross-invocation disk caching, and returns it as an Arc so a CargoPm can hold one.

PluginsConfig (the [plugins] section) is the config surface of the enablement axis: auto-enable (dependency names pre-consented to, "*" for all), use (UseEntry::Global(name) or { name, workspace } — the durable record of a deliberate enablement, scoped to one workspace or to all), and disable (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods — used_names_in(root), is_auto_enabled, is_disabled, is_used_in — all match names hyphen/underscore-insensitively, since these are user-typed package names; has_enablement_entries is the cheap “could enablement pull in a crate plugin?” check the hook path uses to decide whether to resolve the crate graph. The lists are plain Vecs so a later cargo agents use can add and remove entries and call save_config.

pm/cargo/workspace.rs — cargo workspace resolution

The cargo-workspace resolution is CargoPm’s, so it lives in the cargo PM’s module (not a top-level crate::workspace) — cargo metadata is cargo’s ecosystem, not a generic concern. WorkspaceDeps is the lazy, cached resolver for the cargo dependency graph: the first load() reads a disk cache keyed on Cargo.lock mtime, and on miss runs cargo metadata (extracting root, direct crates, and member dirs into a LoadedWorkspace) and writes through. The result is memoized in a OnceLock, so every accessor reads through a shared &self — which is what lets one resolver be shared as an Arc: a CargoPm holds one and drives it (self.workspace.crates() runs the metadata call), and core code that needs the workspace root/members reads the same instance, rather than each caller resolving its own. WorkspaceCrate carries path (the local dir for a path dependency) and source_dir (the extracted source cargo metadata located, populated for registry crates too), so a workspace dependency’s source is served without a fresh probe. detached() is a resolver pre-set to “no workspace” for workspace-independent operations (registry listing, search). The types re-export at crate::pm for the core consumers of the workspace root/members (registry loading, sync, hook). The config-level dirs.rs (SymposiumDirs: config/cache paths + the SYMPOSIUM_CARGO override) stays in core; Symposium::workspace_deps(cwd) is the factory that wires them together.

agents.rs — agent abstraction

Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses .claude/skills/ and Kiro uses .kiro/skills/, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral .agents/skills/. OpenCode and Goose are skills-only agents (no hook registration).

init.rs — initialization command

Implements cargo agents init. Prompts for agents (or accepts --add-agent/--remove-agent flags), hook scope, auto-update behavior, and opt-in telemetry; writes user config; and registers global hooks.

sync.rs — synchronization command

Implements cargo agents sync. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent’s skill directory. The core primitive is sync_skill_dir(source_dir, dest_dir, project_root). It copies the entire source directory (not just SKILL.md) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (sync-debounce-secs, default 5s, keyed on the .symposium marker’s mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent’s skills parent directory and reaps any marker-bearing subdirectory it didn’t install this time, leaving user-managed skills (which lack the marker) untouched. Writes a .gitignore with * only into individual skill directories (not parent directories like .claude/ or .claude/skills/). Also provides register_hooks() for use by init, which registers only symposium’s own global hook handler — individual plugin hooks are never written into agent configs.

Two entry points: sync(sym, cwd) for standalone CLI use (creates its own WorkspaceDeps) and sync_with_deps(sym, deps) for the hook pipeline (shares the cached workspace resolution with other hook stages).

sync takes an UpdateLevel that it threads into skill resolution (skills::collect_skills), controlling how aggressively source.git skill groups are re-fetched. Callers choose: the auto-sync path passes Check on SessionStart (refresh) and None otherwise (debounced); the binary’s global --update flag feeds manual cargo agents sync.

plugins.rs — plugin registry

Loads plugin manifests from the configured registries and parses them into Plugin structs. Loading goes through the package-manager layer: load_registry asks each trusted PmRegistry instance (the configured registries — the cargo transport is not a trust root) for its plugins via active_plugins. A PathPm interprets each entry through load_entry as either a SYMPOSIUM.toml manifest plugin or a bare SKILL.md synthesized into a default plugin (load_standalone_skill_plugin); dependency-embedded crate plugins never load here. Refreshing a git registry’s content is the GitPm’s refresh operation, driven by ensure_registries (startup) and sync_registries (plugin sync). scan_source_dir remains as the offline form used by the plugin validate CLI, which points at an arbitrary directory rather than a configured registry; it walks the same layout rules and synthesizes bare skills the same way.

Validation here turns the raw TOML into:

  • Installation entries (optional source, optional executable/script, optional args, plus requirements and install_commands) collected on Plugin.installations. Inline installation references on hooks or other installations are promoted into synthetic Installation entries with derived names (<hook> for an inline command, <owner>__req_<i> for an inline requirement), so all references in the validated form are plain names.
  • Hook entries with command: String (the name of an Installation) plus optional hook-level executable / script / args. Validation guarantees at most one of executable/script is set across hook + installation, and at most one layer sets args.
  • SkillGroup and PluginMcpServer entries whose depends-on sugar and predicates list are merged into one runtime PredicateSet. Skill group source syntax is deserialized as raw string/table forms, then validated into PluginSource.
  • ChainedPlugin entries from [[plugins]]: a per-edge PredicateSet plus a source.cargo reference (dependency-atom string "widget>=1" or { name, version } table) naming the crate that carries the referenced plugin. This is the “package ≡ plugin” edge — how one plugin (e.g. a recommendations manifest) names another plugin by its package. Validation rejects git/path sources and the retired dependency-table form with hints. Expansion is wired in skills.rs: when the owning plugin is active and the edge predicates hold, the referenced crate is loaded (see important flows) — as a first-class plugin from its own SYMPOSIUM.toml if it ships one, otherwise from the crate’s metadata / default-skills/ path. The recorded version requirement is not yet enforced at resolution — the crate resolves against the workspace.

load_crate_manifest(metadata, file, crate_name) is the entry point for a crate-embedded plugin. It parses each source — the [package.metadata.symposium] table and a SYMPOSIUM.toml file, both in the ordinary plugin-manifest schema — independently and leniently (a malformed layer is logged and dropped), merges them (RawPluginManifest::merge: list fields append, scalar/keyed fields take the later layer, gates AND together), and runs the result through the same validate_manifest pipeline under a new ManifestOrigin::Crate variant: the name defaults to the crate, the dormancy rule does not apply (the reference that reached the crate is the gate), [defaults] is accepted, and the default skills/ group is appended (but not the workspace-only .agents/skills group). A crate with neither source still yields that default group. ParsedPlugin carries a required canonical: PackageId — the resolved crate id for a crate-sourced plugin, or a placeholder id tagged with the source name (registry) / "local" (workspace) for plugins with no real package identity. It keys chained-plugin cycle/diamond detection on the normalized crate name (skills.rs); it does not affect skill identity, which is the SKILL.md path hash (see skills.rs). Every loader (load_plugin_as, load_standalone_skill_plugin, workspace_plugin_for_dir, and CargoPm::build_from_fetched) runs resolve_group_sources before returning, so each [[skills]] source.path group carries an absolute directory plus a display source_label — a ParsedPlugin needs no base/manifest dir. A ParsedPlugin carries no manifest or base path at all — its identity is its canonical id. plugin show renders a plugin’s effective config keyed by that id (not a re-read manifest file); plugin validate reports each item by its id/name (a failed load’s error message still carries the file it came from).

There is no separate “standalone skill” concept: a registry directory holding only a SKILL.md (no SYMPOSIUM.toml) is loaded by load_standalone_skill_plugin as a plugin with default values — named for the skill’s own frontmatter name (falling back to the directory), carrying a single source.path = "." skill group that rediscovers that SKILL.md, and with the skill’s frontmatter depends-on/predicates hoisted to the plugin gate so the ordinary dormancy rule applies (a bare skill that names no dependency is dormant until used). This mirrors how a crate with no manifest still yields a plugin with the default skills/ group. So PluginRegistry holds only plugins; the plugin validate CLI likewise reports a bare skill as its synthesized plugin, whose one child is the skill. Returns a PluginRegistry — a table of contents that doesn’t load skill content.

A registry manifest that references no dependency anywhere — plugin, [[skills]], [[hooks]], [[mcp_servers]], or [[plugins]] chain edge, via depends-on, a depends-on(...) predicate, or a custom predicate — is not an error: it validates and loads with Plugin::requires_use = true, i.e. dormant. Plugin::applies short-circuits to false for a dormant plugin unless PredicateContext::is_used says a [plugins] use entry names it, so every activation path (skills, hooks, MCP, subcommands, help) agrees. depends-on = ["*"] remains the explicit always-active spelling, and plugin validate reports dormancy as a warning. So a recommendations-registry entry — an ordinary flat plugin — stays out of dormancy by declaring its own depends-on (the crates it advises, or ["*"]). The positional origins never go dormant, because where they were found supplies the gate.

Workspace-scoped callers use load_registry_with_workspace, which additionally loads workspace plugins (workspace_plugins): the workspace root and every member directory each define a plugin when they carry a SYMPOSIUM.toml (validated with ManifestOrigin::WorkspaceMembername defaults to the directory name, membership is the gate so dormancy never applies, and the default groups are appended unless [defaults] skills = false: [[skills]] source.path = "skills" plus, when the agents-syncing config is on, a workspace-member()-gated [[skills]] source.path = ".agents/skills" — the maintainer-skills convention, unified into the ordinary pipeline) or a bare skills/ or .agents/skills/ directory (an all-defaults manifest-less plugin). Workspace plugins are stamped workspace_member = true — the producer of the workspace-member() predicate — and attributed to the "(workspace)" source with skill paths relative to the workspace root.

installation.rs — sources and acquisition

Defines Source (the source = "..."-tagged enum: cargo, github) and acquire_source, which downloads / installs / clones the source and returns an AcquiredSource whose resolve_executable / resolve_script methods turn a relative executable/script name into a concrete path. The Runnable enum (Exec(PathBuf) or Script(PathBuf)) is the final form a hook command resolves to. The git submodule handles GitHub tarball acquisition and caching.

acquire_source (and the main-crate acquire_installation wrapper) take an UpdateLevel. None serves the cache without touching the network; Check/Fetch re-resolve. Hook dispatch acquires with None; the SessionStart prewarm uses Check. The three source kinds:

  • crates.io cargo: the resolved version is recorded in a current pointer (<cache>/binaries/<crate>/current). None reads the pointer and serves that version with no crates.io query — so a per-event dispatch never hits the registry. Check/Fetch query for the newest matching version, install into its version-keyed dir, and rewrite the pointer (so newly published versions are picked up at session start, not on every event). Only Fetch forces a same-version reinstall.
  • cargo + git: the cache key folds in only the URL + user version, never the resolved commit, so a moved branch never invalidates it on its own. Check/Fetch resolve the remote HEAD with a cheap git ls-remote and compare against the commit recorded in a .commit-sha file in the cache dir; the binary is reinstalled (cargo install --force) only when the SHA changed (or Fetch, or the binary is missing). None never resolves the remote.
  • github (script/subtree sources): honors the level directly via the git cache manager (freshness checks debounced to a 60s window under None).

Validates skill group source constraints during manifest validation: a group must set exactly one of source.path or source.git — a missing source (or an empty source = {}) is rejected, as are both together. A crate is not a skill-group source — source = "crate" (and the legacy source.crate table) is rejected with a hint to use a [[plugins]] source.cargo chained reference instead.

pm/ — package managers

The in-process seam from the registry-centric plugin distribution RFD. A PackageId is the canonical (pm, name, version) tuple; version may still be a requirement (a semver range, or * for “no requirement”), and fetch canonicalizes it — a FetchedPackage carries the exact resolved id plus the content directory. A PluginInfo (id plus optional description) is the lightweight result of search.

The PackageManager trait is the RFD’s operation set. Plugin loading has two forms — active_plugins(deps) (the plugins a PM activates for the workspace deps) and load_plugin(id) (the plugin(s) a specific id maps to) — both returning fully path-resolved ParsedPlugins and best-effort (failures logged, not surfaced); plus list_deps, search, fetch, refresh (pull a registry’s content — a no-op default for local/dependency sources), and registry_source (the git-vs-path descriptor, for plugin list). A PM value is an instance, not just an ecosystem: a transport can fetch/load_plugin any id of its ecosystem because the id carries the source, while a registry instance fronts one configured source and enumerates its packages via active_plugins. A registry instance’s name() is the configured registry name (user-plugins, symposium-recommendations, …), which is also the pm component of every id it mints and the name its plugins are attributed to. A PM is self-contained: it holds whatever it needs to resolve its own ecosystem, so operations take no ambient context — mirroring the out-of-process shape, where a PM spawned for a workspace answers from its own state. CargoPm holds an Arc<WorkspaceDeps> and drives it (lazy, cached); PathPm holds its directory. PmRegistry is one flat set of instances — fetch / load_plugin dispatch by PackageId::pm; list_deps / search / load_plugin union across all. Each PmInstance carries trusted: registries and the workspace are trust roots, the cargo transport (over dependencies) is not — the one distinction consumers branch on (registry loading takes only trusted instances; discover takes only the untrusted cargo transport). Symposium::package_managers(deps) builds the set — the cargo instance (trusted = false) plus one registry instance per configured registry (trusted = true: a GitPm for a git entry, a PathPm for a path entry); detached_managers() uses a detached resolver for workspace-independent work. workspace_dep_ids(sym, deps) unions list_deps and degrades to empty on failure. CargoPm (pm/cargo/mod.rs): fetch delegates to crate_sources::RustCrateFetch (path override, workspace pin, registry); list_deps reads self.workspace.crates() as cargo ids; active_plugins(deps) builds a ParsedPlugin (via the shared build_from_fetched) for each dependency whose source embeds plugin content (a SYMPOSIUM.toml, [package.metadata.symposium], or the default skills/), fetched cache-only into the already-extracted source (no probe) — these are dependency-embedded, so the caller applies consent; load_plugin(id) builds the named crate whatever it embeds (any fetchable crate yields at least a default skills/ plugin); search queries crates.io (crates_io_api, capped at SEARCH_PAGE_SIZE) so use/search can name a crate the workspace doesn’t depend on. CargoPm also owns crate-to-plugin resolution:

  • build_from_fetched(fetched) -> Option<ParsedPlugin> builds a first-class ParsedPlugin from its manifest sources — [package.metadata.symposium] in Cargo.toml and a SYMPOSIUM.toml at the source root — layered over the crate defaults by plugins::load_crate_manifest (merge order: defaults → Cargo.toml → SYMPOSIUM.toml; see important flows). The plugin is stamped with the resolved crate id as its canonical identity. A crate with no manifest sources still yields a plugin whose only content is the default skills/ group — so load_plugin returns Some for any fetchable crate; None means the fetch failed or the merged manifest was invalid (both logged).

Callers stay ignorant of crates: skills.rs hands over a dependency name and gets back a parsed plugin. Consumers: chained-reference expansion in skills.rs calls load_plugin; crate_command.rs builds ids with CargoPm::id_for and fetches through PmRegistry; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) gets its PredicateContext deps from workspace_dep_ids. Sync helpers that used to take &[WorkspaceCrate] and resolve deps themselves (help_render::render, subcommand_dispatch::find_subcommand) now take an already-resolved &[PackageId], so only the async entry points touch the PM layer.

One registry-instance PM exists today, reading content that is already on disk:

  • pm/layout.rs — the packaging convention it reads: classify(dir) (a directory with a SYMPOSIUM.toml is a plugin entry, one with a SKILL.md is a bare-skill entry — loaded as a default plugin — manifest wins) and enumerate(root) (recursive walk that does not descend into a claimed directory, sorted, erroring when the root is itself an entry). The layout carries no dependency information — an entry declares which dependencies activate it through its own manifest depends-on. Interpreting an entry’s manifest stays in plugins.rs.
  • pm/path.rsPathPm — one local directory in the flat layout: ~/.symposium/plugins/, a [[registry]] path entry, or the git cache directory a git registry unpacks into (serving as a GitPm’s inner reader). Its ids name the entry’s subpath within the source. active_plugins loads every entry (via plugins::load_entry), load_plugin(id) loads the entry an id names, fetch joins the subpath back onto the directory, search substring-matches entry names, and registry_source reports it as a Path. A registry is a trust root, so its plugins activate without consent; entry-load failures surface as report warnings.
  • pm/git.rsGitPm — one [[registry]] git entry. Once fetched, a git repo is just a directory, so the reads (active_plugins / load_plugin / search / fetch) delegate to an inner PathPm over the cache directory; the git-specific part is refresh — pull the repository (honoring the entry’s auto-update unless the caller forces it) — and registry_source reports it as a Git. The builtin symposium-recommendations repo is such a registry (an ordinary flat registry — each entry names the crates it advises with its own depends-on, so no namespacing or dedicated convention is involved).

crate_metadata.rs — extract Cargo.toml metadata

Extracts the [package.metadata.symposium] table from a crate Cargo.toml and returns it verbatim as a toml::Table. That table uses the same schema as a SYMPOSIUM.toml plugin manifest — a crate can define its plugin inline in Cargo.toml instead of (or in addition to) shipping a file. Validation against the manifest schema happens in plugins::load_crate_manifest, which deserializes the table and merges it with any SYMPOSIUM.toml. There is no longer a separate crate-metadata skill schema: the old path = "..." / crate = {..} redirect forms are now ordinary [[skills]] source.path groups and [[plugins]] source.cargo chained references.

predicate.rs — unified activation predicates

Defines one Predicate enum covering both dependency-graph matching and runtime/environment gating, plus PredicateSet (a list ANDed together) and PredicateContext (the workspace dependency list it evaluates against — PackageIds from the package-manager layer’s list_deps — plus the use-enabled plugin names that wake dormant plugins, threaded in with with_used_names and read by is_used). Two surface syntaxes lower to the same tree:

  • The depends-on field uses dependency-atom syntax (serde, serde>=1.0, *) and lowers, via DependsOnList, to depends-on(...) / depends-on(*) predicates OR-combined into a single any(...) that is appended to the same list. So depends-on is sugar — there is no separate dependency-predicate type.
  • The predicates field uses function-call syntax: depends-on(<atom>), shell(<cmd>) (verbatim arg, sh -c, exit 0 holds), path_exists(<arg>) (disk, then $PATH for bare names), env(<name>[=<value>]), workspace-member() (the plugin is defined by a member of the active workspace — provenance stamped per plugin into PredicateContext via ParsedPlugin::applies; registry loading stamps false, workspace-plugin loading stamps true), and the combinators not(<p>), any(<p>, …), all(<p>, …). The retired crate(...) spelling is rejected with a migration hint, as are the old crates fields.

Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged predicates: PredicateSet. Evaluation is PredicateSet::evaluate(ctx) -> bool — a predicate is purely a boolean gate. A depends-on atom matches a dependency by exact name; a version requirement is checked when the dependency id’s version component parses as semver. collect_dep_names (crates.io validation) walks all positions regardless. Plugin/group/skill/MCP predicates are evaluated at sync time; hook dispatch evaluates the plugin-level set (so a plugin’s depends-on now gates its hooks) plus the hook-level set. Hook dispatch threads in the workspace crate list, but resolves it (running cargo) only when some plugin- or hook-level predicate references a concrete depends-on(...), or there is crate-plugin expansion to perform — a chained [[plugins]] edge or a [plugins] enablement entry (hook_dispatch_needs_deps) — since expansion evaluates predicates against the crate graph too. A workspace whose plugins have none of these dispatches without a cargo query. See the predicates reference.

skills.rs — skill resolution and matching

Given a PluginRegistry and workspace dependencies, this module resolves skill group sources, discovers SKILL.md files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. It also owns active_plugins — the crate-expansion walk that produces the shared active plugin set every facet resolves over (see below) — so the same seam that resolves skills also feeds MCP-server, hook, and subcommand dispatch. Every source funnels through one seam: resolve_group_dirs turns a group into a list of ResolvedSkillDir (a base directory + report labels), then collect_skills_from_dirs scans each base for SKILL.md files. PluginSource has exactly two variants — Path (already on disk, relative to the plugin’s source dir) and Git (fetched via the git cache); a source is required, so there is no “no source” state.

The single seam every facet resolves over is active_plugins: it returns the full active set — every registry plugin whose gate holds (cloned), followed by the crate-sourced plugins transitively reached through [[plugins]] chained references and dependency enablement. Skills, MCP servers, hooks, and subcommands all iterate this one list, so a crate-sourced plugin’s extensions dispatch exactly like a registry plugin’s. Because crate loading is cache-only (CargoPm::load_plugin fetches with UpdateLevel::None), building the set is safe even on the per-event hook path.

active_plugins is a worklist fixed-point over the PM set (pms). It seeds the active set with the trust-root plugins the registry loaded (registry.plugins, each gated by record_active), then works a queue of PackageIds: each active plugin’s [[plugins]] chained source.cargo references (edges whose predicates hold, evaluated against the owning plugin’s provenance) plus the consented enabled-dependency ids (below). For each id it calls pms.load_plugin(id) — dispatched to the owning PM (the cargo transport builds the crate as a first-class ParsedPlugin from [package.metadata.symposium] + SYMPOSIUM.toml + defaults) — gates each result, records it, and enqueues its own edges. A visited set keyed on (pm, normalized name) collapses diamonds (a crate reached two ways loads once, so its hooks don’t double-fire and its subcommands aren’t a false conflict) and breaks cycles; the finite crate universe bounds termination, so there is no depth cap. collect_skills then walks the active set and runs each plugin’s skill groups through the ordinary load_skills_for_group pipeline; each discovered skill’s install identity is the hash of its on-disk SKILL.md path (below), so a crate reached two ways dedupes to one install. A crate plugin’s custom predicate definitions are the one facet not yet wired in — warn_undispatched_crate_features logs when a crate declares one.

The enabled-dependency ids seed the same worklist: discovery::enabled_dependencies names the crates covered by [plugins] auto-enable or an applicable use entry — both the workspace deps it enables and the used crates that aren’t deps at all — and each is pushed as a cargo id, so a crate’s skills/ (or manifest) installs with no plugin manifest anywhere pointing at it. A name a configured registry already provides as a plugin (including a dormant one use wakes) is skipped here, so it isn’t also fetched from crates.io. This is where consent lands — enabled_dependencies reads the [plugins] config, so only consented crates enter the worklist. workspace_root is a parameter because both this and the use-name context are scoped per workspace.

Production sync shares one PredicateContext across the skill and MCP passes, so it calls active_plugins then collect_skills directly rather than the skills_applicable_to convenience wrapper (which builds its own context and is test-only).

Each applicable skill carries an origin hash (a String) describing where its bytes live, used at sync time for dedup and install-path disambiguation. skill_origin_hash computes it as an 8-hex-char prefix of SHA-256 over the SKILL.md’s canonical on-disk path — nothing else. Identity is the file’s location, not which plugin manifest pointed at it: two references that resolve to the same file (the same crate reached through two chained plugins, or two source.path groups landing on the same bundle) produce the same hash and dedupe; skills at different paths stay distinct. Canonicalizing inside the hash is what makes that hold across discovery paths, since group scan dirs are canonicalized inconsistently — so on a platform whose temp prefix is a symlink (macOS /var/private/var) the same file would otherwise hash two ways and install twice.

Because the hash is the dedup key itself, a 32-bit collision between two genuinely distinct paths would silently drop one skill (rather than clashing loudly at install time) — a deliberate trade for carrying only a string, not a structured origin, to the sync layer.

sync prefers the plain <agent-skills-dir>/<skill-name>/ and only falls back to <skill-name>-<origin-hash>/ when needed: when more than one origin claims the same skill name, or when the unsuffixed slot is already occupied by a user-managed directory (one without the .symposium marker). The suffix is an 8-hex SHA-256 prefix for every origin kind. The .symposium marker, wildcard .gitignore, and stale-cleanup walk all key on the marker file rather than directory name shape, so transitions between unsuffixed and suffixed names self-heal across syncs.

discovery.rs — dependency discovery and enablement

Enablement is the second axis alongside activation predicates: predicates say when a plugin applies, enablement says whether it may run at all. The workspace and the configured registries are trust roots; a dependency deliberately is not, since depending on a crate should not let its author inject agent context. So a plugin embedded in a dependency runs only with consent, and a registry plugin with no gate to infer stays dormant until named. The two trust roots are loaded and gated directly — registry plugins by load_registry + Plugin::applies, workspace plugins the same way — so they never reach discovery. What discover classifies is exactly the untrusted offers: the dependency-embedded plugins a transport (CargoPm) surfaces. The trust boundary is structural: a positional registry entry (an offer with a subpath) is loaded directly, while a dependency-embedded offer (no subpath) is the consent path’s concern.

discover(sym, deps) derives the workspace root from the resolver (empty when there is none) and asks the untrusted instances — the cargo transport — for their active_plugins(dep_ids): the plugins embedded in the workspace’s dependencies (fetched cache-only into the already-extracted source, no probe). Each is classified by decide against [plugins] on its crate name — Used, Declined, AutoEnabled, or Candidate — and lands in the matching field of the returned Discovery (active / auto_enabled / candidates / declined). Explicit decisions outrank standing ones, so a declined name stays declined. Discovery writes nothing; the trusted registries are skipped, since their plugins are trust roots and never need consent.

enabled_dependencies(sym, dep_ids, workspace_root) is the activation side, consumed by skills.rs and status.rs: the crate names to load — workspace deps that auto-enable or an applicable use entry enables, plus used crates that aren’t deps at all (so cargo agents use <crate> pulls a plugin from crates.io whether or not you depend on it), minus the disabled ones. auto-enable contributes only deps — it is consent for what a dependency carries, not a way to add crates. It reads config rather than the offer list, so a name works even before its source has been fetched.

On top of that read side sits the consent write side. prompt_for_consent(sym, deps, out) asks one question per candidate (enable / ask me later / never ask again) and apply_consent records the answers — approvals into auto-enable, declines into disable, saved to the user config. Only explicit answers are recorded: the default (“ask me later”) and Escape write nothing, so reflexively hitting Enter never permanently declines anything.

The prompt is inert unless out.is_interactive() — a non-quiet, non-capturing Output attached to a terminal on both ends. That is the whole safety property: hook dispatch and anything an agent triggers run with a quiet output, and the library test harness runs with a capturing one, so neither can reach stdin. A bare TTY check would not do, since cargo test inherits the developer’s terminal. The only caller is the Commands::Sync arm in cli.rs — the hook-triggered auto-sync path calls sync::sync directly and never passes through it. pending_candidates is the non-interactive counterpart: hook.rs’s consent_hint renders it into SessionStart context so the agent can tell the user, without symposium ever blocking.

use_command.rs / search_command.rs / status_command.rs — the enablement commands

The user-facing surface over discovery and [plugins].

use_command records enablement. use_plugin first checks whether a configured registry already offers the name — registries are trust roots, so that is a no-op — with dormant plugins the exception, since use is exactly how they wake. It then requires the name to resolve to something (a workspace dependency, checked offline first, or a PmRegistry::search hit — which reaches crates.io via CargoPm::search, so a crate you don’t depend on still resolves) before pushing a UseEntry (workspace-scoped by default, Global with --global) and saving. Both it and remove_plugin re-run sync::sync afterward, so skills install or are reaped immediately. remove_plugin matches on scope and errors when nothing matched rather than silently succeeding.

search_command unions two arms: plugin names in the loaded PluginRegistry (bare skills included, since they are now plugins) and PmRegistry::search across every instance (which matches registry entry subpaths, e.g. a skill’s directory name). A PM without a searchable registry returns an empty list and a failing instance is skipped, so an offline registry degrades the results instead of failing the command. Hits are grouped by originating instance for display; the SearchMatch report event carries the origin for the JSON form.

status_command renders the enablement report. workspace_status walks the registry plugins (root: workspace membership, use, or the registry name; state from ParsedPlugin::applies plus the requires_use gate) — this is where every recommendations-registry plugin appears — then every Discovery bucket of dependency-embedded plugins (Used / AutoEnabled → active with that root, Candidate → awaiting consent, Declined), then the used crates that aren’t dependency offers (from enabled_dependencies, e.g. use-ing a crate the workspace doesn’t depend on — otherwise invisible to discovery), then any [plugins] disable name discovery never saw. The four StatusState values — Active, Dormant, Candidate, Declined — are the report’s vocabulary.

subcommand_dispatch.rs — plugin-vended subcommands

Routes the Commands::External arm of clap’s allow_external_subcommands. dispatch_external first resolves the active plugin set (skills::active_plugins), so crate-sourced subcommands dispatch too; find_subcommand walks that set, applying plugin-level and subcommand-level dependency predicates against the workspace (with the applicable use names, so a dormant plugin’s subcommands appear once it is enabled), and returns the matched (Plugin, Subcommand) (or an error if more than one plugin claims the name). dispatch_external then looks up the named Installation, resolves it via installation::resolve_runnable, and spawns the child with stdio inherited — propagating the exit code as a u8 so callers can convert to ExitCode (binary) or treat non-zero as an error (library). applicable_subcommands is the shared iterator over the active set’s applicable subcommands, taking an already-resolved &[ParsedPlugin] so help rendering and the SessionStart discovery hint reuse it.

help_render.rs--help rendering

Renders cargo agents --help as two audience-grouped sections, “Commands for humans” and “Commands for agents”, mixing built-in subcommands with plugin-vended ones filtered by the active workspace. Built-in audience comes from cli::builtin_audience; plugin subcommands come from subcommand_dispatch::applicable_subcommands over the resolved active plugin set (skills::active_plugins), so crate-sourced subcommands appear in help too.

help_text is the single help decision, shared by the binary and the test harness. clap’s own help flag and help subcommand are disabled (in cli::Cli), --help/-h is a manual global bool, and the entry points parse with try_parse_from — so help is decided after parsing and argument order (--help --quiet) is irrelevant. It returns the top-level grouped help for no subcommand / --help / -h / the bare help keyword; for <built-in> --help it re-renders clap’s own per-command help by walking clap’s command tree (so required-arg commands like crate-info, required-subcommand groups like plugin, and nested commands like plugin list all work); a plugin <name> --help returns None so dispatch forwards --help to the child.

render builds the grouped text by slicing clap’s rendered help — keeping the header (before Commands:) and the options block (from Options: on) and hand-rendering only the two section headings between them. If a slice marker is missing (clap format drift), it falls back to clap’s unmodified help rather than panicking.

hook.rs — hook handling

Handles the hook pipeline: parse agent wire-format input → auto-sync → builtin dispatch → plugin hook dispatch → serialize output. A single WorkspaceDeps (created via sym.workspace_deps(cwd)) is threaded through all stages — run_auto_sync, dispatch_builtin, dispatch_plugin_hooks, and the SessionStart prewarm. In-process, at most one cargo metadata invocation occurs per hook call (down from up to three previously). Across invocations, the disk cache means zero cargo metadata calls when Cargo.lock hasn’t changed — the common case for PreToolUse hooks.

run_auto_sync takes a session_start flag: on SessionStart it skips the Cargo.lock freshness gate and syncs with UpdateLevel::Check (so upstream skill/source changes land once per session); every other event keeps the gated, UpdateLevel::None path. The matching ensure_registries refresh level is decided in the binary entry point from the same event. SessionStart additionally runs prewarm_hook_sources (best-effort, gated by auto-sync): it walks every applicable plugin’s hooks and refreshes each installation’s already-cached source via refresh_installation_if_present (UpdateLevel::Check). This is what keeps hook binaries/scripts (not just manifests) current once per session — in particular the only path that re-pulls a cargo + git hook binary whose branch moved — so the dispatch path can keep acquiring with None (cache/debounced) and pay no per-event network cost. It is refresh-only: a source that was never acquired is left alone (it installs lazily on first dispatch), so SessionStart never eagerly installs a tool a hook may never use.

Builtin dispatch currently only acts on SessionStart, where handle_session_start composes three independently-computed additionalContext fragments: a discovery_hint (suggests cargo agents --help when the workspace exposes applicable plugin subcommands, reusing subcommand_dispatch::applicable_subcommands), a consent_hint (names the dependency plugins awaiting consent, via discovery::pending_candidates — a hook must never block on stdin, so the candidates are reported as context pointing at cargo agents sync / cargo agents use rather than asked about), and an update_nudge (the throttled self-update warning); only the nudge is gated behind the update-check throttle. The plugin dispatch path matches plugin Hooks against the event over the active plugin set (skills::active_plugins, so crate-sourced hooks fire too), selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a ResolvedHook per match (looking up the named installations on the plugin), then for each ResolvedHook: acquires its requirements (best-effort), runs install_commands after the source step, picks a Runnable from (hook-or-install) executable/script, and spawns it (binary directly for Exec, via sh <path> for Script). Input is delivered in the selected format; output is converted back to the agent’s wire format before returning.

state.rs — persistent state

Manages state.toml in the config directory. Deserializes through RawState and validates into the runtime State. Tracks the semver of the binary that last touched the directory (for future migration hooks) and the timestamp of the last update check (to throttle crates.io queries to once per 24 hours). ensure_current() is called on startup to silently stamp the current version. should_check_for_update() / record_update_check() gate the auto-update flow.

telemetry.rs — opt-in usage telemetry

Implements the local, opt-in telemetry event log under <config-dir>/telemetry/, one JSONL file per UTC day. Off by default; gated by [telemetry] enabled. A TelemetryEvent is an at timestamp plus a kind-tagged EventKind (session_start / user_prompt / tool_use), serialized one per line. record / record_kind append an event; roll_off deletes files older than RETENTION_DAYS (30); read_events / recent_events read them back; usage + status_text back telemetry status; recent_events backs telemetry show. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The recording entry points are not yet called from the hook pipeline, so no events are produced today even when telemetry is enabled.

report.rs — structured report layer

Provides user-facing output for all commands via a custom tracing layer. Commands emit tracing::info! or tracing::debug! events with a report = %ReportEvent::Variant { ... } field; the ReportLayer intercepts these and renders them based on mode:

  • Normal — prints format_human() to stdout (default for most commands)
  • Verbose (-v) — prints all events (info + debug) to stderr
  • Json (--json) — accumulates events in a buffer, drained as a JSON array at the end

The ReportEvent enum is the stable schema — #[derive(Serialize, Deserialize)] with #[serde(tag = "kind")]. Each variant carries the fields needed to render both human and JSON forms. The Display impl serializes to JSON (for passing through tracing’s % formatter), and format_human() renders the pretty-printed form.

The layer is always installed by the binary. Commands that want output simply emit report events at the appropriate tracing level (info for actions, debug for decision trace). The --json flag also suppresses the Output-based messages and drains the JSON buffer at exit.

self_update.rs — self-update

Implements cargo agents self-update. Queries the registry for the latest published version via cargo search, then installs it via cargo install symposium --force. Also provides re_exec() which replaces the current process with the newly installed binary (Unix exec, spawn-and-exit on Windows) — used by the auto-update = "on" startup path. Contains maybe_warn_for_update() (sync, for the warn library path) and maybe_check_for_update() (async, for the binary on + re-exec path).

crate_command.rs — crate source lookup

Contains dispatch_crate(), which resolves a crate’s version and fetches its source code. Called by the CLI’s crate-info command. Path dependencies are resolved to their local source directory via WorkspaceCrate.path.

Configuration loading

Directory resolution

User-wide paths are resolved using the directories crate, which handles XDG Base Directory conventions automatically. If XDG environment variables are set, they are respected; otherwise paths fall back to ~/.symposium/.

See the configuration reference for the full resolution table.

Config loading

The user config (~/.symposium/config.toml) is loaded once at startup into the Symposium struct. The file is deserialized into RawConfig, then validated into the runtime Config used by the rest of the code. If the file is missing or empty, defaults are used. If parsing fails, a warning is printed and defaults are used.

Agents

cargo agents supports multiple AI agents. Each agent has its own hook protocol, file layout, and configuration locations. This page documents the agent-specific details that cargo agents needs to handle.

Supported agents

Config nameAgent
claudeClaude Code
copilotGitHub Copilot
geminiGemini CLI
codexCodex CLI
kiroKiro
opencodeOpenCode
gooseGoose

The agent name is stored in [agent] name in either the user or project config.

Agent responsibilities

For each agent, cargo agents needs to know how to:

  1. Register hooks — write the hook configuration so the agent calls cargo-agents hook on the right events.
  2. Install extensions — place skill files (and eventually workflow/MCP definitions) where the agent expects them.

Where these files go depends on whether the agent is configured at the user level or the project level (see sync --agent).

Extension locations

When installing skills, cargo agents prefers vendor-neutral paths where possible:

ScopePathSupported by
Project skills.agents/skills/<skill-name>/SKILL.mdCopilot, Gemini, Codex, OpenCode, Goose
Project skills.claude/skills/<skill-name>/SKILL.mdClaude Code (does not support .agents/skills/)
Project skills.kiro/skills/<skill-name>/SKILL.mdKiro (uses its own path)

At the project level, Claude Code requires .claude/skills/, Kiro requires .kiro/skills/, while Copilot, Gemini, Codex, OpenCode, and Goose all support .agents/skills/. cargo agents uses the vendor-neutral .agents/skills/ path whenever the agent supports it.

At the global level, each agent has its own path:

AgentGlobal skills path
Claude Code~/.claude/skills/<skill-name>/SKILL.md
Copilot(no global skills path)
Gemini~/.gemini/skills/<skill-name>/SKILL.md
Codex~/.agents/skills/<skill-name>/SKILL.md
Kiro~/.kiro/skills/<skill-name>/SKILL.md
OpenCode~/.agents/skills/<skill-name>/SKILL.md
Goose~/.agents/skills/<skill-name>/SKILL.md

Claude Code

Hooks reference · Settings reference · Skills reference

Hook registration

Claude Code hooks live under the "hooks" key in settings JSON files. Each event maps to an array of matcher groups, each containing an array of hook commands.

ScopeFile
Global~/.claude/settings.json
Project (shared).claude/settings.json
Project (personal).claude/settings.local.json

Example hook registration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "cargo-agents hook claude pre-tool-use"
          }
        ]
      }
    ]
  }
}

Supported events

Claude Code supports many hook events. The ones relevant to Symposium are:

EventDescription
PreToolUseBefore a tool is invoked. Can allow, block, or modify the tool call.
PostToolUseAfter a tool completes. Used to track skill activations.
UserPromptSubmitWhen the user submits a prompt. Used for skill nudges.

Other events include SessionStart, Stop, Notification, SubagentStart, and more.

Hook payload/output

Claude Code wraps hook-specific fields in a nested hookSpecificOutput object:

{
  "continue": true,
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "additionalContext": "...",
    "updatedInput": "..."
  }
}

GitHub Copilot

Hooks reference · Using hooks (CLI) · Skills reference

Hook registration

Copilot hooks are defined in JSON files with a version field. Hook entries use platform-specific command keys (bash, powershell) rather than a single command field.

ScopeFile
Global~/.copilot/config.json (under hooks key)
Project.github/hooks/*.json

Example hook registration:

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "cargo-agents hook copilot pre-tool-use",
        "timeoutSec": 10
      }
    ]
  }
}

Note: Copilot uses camelCase event names (preToolUse), unlike Claude Code’s PascalCase (PreToolUse).

Supported events

EventDescription
preToolUseBefore a tool is invoked. Can allow, deny, or modify tool args.
postToolUseAfter a tool completes.
sessionStartNew session begins. Supports command and prompt types.
sessionEndSession completes.
userPromptSubmittedWhen the user submits a prompt.
errorOccurredWhen an error occurs.

Hook payload/output

Copilot uses a flat output structure (no nested hookSpecificOutput). The input payload has toolName and toolArgs (where toolArgs is a JSON string that must be parsed separately):

{
  "permissionDecision": "allow",
  "permissionDecisionReason": "...",
  "modifiedArgs": { ... },
  "additionalContext": "..."
}

Valid permissionDecision values: "allow", "deny", "ask".


Gemini CLI

Hooks reference · Configuration reference · Skills reference · Extensions reference

Hook registration

Gemini CLI hooks live under the "hooks" key in settings.json. Hook groups use regex matchers for tool events and exact-string matchers for lifecycle events.

ScopeFile
Global~/.gemini/settings.json
Project.gemini/settings.json

Example hook registration:

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "name": "symposium",
            "type": "command",
            "command": "cargo-agents hook gemini pre-tool-use",
            "timeout": 10000
          }
        ]
      }
    ]
  }
}

Note: Gemini uses BeforeTool (not PreToolUse), and timeouts are in milliseconds (default: 60000).

Supported events

EventTypeDescription
BeforeToolToolBefore a tool is invoked.
AfterToolToolAfter a tool completes.
BeforeToolSelectionToolBefore the LLM selects tools.
BeforeModelModelBefore LLM requests.
AfterModelModelAfter LLM responses.
BeforeAgentLifecycleBefore agent loop starts.
AfterAgentLifecycleAfter agent loop completes.
SessionStartLifecycleWhen a session starts.
SessionEndLifecycleWhen a session ends.
PreCompressLifecycleBefore history compression.
NotificationLifecycleOn notification events.

Hook payload/output

Gemini uses a structure similar to Claude Code, with a nested hookSpecificOutput:

{
  "decision": "allow",
  "reason": "...",
  "hookSpecificOutput": {
    "hookEventName": "BeforeTool",
    "additionalContext": "...",
    "tool_input": { ... }
  }
}

The input payload includes tool_name, tool_input, mcp_context, session_id, and transcript_path.


Kiro

Hooks reference

Hook registration

Kiro hooks live in agent JSON files under .kiro/agents/. Symposium creates a symposium.json agent file with its hooks. Kiro uses camelCase event names.

ScopeFile
Global~/.kiro/agents/symposium.json
Project.kiro/agents/symposium.json

Example hook registration:

{
  "hooks": {
    "preToolUse": [
      {
        "matcher": "*",
        "command": "cargo-agents hook kiro pre-tool-use"
      }
    ],
    "agentSpawn": [
      {
        "command": "cargo-agents hook kiro session-start"
      }
    ]
  }
}

Kiro uses a flat entry format: each entry has command directly (and optional matcher), with no nested hooks array or type field.

Supported events

EventDescription
preToolUseBefore a tool is invoked. Can block (exit code 2).
postToolUseAfter a tool completes.
userPromptSubmitWhen the user submits a prompt.
agentSpawnSession starts (maps to session-start internally).
stopAgent finishes.

Hook payload/output

Kiro uses exit-code-based control flow:

  • Exit 0: stdout captured as additional context
  • Exit 2: block (preToolUse only), stderr as reason
  • Other: warning, stderr shown

Input includes hook_event_name, cwd, tool_name, and tool_input on stdin as JSON.

Unregistration

Unregistration deletes the symposium.json file.


Codex CLI

Hooks reference

Hook registration

Codex CLI hooks live in hooks.json files. The structure is similar to Claude Code — nested matcher groups with hook command arrays. Codex uses PascalCase event names and timeout in seconds.

ScopeFile
Global~/.codex/hooks.json
Project.codex/hooks.json

Example hook registration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [{
          "type": "command",
          "command": "cargo-agents hook codex pre-tool-use",
          "timeout": 10
        }]
      }
    ]
  }
}

Note: An empty matcher string matches everything in Codex (equivalent to "*" in other agents).

Supported events

EventDescription
PreToolUseBefore a tool is invoked. Can block.
PostToolUseAfter a tool completes. Can stop session (continue: false).
UserPromptSubmitWhen the user submits a prompt.
SessionStartSession starts or resumes.
StopAgent turn completes.

Hook payload/output

Codex uses a protocol similar to Claude Code, with two methods to block:

  1. JSON output: { "decision": "block", "reason": "..." }
  2. Exit code 2 with reason on stderr

Also supports hookSpecificOutput with additionalContext, and { "continue": false } to stop the session.

Input includes session_id, cwd, hook_event_name, model, turn_id, tool_name, tool_use_id, and tool_input.


OpenCode

Hooks reference

Hook registration

OpenCode does not support shell-command hooks. Its extensibility is based on TypeScript/JavaScript plugins. Symposium cannot register hooks for OpenCode.

Supported events

OpenCode’s plugin system supports these events, but Symposium does not currently bridge them:

OpenCode eventSymposium eventDescription
tool.execute.beforepre-tool-useBefore a built-in tool runs. Can block by throwing Error, or mutate output.args.
tool.execute.afterpost-tool-useAfter a built-in tool completes.
message.updateduser-prompt-submitFiltered to role === "user" messages.
session.createdsession-startWhen a new session begins.

Goose

Hooks reference

Hook registration

Goose does not implement lifecycle hooks. It uses MCP extensions for extensibility. symposium cannot register hooks for Goose.

Goose is supported as a skills-only agent — cargo agents sync will install skill files in the vendor-neutral .agents/skills/ path.


Cross-agent event mapping

The following table maps symposium’s internal event names to each agent’s wire-format event name. means the agent does not support shell-command hooks.

Symposium eventClaudeCopilotGeminiCodexKiroOpenCodeGoose
pre-tool-usePreToolUsepreToolUseBeforeToolPreToolUsepreToolUse
post-tool-usePostToolUsepostToolUseAfterToolPostToolUsepostToolUse
user-prompt-submitUserPromptSubmituserPromptSubmittedBeforeAgentUserPromptSubmituserPromptSubmit
session-startSessionStartsessionStartSessionStartSessionStartagentSpawn

Adding a new agent

To add support for a new agent:

  1. Add a variant to the HookAgent enum in hook_schema.rs.
  2. Create an agent module (e.g., hook_schema/newagent.rs) implementing the Agent trait and the event-specific payload/output types.
  3. Implement the AgentHookPayload and AgentHookOutput traits to convert between the agent’s wire format and the internal HookPayload/HookOutput types.
  4. Document the agent’s hook registration locations and extension file layout in this page.

State

Documents the kind of state maintained by the Symposium agent.

Telemetry

Telemetry is opt-in, per-user (not per-project), and local-first: events are gathered into ~/.symposium/, and uploading is a separate step the user takes deliberately. The goal is to learn whether Symposium is actually helping, while keeping the user in control of their data.

Design principles

  • Opt-in, per-user. Nothing is recorded unless the user enables it; the preference lives in the user-wide config.toml.
  • Local-first. Events are written to a local log under ~/.symposium/. Uploading is a separate, deliberate step.
  • Anonymous by construction. Events record counts and coarse metadata only — no prompt text, command lines, or file paths.
  • Extensible. Events are JSON lines, so new event kinds and fields can be added without breaking older readers.
  • Never breaks a hook. Every recording path is best-effort — failures are logged and swallowed.

Event log format

When enabled, events are appended as JSON lines to per-day files under ~/.symposium/telemetry/:

~/.symposium/telemetry/events-2026-06-23.jsonl

Each line is one TelemetryEvent: an at timestamp plus a kind-tagged payload (EventKind), e.g.

{"at":"2026-06-23T17:58:13Z","kind":"session_start","session_id":"P1","agent":"claude","plugins":["tokio-plugin"]}
{"at":"2026-06-23T17:58:14Z","kind":"user_prompt","session_id":"P1"}
{"at":"2026-06-23T17:58:15Z","kind":"tool_use","session_id":"P1","tool":"Bash"}

Files older than RETENTION_DAYS (30) are rolled off — deleted on the next SessionStart.

Configuration

[telemetry]
enabled = true

The preference is collected during cargo agents init (“Enable anonymous usage telemetry?”) and can be toggled later with cargo agents telemetry enable / disable.

The telemetry subcommand

  • cargo agents telemetry status — whether enabled, where data lives, and how much is stored.
  • cargo agents telemetry enable / disable — toggle the opt-in.
  • cargo agents telemetry show [--count N] — print recent events for inspection (the data the user would share).

What we deliberately do not record

No prompt text, no shell command lines, no file paths. “How many tries until the agent got it right” cannot be measured reliably from hook events; the trustworthy version is an explicit user rating, which a later workstream will add.

Hooks

Symposium’s hook system is guided by the project tenets: symposium is always the intermediary, it never dirties the user’s repo, and portability is the default.

Hook formats

A plugin hook declares which wire format its handler expects:

  • format = "symposium" (default) — the handler receives symposium canonical JSON. This is portable across all agents.
  • format = "claude" / "copilot" / "gemini" / "codex" / "kiro" — the handler receives that agent’s native wire format.

Dispatch rule

When symposium’s global handler receives an event from agent A, it loads all plugins and finds hooks matching the event. For each plugin, it picks at most one hook to deliver:

  1. If the plugin declares a hook with format matching agent A → deliver the input unmodified (the handler already expects this agent’s native format).
  2. Otherwise, if the plugin declares a symposium-format hook → convert to symposium canonical and deliver.
  3. Otherwise → nothing fires for this plugin.

Symposium never converts between agent-specific formats. A format = "claude" hook will only fire on Claude — it won’t be translated for Copilot or Gemini. If you want cross-agent coverage, provide a symposium-format hook as a fallback.

Example

A plugin with hooks for claude, gemini, and symposium:

  • On Claude: the format = "claude" hook receives Claude’s native JSON.
  • On Gemini: the format = "gemini" hook receives Gemini’s native JSON.
  • On Copilot: no native handler → the format = "symposium" hook receives symposium canonical JSON.

A plugin with only format = "symposium":

  • Works on all agents. Symposium converts the agent’s wire format to canonical before delivering.

A plugin with only format = "claude":

  • On Claude: receives Claude’s native JSON directly.
  • On other agents: nothing fires (no symposium fallback declared).

Output handling

Symposium converts the hook’s output back to the current agent’s wire format before returning it to the agent:

  • Native format matching the host agent → pass through directly.
  • Symposium format → convert to host agent’s wire format.

Alternatives considered

Registering agent-specific hooks directly

An earlier design had symposium write plugin hook commands directly into agent configuration files (e.g., .claude/settings.json, .github/hooks/*.json) at sync time. The agent would invoke them natively, and symposium’s global handler would skip delivery for those plugins.

We rejected this because it violates the Unobtrusive tenet:

  • Agent config files are often git-tracked. Writing plugin hooks into them creates unexpected diffs that pollute pull requests and cause merge conflicts.
  • Users would need to .gitignore symposium-managed entries, or accept noise in their version history.
  • It couples symposium’s state to files the user considers “theirs,” making it harder to adopt or remove symposium cleanly.

The current design avoids these problems by keeping symposium as the sole registered hook handler. Plugin hooks are dispatched internally — the agent’s config only ever contains one symposium entry, registered at init time.

Cross-agent format conversion

We also considered converting between agent-specific formats (e.g., delivering a format = "claude" hook on Copilot by translating Copilot’s input into Claude’s format). We rejected this because:

  • The conversion is lossy — agents have different fields, semantics, and capabilities.
  • It creates surprising behavior: a hook author declares format = "claude" expecting Claude’s schema, but receives a synthetic approximation on other agents.
  • It’s simpler and more predictable to require a symposium-format fallback for cross-agent coverage.

Subcommands

A subcommand is a top-level cargo agents <name> command vended by a plugin. Subcommands are the fourth thing a plugin can contribute, alongside skills, hooks, and MCP servers. Where skills and MCP servers extend the agent’s surface, subcommands extend cargo agents itself, exposing crate-aware tooling that runs on the user’s machine.

The motivating use cases:

  • A crate ships its own analysis binary alongside the library. The crate author wants cargo agents <name> … to be a discoverable entry point for agents working in projects that depend on that crate, rather than requiring users to install and remember a separate CLI.
  • crate-info is moved out of the built-in CLI into a first-party plugin, shrinking the static command surface.
  • A [subcommand.<name>] named after the crate is the expected convention, but is not enforced.

Relationship to [[installations]]

Subcommands reuse the installation framework introduced for hooks. An installation declares how to acquire a binary or script (cargo install with binstall fast-path, github clone, or a path on disk), where it caches, and which executable or script to run. Subcommands reference installations by name, or declare them inline — the same shape hooks use.

This means a plugin author writes installation logic once and shares it across hooks and subcommands. Symposium owns acquisition, caching, idempotency, and post-install setup; subcommands only own dispatch.

Manifest schema

name = "demo-plugin"
depends-on = ["example-crate"]

[[installations]]
name = "example-tool"
source = "cargo"
crate = "example-tool"
executable = "example-tool"
args = ["serve"]

[subcommand.demo]
description = "Run the demo tool"
audience = "agents"
command = "example-tool"
FieldTypeRequiredDescription
descriptionstringyesShown in cargo agents --help. Capped at 1024 chars.
audience"humans" | "agents"no, defaults to "agents"Controls grouping in cargo agents --help.
commandstring or tableyesA string names an [[installations]] entry; a table is an inline installation, promoted to a synthetic entry named after the subcommand. Same shape as [[hooks]].command.
depends-onstring or arraynoSubcommand-level dependency predicate, AND-combined with the plugin-level depends-on.

Reserved names that cannot be used as subcommand keys: init, sync, hook, plugin, crate-info, help. A plugin cannot shadow a built-in.

The TOML key is singular ([subcommand.<name>]), matching the natural read of a TOML table. The internal field on Plugin is plural (subcommands).

Inline form

For one-off subcommands the inline form avoids a separate [[installations]] block:

[subcommand.demo]
description = "..."
command = { source = "cargo", crate = "example-tool", executable = "example-tool", args = ["serve"] }

The inline table is promoted to a synthetic installation named after the subcommand and resolved through the same pipeline.

Pass-through contract

Symposium does not own the subcommand’s argument grammar. The plugin’s binary owns its own --help, validation, and exit codes. What symposium contributes is mechanical:

  1. Name registration and lookup.
  2. Workspace-aware filtering (the subcommand only appears for projects matching the plugin’s dependency predicates).
  3. A short description shown in cargo agents --help.
  4. Resolution of command through the installation pipeline to a concrete (executable, base_args).
  5. Forwarding the user’s trailing CLI args verbatim, appended after the installation’s args.

This boundary keeps the manifest small, keeps plugins authoritative about their CLI, and avoids inventing a symposium-specific options DSL that would drift from each plugin’s real interface.

Dispatch

cargo-agents’s top-level CLI uses clap’s allow_external_subcommands: unknown subcommands are not errors but are routed to a catch-all variant. The binary then:

  1. Loads the plugin registry and the active workspace’s crates.
  2. Walks active plugins for one whose subcommands map contains the typed name and whose subcommand-level depends-on predicate (if any) also matches.
  3. Resolves the subcommand’s command through the installation pipeline — acquiring the binary if it isn’t already cached, running any install_commands, processing requirements.
  4. Execs the resolved (executable, base_args ++ user_args), inheriting stdio.
  5. Returns the child’s exit code as the cargo agents exit code. A signal-killed child becomes a generic failure.

Argument forwarding uses a structured Vec, not sh -c. User-supplied argv is preserved exactly — spaces, quotes, and shell metacharacters in args are not re-tokenized. This matters more for subcommands than for hooks (whose input arrives over stdin as JSON).

Script-mode installations (script = "...") are still invoked through sh <path>, mirroring hook dispatch. That path is not cross-platform on Windows and is tracked as a follow-up for both hooks and subcommands; in the meantime, plugin authors who need Windows support should use executable = "..." instead.

If no plugin matches the typed name, dispatch fails with a clear error pointing to cargo agents --help. If a matching subcommand exists but installation fails, the installation layer’s error is propagated as-is.

Workspace filtering

Plugin filtering is workspace-aware in two places: help rendering and dispatch.

Inside a Cargo workspace. Symposium reads the workspace’s resolved dependencies. A subcommand appears in cargo agents --help and is dispatchable only if both the plugin-level and subcommand-level depends-on predicates match. Built-in subcommands always appear.

Outside a Cargo workspace (no discoverable Cargo.toml upward). Only built-ins and plugins with depends-on = ["*"] appear. Invoking a crate-specific subcommand from outside a workspace produces an error explaining which crate it needs.

This rule keeps cargo agents --help outside a workspace limited to globally-applicable commands, rather than listing every installed plugin.

Help text grouping

cargo agents --help is rendered in two sections:

  • Commands for humans — operational commands a user runs themselves: init, plugin, search, self-update, status, sync, telemetry, use, plus any plugin-vended subcommand with audience = "humans".
  • Commands for agents — discovery and analysis tools for the agent to invoke: crate-info and plugin-vended subcommands with audience = "agents" (the default).

The default of audience = "agents" reflects the expected shape of plugin-vended commands: most are analysis or context-fetching tools surfaced to agents, not workflows for humans. The exceptional case explicitly opts in.

For this grouping to be useful, crate-info is no longer hidden — it’s a discoverable agent tool. hook remains hidden; it’s an internal protocol entry point, not an end-user surface.

The renderer reads the active plugin registry filtered by workspace, so the help output adapts to the project the user is standing in.

--help, -h, the bare help keyword, and an empty invocation are intercepted after clap parses and routed to this renderer; help is never listed as its own command. A <built-in> --help instead shows that command’s own help (re-rendered from clap), and a plugin-vended <name> --help is forwarded to the plugin’s binary, which owns its --help.

Agent discovery

cargo agents --help is a pull surface — an agent only sees the crate-aware subcommands if it already knows to run it. To push that affordance, the built-in SessionStart hook injects a one-line hint suggesting cargo agents --help whenever the active workspace exposes at least one applicable plugin-vended subcommand. The trigger reuses the same workspace-filtered set as the help renderer (applicable_subcommands), so the hint stays silent in projects with nothing to discover.

The hint shares SessionStart’s additionalContext with the update nudge and the pending-consent hint; each fragment is computed independently, and only the nudge is gated behind the update-check throttle. Agents without hook registration (OpenCode, Goose) don’t receive it; for them cargo agents --help is the only discovery surface.

The consent hint is the same pattern applied to enablement. A hook runs on the agent’s behalf and must never block on stdin, so when dependency discovery finds plugins awaiting consent, SessionStart names them as context and points at cargo agents sync (which asks interactively) or cargo agents use <name> — explicitly telling the agent not to enable them itself. The interactive prompt lives only in the sync command’s own CLI arm, gated on Output::is_interactive().

Audience as metadata, not enforcement

audience controls help-text grouping only. It does not gate dispatch. A user can type cargo agents <agent-audience-subcommand> directly and it will run. The intent is to keep the discovery surface uncluttered for humans, not to lock anyone out.

Conflict resolution

Two plugins may declare the same subcommand name. Rather than silently picking one, dispatch fails with an error listing every plugin that defined the name, leaving the user to disambiguate (typically by tightening one of the plugin’s depends-on predicates or removing one of the plugin sources).

The strict-error stance trades silence for clarity: subcommand names tend to mirror crate names (which are unique on crates.io), so a collision usually signals a real configuration mistake rather than an intended override.

Namespacing (cargo agents <plugin>:<name>) is not implemented; it can be revisited if a real conflict pattern emerges.

What plugins own vs. what symposium owns

ConcernOwned by
Subcommand nameManifest
Short descriptionManifest
audienceManifest
Argument grammar, flags, <subcommand> --helpPlugin’s binary
Argument validationPlugin’s binary
Exit codesPlugin’s binary, propagated by symposium
Binary acquisition, caching, post-install setupShared installation framework
Workspace-aware filteringSymposium
Resolution of command(executable, args)Shared installation framework
Stdio forwardingSymposium (inherited)
cargo agents --help renderingSymposium
Conflict resolutionSymposium

Structured report layer

Commands produce user-facing output by emitting tracing events with a report field. A custom tracing layer (ReportLayer) intercepts these events and renders them in one of three modes depending on CLI flags.

How it works

Command code                  Tracing infrastructure             User
─────────────                 ──────────────────────             ────
tracing::info!(           →   ReportLayer::on_event()      →   stdout/stderr/JSON
  report = %ReportEvent::SkillInstalled { ... }
)
  1. Command code emits a tracing event at info (actions) or debug (decisions) level, carrying a single report field whose value is a ReportEvent formatted via Display (which serializes to JSON).
  2. The ReportLayer checks: does this event have a report field? Is its level within max_level?
  3. If yes, it deserializes the JSON string back into a ReportEvent and renders it based on mode.

Modes

ModeCLI flagsOutput targetLevel filter
Normal(none)stdoutINFO only
Verbose-vstderrINFO + DEBUG
Json--jsonbuffered → stdout at exitINFO (or DEBUG with -v --json)

The layer is always installed — commands don’t need to check whether reporting is active.

Adding a report event to a new command

Step 1: Add a variant to ReportEvent

In src/report.rs, add a new variant to the enum:

#![allow(unused)]
fn main() {
/// A frobnitz was reticulated.
FrobnitzReticulated {
    name: String,
    count: usize,
},
}

Rules for variants:

  • Use #[serde(skip_serializing_if = "Option::is_none")] for optional fields
  • Don’t use a field named kind (conflicts with #[serde(tag = "kind")])
  • Keep fields simple (String, bool, usize, Option)

Step 2: Add a format_human arm

In the format_human() method, add a rendering arm:

#![allow(unused)]
fn main() {
Self::FrobnitzReticulated { name, count } => {
    format!("✅ reticulated {name} ({count} nodes)")
}
}

Use emoji prefixes to match the existing style:

  • — success/action taken
  • — removal
  • ⚠️ — warning
  • ℹ️ — informational
  • 🟢 — already in place / no-op

Step 3: Emit from command code

#![allow(unused)]
fn main() {
tracing::info!(
    report = %crate::report::ReportEvent::FrobnitzReticulated {
        name: frobnitz.name.clone(),
        count: frobnitz.nodes.len(),
    },
);
}

Use tracing::info! for actions the user should always see, tracing::debug! for decision-trace detail that only appears with -v.

Level conventions

LevelWhen to useVisible in
infoActions taken (installed, removed, validated)Normal, Verbose, Json
debugDecisions (plugin matched, skill skipped, directory searched)Verbose only (or -v --json)

The Info and Warning variants

For messages that don’t map to a specific structured event, use the generic variants:

#![allow(unused)]
fn main() {
tracing::info!(
    report = %crate::report::ReportEvent::Info {
        message: format!("scanning {} workspace dependencies", count),
    },
);
}

Prefer specific variants over Info/Warning when the data is structured — they produce better JSON output.

Testing

The test harness (symposium-testlib) provides sync_with_report() which installs a scoped Json-mode layer and returns captured events. Tests assert on the JSON structure:

#![allow(unused)]
fn main() {
let events = ctx.sync_with_report(tracing::Level::DEBUG).await?;
let installed: Vec<&Value> = events
    .iter()
    .filter(|e| e["kind"] == "skill_installed")
    .collect();
assert!(!installed.is_empty());
}

Architecture notes

  • The Display impl on ReportEvent serializes to JSON — this is how the value passes through tracing’s % formatter into the visitor
  • The layer’s visitor checks record_debug (not record_str) because % goes through the debug path
  • Per-layer EnvFilters ensure the report layer receives all events regardless of file log level
  • The ReportHandle (returned alongside the layer) allows draining accumulated JSON after the command completes

Important flows

This section describes the logic of each cargo agents command.

Crate-sourced skill resolution

A plugin loads a crate as a plugin by naming that crate in a [[plugins]] chained reference (source.cargo = "..."); the user can also load one directly by enabling the dependency it lives in (see enablement below). When the owning plugin is active and the edge’s predicates hold, the crate is resolved into the active plugin set — the shared list every facet (skills, MCP servers, hooks, subcommands) resolves over, so a crate-sourced plugin’s extensions dispatch exactly like a registry plugin’s. A single path handles every crate — a crate is always a first-class plugin, whether it describes itself with a SYMPOSIUM.toml, with [package.metadata.symposium], with both, or with neither:

  1. skills::active_plugins seeds a worklist from the trust-root plugins the registry loaded: each active plugin’s plugin.chained edges whose predicates hold (evaluated against the owning plugin’s provenance) contribute a source.cargo crate id.
  2. For each id the fixed-point calls pms.load_plugin(id) on the package-manager set active_plugins was handed (built once by package_managers(deps)). The id’s pm routes it to the cargo transport, which:
    • CargoPm::fetch resolves the source via RustCrateFetch (path overrides for local path deps, then the cargo registry cache, then crates.io) with UpdateLevel::None — cache-only, so this is safe on the per-event hook path. The fetched id carries the exact resolved version.
    • plugins::load_crate_manifest builds the plugin definition by layering three sources (merge order: crate defaults → [package.metadata.symposium] from Cargo.tomlSYMPOSIUM.toml file). Both manifest sources use the ordinary plugin-manifest schema and are parsed leniently (a malformed layer is logged and dropped). Validation runs under ManifestOrigin::Crate (name defaults to the crate, depends-on is waived, [defaults] accepted, default skills/ group appended unless [defaults] skills = false). The result is a ParsedPlugin whose canonical id is the resolved crate. A crate with no manifest sources still yields one whose only content is that default skills/ group.
  3. record_active honors the crate plugin’s own plugin-level predicates (applies, which stamps its provenance — never a workspace member), appends it to the active set, and enqueues its own [[plugins]] edges. This is how a [package.metadata.symposium] redirect (now a [[plugins]] source.cargo chained reference to the target crate) is followed. A visited set keyed on (pm, normalized name) — global across the whole active_plugins call — collapses diamonds (a crate reached through two plugins loads once, so its hooks don’t double-fire and its subcommands don’t read as a false conflict) and breaks cycles; the finite crate universe bounds termination.
  4. Facet extraction then walks the active set. collect_skills runs each plugin’s skill groups through the ordinary load_skills_for_group pipeline — honoring named groups, group predicates, and source.path/source.git, with each discovered skill’s origin hashed from its on-disk SKILL.md path (this is where git skill sources are fetched, hence the update level). MCP-server filtering (sync), hook dispatch (hook::dispatch_plugin_hooks), and subcommand lookup (subcommand_dispatch) each iterate the same set. A crate plugin’s custom predicate definitions are the one facet still not wired in — they resolve only from configured registries, and warn_undispatched_crate_features notes when a crate declares one.

A skill’s install identity is the hash of its on-disk SKILL.md path, so a crate reached two ways dedupes to one install. The edge’s version requirement is recorded but not yet enforced — the crate resolves against the workspace (pin / path override).

The key code paths are in pm/cargo/mod.rs (CargoPm::load_plugin, build_from_fetched), plugins.rs (load_crate_manifest, RawPluginManifest::merge, ManifestOrigin::Crate, ParsedPlugin::canonical), skills.rs (active_plugins, record_active, plugin_key, collect_skills, hash_origin_key), crate_metadata.rs (symposium_metadata), pm/cargo/workspace.rs (WorkspaceDeps, WorkspaceCrate), and crate_sources/mod.rs (RustCrateFetch).

Dependency enablement

A dependency’s own plugin content — a SYMPOSIUM.toml, [package.metadata.symposium], or a skills/ directory — is reachable without any manifest pointing at it, but only with the user’s consent: dependencies are not a trust root.

  1. discovery::discover asks the untrusted cargo transport for its active_plugins(dep_ids): the plugins embedded in the workspace’s dependencies. CargoPm::active_plugins fetches each dependency cache-only and inspects it — a workspace dep resolves into the source cargo metadata already extracted (WorkspaceCrate::source_dir), no probe/network — so registry-dep embedded plugins are discoverable too. The trusted registries (including the recommendations repo) are skipped, because their plugins are trust roots and never need consent. Each candidate is classified against [plugins] on its crate name — enabled by use, auto-enabled, declined, or an undecided candidate. Nothing is prompted or written.
  2. At sync time, skills::active_plugins asks discovery::enabled_dependencies which crate names [plugins] auto-enable or an applicable use entry covers — workspace deps, plus used crates that aren’t deps at all — and seeds each as a cargo id on the same worklist a chained reference feeds, so pms.load_plugin honors the crate’s manifest sources, skill groups, and its own [[plugins]] edges. This reads config rather than the offer list, so cargo agents use <crate> loads a crate from crates.io whether or not the workspace depends on it, and even before its source has been fetched. (CargoPm::search is what lets use name such a crate; a name a configured registry already provides is skipped here so it isn’t double-loaded.)
  3. Independently, a registry plugin with no dependency gate anywhere loads dormant (Plugin::requires_use) and activates only when a use entry names it. The gate rides the PredicateContext (with_used_names / is_used), so skill resolution, hook dispatch, subcommand lookup, help, and MCP filtering all agree.

The consent prompt and the use / search / status commands that record decisions are not implemented yet — today the [plugins] config is edited by hand.

The key code paths are in discovery.rs, config.rs (PluginsConfig, UseEntry), pm/cargo/mod.rs (active_plugins, load_plugin), plugins.rs (Plugin::requires_use), predicate.rs (PredicateContext::is_used), and skills.rs (active_plugins, record_active).

Help rendering

cargo agents --help (and -h, the bare help keyword, or no subcommand) is rendered by help_render, not by clap’s default help.

  1. The binary and the test harness parse argv with Cli::try_parse_from, then call help_render::help_text(parse, args, sym, cwd). Because the decision happens after parsing, argument order (--help --quiet) does not matter and there is no second argv parser to keep in sync.
  2. For no subcommand, --help/-h, or the bare help keyword, help_text returns the top-level grouped help: render slices clap’s own rendered help (header + options block) and hand-renders “Commands for humans” / “Commands for agents” between them, mixing built-ins (cli::builtin_audience) with workspace-filtered plugin subcommands (subcommand_dispatch::applicable_subcommands).
  3. For <built-in> --help, help_text re-renders clap’s per-command help by walking clap’s command tree to the named subcommand — so required-arg commands (crate-info), required-subcommand groups (plugin), and nested commands (plugin list) all work even though clap’s auto help flag is disabled.
  4. A plugin-vended <name> --help is left alone: help_text returns None, and dispatch forwards --help to the child binary, which owns its own help.

clap’s auto help flag and help subcommand are disabled in cli::Cli; --help/-h is a manual global bool. The key code paths are in help_render.rs (help_text, render, subcommand_help), cli.rs (builtin_audience, the Cli flags), and bin/cargo-agents.rs plus symposium-testlib (the parse-then-help_text wiring).

Subcommand dispatch

When the user runs cargo agents <name> for a name not built into the binary, clap’s allow_external_subcommands routes it to Commands::External(argv).

  1. The binary (or library cli::run) calls subcommand_dispatch::dispatch_external(sym, cwd, argv), which first resolves the active plugin set (skills::active_plugins — registry plugins plus crate-sourced ones) so a crate’s subcommands are dispatchable too.
  2. find_subcommand walks that set. For each plugin it applies the plugin-level depends-on predicate against the workspace, then looks up argv[0] in plugin.subcommands. If the entry has its own depends-on predicate, that must also match. Two or more matches → error.
  3. The matched subcommand’s command field names an Installation on the same plugin. installation::resolve_runnable acquires the source if any, runs install_commands, and picks the Runnable (Exec for binaries, Script for shell scripts).
  4. The child is spawned with stdio inherited. Its exit code is collapsed to a u8 — the binary wraps it in ExitCode::from; the library treats non-zero as an error so the test harness can assert on success/failure.

The key code paths are in subcommand_dispatch.rs, cli.rs (the External arm), and bin/cargo-agents.rs (binary-side wrapping that surfaces the numeric exit code to the OS).

cargo agents init

Sets up the user-wide configuration.

Flow

  1. Prompt for agents — ask which agents the user uses (e.g., Claude Code, Copilot, Gemini). Multiple agents can be selected.

  2. Write user config — create ~/.symposium/config.toml with the [[agent]] entries populated:

    [[agent]]
    name = "claude"
    
    [[agent]]
    name = "gemini"
    
  3. Register hooks — register global hooks and MCP servers for each selected agent. Also unregisters hooks for any agents that were removed.

If --add-agent or --remove-agent flags are provided, the interactive prompt is skipped and the specified changes are applied to the existing agent list.

cargo agents sync

Scans workspace dependencies, installs applicable skills into agent directories, and cleans up stale skills.

Flow

  1. Consent prompt (interactive cargo agents sync only) — ask about each dependency plugin awaiting consent and record the answers in [plugins] before resolution runs, so an approval installs in this same sync. Gated on Output::is_interactive(): the hook-triggered auto-sync calls sync::sync directly and never reaches this step. See dependency discovery.

  2. Find workspace root — run cargo metadata to locate the workspace manifest directory.

  3. Load registries — read the user config’s [[registry]] entries, ask each one’s package manager for the plugin-bearing entries it offers, and load their plugin manifests. For git registries, fetch/update as needed.

  4. Scan dependencies — read the full dependency graph from the workspace.

  5. Match skills to dependencies — for each plugin, parse SKILL.md YAML frontmatter, reject malformed or non-string metadata, warn about skipped invalid skills, then evaluate skill group dependency predicates and individual skill depends-on frontmatter against the workspace dependencies.

  6. Install skills per agent — for each configured agent:

    • Copy applicable SKILL.md files into the agent’s expected skill directory.
    • Drop a .symposium marker file into each installed skill directory so future syncs (and other tools) can recognize it as symposium-managed.
    • For every skill directory symposium creates along the way (the skill directory itself or its skills/ parent), write a .gitignore containing a single * so symposium-managed files stay out of version control.
  7. Workspace .agents/skills/ (agents-syncing) — not a separate step: when agents-syncing is enabled, each workspace plugin carries a workspace-member()-gated default group for .agents/skills/, so maintainer skills resolve and install through the same pipeline as everything else. Two marker guards make .agents/skills/ safe as both a source and (for vendor-neutral agents) a destination: discovery skips .symposium-marked directories (installed copies are never sources), and a skill whose source already sits at an agent’s install slot is skipped for that agent (no self-copy, no suffixed duplicate).

  8. Reap stale skills — across every known agent’s skills parent directory, remove any subdirectory that contains the .symposium marker but wasn’t installed this sync. Directories without the marker (user-managed) are left untouched.

  9. Register hooks — ensure symposium’s global hook handler and MCP servers are registered for all configured agents. Unregister hooks for agents no longer in the config. Only symposium’s own handler is registered (e.g., cargo-agents hook claude pre-tool-use) — individual plugin hooks are never written into agent configs. See Hooks for the dispatch model.

Marker file

Each skill directory symposium installs contains an empty .symposium file. Cleanup walks every agent’s skills parent directory (.claude/skills/, .agents/skills/, .kiro/skills/, .gemini/skills/) and reaps any subdirectory whose marker is present but which wasn’t installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker.

Gitignore

Each skill directory symposium creates (and its skills/ parent if new) receives a .gitignore containing just *. Pre-existing directories are left alone. The wildcard also hides the marker file and the gitignore itself, so git status stays clean.

Auto-sync

When auto-sync = true is set in the user config, the hook handler runs sync automatically during agent sessions. This keeps skills in sync as dependencies change.

On most hook events, auto-sync is gated on Cargo.lock (and battery-pack.toml) mtime via per-workspace state, so an unchanged workspace doesn’t pay for cargo metadata on every event. SessionStart is the exception: it runs once per session, ignores that gate, and passes UpdateLevel::Check down through skill resolution so source.git skill groups are re-fetched when their upstream moved. This is what makes upstream skill updates land even when the workspace’s own dependencies haven’t changed. sync takes the UpdateLevel as a parameter; the binary’s global --update flag threads through the same path for manual cargo agents sync.

cargo agents hook

Entry point invoked by the agent’s hook system on session events.

Flow

  1. Auto-sync (if enabled) — when auto-sync = true in the user config, runs cargo agents sync to ensure skills are current. The workspace root is resolved from the payload’s cwd field; if the payload does not include a working directory, the process’s current working directory is used as a fallback. Runs quietly and non-fatally — failures are logged but don’t block hook dispatch.

    SessionStart is the refresh point. Because it fires once per agent session, it does the expensive work that other events skip: it bypasses the Cargo.lock freshness gate (so skills re-sync even when the workspace’s dependencies are unchanged) and passes UpdateLevel::Check so git registries and source.git skill groups are re-fetched if their upstream moved. Every other event keeps the cheap, Cargo.lock-gated path with UpdateLevel::None (debounced) to avoid per-event network and cargo metadata cost. The registry refresh on SessionStart (ensure_registries with Check, decided in the binary entry point from the event) still honors each registry’s auto-update toggle. SessionStart also runs prewarm_hook_sources, which refreshes already-installed hook binaries/scripts (the cargo/github sources backing plugin hooks) — refresh-only, so it never eagerly installs a tool a hook may never use; first install still happens lazily at dispatch.

  2. Built-in dispatch — symposium’s own handling, before plugin hooks. Currently only SessionStart produces output; PreToolUse, PostToolUse, and UserPromptSubmit are no-ops. On SessionStart two fragments are computed independently and, when present, joined into one additionalContext:

    • Discovery hint — when the active workspace exposes plugin-vended subcommands (the same workspace-filtered set listed by cargo agents --help), a line suggesting the agent run cargo agents --help to find them. Computed independently of the update-check throttle, so it fires whenever there is something to discover.
    • Update nudge — when auto-update = "warn", the 24-hour check throttle has elapsed, and the registry reports a newer version: a line suggesting cargo agents self-update.

    Agents without hook registration (OpenCode, Goose) never receive this; for them the only discovery surface is cargo agents --help itself.

  3. Dispatch to plugin hooks — for each enabled plugin that defines a hook handler for the incoming event:

    • Select format: for each plugin, pick the best hook to deliver (see Hooks for priority rules). If the plugin has a hook matching the current agent’s format, deliver the input unmodified. Otherwise deliver in symposium canonical format (or convert to the declared format if only one non-symposium hook exists).
    • Acquire and run:
      • Ensure any requirements for the hook are acquired (on-demand, best-effort).
      • Resolve the hook’s command (a named installation reference or inline declaration) into a runnable form:
        • If the installation declares a source, acquire it (install / cache / clone) and resolve the executable / script against the cached location. Dispatch acquires with UpdateLevel::None — it serves the cache (git checks debounced) rather than hitting the network on every event. Freshness comes from the SessionStart prewarm (step 1), which re-acquires every applicable hook’s source with Check once per session.
        • If no source, the executable / script is taken as a path on disk (relative paths resolve against the plugin directory, so a refreshed plugin-source repo updates these for free).
        • Run the installation’s install_commands (post-source) before invoking the runnable.
        • Spawn path args… directly for Exec, or sh path args… for Script.
      • Pass the event JSON (in the selected format) on stdin to the plugin’s hook.
      • Collect output from each handler.
      • Convert output back to the agent’s wire format.
      • Merge results (e.g., allow/block decisions, output text) across all handlers.
      • Return the merged result to the agent.

Plugin hooks can respond to agent-specific events (e.g., pre-tool-use, post-tool-use, user-prompt-submit for Claude Code). The available events depend on which agent is in use.

Running tests

Quick start

cargo test              # simulation + configured agents

By default, cargo test runs simulation tests and then re-runs agent-mode tests against each agent listed in test-agents.toml. On a fresh clone (no file), the defaults are claude-sdk and kiro-cli-acp.

Configuring test agents

Create test-agents.toml in the repo root (gitignored):

# Run against these agents. Use `acpr --list` to see ACP registry agents.
test-agents = ["claude-sdk"]

Set to [] to skip agent tests entirely (used in CI):

test-agents = []

Available agent names:

NameBackendNotes
claude-sdkClaude Agent SDK (Python)Requires uv + ANTHROPIC_API_KEY
kiro-cli-acpKiro CLI via ACPRequires kiro-cli in PATH
Any name from acpr --listACP registry via acprAuto-downloaded

Filtering to a single agent

Override with the SYMPOSIUM_TEST_AGENT env var:

SYMPOSIUM_TEST_AGENT=kiro-cli-acp cargo test --test hook_agent

This ignores test-agents.toml and runs only the specified agent.

Running specific test files

cargo test --test hook_agent       # just the agent integration tests
cargo test --test init_sync        # just the init/sync tests
cargo test --test dispatch         # just the CLI dispatch tests

Debugging test failures

Add --nocapture to see test output (agent messages, hook traces):

cargo test --test hook_agent -- --nocapture

On failure, the test’s temporary directory is preserved and its path is printed to stderr so you can inspect the fixture state.

Windows

CI runs the full test suite on windows-latest as part of the test matrix (see .github/workflows/ci.yml). To run the tests locally on Windows:

  • Install Git for Windows and make sure sh is on PATH. Git ships it at C:\Program Files\Git\usr\bin. Several tests spawn sh to run script-based hooks and predicates, so a missing sh shows up as unrelated-looking hook failures.
  • The repo’s .gitattributes normalizes checked-out text files to LF. This keeps shebang’d fixtures and shell scripts runnable regardless of core.autocrlf.

The self-update tests run on Windows: set_mock_cargo runs the #!/bin/sh mock through sh via a one-line .cmd shim (production spawns the cargo override directly, so no production code changes). The two auto_update_re_execs_* tests are #[ignore]d on Windows (#[cfg_attr(windows, ignore)]): they overwrite the running binary with a shebang stand-in and re-exec into it, which needs Windows-native process replacement. They still compile on Windows, so they are skipped (not compiled out) and their helpers need no #[cfg]. Porting them to run on Windows is a tracked follow-up.

Writing tests

Symposium tests run in two modes:

  • Simulation mode — hooks and CLI calls are invoked directly by the harness. No real agent needed.
  • Agent mode — a real agent session processes prompts and we verify it triggers the expected hooks.

Tests declare which mode they need via TestMode:

  • TestMode::SimulationOnly — runs once in simulation.
  • TestMode::AgentOnly — runs once per configured test agent.
  • TestMode::Any — runs once in simulation + once per configured agent.

1. Create your setup by composing fixtures

Wrap your test in with_fixture, specifying the mode and fixtures:

#![allow(unused)]
fn main() {
use symposium_testlib::{TestMode, with_fixture};

#[tokio::test]
async fn my_test() {
    with_fixture(TestMode::SimulationOnly, &["plugins0"], async |mut ctx| {
        // test body
        Ok(())
    }).await.unwrap();
}
}

Fixtures are directories under tests/fixtures/. They are overlaid into a tempdir:

#![allow(unused)]
fn main() {
with_fixture(TestMode::SimulationOnly, &["plugins0", "workspace0"], async |mut ctx| { ... })
}

with_fixture scans fixtures for config.toml (user config dir) and Cargo.toml (workspace root). In agent mode it automatically runs init --add-agent and sync.

For TestMode::Any and TestMode::AgentOnly, the test closure runs once per configured agent.

Variable expansion in fixtures

Text files have variables expanded when copied:

  • $TEST_DIR — the tempdir root.
  • $BINARY — path to the cargo-agents binary.

Fixture requirements

All fixture config.toml files must include hook-scope = "project" so that hooks are installed into the project directory rather than globally.

2. Write the test body

Bimodal tests (TestMode::Any)

Use ctx.prompt_or_hook which dispatches based on mode:

#![allow(unused)]
fn main() {
with_fixture(TestMode::Any, &["plugins0", "project-plugins0"], async |mut ctx| {
    let result = ctx
        .prompt_or_hook("Say hello", &[HookStep::session_start()], HookAgent::Claude)
        .await?;

    assert!(!result.hooks.is_empty());
    assert!(result.has_context_containing("symposium start"));
    Ok(())
}).await.unwrap();
}

In agent mode, prompt_or_hook also asserts that the expected hook events appear in the trace.

Agent-only tests (TestMode::AgentOnly)

Use ctx.prompt to send prompts to the real agent:

#![allow(unused)]
fn main() {
with_fixture(TestMode::AgentOnly, &["plugin-tokio-weather0", "workspace-empty0"], async |mut ctx| {
    ctx.prompt("Run `cargo add tokio` please!").await?;
    let result = ctx.prompt("Use the tokio-weather skill to answer: ...").await?;
    assert!(result.response.unwrap().contains("MAGIC SENTENCE"));
    Ok(())
}).await.unwrap();
}

Simulation-only tests (TestMode::SimulationOnly)

Use ctx.symposium to invoke the CLI directly:

#![allow(unused)]
fn main() {
with_fixture(TestMode::SimulationOnly, &["plugins0"], async |mut ctx| {
    ctx.symposium(&["init", "--add-agent", "claude"]).await?;
    ctx.symposium(&["sync"]).await?;
    // assert on files...
    Ok(())
}).await.unwrap();
}

Governance

Symposium operates under the Rust Code of Conduct.

Teams

When a contributor has shown enduring interest in the codebase and made multiple non-trivial changes over time, they are invited to join the Symposium maintainers team:

  • Maintainers team
    • Members of this team can review and merge other PRs.
    • Members are expected to attend the regular sync meeting.

Overall project leadership is provided by the core team:

  • Core team
    • Final decision makers
    • Approve releases
    • All members of the core team are also members of the maintainers team

Decisions proceed by consensus at each level; if needed, @nikomatsakis acts as BDFL to resolve contentious questions.

PR disclosure policy

We request PRs answer the questions in our PR template regarding AI use, confidence level, and questions.

PR merge policy

We want to keep moving quickly, especially in this early phase, therefore we have established the following review policy:

CategoryPolicy
New contributorsPRs need review from a maintainer
Maintainer team memberPRs should be reviewed by another maintainer before landing
Core team memberReview by another maintainer is preferred but not required

Sync meeting

We hold a regular sync meeting to discuss recent changes, plans, and direction. The meeting is open to all maintainers and contributors. If you’re interested in attending, reach out to a core team member on Zulip.

Releases

Merging a release PR is coordinated among core team members.

Getting involved

The best way to get started is to pick up an issue, open a PR, and join the conversation on Zulip. Landing non-trivial contributions and attending the sync meeting is the path to joining the devs team.

Common issues

Known hook implementation gaps

The following issues were identified by auditing our hook implementations against the agent reference docs (md/design/agent-details/). They don’t cause crashes (the fallback path handles events without agent-specific handlers) but mean some features are incomplete.

toolArgs not parsed (Copilot)

Copilot sends toolArgs as a JSON string (not an object). Our CopilotPreToolUsePayload declares it as serde_json::Value and passes it through as-is in to_hook_payload(). Downstream code expecting structured tool args will get a raw string. Should parse the JSON string into a Value during conversion.

permissionDecision dropped (Copilot)

CopilotPreToolUseOutput::from_hook_output() never maps permissionDecision or permissionDecisionReason from the builtin hook output. If a builtin handler wants to deny a tool call, the decision is silently lost in Copilot output.

Gemini SessionStart matcher

ensure_gemini_hook_entry uses "matcher": ".*" for all events including SessionStart. Per the Gemini reference, lifecycle events use exact-string matchers, not regex. Likely harmless in practice since ".*" matches anything.

Windows portability (tests)

The test suite runs on windows-latest. A few patterns recur when writing tests that touch paths or scripts:

  • Paths in TOML/JSON string literals. A Windows path like C:\Users\... is invalid inside a TOML or JSON string (the backslashes read as escapes). When substituting a real path into fixture text, convert to forward slashes first; Windows accepts / in paths. See setup_fixture in symposium-testlib.
  • Paths inside sh script bodies. On Windows sh is git-bash’s MSYS shell, which reads C:\a\b as escapes plus an illegal :. Rewrite to the /c/a/b form and quote the value. See sh_path in predicate.rs tests.
  • .sh files must use script, not executable. A shell script cannot be spawned directly as a process on Windows (no shebang support). In fixtures, reference it via script = "..." so it is run through sh, never executable = "...".
  • Canonicalized paths carry a \\?\ prefix. fs::canonicalize on Windows returns an extended-length path that cargo’s output lacks. Canonicalize both sides before comparing.
  • Snapshot tests and home-abbreviated paths. display_path (in output.rs) abbreviates $HOME to ~/. On Windows the test temp dir lives under $HOME, so printed config paths come out home-relative, not absolute. normalize_paths (in symposium-testlib) replaces both the absolute and the ~/ form; a snapshot leaking a random .tmpXXXX/ path means one form was missed. Do not UPDATE_EXPECT your way past it: that bakes the volatile temp path into the snapshot and it fails on the next run.

Agent details

Disclaimer: These documents reflect our current understanding of each agent’s hook system and extensibility surface. They are maintained as working references for symposium development, not as a substitute for each project’s official documentation. Details may be outdated or incomplete — always consult the primary sources linked in each agent’s page.

For each agent it supports, symposium needs to know:

  1. Hook registration — where and how to write config so the agent calls cargo-agents hook
  2. Hook I/O protocol — event names, input/output field names, exit code semantics
  3. Extension installation — where skill files go (project and global)
  4. Custom instructions — where the agent reads project-level instructions

The tables below summarize the answers for each agent. Individual agent pages contain the full reference. A ? indicates information we have not yet documented.

Hook registration

AgentProject config pathGlobal config pathFormat
Claude Code.claude/settings.json~/.claude/settings.jsonJSON, hooks key with matcher groups
GitHub Copilot.github/hooks/*.json~/.copilot/config.jsonJSON, version: 1 with hooks key
Gemini CLI.gemini/settings.json~/.gemini/settings.jsonJSON, hooks key with matcher groups
Codex CLI.codex/hooks.json~/.codex/hooks.jsonJSON, hooks key with matcher groups
Kiro.kiro/agents/*.json~/.kiro/agents/*.jsonJSON, hooks key in agent config
OpenCode.opencode/plugins/~/.config/opencode/plugins/JS/TS plugins (not shell hooks)
Goose(no hooks)(no hooks)N/A

Command field

AgentCommand fieldPlatform-specific?
Claude CodecommandNo
GitHub Copilotbash / powershellYes
Gemini CLIcommandNo
Codex CLIcommandNo
KirocommandNo
OpenCodeN/A (JS function)N/A
GooseN/AN/A

Timeout defaults

AgentDefault timeoutUnit
Claude Code600seconds
GitHub Copilot30seconds (timeoutSec)
Gemini CLI60,000milliseconds (timeout)
Codex CLI600seconds (timeout or timeoutSec)
Kiro30,000milliseconds (timeout_ms)
OpenCode60,000milliseconds (community hooks plugin)
GooseN/AN/A

Event names

Symposium registers hooks for four events. Each agent uses different names and casing conventions.

Symposium eventClaude CodeCopilotGemini CLICodex CLIKiro CLIOpenCodeGoose
pre-tool-usePreToolUsepreToolUseBeforeToolPreToolUsepreToolUsetool.execute.beforeN/A
post-tool-usePostToolUsepostToolUseAfterToolPostToolUsepostToolUsetool.execute.afterN/A
user-prompt-submitUserPromptSubmituserPromptSubmittedBeforeAgentUserPromptSubmituserPromptSubmitmessage.updated (filter by role)N/A
session-startSessionStartsessionStartSessionStartSessionStartagentSpawnsession.createdN/A

Blocking support

Not all events can block the action in all agents.

AgentPre-tool-use can block?Post-tool-use can block?User-prompt can block?Session-start can block?
Claude CodeYesNoYes (exit 2)No
GitHub CopilotYesNoNoNo
Gemini CLIYesYes (block result)Yes (deny discards message)No
Codex CLIYesYes (continue: false)Yes (continue: false)Yes (continue: false)
KiroYes (exit 2)NoNoNo
OpenCodeYes (throw Error)NoNo (observe only)No (observe only)
GooseN/AN/AN/AN/A

Hook I/O protocol

Input fields (pre-tool-use)

AgentTool name fieldTool args fieldSession/context fields
Claude Codetool_nametool_input (object)session_id, cwd, hook_event_name
GitHub CopilottoolNametoolArgs (JSON string)timestamp, cwd
Gemini CLItool_nametool_input (object)session_id, cwd, hook_event_name, timestamp
Codex CLItool_nametool_input (object)session_id, cwd, hook_event_name, model
Kirotool_nametool_input (object)hook_event_name, cwd
OpenCodetoolargs (mutable output object)sessionID, callID
GooseN/AN/AN/A

Output structure (pre-tool-use)

AgentPermission decision fieldDecision valuesModified input fieldNesting
Claude CodepermissionDecisionallow, deny, ask, deferupdatedInputnested in hookSpecificOutput
GitHub CopilotpermissionDecisionallow, deny, askmodifiedArgsflat
Gemini CLIdecisionallow, denytool_inputnested in hookSpecificOutput
Codex CLIdecision or permissionDecisionblock/deny(not yet implemented)flat or nested hookSpecificOutput
Kiro(exit code only)exit 0 = allow, exit 2 = block(not supported)N/A
OpenCode(throw to block)allow (return) / deny (throw)mutate output.argsJS mutation
GooseN/AN/AN/AN/A

Exit codes

All shell-based agents use the same convention (where applicable):

CodeMeaning
0Success; stdout parsed as JSON
2Block/deny; stderr used as reason
OtherNon-blocking warning, action proceeds

Exceptions: Copilot uses exit 0 = allow, non-zero = deny (no special meaning for exit 2). OpenCode uses JS exceptions, not exit codes.

Extension installation

Skill file paths

AgentProject skills pathGlobal skills path
Claude Code.claude/skills/<name>/SKILL.md~/.claude/skills/<name>/SKILL.md
GitHub Copilot.agents/skills/<name>/SKILL.md(none)
Gemini CLI.agents/skills/<name>/SKILL.md~/.gemini/skills/<name>/SKILL.md
Codex CLI.agents/skills/<name>/SKILL.md~/.agents/skills/<name>/SKILL.md
Kiro.kiro/skills/<name>/SKILL.md~/.kiro/skills/<name>/SKILL.md
OpenCode.agents/skills/<name>/SKILL.md~/.agents/skills/<name>/SKILL.md
Goose(N/A — uses MCP extensions)(N/A)

Symposium uses the vendor-neutral .agents/skills/ path whenever the agent supports it, falling back to agent-specific paths (e.g., .claude/skills/, .kiro/skills/) when required. Codex CLI and OpenCode also support .agents/skills/ natively.

Custom instructions

AgentProject instructionsGlobal instructions
Claude CodeCLAUDE.md, .claude/CLAUDE.md~/.claude/CLAUDE.md
GitHub Copilot.github/copilot-instructions.md, AGENTS.md~/.copilot/copilot-instructions.md
Gemini CLIGEMINI.md (walks up to .git)~/.gemini/GEMINI.md
Codex CLIAGENTS.md (each dir level)~/.codex/AGENTS.md
Kiro.kiro/steering/*.md, AGENTS.md~/.kiro/steering/*.md
OpenCodeAGENTS.md, CLAUDE.md~/.config/opencode/AGENTS.md
Goose.goosehints, AGENTS.md~/.config/goose/.goosehints

MCP server configuration

Relevant if symposium exposes functionality via MCP.

AgentMCP config locationFormat
Claude Code.claude/settings.json (mcpServers key)JSON
GitHub Copilot.vscode/mcp.json (VS Code), ~/.copilot/mcp-config.json (CLI)JSON
Gemini CLI.gemini/settings.json (mcpServers key)JSON
Codex CLI.codex/config.toml / ~/.codex/config.toml (mcp_servers key)TOML
Kiro.kiro/settings/mcp.json, ~/.kiro/settings/mcp.jsonJSON
OpenCodeopencode.json (mcp key)JSON
Goose~/.config/goose/config.yaml (extensions key)YAML

Claude Code Hooks Reference

Disclaimer: This document reflects our current understanding of Claude Code’s hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: Hooks reference · Hooks guide · Extending Claude Code

Hooks are user-defined shell commands, HTTP endpoints, or LLM prompts that execute at specific points in the agent lifecycle. They provide deterministic control — actions always happen rather than relying on the model.

Hook Types

TypeDescription
commandShell script; communicates via stdin/stdout/exit codes
httpPOSTs JSON to a URL endpoint; supports header interpolation with $VAR_NAME
promptSingle-turn LLM evaluation returning {ok: true/false, reason}
agentSpawns a subagent with tool access (Read, Grep, Glob) for up to 50 turns

Events

EventTriggerCan block?Matcher target
SessionStartSession begins/resumesNostartup, resume, clear, compact
SessionEndSession terminates (1.5s default timeout)Noclear, resume, logout, etc.
UserPromptSubmitUser submits prompt, before processingYes (exit 2)None
PreToolUseBefore tool callYesTool name regex (Bash, Edit|Write, mcp__.*)
PostToolUseAfter tool succeedsNoTool name regex
PostToolUseFailureTool failsNoTool name regex
PermissionRequestPermission dialog appearsYesTool name regex
PermissionDeniedAuto-mode classifier denialNo (retry: true available)Tool name regex
StopMain agent finishes respondingYesNone
StopFailureTurn ends on API errorNo (output ignored)rate_limit, authentication_failed, etc.
SubagentStartSubagent spawnedNoAgent type
SubagentStopSubagent finishesYesAgent type
NotificationSystem notificationNopermission_prompt, idle_prompt, etc.
TaskCreatedTask createdYes (exit 2 rolls back)None
TaskCompletedTask completedYes (exit 2 rolls back)None
TeammateIdleTeammate about to go idleYesNone
ConfigChangeConfig file changes during sessionYes (except policy_settings)Config source
CwdChangedDirectory changeNoNone
FileChangedWatched file changesNoBasename
WorktreeCreateGit worktree createdYes (non-zero fails)None
WorktreeRemoveGit worktree removedNoNone
PreCompactBefore compactionNomanual, auto
PostCompactAfter compactionNomanual, auto
InstructionsLoadedCLAUDE.md loadedNoLoad reason
ElicitationMCP server requests user inputYesMCP server name
ElicitationResultMCP elicitation resultYesMCP server name

Configuration

Settings merge with precedence (highest first): Managed → Command line → Local → Project → User.

FileScope
Managed policy (MDM, registry, server, /etc/claude-code/)Organization-wide
.claude/settings.local.jsonSingle project, gitignored
.claude/settings.jsonSingle project, committable
~/.claude/settings.jsonAll projects (user)

Configuration structure

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./validate.sh",
            "if": "Bash(rm *)",
            "timeout": 60,
            "statusMessage": "Validating...",
            "async": false,
            "shell": "bash"
          }
        ]
      }
    ]
  }
}
  • matcher: regex matched against event-specific values (tool name, session source, notification type).
  • if: permission-rule syntax for additional filtering on tool events (e.g., Bash(git *), Edit(*.ts)).

Input Schema (stdin)

Base fields (all events)

{
  "session_id": "string",
  "transcript_path": "string",
  "cwd": "string",
  "permission_mode": "default|plan|auto|bypassPermissions|...",
  "hook_event_name": "string"
}

PreToolUse additions

  • tool_name: string
  • tool_input: object with tool-specific fields (command for Bash, file_path/content for Write, etc.)
  • tool_use_id: string

PostToolUse additions

  • tool_name, tool_input, tool_use_id (same as PreToolUse)
  • tool_response: string (tool output)

Stop additions

  • stop_hook_active: boolean
  • last_assistant_message: string

Output Schema (stdout)

Output is capped at 10,000 characters.

Universal fields

FieldTypeDescription
continuebooleanfalse stops Claude entirely
stopReasonstringMessage for user when continue is false
systemMessagestringWarning shown to user
suppressOutputbooleanOmits stdout from debug log

PreToolUse decision output

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow|deny|ask|defer",
    "permissionDecisionReason": "string",
    "updatedInput": { "command": "safe-cmd" },
    "additionalContext": "string"
  }
}

Decision precedence across parallel hooks: deny > defer > ask > allow. The allow decision does not override deny rules from settings. updatedInput replaces the entire tool input; if multiple hooks return it, the last to finish wins (non-deterministic).

Exit Codes

CodeMeaning
0Success; stdout parsed as JSON
2Blocking error — action blocked, stderr fed to Claude
OtherNon-blocking warning, action proceeds

Execution Behavior

  • All matching hooks run in parallel.
  • Identical handlers deduplicated by command string or URL.
  • Default timeouts: 600s (command), 30s (prompt), 60s (agent), 1.5s (SessionEnd, overridable via CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS).

Environment Variables

VariableDescription
CLAUDE_PROJECT_DIRAbsolute path to project root
CLAUDE_ENV_FILEFile for persisting env vars (SessionStart, CwdChanged, FileChanged only)
CLAUDE_CODE_REMOTE"true" in remote web environments

Enterprise Controls

  • allowManagedHooksOnly: true — blocks user/project/plugin hooks.
  • allowedHttpHookUrls — restricts HTTP hook destinations.
  • disableAllHooks: true — disables everything.
  • PreToolUse deny blocks even in bypassPermissions mode.

MCP Server Registration

In addition to hooks, symposium registers itself as an MCP server in the agent’s settings file. This provides an alternative integration path alongside the hook-based approach.

Configuration structure

The MCP server entry is added under mcpServers in the same settings file used for hooks:

{
  "mcpServers": {
    "symposium": {
      "command": "/path/to/cargo-agents",
      "args": ["mcp"]
    }
  }
}
  • Project-level: .claude/settings.json
  • User-level: ~/.claude/settings.json

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

GitHub Copilot Hooks Reference

Disclaimer: This document reflects our current understanding of GitHub Copilot’s hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: About hooks · Using hooks · Hooks configuration

GitHub Copilot hooks are available for the Cloud Agent (coding agent), Copilot CLI (GA February 2026), and VS Code (8-event preview). The system is command-only and repository-native.

Hook Types

Only type: "command" is supported.

Events

Cloud Agent and CLI (6 events, lowerCamelCase)

EventTriggerCan block?
sessionStartNew or resumed sessionNo
sessionEndSession completes or terminatesNo
userPromptSubmittedUser submits a promptNo
preToolUseBefore tool callYes
postToolUseAfter tool completes (success or failure)No
errorOccurredError during agent executionNo

VS Code (8 events, PascalCase, preview)

SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, SubagentStart, SubagentStop, Stop.

Only preToolUse/PreToolUse can make access-control decisions. All other events are observational.

Configuration

Cloud Agent and CLI

Hooks defined in .github/hooks/*.json. For the Cloud Agent, files must be on the repository’s default branch.

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "./scripts/security-check.sh",
        "powershell": "./scripts/security-check.ps1",
        "cwd": "scripts",
        "env": { "LOG_LEVEL": "INFO" },
        "timeoutSec": 15,
        "comment": "Documentation string, ignored at runtime"
      }
    ]
  }
}
FieldTypeDescription
typestringMust be "command"
bashstringCommand for Linux/macOS
powershellstringCommand for Windows
cwdstringWorking directory relative to repo root
envobjectEnvironment variables
timeoutSecnumberDefault 30 seconds
commentstringDocumentation, ignored at runtime

There is no matcher field — hooks fire on all invocations of their event type. Tool-level filtering must be done inside the script by inspecting toolName from stdin.

VS Code

Also reads hooks from .claude/settings.json, .claude/settings.local.json, and ~/.claude/settings.json (Claude Code format compatibility). Converts lowerCamelCase to PascalCase and maps bashosx/linux, powershellwindows.

Input Schema (stdin)

preToolUse

{
  "timestamp": 1704614600000,
  "cwd": "/path/to/project",
  "toolName": "bash",
  "toolArgs": "{\"command\":\"rm -rf dist\",\"description\":\"Clean build\"}"
}

Note: toolArgs is a JSON string, not an object. Scripts must parse it (e.g., with jq).

sessionStart

  • source: "new" | "resume"
  • initialPrompt: string

sessionEnd

  • reason: string

Output Schema (stdout)

preToolUse output (Cloud Agent / CLI)

{
  "permissionDecision": "deny",
  "permissionDecisionReason": "Destructive operations blocked"
}
ValueMeaning
"allow"Permit the tool call
"deny"Block the tool call
"ask"Prompt user for confirmation

Exit code 0 = allow (if no JSON output), non-zero = deny.

VS Code output (preview, extended fields)

FieldTypeDescription
continuebooleanfalse stops agent
stopReasonstringMessage when continue is false
systemMessagestringWarning shown to user
hookSpecificOutput.permissionDecisionstringallow, deny, ask
hookSpecificOutput.updatedInputobjectReplace tool arguments
hookSpecificOutput.additionalContextstringExtra context for agent

Execution Behavior

  • Hooks run synchronously and sequentially (array order).
  • If the first hook returns deny, subsequent hooks are skipped.
  • Recommended execution time: under 5 seconds.
  • Default timeout: 30 seconds. On timeout, hook is terminated and agent continues.
  • Scripts read JSON from stdin (INPUT=$(cat)) and write to stdout; debug output goes to stderr.

Environment Variables

No built-in variables beyond those specified in the hook’s env field. The cwd field controls the working directory.

Custom instructions (soft/probabilistic)

FileScope
.github/copilot-instructions.mdRepository-wide instructions
.github/instructions/**/*.instructions.mdPath-specific instructions (with applyTo globs)
AGENTS.mdAgent-mode instructions
~/.copilot/copilot-instructions.mdUser-level (personal)
Organization-level instructionsAdmin-configured

Priority: Personal (user) > Repository (workspace) > Organization.

MCP server configuration

ScopeConfig pathRoot key
VS Code (workspace).vscode/mcp.jsonservers
VS Code (user)Via “MCP: Open User Configuration” commandservers
CLI~/.copilot/mcp-config.jsonmcpServers

Note: VS Code uses "servers" as root key while the CLI uses "mcpServers". MCP tools only work in Copilot’s Agent mode. Supported transports: local/stdio, http/sse.

MCP Server Registration

Symposium registers MCP servers in the Copilot config as top-level keys (matching the CLI’s mcpServers format, not VS Code’s servers format):

{
  "symposium": {
    "command": "/path/to/cargo-agents",
    "args": ["mcp"]
  }
}
  • Project-level: .vscode/mcp.json
  • User-level: ~/.copilot/mcp-config.json

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

Copilot SDK (programmatic hooks)

The @github/copilot-sdk (Node.js, Python, Go, .NET, Java) provides callback-style hooks for applications embedding the Copilot runtime:

  • onPreToolUse — can return modifiedArgs
  • onPostToolUse — can return modifiedResult
  • onSessionStart, onSessionEnd, etc.

Agent firewall (Cloud Agent)

Network-layer control with deny-by-default domain allowlist, configured at org/repo level. Not a hook — controls outbound network access.

Gemini CLI Hooks Reference

Disclaimer: This document reflects our current understanding of Gemini CLI’s hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: Hooks reference · Extensions reference · GitHub repo

Gemini CLI’s hook system (v0.26.0, January 2026) mirrors Claude Code’s JSON-over-stdin contract and exit-code semantics. It adds model-level and tool-selection interception events unique to Gemini.

Hook Types

Only type: "command" is currently supported.

Events

EventTriggerCan block?Category
BeforeToolBefore tool invocationYesTool
AfterToolAfter tool executionYes (block result)Tool
BeforeAgentUser submits prompt, before planningYesAgent
AfterAgentAgent loop ends (final response)Yes (retry/halt)Agent
BeforeModelBefore sending request to LLMYes (mock response)Model
AfterModelAfter receiving LLM response (per-chunk during streaming)Yes (redact)Model
BeforeToolSelectionBefore LLM selects toolsFilter tools onlyModel
SessionStartSession beginsNo (advisory)Lifecycle
SessionEndSession endsNo (best-effort)Lifecycle
NotificationSystem notification (e.g., ToolPermission)No (advisory)Lifecycle
PreCompressBefore context compressionNo (async, cannot block)Lifecycle

Model-level events (unique to Gemini)

  • BeforeModel: can swap models, modify temperature, or return a synthetic response to skip the LLM call entirely.
  • BeforeToolSelection: can filter the candidate tool list using toolConfig.mode (AUTO/ANY/NONE) and allowedFunctionNames whitelists. Multiple hooks use union aggregation across allowed function lists.
  • AfterModel: can redact or modify the LLM response per-chunk during streaming.

Configuration

Four-tier precedence: Project → User → System → Extensions.

FileScope
.gemini/settings.jsonProject
~/.gemini/settings.jsonUser
/etc/gemini-cli/settings.jsonSystem
ExtensionsPlugin-provided

Configuration structure

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "sequential": false,
        "hooks": [
          {
            "name": "secret-scanner",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
            "timeout": 5000,
            "description": "Prevent committing secrets"
          }
        ]
      }
    ]
  }
}
  • matcher: regex for tool events, exact string for lifecycle events.
  • sequential: boolean (default false). When true, hooks run in order with output chaining.
  • timeout: milliseconds (default 60,000).

Input Schema (stdin)

Base fields (all events)

{
  "session_id": "string",
  "transcript_path": "string",
  "cwd": "string",
  "hook_event_name": "string",
  "timestamp": "2026-03-03T10:30:00Z"
}

BeforeTool additions

  • tool_name: string
  • tool_input: object (raw model arguments)
  • mcp_context: object (optional)
  • original_request_name: string (optional)

AfterTool additions

  • tool_name, tool_input (same as BeforeTool)
  • tool_response: object containing llmContent, returnDisplay, and optional error

BeforeModel additions

  • llm_request: object with model, messages, config, toolConfig

BeforeAgent additions

  • prompt: string (the user’s original prompt text)

AfterAgent additions

  • stop_hook_active: boolean (loop detection)

Output Schema (stdout)

Universal fields

FieldTypeDescription
decisionstring"allow" or "deny" (alias "block")
reasonstringFeedback sent to agent when denied
systemMessagestringDisplayed to user
continuebooleanfalse kills agent loop
stopReasonstringMessage when continue is false
suppressOutputbooleanHide from logs/telemetry

Event-specific output via hookSpecificOutput

BeforeTool: tool_input — merges with and overrides model arguments.

AfterTool:

  • additionalContext: string appended to tool result
  • tailToolCallRequest: object triggering a follow-up tool call

AfterAgent: when denied, reason is sent as a new prompt for retry.

BeforeAgent: additionalContext — string appended to the prompt for that turn. decision: "deny" discards the user’s message from history; continue: false preserves it.

BeforeModel:

  • llm_request: overrides outgoing request (swap model, modify temperature, etc.)
  • llm_response: provides synthetic response that skips the LLM call

Exit Codes

CodeMeaning
0Success; stdout parsed as JSON
2System block — stderr used as reason
OtherWarning (non-fatal), action proceeds

Execution Behavior

  • Hooks run in parallel by default; set sequential: true for ordered execution with output chaining.
  • Default timeout: 60,000ms.

Environment Variables

VariableDescription
GEMINI_PROJECT_DIRAbsolute path to project root
GEMINI_SESSION_IDCurrent session ID
GEMINI_CWDCurrent working directory
CLAUDE_PROJECT_DIRCompatibility alias for GEMINI_PROJECT_DIR

Environment redaction for sensitive variables (KEY, TOKEN patterns) is available but disabled by default.

Migration from Claude Code

gemini hooks migrate --from-claude

Converts .claude configurations to .gemini format. Tool name mappings:

Claude CodeGemini CLI
Bashrun_shell_command
Editedit_file
Writewrite_file
Readread_file

Custom Instructions

Gemini CLI reads GEMINI.md files at multiple levels:

ScopePath
Global~/.gemini/GEMINI.md
ProjectGEMINI.md in CWD and parent directories up to .git root
Just-in-timeGEMINI.md discovered when tools access a file/directory

The filename is configurable via context.fileName in settings.json (e.g., ["AGENTS.md", "GEMINI.md"]). Supports @file.md import syntax for including content from other files.

Skills

ScopePathNotes
Workspace.agents/skills/ or .gemini/skills/.agents/ takes precedence
User~/.agents/skills/ or ~/.gemini/skills/.agents/ takes precedence
Extension~/.gemini/extensions/<name>/skills/Bundled with extensions

Skills use SKILL.md with YAML frontmatter (name, description). Metadata is injected at session startup; full content loads on demand via activate_skill.

MCP Server Configuration

Configured under mcpServers in .gemini/settings.json or ~/.gemini/settings.json:

{
  "mcpServers": {
    "serverName": {
      "command": "path/to/executable",
      "args": ["--arg1"],
      "env": { "API_KEY": "$MY_TOKEN" },
      "timeout": 30000
    }
  }
}

Transport is auto-selected by key: command+args (stdio), url (SSE), httpUrl (streamable HTTP).

MCP Server Registration

In addition to hooks, symposium registers itself as an MCP server in the agent’s settings file. This provides an alternative integration path alongside the hook-based approach.

Configuration structure

The MCP server entry is added under mcpServers in the same settings file used for hooks:

{
  "mcpServers": {
    "symposium": {
      "command": "/path/to/cargo-agents",
      "args": ["mcp"]
    }
  }
}
  • Project-level: .gemini/settings.json
  • User-level: ~/.gemini/settings.json

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

Codex CLI Hooks Reference

Disclaimer: This document reflects our current understanding of Codex CLI’s hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: Hooks · GitHub repo

OpenAI’s Codex CLI implements a shell-command hook system configured in hooks.json. It is experimental (disabled by default, not available on Windows) and first shipped in v0.114 (March 2026).

Enabling

Add to ~/.codex/config.toml:

[features]
codex_hooks = true

Configuration

FileScope
~/.codex/hooks.jsonUser-global
<repo>/.codex/hooks.jsonProject-scoped

Both are additive — all matching hooks from all files run. Project hooks follow the untrusted-project trust model.

Configuration structure

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "python3 ~/.codex/hooks/check_bash.py",
        "statusMessage": "Checking command safety",
        "timeout": 30
      }]
    }]
  }
}

Only handler type is "command". matcher is a regex string; omit or use "" / "*" to match everything. Default timeout: 600 seconds, configurable via timeout or timeoutSec.

Events

EventTriggerMatcher filters onCan block?
SessionStartSession starts or resumessource ("startup" or "resume")Yes (continue: false)
PreToolUseBefore tool executiontool_name (currently only "Bash")Yes
PostToolUseAfter tool executiontool_name (currently only "Bash")Yes (continue: false)
UserPromptSubmitUser submits a promptN/AYes (continue: false)
StopAgent turn completesN/AYes (deny → continuation prompt)

Input Schema (stdin)

Base fields (all events)

{
  "session_id": "string",
  "transcript_path": "string|null",
  "cwd": "string",
  "hook_event_name": "string",
  "model": "string"
}

Turn-scoped events (PreToolUse, PostToolUse, UserPromptSubmit, Stop) add turn_id.

PreToolUse additions

  • tool_name: string
  • tool_use_id: string
  • tool_input: object with command field

PostToolUse additions

  • tool_name, tool_use_id, tool_input (same as PreToolUse)
  • tool_response: string

UserPromptSubmit additions

  • prompt: string

Stop additions

  • stop_hook_active: boolean
  • last_assistant_message: string

Output Schema (stdout)

Deny/block (two equivalent methods)

Method 1 — JSON output:

{ "decision": "block", "reason": "Destructive command blocked" }

or:

{
  "hookSpecificOutput": {
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked"
  }
}

Method 2 — Exit code 2 with reason on stderr.

Inject context

{
  "hookSpecificOutput": {
    "additionalContext": "Extra info for the agent"
  }
}

Plain text on stdout also works for SessionStart and UserPromptSubmit (ignored for PreToolUse, PostToolUse, Stop).

Stop session

{ "continue": false, "stopReason": "Session terminated by hook" }

Supported on SessionStart, UserPromptSubmit, PostToolUse, Stop.

System message (UI warning)

{ "systemMessage": "Warning text shown to user" }

Stop event special behavior

For the Stop event, { "decision": "block", "reason": "Run tests again" } tells Codex to create a continuation prompt — it does not reject the turn.

Exit Codes

CodeMeaning
0Success; stdout parsed. No output = continue normally.
2Block/deny; stderr used as reason
OtherNon-blocking warning

Execution Behavior

  • Multiple matching hooks run concurrently — no ordering guarantees.
  • Commands run with session cwd as working directory.
  • Shell expansion works.

Parsed but Not Yet Implemented

These fields are accepted but fail open (no effect): suppressOutput, updatedInput, updatedMCPToolOutput, permissionDecision: "allow", permissionDecision: "ask".

Current Limitations

  • Only Bash tool events fire PreToolUse/PostToolUse — no file-write or MCP tool hooks.
  • PreToolUse can only deny, not modify tool input.
  • No async hook mode.
  • Stop event requires JSON output (plain text is invalid).

Environment Variables

No dedicated environment variables are set during hook execution (unlike Claude Code’s CLAUDE_PROJECT_DIR). All context is passed via stdin JSON. The cwd field serves as the project directory equivalent. CODEX_HOME (defaults to ~/.codex) controls where Codex stores config and state.

Custom Instructions

ScopePath
Global~/.codex/AGENTS.md (or AGENTS.override.md)
ProjectAGENTS.md (or AGENTS.override.md) at each directory level from git root to CWD

The project_doc_fallback_filenames config option in ~/.codex/config.toml allows alternative filenames. Max combined size: 32 KiB (project_doc_max_bytes).

Skills

ScopePath
Repository.agents/skills/<name>/SKILL.md (each dir from CWD up to repo root)
User~/.agents/skills/<name>/SKILL.md
Admin/etc/codex/skills/<name>/SKILL.md
SystemBundled with Codex

Skills use SKILL.md with YAML frontmatter (name, description) and may include scripts/, references/, assets/, and agents/openai.yaml.

MCP Server Configuration

Configured in ~/.codex/config.toml or .codex/config.toml under [mcp_servers.<name>]:

[mcp_servers.my-server]
command = "path/to/executable"
args = ["--arg1"]
env = { API_KEY = "value" }
startup_timeout_sec = 10
tool_timeout_sec = 60

Supports stdio (command/args) and streamable HTTP (url/bearer_token_env_var). CLI management: codex mcp add <name> ....

MCP Server Registration

In addition to hooks, symposium registers itself as an MCP server in the agent’s config file. This provides an alternative integration path alongside the hook-based approach.

Configuration structure

The MCP server entry is added under [mcp_servers] in the TOML config:

[mcp_servers.symposium]
command = "/path/to/cargo-agents"
args = ["mcp"]
  • Project-level: .codex/config.toml
  • User-level: ~/.codex/config.toml

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

Other Extensibility

  • notify in config.toml (fire-and-forget on agent-turn-complete)
  • Execpolicy command-level rules
  • Subagents
  • Slash commands

Goose Hooks Reference

Disclaimer: This document reflects our current understanding of Goose’s extensibility surface. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: Extensions · Configuration · GitHub repo

Goose does not implement lifecycle hooks. There are no shell-command or programmatic interception points for tool execution, session start/stop, or prompt submission. No hooks.json equivalent. No JSON stdin/stdout protocol.

What Goose Offers Instead

MCP Extensions

The primary extensibility mechanism. Extensions are MCP servers (stdio or HTTP) that expose new tools, resources, and prompts. Configured in ~/.config/goose/config.yaml under extensions:. Built-in extensions include Developer (shell, file editing), Computer Controller, Memory, and Todo. Custom extensions are standard MCP servers built in Python, TypeScript, or Kotlin. Extensions add capabilities but cannot intercept or modify existing tool behavior.

Permission Modes

The closest analog to hook-based control flow. Static configuration, not programmable logic.

ModeBehavior
autoTools execute without approval (default)
approveEvery tool call requires manual confirmation
smart_approveAI risk assessment auto-approves low-risk, prompts for high-risk
chatNo tool use

Per-tool permissions can be set to Always Allow, Ask Before, or Never Allow.

Goosehints / AGENTS.md

Instruction files injected into the system prompt. Influence LLM behavior through prompting, not deterministic interception.

FileScope
~/.config/goose/.goosehintsGlobal
.goosehints (project root)Project
AGENTS.mdProject

GOOSE_TERMINAL Environment Variable

Shell scripts can detect whether they’re running under Goose and alter behavior (e.g., wrapping git to block git commit). This is a shell-level workaround, not a Goose-native mechanism.

Other Mechanisms

  • .gooseignore — gitignore-style file access restriction
  • Recipes — YAML workflow packages
  • Custom slash commands
  • Subagents
  • ACP integration
  • Tool Router — internal optimization for tool selection

MCP Server Registration

Since Goose has no lifecycle hooks, symposium integrates exclusively via MCP server registration. Symposium registers itself as an extension in the Goose config file.

Configuration structure

The MCP server entry is added under extensions in the YAML config:

extensions:
  symposium:
    provider: mcp
    config:
      command: /path/to/cargo-agents
      args: [mcp]
  • Project-level: .goose/config.yaml
  • User-level: ~/.config/goose/config.yaml

Registration is idempotent — if the entry already exists with the correct values, no changes are made. Stale entries are updated in place.

Kiro Hooks Reference

Disclaimer: This document reflects our current understanding of Kiro’s hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: CLI hooks · Agent configuration reference · IDE hooks

Kiro is Amazon’s AI coding agent available as an IDE (VS Code fork) and CLI. Both have hook systems but they differ in configuration format, trigger types, and capabilities.

Kiro CLI Agent Definition

Each .kiro/agents/*.json file defines a complete agent. All fields are optional; omitting a field has specific defaults.

FileScope
.kiro/agents/*.jsonProject
~/.kiro/agents/*.jsonGlobal

Agent Definition Fields

FieldTypeDefault if omitted
namestringDerived from filename
descriptionstring(none)
promptstring or file:// URINo custom system context
toolsarray of stringsNo tools available
allowedToolsarray of strings/globsAll tools require confirmation
toolAliasesobject(none)
resourcesarray of URIs/objects(none)
hooksobject(none)
mcpServersobject(none)
toolsSettingsobject(none)
includeMcpJsonboolean(none)
modelstringSystem default
keyboardShortcutstring(none)
welcomeMessagestring(none)

Critical: Omitting tools means the agent has zero tools. Use "tools": ["*"] for all tools, "@builtin" for built-ins only, or list specific tools.

Tools Field Values

  • "*" — all available tools
  • "@builtin" — all built-in tools
  • "read", "write", "shell" — specific built-in tools
  • "@server_name" — all tools from an MCP server
  • "@server_name/tool_name" — specific MCP tool

AllowedTools Field

Specifies tools that execute without user confirmation. Supports exact matches and glob patterns ("@server/read_*", "@git-*/status"). Does not support "*" wildcard for all tools.

Resources Field

  • "file://README.md" — load file into context at startup
  • "skill://.kiro/skills/**/SKILL.md" — skill metadata loaded at startup, full content on-demand

Custom agents do not auto-discover skills. They require explicit skill:// URIs in resources.

Kiro CLI Hooks

Configured inside agent configuration JSON files. Shell commands receive JSON on stdin and use exit codes for control flow.

Events

EventTriggerMatcher?Can block?
agentSpawnSession startsNoNo
userPromptSubmitUser submits promptNoNo
preToolUseBefore tool executionYesYes (exit 2)
postToolUseAfter tool executionYesNo
stopAgent finishesNoNo

Input Schema (stdin)

All events include hook_event_name and cwd.

userPromptSubmit adds:

  • prompt: string

preToolUse adds:

  • tool_name: string
  • tool_input: object (full tool arguments)

postToolUse adds:

  • tool_name: string
  • tool_input: object
  • tool_response: string

MCP tools use @server/tool naming (e.g., @postgres/query).

Exit Codes

CodeMeaning
0Success; stdout captured as context
2Block (preToolUse only); stderr sent to LLM as reason
OtherWarning; stderr shown but execution continues

Matcher Patterns

  • Tool name strings: execute_bash, fs_write, read
  • Aliases: shell, write
  • MCP server globs: @git, @git/status
  • Wildcards: *
  • Built-in group: @builtin
  • No matcher = applies to all tools

Execution Behavior

  • Hooks execute in array order within each trigger type.
  • Default timeout: 30 seconds (30,000ms), configurable via timeout_ms.
  • cache_ttl_seconds: default 0 (no caching). agentSpawn hooks are never cached.

Configuration Example

{
  "hooks": {
    "preToolUse": [
      {
        "matcher": "execute_bash",
        "command": "./scripts/validate.sh"
      }
    ],
    "postToolUse": [
      {
        "matcher": "fs_write",
        "command": "cargo fmt --all"
      }
    ],
    "agentSpawn": [
      {
        "command": "git status"
      }
    ]
  }
}

Each entry is a flat object with command (required) and optional matcher. There is no nested hooks array or type field.

Kiro IDE Hooks

Stored as individual .kiro.hook files in .kiro/hooks/. Created via the Kiro panel UI or command palette.

Hook File Format

name: Format on save
description: Run formatter after file saves
when:
  type: fileEdit
  patterns: **/*.ts
then:
  type: shellCommand
  command: npx prettier --write {file}

Trigger Types (10)

TypeTrigger
promptSubmitUser submits a prompt
agentStopAgent finishes responding
preToolUseBefore tool execution
postToolUseAfter tool execution
fileCreateFile created
fileEditFile saved
fileDeleteFile deleted
preTaskExecutionBefore spec task runs
postTaskExecutionAfter spec task runs
userTriggeredManual invocation

The IDE adds file-event and spec-task triggers not available in the CLI.

Action Types (2)

TypeDescription
askAgentSends a natural language prompt to the agent (consumes credits)
shellCommandRuns locally; exit 0 = stdout added to context, non-zero = blocks on preToolUse/promptSubmit

IDE Tool Matching Categories

read, write, shell, web, spec, *, @mcp, @powers, @builtin, plus regex patterns with @ prefix.

IDE Execution Behavior

  • Default timeout: 60 seconds.
  • USER_PROMPT env var is available for promptSubmit shell commands.

Environment Variables

No dedicated environment variables are documented for CLI hook execution. Context is passed via stdin JSON. The IDE provides USER_PROMPT for promptSubmit shell command hooks.

Custom Instructions (Steering)

Kiro uses “steering files” instead of a single instructions file:

ScopePath
Workspace.kiro/steering/*.md
Global~/.kiro/steering/*.md
StandardAGENTS.md at workspace root (always included)

Steering files support YAML frontmatter with four inclusion modes: Always, FileMatch (glob pattern), Manual (referenced via #name in chat), and Auto (description-based matching). Kiro also auto-generates product.md, tech.md, and structure.md.

Skills

ScopePath
Workspace.kiro/skills/<name>/SKILL.md
Global~/.kiro/skills/<name>/SKILL.md

Workspace skills take precedence over global skills with the same name. The default agent auto-discovers skills from both locations. Custom agents require explicit skill:// URIs in their resources field. Skills use SKILL.md with YAML frontmatter (name, description).

MCP Server Configuration

ScopePath
Workspace.kiro/settings/mcp.json
Global~/.kiro/settings/mcp.json
Agent-levelmcpServers field in .kiro/agents/*.json

Priority: Agent config > Workspace > Global. Format is JSON with mcpServers key, supporting command/args/env for stdio and url/headers for remote servers.

MCP Server Registration

In addition to hooks, symposium registers itself as an MCP server in the agent’s MCP config file. This provides an alternative integration path alongside the hook-based approach.

Configuration structure

The MCP server entry is added under mcpServers:

{
  "mcpServers": {
    "symposium": {
      "command": "/path/to/cargo-agents",
      "args": ["mcp"]
    }
  }
}
  • Project-level: .kiro/settings/mcp.json
  • User-level: ~/.kiro/settings/mcp.json

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

OpenCode Plugin System Reference

Disclaimer: This document reflects our current understanding of OpenCode’s plugin/hook system. It is a working reference for symposium development, not a substitute for the official docs. Details may be outdated or incomplete — always consult the primary sources.

Primary sources: Plugins · GitHub repo

OpenCode’s extensibility centers on TypeScript/JavaScript plugins, not shell commands. Plugins are async functions that receive a context object and return a hooks object. A secondary experimental system supports shell-command hooks in opencode.json.

Symposium does not currently integrate with OpenCode’s hook system. OpenCode is supported as a skills-only agent.

Plugin Locations and Load Order

Hooks run sequentially in this order:

  1. Global config plugins (~/.config/opencode/opencode.json"plugin": [...])
  2. Project config plugins (opencode.json)
  3. Global plugin directory (~/.config/opencode/plugins/)
  4. Project plugin directory (.opencode/plugins/)

npm packages are auto-installed via Bun and cached in ~/.cache/opencode/node_modules/.

Plugin Context Object

All plugins receive: { project, client, $, directory, worktree }.

Core Plugin Hooks

HookTriggerControl Flow
eventEvery system event (~30 types including session.idle, session.created, tool.execute.before, file.edited, permission.asked)Observe only
tool.execute.beforeBefore any built-in tool runsThrow Error → block. Mutate output.args → modify tool arguments. Return normally → allow.
tool.execute.afterAfter a built-in tool completesMutate output.title, output.output, output.metadata
shell.envBefore any shell executionMutate output.env to inject environment variables
stopAgent attempts to stopCall client.session.prompt() to prevent stopping and send more work
configDuring configuration loadingMutate config object directly
toolPlugin load time (declarative)Registers custom tools via tool() definitions; overrides built-ins with same name
authAuth initializationObject with provider, loader, methods
chat.messageChat message processingMutate message and parts via output object
chat.paramsBefore LLM API callMutate temperature, topP, options via output object
permission.askPermission requestedSet output.status to 'allow' or 'deny'reportedly never called (bug #7006)

Experimental Hooks (prefix experimental.)

HookDescription
chat.system.transformPush strings to output.system array to augment system prompt
chat.messages.transformMutate output.messages array
session.compactingPush to output.context or replace output.prompt during compaction

tool.execute.before Schema

Input

{
  "tool": "string",
  "sessionID": "string",
  "callID": "string"
}

Output (mutable)

{
  "args": { "key": "value" }
}

Mutate output.args to change tool arguments before execution.

chat.params Schema

Input

{
  "model": "string",
  "provider": "string",
  "message": "object"
}

Output (mutable)

{
  "temperature": 0.7,
  "topP": 0.9,
  "options": {}
}

Limitations

  • MCP tool calls do NOT trigger tool.execute.before or tool.execute.after.
  • Plugin-level syntax errors prevent loading entirely.
  • tool.execute.before errors block the tool.
  • No explicit timeout documentation for plugin hooks.
  • No hook ordering guarantees beyond load order.

Experimental Config-Based Shell Hooks (opencode.json)

A simpler shell-command system under "experimental.hook":

{
  "experimental": {
    "hook": {
      "file_edited": {
        "*.ts": [{ "command": ["prettier", "--write"], "environment": {"NODE_ENV": "development"} }]
      },
      "session_completed": [{ "command": ["notify-send", "Done!"], "environment": {} }]
    }
  }
}

Only two events: file_edited (glob-matched) and session_completed. No session_start (requested in issue #12110).

Environment Variables

Core OpenCode sets these on child processes:

  • OPENCODE_SESSION_ID — current session identifier
  • OPENCODE_SESSION_TITLE — human-readable session name

The shell.env plugin hook allows injecting custom environment variables into all shell execution.

Configuration-related env vars (not hook-specific): OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, OPENCODE_MODEL.

Custom Instructions

ScopePath
ProjectAGENTS.md at project root
Global~/.config/opencode/AGENTS.md
Legacy compatCLAUDE.md (project), ~/.claude/CLAUDE.md (global)
Config-based"instructions" array in opencode.json (file paths and globs)

Priority: local AGENTS.md > local CLAUDE.md > global ~/.config/opencode/AGENTS.md > global ~/.claude/CLAUDE.md.

Skills

ScopePath
Project.opencode/skills/, .claude/skills/, .agents/skills/
Global~/.config/opencode/skills/, ~/.claude/skills/, ~/.agents/skills/

OpenCode walks up from CWD to the git worktree root, loading matching skill definitions. Skills use SKILL.md with YAML frontmatter (name, description) and are loaded on-demand via the native skill tool.

Additional Events (Plugin System)

The full event list includes: session.created, session.idle, session.compacted, message.updated, file.edited, file.watcher.updated, permission.asked, permission.replied, tool.execute.before, tool.execute.after, shell.env, tui.prompt.append, tui.command.execute, and others (~30 total). The message.updated event (filtered by role === "user") is the closest equivalent to a user-prompt-submit hook. The session.created event is the session-start equivalent.

MCP Server Registration

In addition to hooks, symposium registers itself as an MCP server in the agent’s config file. This provides an alternative integration path alongside the hook-based approach.

Configuration structure

The MCP server entry is added under mcp in the JSON config:

{
  "mcp": {
    "symposium": {
      "command": "/path/to/cargo-agents",
      "args": ["mcp"]
    }
  }
}
  • Project-level: opencode.json
  • User-level: ~/.config/opencode/opencode.json

Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place.

Requests for Discussion (RFDs)

RFDs are a way of planning out larger changes. They aren’t required but they can be useful.

The basic idea is that you open a PR adding an RFD based on the RFD template into the rfds directory. Each RFD is itself a subdirectory like rfds/my-rfd/README.md. Be sure to add that to the SUMMARY.md file. RFDs can have subchapters or other accompanying material.

If the PR is accepted, the RFD will be merged in. At that point you open implementation PRs based on the RFD until it is completed. Each implementation PR should update the RFD to reflect its status.

Finally, you move it to the other section (the path stays the same).

Your title here

TL;DR

Motivation

Change in a nutshell

Detailed plans

Frequently asked questions

Implementation plan and status

Accepted RFDs

RFDs that have been accepted and are in progress.

MCP meta-server for progressive tool disclosure

TL;DR

  • Instead of writing plugin MCP servers directly into agent config, Symposium runs a single “meta” MCP server that gates access to all plugin-provided servers.
  • The meta-server exposes two tools — list_tools and execute — following the progressive disclosure pattern.
  • list_tools returns TypeScript declarations describing available tools. execute runs a JavaScript program with those tools available as global functions.
  • This avoids context bloat, keeps .claude/ (and equivalents) clean, gives Symposium a persistent in-process channel to the agent, and lets the agent compose multi-step tool workflows in a single round trip.

Motivation

Today, [[mcp_servers]] entries in plugins are registered directly into the agent’s MCP configuration during sync. This has three problems:

  1. Context bloat. Every registered MCP server’s tools are loaded into the agent’s context window at startup. A workspace with many plugins could inject dozens of tool schemas the agent never uses.

  2. Config pollution. Writing entries into .claude/settings.local.json (or equivalent) leaves artifacts that are visible to the user, hard to .gitignore cleanly, and create merge friction in shared repos.

  3. No return channel. Once tools are registered, Symposium has no way to communicate with the agent session — for elicitation, status updates, or dynamic capability changes.

A single Symposium-owned MCP server solves all three: one config entry, progressive tool loading, and an always-available communication channel. Adding code execution on top eliminates the “one round trip per tool call” bottleneck.

Change in a nutshell

At cargo agents init (or sync), Symposium registers exactly one MCP server — itself — into the agent’s config:

// .claude/settings.local.json (Claude Code example)
{
  "mcpServers": {
    "symposium": {
      "command": "cargo-agents",
      "args": ["mcp-serve"]
    }
  }
}

When the agent starts a session, it connects to this server and sees two tools:

symposium__list_tools  — Show available tools as TypeScript declarations
symposium__execute     — Run a JavaScript program with tools as globals

The list_tools description contains a capability index (the “menu”) built from all applicable plugin MCP servers. When the agent needs details, it calls list_tools and receives TypeScript declarations. It then writes a JavaScript program that calls those functions and passes it to execute.

Example flow

The agent sees this in the list_tools description:

Available servers: sqlx, sea_orm
Call list_tools for full declarations.

It calls list_tools({ servers: ["sqlx"] }) and gets:

declare namespace sqlx {
  /** Execute a SQL query and return rows */
  function query(params: { sql: string; params?: any[] }): any;
  /** Explain a query plan */
  function explain(params: { sql: string }): any;
  /** Show pending and applied migrations */
  function migrate_status(): any;
}

It then calls execute with a JavaScript program:

const users = await sqlx.query({ sql: "SELECT id, name FROM users WHERE active = $1", params: [true] });
const tables = users.rows.map(u => u.table_name);
const entities = [];
for (const t of tables) {
  entities.push(await sea_orm.generate_entity({ table: t }));
}
return entities.filter(e => e.code.includes("DateTime"));

The meta-server runs this in a sandboxed JS interpreter, dispatching sqlx.query(...) and sea_orm.generate_entity(...) to the respective backing MCP servers, and returns the final value to the agent.

Detailed plans

Meta-server architecture

The meta-server is a stdio MCP server implemented in cargo-agents mcp-serve. It:

  1. Resolves the workspace (same WorkspaceDeps logic as sync/hooks).
  2. Collects all applicable [[mcp_servers]] from the plugin registry.
  3. Exposes two tools: list_tools and execute.
  4. Embeds a JavaScript interpreter for executing agent-submitted programs.

The two tools

list_tools

description: |
  List available tools as TypeScript declarations.
  
  Available servers: sqlx, sea_orm, tokio_console
  Call list_tools for full declarations.

parameters:
  servers: array of strings (optional) — which servers to show (default: all)

Returns TypeScript declarations for the requested servers. When called with no arguments, returns declarations for all available servers.

execute

parameters:
  script: string — JavaScript program to run

Runs the script in a sandboxed JS interpreter. Each MCP server is exposed as a namespace object on the global scope (e.g., sqlx, sea_orm). Tool functions within each namespace are async — the script should use await. The return value of the script (last expression or explicit return) is serialized as JSON and returned to the agent.

TypeScript declarations from MCP schemas

MCP tool schemas are JSON Schema with type: "object" at the root. The conversion to TypeScript declarations is mechanical:

JSON SchemaTypeScript
{ "type": "string" }string
{ "type": "number" } or "integer"number
{ "type": "boolean" }boolean
{ "type": "array", "items": T }T[]
{ "type": "object", "properties": {...} }{ field: T; ... }
{ "enum": ["a", "b"] }"a" | "b"
not in requiredfield?: T
anything elseany

Return types are any since the MCP spec (2025-03-26) does not type tool outputs. If a server declares outputSchema (2025-11-25 spec), we can generate a return type from it.

Descriptions from the JSON Schema description field become JSDoc comments on the declaration.

JavaScript execution engine

The meta-server embeds a lightweight JS engine. Two candidates:

  • rquickjs — Rust bindings to QuickJS. ~500KB, sub-ms startup, ES2020, easy host function registration.
  • Boa — pure Rust. No C dependency, still maturing on spec compliance.

We start with rquickjs (better spec compliance, proven in production). The sandbox exposes only the MCP tool namespaces — no filesystem, network, or other ambient capabilities.

Each namespace function is registered as a host-backed async function. When the script calls await sqlx.query(...), the engine suspends, the meta-server dispatches to the backing MCP server, and resumes the script with the result.

Lazy server lifecycle

Plugin MCP servers are not started until the agent requests their declarations (via list_tools) or executes a script that calls one of their tools. The meta-server maintains a process table:

  • Cold — server not running, tool list known from plugin manifest.
  • Starting — server process spawning, calls queue.
  • Ready — server running, calls dispatched directly.
  • Dead — server exited unexpectedly, restart on next call.

Servers are shut down when the meta-server exits (agent session ends).

Capability index in the description

The list_tools description is dynamically generated from applicable plugins:

Available servers: sqlx, sea_orm, tokio_console
Call list_tools for full TypeScript declarations.

This gives the agent orientation without consuming tokens on full schemas. The model calls list_tools when it needs the actual function signatures.

If the index exceeds a reasonable size (TBD, likely ~2000 chars), the description is truncated with a note to call list_tools for the full listing.

Registration mechanics

During init/sync, Symposium writes a single MCP entry named "symposium" pointing to cargo-agents mcp-serve. The entry is identified by its well-known name — no additional ownership markers are needed. Individual plugin server entries are never written to agent config.

Agent compatibility

AgentMCP config locationTransport
Claude Code.claude/settings.local.jsonstdio
Gemini CLI.gemini/settings.jsonstdio
Copilot.github/copilot-mcp.jsonstdio
Codex CLIcodex.jsonstdio
Kiro.kiro/mcp.jsonstdio
OpenCode.opencode/config.jsonstdio
Goose.goose/mcp.jsonstdio

All supported agents use stdio transport for local servers, so one implementation covers all.

Future: elicitation and notifications

Because the meta-server is an always-connected channel, it can also:

  • Surface notifications (e.g., “new plugin version available”).
  • Provide a symposium__status resource with sync state.
  • Act as an elicitation endpoint if MCP gains that capability, or use sampling requests.

These are out of scope for the initial implementation but inform the architecture.

The progressive disclosure pattern for MCP tools is well-established. Our design draws on and is compatible with this landscape.

Anthropic guidance

  • Advanced Tool Use — recommends keeping 3–5 tools always loaded, deferring the rest. Reports 85% token reduction and accuracy improvements.
  • Effective Context Engineering for AI Agents — introduces “just-in-time retrieval”: agents maintain lightweight identifiers and load data at runtime via tools.
  • Code Execution with MCP — proposes presenting MCP tools as code APIs rather than direct tool calls. The agent writes programs that compose tools, filter intermediate results, and use control flow — all in one execution. Reports 98.7% token reduction. This directly informs our execute tool design.
  • Tool Search Tool — Anthropic’s API-level implementation of progressive disclosure (defer_loading: true, BM25/regex search over up to 10,000 tools). Only works via the Claude API, not for MCP-based agent sessions.

Community MCP aggregators

Several projects have converged on the same “2–4 meta-tools” pattern:

  • mcp-gateway (ViperJuice) — 26 meta-tools including catalog_search, describe, invoke. 4-step progressive disclosure with on-demand server provisioning.
  • mcp-gateway (MikkoParkkola) — Rust. 4 meta-tools: gateway_list_servers, gateway_list_tools, gateway_search_tools (TF-IDF), gateway_invoke. Claims 89% token savings.
  • 1MCP — unified runtime with 3-step CLI: instructions, inspect, run.
  • NCP — 2–3 meta-tools: find (vector similarity), code, run. Claims 97% fewer tokens.
  • MCPProxy-Go — Go proxy with BM25 retrieve_tools filtering. Claims 99% token reduction.

Bounded context packs literature

  • The Meta-Tool Pattern — articulates the two-tool discovery+execution pattern and three-layer architecture (meta-tools, domain agents, atomic tools).
  • From Theory to Production — production walkthrough via the “Nexus” Obsidian plugin. getTools/useTools, dynamic description-embedded index, schema stripping.

MCP spec primitives

The MCP 2025-03-26 spec provides building blocks but no built-in discovery/search mechanism:

  • tools/list supports cursor-based pagination (for large result sets, not progressive disclosure).
  • notifications/tools/list_changed lets servers signal that their tool set changed mid-session.
  • inputSchema is always JSON Schema with type: "object" at the root — straightforward to convert to TypeScript declarations.

Progressive disclosure must be implemented at the application layer — which is what the meta-server does.

How Symposium differs

The key differentiator from existing aggregators: the meta-server is workspace-aware and uses code execution rather than one-call-at-a-time proxying. It uses crate predicates to determine which plugin servers are applicable, starts them lazily, presents their schemas as TypeScript declarations, and lets the agent compose multi-step workflows in a single execute call. No manual server configuration is needed — plugins declare [[mcp_servers]] and the meta-server handles the rest.

Frequently asked questions

Why not just register plugin servers directly with a .gitignore?

Three reasons. First, .gitignore patterns for agent config directories (.claude/, .github/) are coarse — you’d either ignore too much or need per-file patterns that users have to maintain. Second, direct registration means every plugin’s tools land in context at startup regardless of whether the agent needs them. Third, direct registration gives Symposium no way to communicate with the agent after initialization.

Why execute instead of a simple call_tool?

A call_tool proxy still requires one round trip per tool invocation. For multi-step workflows (query a database, filter results, pass them to another tool), the agent must go back and forth with the meta-server for each step, paying latency and token cost each time. With execute, the agent writes a short program that composes multiple calls, filters intermediate data, and uses control flow — all in a single invocation. Intermediate results never enter the agent’s context unless explicitly returned. See Code Execution with MCP for the detailed rationale.

Why TypeScript declarations instead of JSON Schema?

Models have seen millions of TypeScript type definitions in training. .d.ts syntax is the most token-efficient, highest-fidelity way for a model to understand a function’s signature. JSON Schema is verbose and less directly actionable — the model would have to mentally convert it before writing code anyway.

Why JavaScript (QuickJS) instead of Rhai or Lua?

Models produce correct JavaScript at extremely high rates — it’s by far the most represented language in training data. JSON is a literal in the language, so there’s no serialization ceremony. QuickJS provides ES2020 compliance in ~500KB with sub-millisecond startup. Rhai is Rust-native but less familiar to models; Lua is lightweight but requires explicit JSON handling.

What about tool namespacing and conflicts?

Each MCP server becomes a namespace: sqlx.query(...), sea_orm.generate_entity(...). If two plugins declare a server with the same name, the first-registered wins and a warning is emitted.

How does this affect latency?

First call to a cold server pays startup cost (process spawn + MCP handshake). Subsequent calls go directly. For most plugin servers (small Rust binaries), startup is <100ms. The JS interpreter itself is sub-millisecond startup.

What about HTTP/SSE backing servers?

The meta-server acts as an MCP client to each backing server using whatever transport that server declares (stdio, HTTP, or SSE). From the agent’s perspective it’s always stdio — the meta-server bridges the transport gap.

What if a backing server crashes mid-execution?

The meta-server surfaces the error as a JavaScript exception within the script. If the script doesn’t catch it, the execute call returns an error with the exception message. The server transitions to Dead state and is restarted on the next call.

What if Cargo.toml changes mid-session?

The meta-server re-resolves the workspace on list_tools calls when Cargo.lock mtime has changed (same freshness gate as hooks). This is gated behind the user’s auto-sync configuration setting — if auto-sync is disabled, the index stays static until the next manual cargo agents sync.

Is there a script size or execution time limit?

Yes. Scripts are limited to a configurable timeout (default: 30s) and the JS engine runs with bounded memory. These limits prevent runaway loops from hanging the agent session.

What about the sync --agent flow?

sync --agent currently writes MCP entries directly. With this change, it writes only the single meta-server entry. The --agent flag remains for agents that need explicit sync, but the MCP section of the output shrinks to one entry.

Implementation plan and status

Each step is independently mergeable and leaves the codebase green.

Step 1: Register the meta-server entry during sync (refactor, new tests)

Change sync to write a single "symposium" MCP entry (pointing to cargo-agents mcp-serve) instead of per-plugin entries. The existing mcp_server_registration.rs infrastructure handles the per-agent format differences — we just change the input from the collected plugin servers to one fixed entry. The mcp-serve subcommand doesn’t exist yet, but registration is just writing config JSON.

This is partly a refactor (removing the per-plugin write path) and partly new behavior (the fixed entry). The existing sync_filters_mcp_servers_by_crates test and friends update to assert a single "symposium" entry rather than per-plugin entries.

  • Replace per-plugin MCP registration in sync.rs with a single "symposium" entry
  • Update existing MCP integration tests to expect the new behavior
  • Verify: cargo test passes, .claude/settings.json contains only "symposium" after sync

Step 2: mcp-serve subcommand with two-tool skeleton (new tests)

Add cargo agents mcp-serve as a new Commands variant. It starts a stdio MCP server (via rmcp) that advertises list_tools and execute with hardcoded descriptions and returns empty/stub responses. Exits cleanly on stdin EOF.

Integration test: spawn cargo-agents mcp-serve as a child process, send MCP initialize + tools/list JSON-RPC requests over stdin, assert the response contains exactly the two tools with expected names.

  • Add rmcp dependency
  • Add McpServe variant to Commands, wire handler
  • Implement stdio MCP server with list_tools and execute stubs
  • Integration test: spawn process, verify JSON-RPC handshake and tool listing

Step 3: Plugin-driven list_tools with TypeScript generation (new tests)

Wire list_tools to the real plugin registry. At startup, the meta-server resolves WorkspaceDeps and collects applicable [[mcp_servers]]. Calling list_tools starts the relevant backing servers, fetches their tools/list schemas, converts them to TypeScript declarations, and returns the result.

Integration test: use an existing fixture (e.g., mcp-filtering0 + workspace0), spawn mcp-serve in that workspace, call list_tools, assert the response contains TypeScript declarations for always-server tools but not missing-crate-server tools.

  • Resolve workspace and plugin registry at meta-server startup
  • Implement JSON Schema → TypeScript declaration conversion
  • On list_tools, start backing servers and fetch their tool schemas
  • Generate and return TypeScript declarations grouped by namespace
  • Integration test: verify declarations reflect workspace-filtered plugins
  • Unit tests: JSON Schema → TypeScript conversion for common schema patterns

Step 4: execute with embedded JS engine (new tests)

Embed rquickjs (QuickJS). Register each backing server’s tools as async functions on namespace globals. The execute tool runs the agent-submitted script, dispatching tool calls to backing servers, and returns the final value.

Integration test: create a minimal mock MCP server (a small script in the fixture that responds to tools/list and tools/call). Spawn mcp-serve, call execute with a script that calls the mock, assert the return value passes through correctly. Test error propagation by having the mock return an error.

  • Add rquickjs dependency
  • Register namespace globals from backing server tool lists
  • Implement async dispatch: JS await → MCP tools/call → resume
  • Return script result as JSON to agent
  • Timeout and memory limits
  • Integration tests: successful execution, multi-call scripts, error propagation

Step 5: Freshness and auto-sync gating (new tests)

Re-resolve the workspace on list_tools when Cargo.lock mtime has changed since last resolution, gated by the auto-sync config setting. When auto-sync is off, the index stays static for the session lifetime.

  • Track Cargo.lock mtime at startup
  • On list_tools, check mtime; if changed and auto-sync enabled, re-resolve
  • Integration test: modify fixture’s Cargo.lock mid-session, verify index updates

Registry-centric plugin distribution

TL;DR

Generalize Symposium’s plugin system around package managers (PMs). A plugin is identified by a canonical tuple (pm, name, version), fetched by its PM, and unpacked into a cached directory. Users install plugins with symposium use, projects auto-discover them via their dependencies, and predicates gate activation without changing what’s installed.

Motivation

Leverage existing package managers. Registries like crates.io already handle versioning, distribution, authentication, and mirroring. Enterprises already integrate them into their workflows. Rather than building our own distribution mechanism, we treat existing PMs as the delivery channel for plugins — keeping things simple for users and ops-free for us.

Integrate across ecosystems. Today Symposium only works with crates.io. We want to extend support to npm, PyPI, and beyond (including internal/proprietary registries). The PM abstraction makes each ecosystem a plug-in capability: implement four operations and your ecosystem’s packages become plugin sources.

Bundle executable code with plugin configuration. Plugins can define hooks and MCP servers, but these need supporting binaries — a custom linter, a token-reduction tool like RTK, a code generation tool. Today there’s no clean way to distribute an executable alongside the TOML that references it. By connecting plugins to PMs, binaries and configuration ship together. The PM handles building and versioning; Symposium just fetches the directory and scans it.

As a user

To start, users install symposium:

cargo install symposium
symposium init

Dependency discovery

Symposium will automatically scan the dependencies of your project to find relevant plugins. This scan is done by executing symposium sync. Users can also configure Symposium to automatically sync every time an agent executes in their workspace.

When users run symposium sync, Symposium will scan their dependencies and look for eligible plugins. If it finds plugins that the user has not yet installed, it will prompt them to confirm installation. Users can approve the plugins or else decline; these choices are recorded in the Symposium configuration. We can expand these options later to e.g. permit “accept this automatically across all workspaces in the future” etc.

If auto-sync is not enabled, Symposium still checks to see if there are new plugins (or new versions of plugins) available since the user last synchronized. If there are, then a hint is added to the agent to prompt the user to run symposium sync.

Workspace-local extensions

Projects can also define plugins that should be made available whenever that project is part of the user’s active workspace (i.e., the user is hacking on that project). For example, consider a Rust project like widget, which has a workspace with two crates, widget-lib and widget-test:

widget/
  Cargo.toml <-- defines the workspace
  crates/
    widget-lib/
      Cargo.toml <-- defines the `widget-lib` crate
    widget-test/
      Cargo.toml <-- defines the `widget-test` crate

The user could add plugins alongside any of those Cargo.toml files and they’ll be picked up by Symposium. We always activate all plugins for any project in the workspace, so you would get plugins from both widget-lib and widget-test regardless of which specific crate you are working on.

There are two ways to define a plugin. The simplest is to follow common conventions that Symposium supports:

  • If you add skills into .agents/skills, they will be installed for anyone working in that workspace.
  • If you add skills into skills, they will be installed for anyone working in that workspace and through dependency discovery.

You can also define a Symposium.toml that contains other kinds of plugins and extensions (e.g., mcp servers). We may add additional conventions in the future (e.g., apm, openplugin standard, etc).

To continue the widget example:

widget/
  Cargo.toml
  Symposium.toml                 <-- defines add'l plugins loaded when working in this workspace
  crates/
    widget-lib/
      Cargo.toml
      Symposium.toml         <-- defines add'l plugins loaded in this workspace; can also define
      skills/                      plugins for workspaces that depend on widget-lib
        widget-test-skill/ <-- available when working in the workspace 
          SKILL.md             *and* to other workspaces that depend on widget-lib
    widget-test/
      Cargo.toml
      .agents/
        skills/
          widget-test-skill/ <-- available when working in the workspace only
            SKILL.md

Explicit use

Users can also explicitly install plugins with the use command. The default is to install the plugin locally for the current workspace.

symposium use X

This will search across all registries for a package named X and show the matches to the user. So, if X is a plugin name, it would show the most recent plugin; if there is an entry in the recommendations repository, that would also be shown. Users can pick the one(s) they wish to install. This will add the entries into ~/.symposium/config.toml along with the workspace directory so that they are known to be activated.

Users can also install plugins globally:

symposium use --global X

This works the same way but activates those plugins across all workspaces.

use is also how a user reaches a plugin that nothing about the workspace implies: a curated plugin that names no dependency, so no activation root would ever pick it up on its own.

Users could also edit their config.toml to define their specific predicates for when they want plugins to be activated (e.g., when a certain file is present in the workspace, for Rust workspaces only, etc).

Turning plugins off

disable is the off switch, listing plugins that must not run whatever else says otherwise:

[plugins]
disable = [{ pm = "symposium-recommendations", name = "rtk" }]

It is deliberately the last word. Every other mechanism (a trusted registry, auto-enable, an explicit use) says a plugin may run; disable is the single place that says it may not. So a plugin that is both used and disabled stays off, and re-enabling it means dropping the disable entry. symposium use --remove does not do that: it removes a use entry, so it cannot cancel a decision the user made in the other direction.

A decline at the discovery prompt is recorded here too, which is the same rule seen from the other side: having said “never ask again” about a dependency’s plugin, the user does not get asked again, and does not silently get the plugin either.

Unlike use, a disable entry carries no workspace scope: it is global. See enablement configuration for the full precedence rules.

As a crate author

The core workflows for publishing plugins via Symposium are as follows. We use Rust crates as an example but everything we say about cargo applies equally to other supported package registries like PyPI, npm, etc.

Publishing in your crate

Rust crates (and packages in other languages) can package extensions within their sources that are distributed inline. Simply add skills or plugins directly into your repository and Symposium will pick them up.

Publishing plugins directly with your crate has the advantage that they are versioned together. But you may wish to be able to update plugins independently. In that case, you can have your crate’s plugin redirect Symposium to load a chained plugin with another crate name, such as widget-symposium. This way you can publish widget-symposium as often as you like.

The conventions for publishing in your own crate are the same as when defining plugins for your workspace. Recalling our widget example:

widget/
    Cargo.toml
    crates/
      widget-lib/
        Cargo.toml
        Symposium.toml         <-- defines `[[plugins]] source.cargo = "widget-symposium>=1"`
      widget-test/
        Cargo.toml
      widget-symposium/
        Cargo.toml

Publishing for someone else’s crate

You can also add a plugin into the central symposium recommendations repository. This uses the “recommendations” package manager. Our convention is that the symposium-recommendations repository contains a subdirectory structure with directories named for other package managers:

symposium-recommendations/
    ...
    cargo/
      widget-lib/              <-- defines `[[plugins]] source.cargo = "widget-symposium>=1"`
        Symposium.toml

So you can add a new plugin in a subdirectory of cargo (e.g., cargo/widget-lib) that adds a plugin for that crate. When a project in the workspace has a dependency on a crate widget-lib=1.2, we will search for plugins that match cargo:widget-lib:1.2 for all registered package managers. The cargo package manager uses this to find the source for widget-lib at version 1.2 and look for embedded plugins. The recommendations package manager looks for a directory cargo/widget-lib (the version is ignored) and returns a match.

Publishing a plugin not associated with a crate

The symposium-recommendations repository can also be used to publish centralized plugins that don’t have an associated crate or whatever. For example, this might be used to distribute a collection of skills from a github repository or to distribute a tool whose installation is not managed by Symposium. To do that, you simply add to the directory called symposium:

symposium-recommendations/
  symposium/
    yolo-skills/
      Symposium.toml             <-- defines whatever

Key concepts

Plugins

A plugin is defined by a directory with an optional Symposium.toml file. The directory is typically the root directory of a workspace or a project in the workspace, but it could also be specified via a path or be found in a cloned github repository or other means. If there is no Symposium.toml file, that is equivalent to having an empty file.

Plugin identifier

Every plugin has a canonical identifier — a tuple (pm, name, version) — as described in the package managers section.

Agentic extensions

Symposium.toml files contain the following kinds of content:

  • [[plugins]] defines a set of additional chained plugins. If a plugin X defines a chained plugin Y, then whenever X is loaded, Y will be loaded.
  • [[skills]] identifies directories where we should search for skills. Any skills found in there will be installed into the user’s workspace in the appropriate place(s) for the agent(s) they’ve selected.
  • [[mcp]] identifies mcp-servers.
  • [[hooks]] identifies hooks. Symposium allows you to define vendor-neutral hooks that work for any vendor or vendor-specific hooks that target a particular agent (e.g., Claude Code or Codex).
  • [[installable]] identifies installable content, which can be referenced by MCP servers or hooks (which need an executable). An easy option is to package your content as a cargo package that will be cargo-install’d and managed by Symposium, but there are other options.

Predicates

The plugin itself and each of its subsections can be gated with a predicates = [...] field (plus the depends-on shorthand). When a plugin is installed, the content is only activated if the predicates match. The full model is in the predicates reference; the functions are:

  • depends-on(<name>), true if some project in the workspace depends on <name>. A version requirement is allowed (depends-on(serde>=1.0)), and depends-on(*) matches any workspace.
  • workspace-member(), true if the plugin this predicate belongs to is defined by a member of the active workspace.
  • env(FOO) / env(FOO=BAR), true if the environment variable is set (to BAR).
  • path_exists(<arg>), true if the argument resolves to an existing path — checked on the filesystem, then on $PATH for a bare name (so it matches a local file or an installed binary).
  • shell(<command>), true if <command> run via sh -c exits 0.
  • the combinators not(<p>), any(<p>, …), all(<p>, …), which together give full boolean logic.

depends-on is sugar for the common dependency case: depends-on = ["serde", "tokio"] lowers to any(depends-on(serde), depends-on(tokio)), ANDed with any predicates.

Whether a plugin was explicitly used and whether it is a workspace dependency are not predicates. “Used” is the enablement axis, a [plugins] use entry (see Explicit use), which is also the only activation root available to a plugin that names no dependency; dependency presence is depends-on(<name>). These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once.

Activation roots

Predicates say when a plugin applies, not why it was in play at all. That is a separate question, and every active plugin answers it with an activation root. There are three:

  • Workspace membership, for a plugin defined by the workspace root or one of its members.
  • A dependency, either one the plugin is embedded in, or one it names with depends-on and the workspace has (depends-on = ["*"] names every workspace).
  • An explicit use entry, which needs nothing from the workspace at all.

symposium status reports the root each plugin came in on. A registry entry that names no dependency has none of the three until a use entry gives it one, so it loads and is listed but contributes nothing.

Default content

Finally, plugins have some default content that is added automatically unless it is disabled via a [defaults] section. Currently we have one default, default.skills = (true|false). Assuming the default is not set to false, then the following is added to the plugin.

[[skills]]
source.path = "skills"

[[skills]]
predicates = ["workspace()"]
source.path = ".agents/skills"

These defaults establish the skills conventions described earlier. For example, the widget-test crate had skills defined in .agents/skills. If you were to depend on widget-test, but you don’t have it in your workspace, those skills would not be added to your workspace, because they are gated behind a predicate.

Package managers

A package manager (PM) is a pluggable backend that knows how to find, fetch, and enumerate plugins from a particular ecosystem. A PM may run in Symposium’s own process or as a separate binary it speaks to over stdio; both implement the same operations, so nothing above the PM layer knows which it is talking to.

path and git are built in, since both only read local directories. cargo is a crate of its own that can run either way, and any other ecosystem (npm, pypi, an internal registry) arrives as a binary named by a [[package-manager]] config entry.

Every PM implements these operations:

OperationInputOutputUsed by
active_pluginsthe workspace’s dependency idsset of plugin offersdiscovery, sync
load_pluginpackage-idset of plugin offerschained references, use
searchpartial query stringset of package-ids + metadatasymposium use, symposium search
fetchpackage-iddirectory with plugin contentsync/install
list_deps(none)set of package-idsauto-discovery
workspace_info(none)workspace root and membersworkspace plugins, scoping
refreshupdate levelwhether content was pulledregistry sync

A plugin offer is a resolved id, a content directory, and an unvalidated manifest. Returning a manifest rather than only a directory is what lets a PM synthesize a plugin for a package that ships no manifest, or translate one from its own ecosystem’s format, without Symposium learning that ecosystem’s conventions. Validation and defaults are applied by Symposium once the manifest arrives. Which plugins actually run is a separate decision, made from the user’s [plugins] configuration and from the source the offer came from.

A package-id is a tuple (pm, name, version) where all three components are PM-defined strings. Examples: (cargo, serde, 1.0.210), (git, github.com/rtk-ai/rtk, abc123def), (recommendations, cargo/serde, 0.1.0). There is no mandated string-serialized format — the tuple is the identity.

See the PM interface sub-RFD for full protocol details.

Example: The recommendations registry

The symposium-recommendations repository is an ordinary flat registry, read by the built-in path PM once its content has been fetched. Each entry is a plugin directory that declares which crates activate it with its own depends-on:

symposium-recommendations/
  serde-guidance/
    Symposium.toml     # depends-on = ["serde"]
  tokio-guidance/
    Symposium.toml     # depends-on = ["tokio>=1"]

No dedicated PM and no namespace convention are involved, because none are needed: a recommendation is just a plugin that activates when certain dependencies are present, which the ordinary depends-on predicate already expresses. The layout therefore carries no dependency information of its own, and a recommendations entry is validated and gated exactly like any other registry plugin.

Example: The cargo manager

The cargo package manager works with Symposium packages embedded within crates or cargo workspaces.

It defines package-ids like (cargo, $crate-name, $version).

It defines the core operations as follows:

OperationDefinition
resolveaccepts a object like {foo = "1"} using the same format as expected by cargo; resolves per cargo algorithm
searchif PM = cargo, search cargo registry for matching crates; otherwise, return empty
fetchcreates a dummy project to populate the cargo cache and returns the crate source directory from there
list-depsreturns direct dependencies from the workspace Cargo.toml and all workspace members

Example: The git manager

The git package manager works with Symposium packages found in git repositories.

It defines package-ids like (git, $git-url, $sha-hash). The git-url component uses a URL fragment to encode the ref (following npm’s convention), e.g., git@github.com:rtk-ai/rtk#main. The version is always the resolved commit SHA.

It defines the core operations as follows:

OperationDefinition
resolveaccepts an object like {url = "...", branch = "...", rev = "..." } and resolves to a commit SHA
searchreturns empty (git repos aren’t a searchable registry)
fetchclones/fetches the repo at the specified commit SHA and returns the directory
list-depsreturns empty (no concept of “workspace depends on a git repo”)

Frequently asked questions

How does Symposium work in the enterprise?

Symposium routes all plugin distribution through existing package registries (crates.io, npm, PyPI, etc). Enterprises already operate internal mirrors and proxies for these registries — Symposium inherits that infrastructure automatically.

The primary control point is the recommendations repository. Companies supply their own symposium-recommendations crate (or override the default) to curate which plugins are offered to their developers. In the future, the recommendations repository may also supply allow/deny lists and other centralized controls (e.g., “these plugins are approved for production use,” “these plugins require security review before installation”). This is left for future design.

Companies can also disable specific PMs entirely — for example, disabling the git PM to prevent developers from installing unvetted plugins from arbitrary repositories, restricting installs to only those that flow through a scanned registry.

Why route through existing registries?

Routing through existing registries gives enterprises central scanning (malware, license, vulnerability), access control, audit trails, and air-gapped environment support — all using tooling they already have.

The tradeoff is that some plugins don’t have a natural “home” in a language-specific registry (e.g., a collection of general-purpose agent skills not tied to any library). For these, the recommendations repository or a dedicated “symposium plugins” crate serves as the packaging vehicle — slightly artificial but consistent with the model.

Detailed design

We plan follow-up RFDs with more details on each component:

  • Plugin model — what a plugin is, Symposium.toml structure, defaults (skill discovery, implicit installations), predicates, chained plugins, installed vs. active.
  • PM interface + Cargo PM — the JSON-RPC protocol for PM binaries, error semantics, caching contract. The cargo PM specifically: resolve schema, fetch via cargo toolchain, list-deps from Cargo.lock.
  • Discovery & sync — the two-phase discovery algorithm (list-deps on all PMs, then search on all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration.
  • User-managed pluginssymposium use/remove/status commands, config file format, version requirement syntax, global vs. workspace-local scoping.

Future work

The remaining work, roughly in dependency order:

  • Acquiring a PM binary: a [[package-manager]] entry names a command that must already exist. Running it through the existing installation machinery (source = "cargo" / "github", as hooks and subcommands do) would let an entry install what it names. Plugin-vended PMs layer on after that.
  • Registries as PMs over the wire: path and git registries stay in-process, since both only read local directories. Nothing stops a registry from being a PM binary too; there has just been no reason yet.
  • PMs defined by plugins — letting a plugin register a new PM type (so an org can ship an internal-registry PM, or an ecosystem PM like npm/pypi, as an ordinary plugin). Depends on the out-of-process protocol above; the registration and discovery mechanism is TBD.
  • Additional built-in ecosystems — there is no git PM yet (git sources for skill groups and installations exist, but a chained source.git is rejected); npm/pypi are unstarted.
  • Custom predicate dispatch across plugins (fixed-point) — a crate-embedded plugin can define a custom predicate, but its definition is not yet registered, so it cannot be evaluated (only registry plugins’ custom predicates are). Wiring a crate’s own custom predicates into its facet evaluation is tractable; the general case — one plugin defines a predicate that another plugin’s gate references — needs a convergence loop, since the definition must be loaded before the gate that uses it can be evaluated.
  • Chained-edge version enforcement[[plugins]] source.cargo = "widget>=1" records the version requirement but does not enforce it: expansion enqueues the crate with no version, so it resolves against the workspace pin regardless. Enforcement would compare the resolved version to the recorded requirement and warn/skip on mismatch.
  • Workspace-scoped disable: a use entry can be scoped to one workspace; a disable entry cannot, so turning a plugin off in one project turns it off everywhere. The scoping machinery already exists on the use side, so this is mostly a matter of deciding how a scoped “off” and a global “on” compose.
  • Policy plugins — org-level enforcement (deny-lists, approval gates). Separate extension point, design TBD.

Implementation status

  1. Plugin model. Plugins, [defaults], predicates, chained plugins, activation roots.
  2. PM interface and the cargo PM. The identity tuple, the operation set, the JSON-RPC transport (symposium_sdk::pm::protocol and pm::server on the PM’s side, pm::RemotePm on Symposium’s), and symposium-pm-cargo as a standalone crate and binary. A PM answers with a PluginOffer: an id, a content directory, and an unvalidated manifest, so it can synthesize a plugin for a package that has none. [[package-manager]] config entries add ecosystems beyond cargo.
  3. Discovery and sync. Dependency-embedded plugin discovery, the consent prompt, and the [plugins] config. Recommendations are a flat registry rather than a search result (see the note under the recommendations registry).
  4. User-managed plugins. use / remove / status, workspace vs. global scope.
  5. Remaining — see Future work.

Plugin model

TL;DR

  • A plugin is a directory. Every directory is a valid plugin — no manifest required.
  • An optional Symposium.toml provides explicit configuration. If absent, an empty one is synthesized.
  • Defaults apply to every plugin: skills/ and .agents/skills/ are discovered as skill directories.
  • Plugins can declare chained plugins (additional plugins to load when activated).
  • Predicates gate activation, not installation.

Motivation

The old plugin model was built around explicit manifests in “plugin source” directories. This made it hard for crate authors to ship skills without learning a new configuration system. The new model inverts the default: everything is a plugin, configuration is optional, and conventions do the heavy lifting.

Change in a nutshell

A plugin directory with nothing but a skills/ subdirectory:

my-plugin/
└── skills/
    └── usage-guide/
        └── SKILL.md

This is a valid, complete plugin. No Symposium.toml needed. Symposium synthesizes an empty manifest and applies defaults, which discovers the skill.

Adding a Symposium.toml lets you control behavior — add predicates, declare hooks, reference binaries, suppress defaults, or chain other plugins:

# Symposium.toml
depends-on = ["tokio>=1"]

[[hooks]]
event = "PreToolUse"
command = "my-linter"

[[plugins]]
source.cargo = "tokio-extras"

Detailed plans

What is a plugin?

A plugin is a directory. That’s it. The directory may contain:

  • Symposium.toml — optional manifest
  • skills/ — conventional skill directory (exposed to workspace and dependency consumers)
  • .agents/skills/ — conventional skill directory (workspace-only)
  • Any other files (scripts, assets, etc. referenced by hooks or MCP servers)

Synthesized manifest

When a directory has no Symposium.toml, Symposium behaves as if an empty one exists. This empty manifest still triggers default behavior (see below).

An empty manifest is enough because where the directory was found supplies the plugin’s activation root: a workspace member is rooted in workspace membership, a crate in the reference that reached it. A registry entry is not found anywhere in particular, being offered to every workspace equally, so it has to name its own root, which for a curated plugin means naming the dependencies it advises on (depends-on = ["*"] claims every workspace as one). An entry that names none is left with use as its only root: it loads and is reported, but contributes nothing until a [plugins] use entry names it.

Symposium.toml structure

# Predicates gating activation
predicates = ["workspace-member()", "path_exists(build.rs)"]

# Shorthand for the common dependency case
depends-on = ["tokio>=1", "serde>=1"]

# Suppress defaults
[defaults]
skills = false

# Skills (beyond those discovered by convention)
[[skills]]
source.path = "extra-skills/advanced"
predicates = ["env(ADVANCED_MODE=1)"]

# Hooks
[[hooks]]
event = "PreToolUse"
command = "my-linter"
args = ["--strict"]

[[hooks]]
event = "SessionStart"
command = "my-greeter"

# MCP servers
[[mcp]]
name = "my-server"
command = "my-mcp-binary"
args = ["serve"]

# Chained plugins — loaded when this plugin activates
[[plugins]]
source.cargo = "tokio-extras>=1"

# Installable content (binaries referenced by hooks/MCP servers)
[[installable]]
name = "my-linter"
source.cargo = { my-linter-crate = "1.0" }

Agentic extensions

Symposium.toml files contain the following kinds of content:

  • [[plugins]] defines a set of additional chained plugins. If a plugin X defines a chained plugin Y, then whenever X is loaded, Y will be loaded.
  • [[skills]] identifies directories where we should search for skills. Any skills found there will be installed into the user’s workspace in the appropriate place(s) for the agent(s) they’ve selected.
  • [[mcp]] identifies MCP servers.
  • [[hooks]] identifies hooks. Symposium allows you to define vendor-neutral hooks that work for any vendor or vendor-specific hooks that target a particular agent (e.g., Claude Code or Codex).
  • [[installable]] identifies installable content, which can be referenced by MCP servers or hooks (which need an executable). An easy option is to package your content as a cargo package that will be cargo-install’d and managed by Symposium, but there are other options.

Default content

Plugins have default content added automatically unless disabled via [defaults]. Currently we have one default, defaults.skills = (true|false). Assuming the default is not set to false, the following is added to the plugin:

[[skills]]
source.path = "skills"

[[skills]]
predicates = ["workspace()"]
source.path = ".agents/skills"

These defaults establish the skills conventions:

  • skills/ is exposed to anyone who depends on the crate (no predicate gate).
  • .agents/skills/ is only exposed when working directly in the workspace (gated by workspace()).

Predicates

The plugin itself and each of its subsections can be gated with a predicates = [...] field. When a plugin is installed, the content is only activated if the predicate matches.

The functions are listed in the parent RFD’s predicates section and specified in full in the predicates reference: depends-on(<atom>), workspace-member(), env(...), path_exists(...), shell(...), and the combinators not, any, all.

Explicit enablement is deliberately not a predicate. Enablement is a separate axis deciding whether a plugin may run at all, recorded in [plugins] and consulted before predicates are evaluated.

The depends-on shorthand covers the common dependency case:

depends-on = ["tokio>=1", "serde>=1"]

This is equivalent to predicates = ["any(depends-on(tokio>=1), depends-on(serde>=1))"].

Predicates can appear at any level (plugin, skill, hook, MCP server). A predicate on a plugin gates all its direct contents. Chained plugins have their own predicates and are evaluated independently.

Chained plugins

A plugin can declare additional plugins to be loaded when it activates:

[[plugins]]
source.cargo = "serde-extras>=1"

A chained edge names a package, which its package manager resolves. source.path and source.git are rejected with a hint: a path is not a package, and local content is reachable as a [[skills]] source.path group or as a workspace plugin.

Chaining is an activation-time relationship: when this plugin becomes active, also load these. Chained plugins:

  • Are fetched and cached transitively (installing A also fetches A’s chained plugins)
  • Have their own predicates (they may not activate even if the parent does)
  • Are independent after loading

Use chaining when a library crate wants agent support but ships it in a separate package for release-cycle independence.

Installed vs. active

StateMeaningWhere
InstalledContent is in cache, ready to activate~/.symposium/cache/
ActivePredicates pass, content wired into agent dirs.claude/skills/, etc.
InactiveInstalled but predicates don’t passCache only

A plugin transitions between active and inactive as workspace state changes (e.g., adding a dependency). No re-fetch needed.

Frequently asked questions

Why is every directory a plugin?

It makes the cargo PM simple: every crate is a plugin, no detection heuristic needed. Most crates won’t have any plugin content (no skills/, no Symposium.toml), so they result in empty plugins that are effectively no-ops.

What happened to “plugin sources”?

Gone. In the old model, [[plugin-source]] pointed at directories that contained plugins. Now there’s just plugins — and plugins can chain other plugins.

Can a plugin contain sub-directories that are also plugins?

Only via explicit [[plugins]] with source.path. We don’t recursively scan for nested Symposium.toml files.

What if skills/ exists but I don’t want it discovered?

[defaults]
skills = false

Implementation plan and status

All five steps landed. One follow-on remains: a crate-embedded plugin can define a custom predicate, but the definition isn’t registered, so it can’t be evaluated. See the parent RFD’s future work.

Step 1: Plugin struct and manifest parsing

Define the Plugin struct, parse Symposium.toml, synthesize empty manifests for directories without one.

  • PR: plugin struct + TOML parsing

Step 2: Default application

Implement skill discovery from skills/ and .agents/skills/. Suppression via [defaults].

  • PR: plugin defaults

Step 3: Predicates on plugins

Evaluate predicates at the plugin level and per-construct level. Gate activation. Implement the [depends-on] shorthand.

  • PR: predicate evaluation

Step 4: Chained plugins

Parse [[plugins]] entries, resolve via PMs, fetch transitively, evaluate independently.

  • PR: chained plugin loading

Step 5: Integration with sync

Wire the new plugin model into the sync pipeline: iterate installed plugins, evaluate predicates, sync active content to agent directories.

  • PR: sync integration

PM interface

TL;DR

  • Define an operation set (initialize, active_plugins, load_plugin, list_deps, search, fetch, refresh) that all package managers implement.
  • PMs are separate binaries speaking newline-delimited JSON-RPC over stdio. One long-lived process per PM per Symposium invocation.
  • A PM returns plugin manifests, not just directories, so it can synthesize a plugin for a package with no Symposium.toml, or one whose manifest is in another ecosystem’s format.
  • Trust is assigned by Symposium, never claimed by the PM.

Motivation

Symposium needs to fetch plugins from multiple ecosystems without hard-coding each one. The PM interface is the seam: implement a handful of operations and your ecosystem becomes a plugin source. Cargo, npm, pypi, and an enterprise’s internal registry all arrive the same way — all without changing core.

Change in a nutshell

A PM is a separate binary that speaks JSON-RPC over stdio. Here’s the cargo PM responding to load_plugin:

# User writes in Symposium.toml:
[[plugins]]
source.cargo = "serde-skills>=1"

Symposium sends load_plugin with the id (cargo, serde-skills, >=1). The cargo PM resolves the requirement, obtains the crate source, and returns the exact id, the content directory, and the plugin manifest it read (or synthesized) from that directory:

{ "result": [{
    "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" },
    "root": "/home/user/.cargo/registry/src/index.crates.io-.../serde-skills-1.2.3",
    "manifest": { "skills": [{ "source": { "path": "skills" } }] }
}] }

Symposium validates that manifest, applies its own defaults and trust rules, and resolves the skill group against root.

Detailed plans

Package-ids

A package-id is a tuple (pm, name, version) where all three components are PM-defined strings. There is no mandated string-serialized format — the tuple is the identity.

Examples:

  • (cargo, serde, 1.0.210)
  • (git, git@github.com:rtk-ai/rtk#main, abc123def)
  • (recommendations, cargo/serde, 0.1.0)

In the JSON-RPC protocol, a package-id is represented as:

{ "pm": "cargo", "name": "serde", "version": "1.0.210" }

The protocol

PMs are separate binaries invoked by Symposium. Communication uses JSON-RPC 2.0 over stdio, newline-delimited: one JSON object per line, no Content-Length framing. Nothing in the payloads needs an embedded newline, so the simpler framing is enough. Each PM binary is long-lived: Symposium spawns it once per invocation and sends multiple requests, multiplexed by request id.

initialize

// Request
{ "method": "initialize", "params": {
    "protocol_version": 1,
    "workspace": "/home/user/projects/my-app",
    "cache_dir": "/home/user/.symposium/cache",
    "env": { "SYMPOSIUM_CARGO": "/usr/bin/cargo" }
} }

// Response
{ "result": { "protocol_version": 1, "name": "cargo", "capabilities": ["search", "list_deps"] } }

Sent once, before any other method. Carries the per-invocation context the PM needs; the PM answers with the name it owns (the pm component of every id it mints) and which optional operations it implements.

A PM is otherwise self-contained: it holds whatever it needs to resolve its own ecosystem, so no later method takes ambient context. This is why workspace lives here rather than on list_deps as originally proposed: with a long-lived process the workspace is fixed for the connection’s lifetime.

Version negotiation is strict for now: a PM reporting a protocol_version Symposium doesn’t know is refused with a warning, and its plugins are simply absent.

active_plugins

// Request
{ "method": "active_plugins", "params": { "deps": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }] } }

// Response
{ "result": [{ "id": {...}, "root": "...", "manifest": {...} }] }

The plugins this PM activates for the workspace’s dependency set. The two shapes it covers:

  • A registry instance lists its own entries and ignores deps.
  • An ecosystem transport (cargo) surfaces the plugins its dependencies embed.

Whether the result may run without the user’s consent is Symposium’s decision, not the PM’s: see Enablement.

load_plugin

// Request
{ "method": "load_plugin", "params": { "id": { "pm": "cargo", "name": "serde-skills", "version": ">=1" } } }

// Response
{ "result": [{ "id": {...}, "root": "...", "manifest": {...} }] }

The plugin(s) a specific id maps to: a [[plugins]] chained reference, or a crate the user enabled by name. Resolves the version requirement, obtains the content, and returns the plugin(s) found there. Returning zero plugins is not an error.

This is the method the original resolve folded into. The version component of the request id may be a requirement (">=1", or "*" for none); the response id always names the exact resolved version.

list_deps

// Response
{ "result": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }, { "pm": "cargo", "name": "tokio", "version": "1.38.0" }] }

The dependencies of the workspace given at initialize, in this PM’s ecosystem. PMs with no workspace notion return empty.

Contract:

  • Direct dependencies only (not transitive).
  • Must be fast: this is on the hook path. Read lockfiles, don’t query the network, cache on the lockfile’s mtime.
// Request
{ "method": "search", "params": { "query": "serde" } }

// Response
{ "result": [{ "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "description": "..." }] }

Find packages matching a partial query string; backs cargo agents use and cargo agents search. PMs without a searchable registry return empty.

The query is a fragment of a name a person typed, never a package-id: the cargo PM queries crates.io with it, a registry PM substring-matches its entry names. Discovery does not use search: it works from list_deps and active_plugins (see discovery), so a PM that implements nothing but active_plugins still participates fully in it.

fetch

// Request
{ "method": "fetch", "params": { "id": {...}, "update": "none" } }

// Response
{ "result": { "id": {...}, "root": "/home/user/.cargo/registry/src/.../serde-skills-1.2.3" } }

Acquire a package’s content and report where it landed, canonicalizing the id’s version. update is none (serve from cache, never touch the network), check, or fetch (force).

Contract:

  • The same package-id always produces the same content.
  • The PM owns the directory and guarantees it stays valid for the connection’s lifetime.
  • update: "none" must not make a network call. This is what keeps per-event hook dispatch offline.

refresh

// Request
{ "method": "refresh", "params": { "update": "check", "force": false } }

// Response
{ "result": { "refreshed": true } }

Pull the PM’s backing source: for a git-backed registry, fetch the repository. A no-op returning false for PMs whose content is already local. force overrides a source’s auto-update opt-out, for an explicit cargo agents plugin sync.

What crosses the wire

A PM returns a plugin manifest, not merely a directory:

{ "id": {...}, "root": "/path/to/content", "manifest": { /* Symposium.toml schema, as JSON */ } }

Returning a manifest rather than only a path is what lets a PM synthesize a plugin: for a package with no manifest at all, or one whose configuration lives in a different ecosystem’s format (an npm PM reading package.json, say). A PM that does nothing special just parses the Symposium.toml it found and hands it back.

The manifest on the wire is the raw, unvalidated schema: the same shape a Symposium.toml deserializes into. Validation stays in Symposium:

ConcernOwner
Producing a manifest (parse, synthesize, translate)PM
Schema validation, inline-installation promotionSymposium
Defaults (skills/, .agents/skills/), [defaults] handlingSymposium
Activation roots, trust, consentSymposium
Resolving source.path against rootSymposium

This split keeps policy in one place. A PM reports which plugins exist and what they contain; which of them are enabled is decided from configuration and from the source the plugin came from, neither of which is anything the PM says.

The schema is published as a Rust crate that both Symposium and PM authors depend on, so a Rust PM builds the manifest as a typed value rather than assembling JSON by hand. PMs in other languages target the JSON shape directly.

Future optimization. A PM could answer with {"manifest_path": "Symposium.toml"} instead of an inline manifest, letting Symposium read the file itself and skipping a serialize/deserialize round trip for the common case. Not needed to start.

Enablement

A PM reports what is available. Symposium decides what runs, from two inputs: the user’s [plugins] configuration, and which source the plugin came from.

Some sources are trusted, meaning a plugin from them is enabled without the user being asked:

  • the recommendations registry,
  • the current workspace (its root and members),
  • the configured [[registry]] entries the user added by hand.

A plugin embedded in a dependency is not: depending on a package should not let its author add to your agent’s context, so it runs only once the user consents.

Naming a plugin in configuration

To enable or disable a specific plugin, the user has to be able to name it, and the name has to survive across runs. So every plugin has a canonical name, supplied by the PM that offers it, and configuration entries are the pair (pm, canonical-name):

[plugins]
# Turn off one recommendation, overriding the registry's trusted-by-default
# status.
disable = [{ pm = "symposium-recommendations", name = "rtk" }]

# Consent to a plugin embedded in a dependency.
auto-enable = [{ pm = "cargo", name = "my-internal-crate" }]

Each PM picks names that are stable and meaningful for its ecosystem. The cargo PM uses the crate name. A registry PM uses the entry’s path within the registry. The pair is qualified by PM so that two ecosystems using the same word do not collide, and so that a name always identifies exactly one thing.

This is what makes a trusted source overridable. Recommendations are enabled without asking, which is the point of them, but a user who does not want a particular one names it and turns it off. disable beats every other entry, including a use naming the same plugin: see precedence.

Error handling

Errors use JSON-RPC error codes:

CodeMeaningSymposium behavior
-32001Not foundSkip gracefully, report in status
-32002Network errorRetry with backoff, fall back to cache
-32003Invalid inputHard error at parse time
-32004Auth requiredReport to user with setup instructions

Beyond named codes, plugin loading is best-effort and must stay that way across the process boundary. A PM that errors, hangs past its timeout, crashes, or fails its initialize handshake degrades to “contributes no plugins,” logged as a warning. One broken PM never aborts a sync or a hook: the same contract the in-process layer already holds, where a plugin that fails to load is dropped rather than surfaced.

Anything written to a PM’s stderr is captured and logged at debug level, so a PM can be diagnosed without disturbing the protocol on stdout.

PM lifecycle

Symposium manages PM binaries as follows:

  1. On first use, Symposium spawns the PM binary, connects via stdio, and sends initialize.
  2. The PM stays alive for the rest of the Symposium invocation, and is shut down when PmRegistry drops.
  3. Spawning is lazy: a PM whose operations are never needed is never started.
  4. Symposium may have several requests in flight (the PM handles this or serializes internally).

A PM binary is found one of three ways:

  1. Built in. The PMs Symposium ships with are located by name, with no configuration required.
  2. Config-declared. A [[package-manager]] section names the PM and points at an installation source, acquired through the same machinery hook binaries already use. This is the bootstrap channel: it cannot depend on plugins being loaded, since loading plugins is what needs PMs.
  3. Plugin-vended. A plugin registers a new PM type, per the parent RFD’s future work. The initialize handshake is designed so this needs no protocol change.

Cache layout

Symposium hands each PM a cache_dir in the initialize handshake and the PM caches whatever it likes underneath it. What goes there, and how it is arranged, is entirely the PM’s business: Symposium never reads or interprets the contents.

The trade runs both ways. A PM gets one canonical place to write, so it does not have to invent a location or ask the user to configure one, and everything Symposium caused to be downloaded is in one place. In exchange, Symposium may delete that directory at any time, so a PM must treat it as a cache and never as storage: anything it cannot rebuild does not belong there.

A PM is free to serve content from outside cache_dir when its ecosystem already has a cache worth reusing. The cargo PM does exactly this, serving sources out of ~/.cargo/registry/src/, which is why the directory is offered rather than imposed.

fetch returns a root the PM guarantees valid for the connection’s lifetime. Symposium reads it and never writes to it.

Built-in PMs

path and git are built into the Symposium binary, for one reason: bootstrap. A configured PM is a binary that has to be acquired, and acquiring anything means reading a registry first. path and git are what make that first read possible, so they cannot themselves be things you acquire. The default recommendations registry is git-sourced, so a fresh install has to be able to read a git registry before it has acquired anything at all.

Neither is built in because a separate process would be technically awkward. git in particular does need the network, and the fetching and caching it needs already exist in Symposium for git skill-group sources and hook binaries, so building it in reuses machinery rather than adding any. If the bootstrap constraint ever went away, either could become an ordinary PM binary without a protocol change.

Every other PM needs ecosystem tooling that Symposium has no reason to carry, and is a separate binary.

cargo

Separate binary (symposium-pm-cargo). See the cargo PM sub-RFD for details.

git as a chained source

source.git on a [[plugins]] chained reference is still rejected. Git registries and git skill-group sources both work today through the built-in reader; what’s missing is naming a git repository as a chained plugin. That does not obviously need a separate binary either, and is left open.

Frequently asked questions

Why JSON-RPC over stdio?

It’s the same pattern used by MCP servers and LSP: well-understood, language-agnostic, and debuggable. It also means PMs can be written in any language.

Why not compile PMs into the binary?

Language-agnosticism. We want npm/pypi PMs eventually, and those may be best written in JS/Python. Even for Rust-based PMs, the binary boundary keeps the core small and lets PMs be updated independently.

Why is the manifest on the wire instead of a directory?

So a PM can describe a package that doesn’t describe itself. A crate with a bare skills/ directory has no manifest; an npm package’s configuration would live in package.json. If the wire form were a path, every such case would need Symposium to learn that ecosystem’s conventions, which is exactly what the PM boundary exists to avoid.

Doesn’t returning a manifest let a PM claim anything it likes?

It describes content, which is its job. What it does not decide is whether any of that runs: validation, defaults, and enablement are applied by Symposium after the manifest arrives, from configuration and from the source the offer came from. See Enablement.

Who resolves version requirements — Symposium or the PM?

The PM. Symposium sends load_plugin with the requirement in the id’s version component; the PM interprets the range for its ecosystem and answers with the exact version.

What does this cost on the hook path?

A process spawn per PM per invocation. The property worth protecting is not “no subprocess” but “no cargo metadata”: that’s the expensive part, since it reads and resolves the whole graph. The update: "none" contract keeps fetch offline, list_deps caches on the lockfile mtime, and lazy spawning means a workspace whose predicates never reference a dependency starts no PM at all.

If spawn cost does turn out to matter, the answer is a daemon mode for PMs (and for Symposium) rather than folding PMs back into the binary. That’s a larger change and not proposed here.

Implementation plan and status

Step 1: Extract the manifest schema into a shared crate

Move the raw Symposium.toml schema and the predicate syntax types (parsing, Display, serde, not evaluation) into a crate both Symposium and PM authors depend on. Add Serialize alongside the existing Deserialize.

Tests: round-trip every manifest fixture in the repo through JSON and assert the validated Plugin is identical.

  • PR: manifest schema crate

Step 2: Reshape the in-process trait to the wire shape

active_plugins / load_plugin return {id, root, manifest} instead of an already-validated plugin. Manifest production moves to the PM side; validation, defaults, and trust move to a single core seam. Still fully in-process: this is a refactor with no protocol involved, and it is what de-risks step 3.

Tests: the existing suite passes unchanged.

  • PR: offer-shaped PM trait

Step 3: PM process management

Newline-delimited JSON-RPC client, the initialize handshake, lazy spawn, lifecycle and shutdown, timeout and crash handling. A server harness in the SDK so a Rust PM is a main plus a trait impl.

Tests: a fixture PM binary that returns canned manifests, driven end to end; plus failure cases: a PM that exits immediately, one that returns malformed JSON, one that never answers.

  • PR: PM process manager + SDK harness

Step 4: Implement symposium-pm-cargo

Port workspace resolution, crate fetching, and crate-manifest merging into the binary. Add whatever the workspace root and members need to cross the boundary, since core reads them in a dozen places.

Concretely:

  1. Split the cargo PM into a library the binary wraps, so unit tests can keep driving it in-process through the trait.
  2. Carry workspace information over the protocol. Core reads the workspace root and member directories off the cargo resolver in a dozen places, so this is the bulk of the change. Loading plugins from those directories stays in Symposium: they are local directory reads, and the workspace is a trust root whose policy core owns. The cargo PM’s job is to report where the workspace is, not what it contains.
  3. Forward SYMPOSIUM_CARGO into the child, since the test harness installs a fake cargo and a child inherits no environment.

Tests: the existing integration suite, driven through the real subprocess.

  • PR: cargo PM binary + tests

Step 5: Configuration surface

The [[package-manager]] section and acquisition through the existing installation machinery, replacing the hard-coded lookup from step 3.

  • PR: PM configuration

Step 6: A non-Rust-ecosystem reference PM

One PM that synthesizes manifests from a foreign format, proving the boundary carries an ecosystem Symposium knows nothing about.

  • PR: reference PM

The cargo PM

TL;DR

  • The cargo PM bridges crates.io (and alternative Rust registries) to Symposium’s plugin system.
  • It is a separate binary (symposium-pm-cargo) communicating with Symposium via JSON-RPC over stdio.
  • load_plugin takes a crate name and version requirement in cargo’s format.
  • fetch leverages the existing cargo toolchain to obtain crate sources.
  • list_deps reports direct workspace dependencies.
  • Every crate is implicitly a plugin — no opt-in required.

Motivation

Most Symposium users today are Rust developers. Their project dependencies live on crates.io. The cargo PM makes these dependencies discoverable as plugin sources — if serde ships skills, or if a recommendations entry references serde, the cargo PM is what connects the dots.

Change in a nutshell

In the cargo PM, every crate is a plugin. No opt-in is required. A crate can optionally include a Symposium.toml at its root directory for explicit configuration, but if absent, an empty one is synthesized and plugin defaults apply (which discovers skills/ and .agents/skills/ directories).

This means a crate author can ship skills by simply adding a skills/ directory:

my-crate/
├── Cargo.toml
├── src/
│   └── lib.rs
└── skills/
    └── my-crate-usage/
        └── SKILL.md

No Symposium.toml needed. When a user depends on my-crate, the cargo PM’s list_deps reports it, discovery finds the plugin content (via defaults), and the skills are offered for installation.

Detailed plans

Package-ids

The cargo PM defines package-ids as (cargo, $crate-name, $version). For example: (cargo, serde, 1.0.210), (cargo, tokio, 1.38.0).

Chained-reference schema

A [[plugins]] chained reference names one crate, as a dependency atom or a table:

[[plugins]]
source.cargo = "serde-skills>=1"

[[plugins]]
source.cargo = { name = "serde-skills", version = "1.*" }

Symposium lowers either spelling to a package-id whose version component is the requirement, and sends it to load_plugin. The cargo PM resolves the requirement and answers with the exact version.

search behavior

search receives a partial query string and searches crates.io by name, returning candidate crates.

The results are candidates, not confirmed plugin carriers: because every crate is implicitly a plugin, whether a given crate contributes anything is only known once it is fetched. This is deliberate: it lets cargo agents use <crate> name a crate the workspace doesn’t depend on, and defers the question to the fetch/load step.

fetch behavior

Given a package-id like (cargo, serde-skills, 1.2.3):

  1. A path dependency resolves to its local directory directly.
  2. A (name, version) already unpacked resolves to that directory with no work at all. A published version is immutable, so once its source is on disk there is nothing to re-check and no reason to ask the network.
  3. Otherwise use the existing cargo toolchain: ~/.cargo/registry/src/ (the unpacked source cache), falling back to a crates.io download.
  4. The crate root directory is the plugin directory (defaults apply to discover skills, etc.).
  5. Return that directory in place.

Step 2 is what makes fetch cheap enough to sit on the hook path. list_deps caching keyed on Cargo.lock avoids re-resolving the graph; this avoids re-acquiring the sources that resolution named. Only an unresolved version requirement needs the registry, and only to turn it into an exact version.

This approach ensures compatibility with users who have custom registry configurations, alternative registries, or corporate mirrors — we go through cargo rather than around it.

list_deps behavior

Reads the workspace to report direct Rust dependencies.

Input: the workspace root, supplied once at initialize.

Output: set of package-id tuples, e.g., [(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)].

Workspace handling:

  • For a workspace with multiple members, union all members’ direct dependencies.
  • Dev-dependencies are included (they’re still dependencies the user works with).

Performance:

  • Cache results on disk, keyed on Cargo.lock mtime.
  • If Cargo.lock hasn’t changed, return cached results immediately: no resolution at all.

Workspace information

Symposium itself needs the workspace root and the member directories: for workspace-local plugins, for scoping use entries, and for locating agent skill directories. It reads them off the cargo resolver today.

Moving the cargo PM out of process means these cross the boundary, either as an extra method or as part of the initialize response. Loading plugins from those directories should stay in Symposium: they are local directory reads, and the workspace is a trust root whose policy core owns. The cargo PM’s job is to report where the workspace is, not what it contains.

Chained plugins for independent release

If a crate author wants to release plugin content on a separate schedule from their library, they add a Symposium.toml to their crate with a chained plugin:

# In widget-lib's Symposium.toml
[[plugins]]
source.cargo = "widget-symposium>=1"

This tells Symposium: “when this plugin is loaded, also load widget-symposium.” The chained plugin can be published and updated independently.

Alternative registries

The cargo PM defaults to crates.io but can be configured to use alternative registries. Configuration mechanism TBD — likely via cargo’s own registry configuration in ~/.cargo/config.toml, which the cargo PM inherits naturally since it uses the cargo toolchain.

Frequently asked questions

How does search know which crates have plugin content without downloading them all?

It doesn’t, and doesn’t try. Every crate is implicitly a plugin, so “has plugin content” is not knowable from the registry index: search returns name matches and the load step decides what each contributes.

A keyword convention such as symposium-plugin is deliberately not used as a filter: it would only distinguish anything once crate authors adopted it, and until then it would hide plugin-bearing crates that had not.

When should a crate use [package.metadata.symposium] rather than a Symposium.toml?

Both work, and a crate may use both: the table is the same manifest schema, embedded, and the two are merged with the file taking precedence. The table suits a crate declaring a small amount of plugin configuration that does not justify another file. A crate with real plugin content should ship a Symposium.toml, where the configuration is easier to find and to read.

Note this is the same capability the PM interface generalizes. Reading plugin configuration out of an ecosystem’s own manifest is exactly what returning a synthesized manifest is for; [package.metadata.symposium] is that idea applied to cargo, and an npm PM would do the same with package.json.

Implementation plan and status

Steps here follow the PM interface plan: the cargo PM binary is step 4 there, and cannot start before the protocol exists.

Step 1: Extract the cargo PM into a standalone library

Separate workspace resolution, crate fetching, and crate-manifest merging from Symposium’s core, so the binary is a thin wrapper. Keeping it a library is also what lets unit tests keep driving it in-process.

  • PR: cargo PM library split

Step 2: Carry workspace information over the protocol

Add the workspace root, member directories, and crate list to the protocol, and move Symposium’s readers onto it.

  • PR: workspace info over the wire

Step 3: symposium-pm-cargo binary

Wrap the library in the SDK’s server harness. Forward the cargo binary override so the test harness’s fake cargo still applies.

  • PR: cargo PM binary

Step 4: Switch Symposium to the subprocess

Replace the in-process instance with the spawned one. Measure the hook path before and after; confirm Cargo.lock-unchanged still means no resolution.

  • PR: cargo PM cutover + benchmark

Discovery & sync

TL;DR

  • symposium sync resolves installed plugins, discovers new ones from workspace dependencies, prompts the user, fetches, evaluates predicates, and wires active content into agent directories.
  • Every PM is asked the same two things: list-deps, then active_plugins over that dependency set. What differs is the answer’s source: a trusted PM’s plugins load directly, an untrusted PM’s need the user’s consent first.
  • A session-start hook notifies users of available extensions without auto-installing.

Motivation

Users shouldn’t have to manually find and install plugins for every crate they depend on. Discovery bridges the gap: when you add serde to your Cargo.toml, Symposium notices and offers relevant extensions. The sync pipeline ensures everything stays consistent.

Change in a nutshell

User adds axum to their project. On next agent session start, they see:

New extensions available for 1 dependency. Run `symposium sync` to review.

They run symposium sync:

New extensions available:

  [1] (cargo, axum-agents, 0.5.1) — Route documentation and testing skills
      (because you depend on axum)

Install? [1,all,none]: 1
✓ Installed (cargo, axum-agents, 0.5.1)
✓ Synced 2 skills: axum-routing, axum-testing

Detailed plans

The discovery algorithm

The core loop:

  1. Call list-deps on all PMs. Each PM reports the workspace’s dependencies in its ecosystem. For example, the cargo PM returns [(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]. PMs with no notion of workspace deps (a registry) return empty.

  2. Call active_plugins on all PMs, passing that dependency set. A registry answers with its own entries, ignoring the deps. An ecosystem transport answers with the plugins its dependencies embed: the crate that ships a skills/ directory or a Symposium.toml of its own. Fetching is cache-only here, so this makes no network calls and a workspace dependency is inspected in the source the PM already extracted.

  3. Split the offers by the source they came from. A registry is a trust root, so its plugins are loaded straight away and gated by nothing but their own predicates. A dependency is not: depending on a package means compiling its code, not letting its author add to the agent’s context, so a plugin embedded in one needs the user’s say-so. Only these reach the next step.

  4. Classify each remaining offer against [plugins]. A name may be enabled by use, pre-consented by auto-enable, previously declined via disable, or undecided, which makes it a candidate.

  5. Prompt the user about the candidates, and record the answers: approvals into auto-enable, declines into disable.

So every PM is asked, and asked the same thing. Trust does not decide who gets asked; it decides what happens to the answer. Steps 3 to 5 are what “discovery” names in the narrow sense, the consent decision, and only dependency-embedded plugins ever need one. Nothing here fetches or writes until the prompt is answered.

Note what is not here: no per-dependency search. Curated recommendations do not need one, because a recommendation is an ordinary registry plugin that names the crates it advises on with depends-on, which the ordinary predicate pass already evaluates. search is a user-facing lookup, backing symposium use and symposium search, where the input is a partial name typed by a person rather than a package-id.

The sync pipeline

symposium sync runs the full pipeline:

1. Resolve config       → installed plugin package-ids (exact versions)
2. Discover deps        → candidate plugin package-ids (via list-deps + active_plugins)
3. Prompt/auto-install  → updated installed set
4. Fetch                → populate cache
5. Evaluate predicates  → active set
6. Sync to agent dirs   → skills, hooks, MCP servers wired in

Step 1: Resolve config

Read ~/.symposium/config.toml. Each [plugins] entry names a (pm, canonical-name) pair; load_plugin on the owning PM turns it into the current best match.

Step 2: Discover deps

Run the discovery algorithm described above.

Step 3: Prompt or auto-install

Present new discoveries to the user:

New extensions available:

  [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers
      (because you depend on serde)

  [2] (cargo, axum-agents, 0.5.1) — Route documentation and testing skills
      (because you depend on axum)

Install? [1,2,all,none]:

If auto-sync = true in config, skip the prompt and install all.

Selected plugins are added to config. Declined plugins are recorded as dismissed.

Step 4: Fetch

For each installed plugin, call fetch on its PM to populate the cache. Chained plugins declared in a plugin’s Symposium.toml are fetched transitively.

Fetching happens in parallel across PMs and packages.

Step 5: Evaluate predicates

For each cached plugin, evaluate its predicates against the workspace:

  • workspace-member() → is this plugin defined by a member of the workspace?
  • depends-on(axum>=0.7) → did some PM’s list-deps include a matching axum?
  • etc.

Plugins that pass are active. Plugins that don’t pass are installed but dormant.

Step 6: Sync to agent dirs

Copy active skills/hooks/MCP servers into agent directories. Same change-awareness as today:

  • Compare source and destination content
  • Only write when files differ
  • Clean up stale entries from deactivated/removed plugins

Hook-triggered notification

On session start, a lightweight check runs:

  1. Use cached list-deps results (from lockfile mtime — no network calls).
  2. Run discovery over them, cache-only.
  3. If undecided candidates exist, include in hook response:
    New extensions available for 3 dependencies. Run `symposium sync` to review.
    

The hook does NOT install anything. It only notifies. Installation goes through symposium sync.

Enablement configuration

Enablement is keyed on (pm, canonical-name), the identity every PM gives the plugins it offers (see the PM interface). The pair is what lets a user name one specific plugin: a crate for the cargo PM, an entry path for a registry PM, and never an ambiguous bare word.

# In ~/.symposium/config.toml

[plugins]
# Pre-consented, so a discovery installs without prompting.
auto-enable = [{ pm = "cargo", name = "my-internal-crate" }]

# Deliberate enablements, global or scoped to one workspace.
use = [
  { pm = "cargo", name = "widget" },
  { pm = "cargo", name = "gadget", workspace = "/path/to/project" },
]

# Pruned from enablement, which is also where a decline is recorded, and how a
# plugin from a trusted source is turned off.
disable = [{ pm = "symposium-recommendations", name = "rtk" }]

auto-enable also accepts "*", meaning every dependency-embedded plugin is consented to. disable still applies on top, so blanket consent stays overridable one plugin at a time.

Precedence

The three lists answer different questions, so they can name the same plugin at once. The rule is that disable wins, unconditionally:

ConfigurationResult
use onlyenabled, subject to its predicates
auto-enable onlyenabled if a dependency embeds it
use + auto-enableenabled; use additionally reaches a plugin no dependency embeds
anything + disableoff

disable has to be the last word to be worth having. Everything else (a trusted registry, auto-enable, an explicit use) is a way of saying a plugin may run, and disable is the only way to say it may not; a precedence rule that let any of them beat it would mean there is no way to turn a plugin off.

So use on a disabled plugin does not re-enable it, and symposium use --remove does not cancel a disable (it removes a use entry, which is the opposite decision). Re-enabling means dropping the disable entry.

Scope

use carries a scope: an entry is either global or recorded for one workspace root, so a plugin can be enabled in the one project that wants it.

auto-enable and disable do not: both are global. For auto-enable this is a consequence of what it means, namely standing consent to what your dependencies carry, which is a judgment about the plugin’s author rather than about a project. For disable it is a simplification worth naming: turning a plugin off in one workspace turns it off in all of them. See the parent RFD’s future work.

Declined discoveries

A decline is recorded in [plugins] disable in the user config, and is permanent until the user edits it. It lives in config rather than state because it is a decision the user made and should be able to see and revise, not a cache Symposium is free to invalidate; a version bump does not re-raise it.

Only an explicit “never ask again” is written. The prompt’s default answer (“ask me later”) and Escape record nothing, so hitting Enter reflexively never declines anything permanently.

The prompt is inert unless the output is attached to a terminal on both ends. A hook must never block on stdin, so on the hook path the pending candidates are rendered into SessionStart context pointing at cargo agents sync instead.

Debouncing and caching

  • list-deps results are cached based on lockfile mtime. No cargo invocation if Cargo.lock hasn’t changed.
  • Discovery search results are cached with a 24-hour TTL.
  • The session-start hook path uses cached results exclusively — no network calls during hook handling.

Frequently asked questions

Why not auto-install by default?

Installing code without consent is a security concern. Users should see what’s being proposed and approve it. The auto-sync = true opt-in is for users who trust the recommendations set and want zero friction.

Why only direct dependencies?

Transitive deps are numerous and usually not relevant to the user’s workflow. Direct deps keep discovery focused.

What if list-deps is slow?

The cargo PM’s list-deps reads Cargo.lock directly (fast parse). The result is cached on lockfile mtime. In the common case (lock unchanged), list-deps is a no-op.

Can discovery be disabled entirely?

Yes: auto-sync = false (the default) means you only get notified, never auto-installed. To suppress even the notification, set discovery = false in config.

Implementation plan and status

Step 1: Sync pipeline skeleton

Wire up the pipeline with the path PM initially to validate the flow end-to-end.

  • PR: sync pipeline with path PM

Step 2: Discovery algorithm

Implement list-depssearch loop across all PMs.

  • PR: discovery algorithm

Step 3: Prompt UX

Present discoveries, record choices (accept/dismiss).

  • PR: discovery prompt

Step 4: Hook notification

Add discovery check to session-start hook. Use cached results only.

  • PR: session start notification

Step 5: Auto-install and dismissal

Add auto-sync config, per-PM granularity, and dismissed-discovery tracking.

  • PR: auto-install + dismissal state

User-managed plugins

TL;DR

  • symposium use [--global] X searches PMs, installs a plugin, records it in config.
  • symposium use --remove X removes that record from config.
  • symposium status shows what’s installed, what’s active, and why.
  • Global installs apply everywhere; local installs are scoped to a workspace directory without modifying workspace files.

Motivation

Users need to explicitly manage plugins: install tools they’ve heard about, remove ones they don’t want, and understand what’s active. The UX should be as familiar as cargo install or npm install -g — search, pick, done.

Change in a nutshell

$ symposium use serde-skills
Found plugins matching "serde-skills":

  [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers

Install? [1]: 1
✓ Installed (cargo, serde-skills, 1.2.3)
✓ Active (depends-on(cargo, serde, 1.0) matches in this workspace)

$ symposium status
Installed plugins:

  (cargo, serde-skills, 1.2.3) [local: ~/projects/my-app]
    Active: yes
    Skills: serde-usage, serde-derive-helper

$ symposium use --remove serde-skills
✓ Removed (cargo, serde-skills, 1.2.3)

Detailed plans

symposium use [--global] <query>

Query: A plugin name. A name is not an identity, since two ecosystems may use the same word, so use resolves it across every PM and then decides.

Flow:

  1. Collect every plugin the name could mean: registry plugins that name themselves, the workspace’s own dependencies (checked offline, before anything reaches the network), and search hits from every PM, which is what lets use name a package the workspace does not depend on yet. Matches are deduplicated on (pm, canonical-name).
  2. Exactly one match is used. Several is an error naming them, which the user resolves by picking the ecosystem:
    `serde` is offered by more than one package manager:
      cargo (--pm cargo)
      symposium-recommendations (--pm symposium-recommendations)
    pick one with `--pm <name>`
    
  3. Record the (pm, canonical-name) pair in config, so the entry round-trips to the same plugin.
  4. Fetch into cache.
  5. Run sync to activate if predicates pass.

A plugin a trust root already offers needs no entry, and use says so rather than writing one. The exception is a plugin with no activation root of its own, where use is precisely the root being supplied.

Flags:

  • --global: active in all workspaces.
  • Without --global: scoped to the current workspace directory.
  • --pm <name>: the package manager to pick when more than one offers the name.

symposium use --remove <name>

Drop the use entry for <name> and re-sync, so the plugin’s content is reaped from the agent directories straight away. The cache entry stays (garbage-collected separately).

The scope has to match: without --global this removes the entry recorded for the current workspace, with it the unscoped one. A scope mismatch is an error rather than a silent success, since “nothing to remove” and “removed” are answers the user needs to tell apart.

Removal is the inverse of use, not a general off switch: it withdraws an enablement the user recorded. Turning off a plugin that was never used, such as one a registry offers, is disable.

symposium status

Shows installed plugins grouped by scope, with activation status:

Global plugins:
  (cargo, rtk, 2.1.0)
    Active: yes
    Skills: rtk-reduce, rtk-expand

Local plugins (~/projects/my-app):
  (cargo, axum-agents, 0.5.1)
    Active: yes (workspace-dependency() ✓)
    Skills: axum-routing, axum-testing

  (cargo, diesel-helpers, 1.0.0)
    Active: no (workspace-dependency() ✗)
    Source: discovery (auto-installed 2026-05-15)

Workspace plugins (from Symposium.toml):
  Skills: project-guide, testing-conventions

Config file format

Location: ~/.symposium/config.toml

[plugins]
use = [
  # Global: active in every workspace.
  { pm = "cargo", name = "serde-skills" },
  { pm = "cargo", name = "rtk" },

  # Workspace-scoped, keyed by absolute path.
  { pm = "cargo", name = "axum-agents", workspace = "/home/user/projects/my-app" },
  { pm = "cargo", name = "diesel-helpers", workspace = "/home/user/projects/my-app" },
]

An entry names a plugin, not a version requirement: the pair (pm, canonical-name) is the identity (naming a plugin in configuration), and the version is whatever the PM resolves at load time. A bare string is read as a cargo package, since that is what an unqualified name has always meant.

Scoping: global vs. local

Global (--global): Plugin activates in every workspace. Good for universally useful tools.

Local (default): Plugin scoped to the current workspace directory. Stored as a use entry carrying that absolute path.

Scope is a property of use only. disable is global, so it is not the way to turn a plugin off in one project. See precedence and scope.

Key constraint: local installs don’t modify workspace files. Scoping lives entirely in ~/.symposium/config.toml. This means:

  • No dotfiles added to the project
  • Team members don’t see each other’s local installs
  • Workspace stays clean for version control

Workspace plugins (from Symposium.toml) are a separate concept — they’re project-managed, apply to all developers, and aren’t touched by use/remove.

Version updates

On each symposium sync, Symposium calls load_plugin with the configured (pm, canonical-name) pair. The PM finds the best matching version. Upgrades happen within the allowed range; downgrades don’t.

There is no separate symposium update command — sync handles this naturally.

Interaction with discovery

Discovery also writes to [plugins] when the user answers its prompt: approvals go to auto-enable, declines to disable. use entries and auto-enable entries both enable, and status shows which root a plugin came in on:

Source: discovery (auto-installed 2026-05-15)

vs.

Source: symposium use axum-agents

Both are equivalent in config. The distinction is informational.

Frequently asked questions

Why not modify workspace files for local installs?

Local installs are personal preferences. Putting them in workspace files would commit them to version control, affecting the whole team. The Symposium.toml in the workspace is for team-wide plugins; ~/.symposium/config.toml is for personal ones.

What if I move my project directory?

Workspace-scoped use entries record absolute paths. If you move the directory, they stop matching. Fix: update the path in config manually, or re-run symposium use in the new location.

What happens when global and local plugins conflict?

If a global and local plugin provide a skill with the same name, the local one wins. status shows a warning.

What if a plugin is both used and disabled?

It stays off. disable is the last word over every enabling mechanism, so a use entry naming a disabled plugin has no effect, and use --remove cannot cancel a disable: it removes a use entry, which is the opposite decision. Re-enabling means dropping the disable entry. See precedence.

Can I install without a workspace?

symposium use --global X works from anywhere. Without --global, you need to be in a workspace directory (so Symposium knows what to scope to).

Implementation plan and status

Step 1: Config file format

Define and parse the [plugins] section: use, auto-enable, and disable, with global and workspace-scoped use entries.

  • PR: config format + parsing

Step 2: symposium use

Search flow, selection UX, writing to config, triggering sync.

  • PR: use command

Step 3: symposium use --remove

Matching, removal from config, cleanup on next sync.

  • PR: remove command

Step 4: symposium status

Display installed/active/inactive plugins with provenance and predicate status.

  • PR: status command

Predicate caching

TL;DR

Predicates, especially custom predicates, spawn processes on every sync. This RFD lets custom predicates emit granular JSONL watch events for files, environment variables, and time. Symposium caches their results and skips reevaluation while every watched input is unchanged and no watch expires. No watch events means cached indefinitely; WatchTime(0) means never cached.

Problem

Auto-sync means predicates re-evaluate on every agent session start. A workspace with 10 plugins, each with a custom predicate, forks 10+ processes every time.

Design

Custom predicates already emit JSONL events to stdout. We add one event per watched resource:

#![allow(unused)]
fn main() {
#[non_exhaustive]
enum CustomPredicateEvent {
    // ... existing variants ...
    /// Result depends on the contents of the given file.
    WatchFile(PathBuf),
    /// Result depends on the value of the given environment variable.
    WatchEnv(String),
    /// Result becomes stale after this many milliseconds.
    WatchTime(usize),
}
}

A predicate can emit any number of these events:

{"watchFile": "CargoBrazil.toml"}
{"watchFile": "Config"}
{"watchEnv": "LAMBDA_ENV"}
{"watchTime": 60000}

Symposium unions the file and environment events. A change to any watched input or expiry of the shortest WatchTime causes one reevaluation. Files are relative to the workspace root.

The process exit status determines the predicate result. Watch events only control caching. No watch events means the result is cached indefinitely; predicates must report every changing input or Symposium may reuse a stale result. WatchTime(0) means the result is stale immediately, which effectively disables caching. The #[non_exhaustive] attribute leaves room for new watch kinds.

SDK helper

The symposium-sdk crate provides a helper that reads an environment variable and emits its watch event:

#![allow(unused)]
fn main() {
let val = symposium_sdk::env::var("LAMBDA_ENV")?;
// Emits {"watchEnv": "LAMBDA_ENV"}.
}

Multiple helper calls emit multiple events, which Symposium unions.

How it works

File fingerprints use mtime + size; missing is a valid state. Environment fingerprints use the current value or absent state. Time fingerprints use the wall-clock time at which the entry becomes stale.

Cache lives at ~/.symposium/cache/predicates.json.

  1. Look up the predicate in the cache.
  2. If all watched inputs match and no WatchTime has elapsed, use the cached result. An empty watch set always matches.
  3. Otherwise, evaluate the predicate and obtain its result from the exit status.
  4. Store the result with its emitted watch events. WatchTime(0) yields an immediately stale entry.

Cache is discarded on Symposium version upgrade.

Built-in predicates

  • workspace-member() requires no cache because it is already cheap and evaluated in memory.
  • path_exists(path) emits WatchFile(path).
  • env(FOO=BAR) emits WatchEnv("FOO").
  • shell(cmd) emits WatchTime(0) because its inputs are unknown.
  • Caching depends-on(name) is deferred to the PM interface work.

PM integration

Changes to list_deps and PM-derived caching are deferred to the PM interface work.

Implementation steps

  1. Add and parse WatchFile, WatchEnv, and WatchTime events while keeping the exit status as the predicate result.
  2. Union watch events, cache an empty watch set indefinitely, and treat WatchTime(0) as immediate staleness.
  3. Add cache storage and fingerprint comparison for files, environment variables, and expiry times.
  4. Wire path_exists, env, and shell to emit their watch events.
  5. Add symposium_sdk::env::var() to emit environment watch events.

Completed RFDs

RFDs whose implementation is finished.

Configuration parsing and normalization

TL;DR

  • Parse TOML into raw structs that describe the accepted file syntax.
  • Convert raw structs into normalized runtime structs in explicit validation steps.
  • Runtime code consumes normalized structs, not TOML-shaped structs.
  • Commands that edit user-owned TOML preserve formatting through a CST-aware editing path.
  • This RFD does not change user-facing configuration syntax.

Motivation

Symposium currently uses more than one pattern for TOML parsing.

The user configuration in config.rs mostly mirrors the file shape. Config contains fields like defaults and plugin_source, and methods such as Symposium::plugin_sources() compute the effective view used by runtime code.

Plugin manifests in plugins.rs are closer to a raw-to-normalized pipeline. RawPluginManifest deserializes the manifest, then validate_manifest() produces a validated Plugin. During that step, inline installation references are promoted into named installations and plugin-level crates plus predicates are merged into one PredicateSet.

Some manifest types also normalize during deserialization. For example, SkillGroup and PluginMcpServer implement custom Deserialize so their crates and predicates fields are merged before validation sees them.

The registry-centric plugin work adds more syntax with separate file and runtime shapes: [[plugins]], source.*, where.*, provenance, discovery policy, and plugin defaults. Before adding those pieces, we should make the parsing boundary consistent.

Change in a nutshell

separate “raw” from “normalized”, prefer derived serde traits

Every parsed TOML file has a raw root struct and a normalized runtime representation. The raw tree reflects the file shape; the normalized tree reflects the runtime model.

The root type used to deserialize a TOML file is always raw. Nested structs should also be raw when they represent TOML sections or entries that need normalization before runtime use. Field-level syntactic types, such as a parsed predicate expression or source specifier, may be non-raw when their invariants are local to that value.

This keeps the overall parse path consistent: deserialize the file through a raw root, then validate into a normalized runtime root. It still permits small non-raw field types where a separate raw/runtime split would only add ceremony.

Raw structs should use derived Deserialize where possible. We recommend denying unknown fields to catch typos.

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSkillGroup {
    #[serde(default)]
    crates: Option<CrateList>,
    #[serde(default)]
    predicates: PredicateSet,
    #[serde(default)]
    source: PluginSource,
}
}

There is then a distinct normalized struct used throughout the codebase:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize)]
pub struct SkillGroup {
    pub predicates: PredicateSet,
    pub source: PluginSource,
}
}

And finally inherent methods to convert from the “raw” version to the normalized one:

#![allow(unused)]
fn main() {
impl RawSkillGroup {
    fn validate(self) -> anyhow::Result<SkillGroup> {
        Ok(SkillGroup {
            predicates: PredicateSet::merged(self.crates, self.predicates),
            source: self.source,
        })
    }
}
}

rewrite with toml-edit

For files that Symposium rewrites, keep a fourth concern separate: the concrete syntax tree used to preserve user formatting.

#![allow(unused)]
fn main() {
struct EditableConfig {
    document: toml_edit::DocumentMut,
    config: Config,
}
}

Runtime code should not depend on the editable representation. Editing commands load the document, parse or validate the relevant parts, apply localized edits to the document, and write the document back.

Detailed plans

Raw structs

Raw structs describe accepted TOML syntax. Every TOML file type should have a raw root struct, even if some fields currently convert one-to-one into runtime values. Raw structs should:

  • derive Deserialize where possible;
  • use #[serde(deny_unknown_fields)] unless the format intentionally accepts extension fields;
  • keep aliases, deprecated fields, and migration-only fields visible at the parsing boundary;
  • avoid runtime-only derived fields.

Raw structs may contain other raw structs, field-level syntactic types, and plain scalar/container values. They should not contain normalized runtime structs for nested TOML sections that still need cross-field validation.

Raw structs should prefer Serde’s derived enum representations for syntactic unions. Use #[serde(flatten)], untagged enums, or externally tagged enums when they describe the TOML shape directly. Custom Deserialize implementations should be rare and limited to cases that derived Serde cannot express clearly.

Raw structs should not perform semantic normalization that depends on sibling fields or later validation context.

Current custom deserializer audit

The current custom Deserialize implementations fall into these categories:

TypeCurrent roleDecision
CrateListAccepts crates = "serde" or crates = ["serde"], then parses crate-atom strings into predicates.Split the TOML shape from the grammar parsing. The string-or-list shape can be expressed as an untagged raw enum; crate-atom parsing remains field-level parsing.
PredicateParses one predicate expression string, such as crate(serde) or any(crate(a), crate(b)).Keep as a field-level syntactic parser. The function-call grammar is not TOML shape normalization.
PredicateSetDeserializes a list of predicate expression strings by delegating to Predicate.Keep near the predicate parser. It may be replaceable with #[serde(transparent)], but it is not the main normalization problem.
PluginMcpServerReads crates, predicates, and a flattened MCP server, then merges activation fields into one PredicateSet.Move to RawPluginMcpServer::validate. This is semantic normalization across sibling fields.
PluginSourceAccepts string and table forms, rejects removed table fields, enforces mutually exclusive table keys, and produces a runtime enum.Move semantic checks into a raw source type with derived Serde where possible. Use an untagged enum or flattened table representation for the accepted TOML shapes.
SkillGroupReads crates, predicates, and source, then merges activation fields into one PredicateSet.Move to RawSkillGroup::validate. This is semantic normalization across sibling fields.

This audit is intentionally about custom deserializers, not every validation step. Existing raw structs such as RawPluginManifest, RawHook, and RawSubcommand already follow the desired shape: Serde reads TOML fields, then validation functions produce normalized runtime values.

Normalized runtime structs

Runtime structs describe the validated model used by sync, hooks, subcommands, plugin loading, and reporting. They should:

  • have fields that correspond to runtime concepts;
  • avoid preserving aliases or deprecated syntax;
  • store derived relationships when that simplifies runtime code;
  • be serializable for tests, reports, or debug output when useful.

For example, a normalized plugin should have one PredicateSet for activation even if the source TOML can express that predicate set through more than one field.

Validation and conversion

Conversion from raw to normalized structs is explicit. Prefer inherent methods on raw structs:

#![allow(unused)]
fn main() {
impl RawPluginManifest {
    fn validate(self, sym: &Symposium) -> anyhow::Result<Plugin> {
        // ...
    }
}
}

Context-free conversions use fn validate(self). Conversions that need configuration directories, cache directories, the active Symposium, workspace state, or a registry resolver take those values as ordinary parameters. Named helper functions are appropriate for shared validation logic or when a conversion spans multiple raw values.

The conversion step handles:

  • merging syntactic sugar into runtime fields;
  • conflict checks, such as mutually exclusive fields;
  • duplicate-name checks;
  • promotion of inline entries into named entries;
  • migration errors and migration hints;
  • cross-field validation.

Serde errors should be limited to syntax shape problems: unknown fields, wrong types, missing required fields, and invalid field-level enum forms.

CST-preserving edits

Parsing and editing are separate concerns.

toml::from_str plus Serde is appropriate when Symposium only needs to read a file. It does not preserve comments, ordering, whitespace, or original spelling.

Commands that modify user-owned TOML should use a CST-aware path based on toml_edit. This applies to commands such as future plugin-management commands that add or remove entries from user config.

The CST-aware path should still validate through the same raw-to-normalized logic before relying on the edited file.

This is red-green testable. A test should start with a user config containing comments, non-default ordering, and unrelated tables. It should apply a narrow edit through the config-editing API, then assert that:

  • the intended semantic change is present;
  • unrelated comments are still present;
  • unrelated table and key ordering is unchanged;
  • the resulting file parses through the normal raw-to-normalized validation path.

Accepted aliases

Aliases should be rare. Each accepted spelling increases documentation, validation, and migration surface area.

When an alias is accepted, the raw struct should represent both spellings and the conversion step should normalize them into one runtime field. If both spellings are present and conflict, conversion should report a semantic error.

Frequently asked questions

Does this change any user-facing syntax?

No. This RFD describes an internal organization rule. Existing accepted syntax continues to parse unless a separate RFD removes or migrates it.

Why not keep runtime structs shaped like TOML?

Runtime code should not have to know every accepted spelling or deprecated field. A normalized runtime model keeps validation at the boundary and makes later code depend on stable concepts.

Why not normalize everything inside custom Deserialize implementations?

Custom deserializers are useful for local field-shape problems, but they hide normalization inside parsing. That makes it harder to report semantic errors with context and harder to keep a consistent boundary between file syntax and runtime model. They are also harder to read: a raw struct lets the reader infer the expected TOML shape from the Rust fields and Serde attributes.

Does this require preserving the exact TOML CST everywhere?

No. CST preservation is only needed for commands that rewrite user-owned TOML. Read-only paths can parse through Serde and discard formatting.

Implementation plan and status

Step 1: Document the rule

Add this RFD and link it from the mdbook RFD section.

  • RFD added.

Step 2: Refactor plugin manifest parsing without behavior changes

Use the custom deserializer audit above to move semantic normalization out of custom Deserialize implementations. Start with SkillGroup and PluginMcpServer because they already perform simple crates plus predicates normalization. Then move PluginSource semantic checks behind a raw source type with derived Serde where possible.

Verification:

  • targeted plugin manifest parsing tests cover the moved cases;

  • cargo fmt;

  • cargo clippy --all --workspace;

  • cargo test --all --workspace

  • Existing plugin manifest parsing tests still pass.

  • Custom deserializer audit.

  • Behavior-preserving parser refactor.

Implemented by deserializing skill groups, MCP servers, and skill group source syntax into raw manifest structs, then validating them into SkillGroup, PluginMcpServer, and PluginSource. CrateList now names the raw string-or-list TOML union separately from crate-atom parsing.

Step 3: Refactor user config parsing without behavior changes

Introduce raw config structs where doing so clarifies the distinction between file shape and effective runtime view. Keep existing user-facing config syntax.

Verification:

  • cargo test --all --workspace

  • Existing config parsing tests still pass.

  • Behavior-preserving config parser refactor.

Implemented by loading ~/.symposium/config.toml through RawConfig and validating into the runtime Config. Config remains serializable for save_config, but it is no longer the Serde root used by config loading. The internal state.toml file follows the same pattern with RawState.

Step 4: Add CST-aware editing helpers when needed

When the first command needs to edit user config while preserving formatting, introduce a small toml_edit-based helper. Do not introduce this before there is a command that needs it.

Verification:

  • a red-green test with comments, non-default ordering, and unrelated tables;

  • assertions that the intended semantic change is present;

  • assertions that unrelated comments and ordering survive the edit;

  • assertions that the edited document still parses through the normal validation path.

  • CST-aware editing helper. Deferred until a command needs to rewrite user-owned TOML while preserving comments and ordering.

RFD Process

TL;DR

  • Add a lightweight RFD (Request for Discussion) process for planning larger changes.
  • RFD PRs focus on the design and plans.
  • For trusted contributors, once an RFD is accepted, they are encouraged to land PRs independently.

Motivation

It is easier to understand changes by talking first through the intended design and in particular the user-facing impact. Implementation is often secondary, particularly in an era of agentic development. The RFD process is designed to focus our attention on the design and plans.

This particular RFD process is also experimenting with the development process to account for the use of agents. In an agentic environment, the review process often becomes the bottleneck, so we want to focus our discussion on the design and plans. Coding can then proceed more easily. The RFD also serves as a reference for agents doing a review.

Change in a nutshell

When planning a larger change, first create a PR adding an RFD that lays out the design and plans. Once the PR is accepted, implementation PRs should modify the implementation plan and keep it up-to-date, making it easier for reviewers or others to understand how the work that is being done relates to the design.

Once the RFD is accepted, team members are encouraged to land PRs independently if they feel confident in the changes. For newer contributors, review should be a simpler process.

Each RFD is created as a subdirectory under md/rfds followed a standard template. It can consist of a single file, but you are also encouraged to leverage the subdirectory structure to include other files such as sample documentation, images, or other planning documents.

Detailed plans

Template structure

The template contains the following sections:

  • TL;DR — bullet points covering the key changes
  • Motivation — why we’re making this change
  • Change in a nutshell — the most important changes
  • Detailed plans — full design, with subchapters as needed
  • Frequently asked questions — rationale, alternatives, discussion
  • Implementation plan and status — checklist of steps, updated as work lands

Style requirements

  • No promotional text or dramatic language. Be factual and brief.
  • Lead with concrete concepts, then generalize.
  • Include examples (code snippets, config fragments).
  • Include proposed user-facing documentation as subchapters when the change affects docs.

Agent skill

The .agents/skills/authoring-rfds/SKILL.md skill teaches agents the process and style guide so they can help draft RFDs consistently.

We may wish to create additional skills over time similar to github spec-kit, e.g., for impl planning, review, etc.

Copilot code review instructions

The .github/copilot-code-review-instructions.md file configures GitHub’s Copilot reviewer to:

  • Check whether a PR relates to an active RFD and, if so, verify that the implementation plan is updated, the approach is consistent with the design, and any deviations are documented.
  • Watch for Rust coding practices: exhaustive matches over wildcards, narrow visibility defaults, no backwards-compatibility stubs, and documentation that describes current behavior rather than historical context.

Decision making

Input from multiple core team members is preferred, but any single core team member can accept or reject an RFD independently. The BDFL has final call if there’s disagreement.

Frequently asked questions

Are RFDs required for every change?

No. They’re for larger changes where upfront discussion helps — roughly, anything that touches multiple modules or introduces new concepts. Bug fixes, small features, and refactors don’t need one.

Why is this process so lightweight?

The goal is to stay out of the way and keep a record. Merging an RFD or even a PR doesn’t commit us to anything — issuing a release does. That’s the point where we need to be careful, not at the proposal or implementation stage.

What if the design changes during implementation?

Update the RFD’s status section to note deviations. The RFD is a living document until it moves to “Completed”.

Implementation plan and status

Initial PR

  • Add RFD template (md/rfds/TEMPLATE/README.md)
  • Add RFD index pages (accepted.md, completed.md)
  • Add SUMMARY.md entries
  • Add authoring skill (.agents/skills/authoring-rfds/)
  • Write this meta-RFD as a demonstration

No tests required.