Introduction
Getting started
cargo binstall symposium # or: cargo install symposium
cargo agents init
After initialization, start your agent in a Rust project as usual.
- For first-time setup details, see Installing Symposium.
- For command reference, see The
cargo agentscommand. - For agent compatibility, see Supported agents.
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, use it in your Rust projects. When your agent starts a session in one, or when you run cargo agents sync, Symposium scans your dependencies and installs customized skills, tools, and other improvements. These extensions come from our central recommendations repository and from the crates themselves: crate authors can embed extensions in their crates, and you enable them with a single confirmation.
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 — April 21, 2026
A short launch post introducing the project, its goals, and how to get involved.
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
On Arch Linux, install the symposium package from the official repositories:
pacman -S 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 (Antigravity, Claude Code, Copilot, etc.) — you can pick more than one:
Which agents do you use? (space to select, enter to confirm):
> [ ] Antigravity CLI
[ ] Claude Code
[x] Codex CLI
[ ] GitHub Copilot
[ ] 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 syncto 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.
Most plugins come from two places: the central recommendations repository, which is enabled by default, and crates your project depends on directly that ship their own. A dependency’s plugin stays off until you enable it: an interactive cargo agents sync asks about each one, and cargo agents status lists the ones waiting for an answer. 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 crate | skills/ |
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 and enable its plugin.
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:
namemay be omitted; it defaults to the directory name.- No top-level
depends-onis 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.
Recommended git setup
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 operations on the installed plugin sources, like synchronizing their contents or validating their structure.
Supporting your crate
Working with an agent? Point it at
llms.txt, a single file written for agents with what it needs to write a Symposium plugin. Ask it to download the file withcurl -fsSL https://symposium.dev/crate-authors/llms.txtand read all of it: web-fetch tools can hand the agent a summary instead of the text.
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 and publish your crate. Users who depend on it directly are asked whether to enable its plugin when they run cargo agents sync, and then 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, several skill 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 (reaching your crate, through the user’s enablement or a chained reference, 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
- No metadata section and no
Symposium.toml: Symposium uses the defaultskills/subdirectory. - Opt out of the default group:
[package.metadata.symposium.defaults] skills = falseand declare no skill groups: no skills from this crate. source.pathgroups: look in that subdirectory of your crate’s source, in addition to the defaultskills/group unless you opt out of it.[[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. 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. When a user enables your crate, the crates it delegates to load with it.
Cycles are safe: a crate reached a second time is skipped, so A -> B -> A loads each crate once. Crate name comparison is hyphen/underscore-insensitive (my-crate and my_crate are the same crate).
Edge cases
- Malformed metadata: If
[package.metadata.symposium]orSymposium.tomldoesn’t parse as a manifest (wrong types, unknown fields), Symposium ignores that layer and still resolves the rest (at minimum the defaultskills/group). A manifest that parses but fails validation, for example a hook naming an installation that doesn’t exist, disables your crate’s whole plugin, defaultskills/group included. Both problems are reported only in Symposium’s log (~/.symposium/logs/), so try your plugin in a project before publishing. - Missing path directory: If a
source.pathgroup 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 loads once and its skills are installed once (a skill’s identity is the location of its
SKILL.md, 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 three 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.
- Subcommands: your crate’s own command-line tool, which agents and users run as
cargo agents my-crate <command>.
See Authoring a plugin for how to set one up.
Authoring a plugin
Working with an agent? Point it at
llms.txt, a single file written for agents with what it needs to write a Symposium plugin. Ask it to download the file withcurl -fsSL https://symposium.dev/crate-authors/llms.txtand read all of it: web-fetch tools can hand the agent a summary instead of the text.
Symposium lets you ship skills, hooks, and MCP servers to the AI agents of people whose projects depend on your crate. This page walks through how to create a plugin and configure each extension type.
Step 1. Decide where your plugin lives
A plugin can live in one of two places:
- In your crate. If you maintain the crate, ship the plugin in its source tree: a
skills/directory is enough, and aSymposium.tomladds hooks, MCP servers, and more. See Embedding skills in your crate. - In the central recommendations repository. This is the place for plugins for crates you don’t maintain.
A recommendations entry is a Symposium.toml manifest declaring 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: depends-on=["foo"] means that your plugin will load if the current workspace directly depends on "foo". Use ["*"] to apply to all projects.
See the plugin definition reference for the full manifest schema.
Will users get every crate from all their dependencies by default?
No, the default is that Symposium makes users away when there are suggested plugin for their workspace, but users must explicitly run cargo agents sync and then agree for those plugins to be enabled. However, once a plugin has been enabled in one workspace, it will automatically be enabled in other workspaces.
NB: Plugins in the recommendations repository are reviewed before they are merged so they do not require explicit confirmation.
Step 2. Add skills, hooks, and/or MCP servers
Wherever your plugin lives, 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: Basics of my-crate. Use when building or validating widgets with my-crate.
---
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.
Embedding skills in your crate (recommended)
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. Publish your crate
That is all Symposium needs. When a project depends directly on my-crate, an interactive cargo agents sync offers to enable its plugin, and cargo agents status lists it as a candidate until the user decides. cargo agents use my-crate enables it directly, even for a project that doesn’t depend on the crate.
Symposium reads your plugin from the published package, so make sure skills/ (and Symposium.toml, if you have one) are part of it: check with cargo package --list (add --allow-dirty if you have uncommitted changes), and add them to include if your Cargo.toml uses that field.
Prefer a directory other than skills/?
Add [package.metadata.symposium] to your crate’s Cargo.toml to declare your own skills directory. 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"
By default, Symposium looks for skills in skills/. Adding a skills section does not disable this default, it adds another source for skills in addition to the default. If you wish to disable the default, set [package.metadata.symposium.defaults] skills = false. If you only want to use the default location, you don’t even need a Symposium.toml. 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 (several skill groups, per-group predicates, a git skill source, hooks, or MCP servers), put a Symposium.toml at your crate root. When the crate’s plugin loads, whether the user enabled it or a [[plugins]] source.cargo reference reached it, 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 reaching your crate 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: lists from both are kept, and where both set the same value the Symposium.toml file wins. Use whichever is convenient.
Hooks, MCP servers, and subcommands declared in a crate Symposium.toml load just like its skills.
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 = "."
The manifest’s depends-on says which crate the skills apply to, so the skills themselves don’t need one.
A skill can also be an entry on its own: a directory in the recommendations repo holding just a SKILL.md, with no Symposium.toml. Such a skill names the crates it applies to with depends-on in its frontmatter (or a depends-on(...) predicate in predicates). Without one it loads dormant, and only users who run cargo agents use <skill-name> get it:
---
name: widgetlib-basics
description: Basics of widgetlib. Use when building or configuring widgets with widgetlib.
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 subcommands 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 installs the binary into its cache (~/.symposium/cache/) the first time a hook needs it, and checks crates.io for a newer release at the start of each agent session, unless the installation pins an exact version.
The binary must be installable with cargo install: publish it as its own crate, as above, or add a [[bin]] target to your main crate and set crate = "my-crate" with executable naming that binary.
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 agents. Hooks run on every supported agent except Goose and OpenCode, which have no shell hooks Symposium can register (they still receive skills and MCP servers).
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|bash|run_command|execute_bash)$"
command = "my-crate-hooks"
The matcher is a regular expression tested against the tool name. Agents name the same tool differently (the shell is Bash in Claude Code and Codex, bash in Copilot, run_command in Antigravity, and execute_bash in Kiro). The tool name reaches your handler exactly as the agent sent it, and the input keeps the agent’s own argument names, so match every name you care about. See Matcher for the details.
The command field references the name of an installation defined in the [[installations]] section described previously. For example:
[[installations]]
name = "my-crate-hooks"
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 on every agent that runs hooks. See Writing a hook handler for how to implement the binary using the symposium-sdk 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.
[[mcp_servers]]
name = "my-crate-tools"
command = "my-crate-mcp"
args = ["--stdio"]
env = []
command is written into the agent’s MCP configuration as is, so it must be an absolute path or a program on the user’s PATH. Unlike hooks, MCP servers don’t use [[installations]]: Symposium does not install the server, so tell your users how to install it, for example in your README (cargo install my-crate-mcp). args and env are required, even when empty.
See the plugin definition reference for HTTP and SSE transports, crate filtering, and registration details.
Subcommands
A subcommand lets agents run your crate’s own command-line tool through cargo agents, so they can find crate-aware tooling before they work on the code. By convention a crate adds one subcommand named after itself, and its tool handles everything typed after that name: cargo agents my-crate --help prints your tool’s help, and cargo agents my-crate check runs its check command.
[[installations]]
name = "my-crate-cli"
source = "cargo"
crate = "my-crate-cli"
executable = "my-crate-cli"
# `cargo agents my-crate check` runs `my-crate-cli check`
[subcommand.my-crate]
description = "Check and inspect the widgets in this workspace"
command = "my-crate-cli" # the [[installations]] entry that provides the tool
cargo agents --help lists the subcommand under “Commands for agents” with its description, and on agents that run hooks, Symposium suggests running cargo agents --help at the start of each session in a project that has one. Your tool’s output and exit code are passed through. See the plugin definition reference for the other [subcommand.<name>] fields.
Step 3. Validate your plugin
Before submitting a PR to the recommendations repo, validate your entry to catch errors early: missing fields, bad crate predicates, unreachable skill paths, and crate names that don’t exist on crates.io. Run this from the root of your local checkout once you’ve prepared your changes:
# Validate every entry in the checkout
cargo agents plugin validate .
# Skip the crates.io name check (e.g., for private crates)
cargo agents plugin validate . --no-check-crates
Pass the directory that contains your entry, not the entry’s own directory: a directory holding a Symposium.toml or SKILL.md is an entry, not a registry.
To try the entry before opening the pull request, copy its directory into ~/.symposium/plugins/ (a registry Symposium reads by default), run cargo agents sync in a project that depends on the crate, and delete the copy when you are done.
Step 4. Try it in a project
Before publishing, check what your users will get. In a scratch project that depends on your crate by path, enable the plugin (this assumes you have run cargo agents init):
cargo new try-my-crate && cd try-my-crate
cargo add my-crate --path ../my-crate
cargo agents use my-crate
cargo agents status
cargo agents use records the choice and syncs, and cargo agents status should list your plugin as active; if it doesn’t, see Edge cases. Its skills are now in each configured agent’s skills directory, for example .claude/skills/ for Claude Code.
To try a hook or subcommand before its binary is published, point the installation at your local build: drop source and crate, and set executable to the binary’s absolute path:
[[installations]]
name = "my-crate-hooks"
executable = "/path/to/my-crate-hooks/target/debug/my-crate-hooks"
Then, from the scratch project, run a subcommand as an agent would, for example cargo agents my-crate --help. To try a hook, send Symposium the payload an agent would send, using a tool call your hook reacts to (this one runs ls). This runs the same dispatch the agent triggers, so it prints what the agent would receive:
echo '{"session_id":"test","cwd":"'"$PWD"'","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' \
| cargo agents hook claude pre-tool-use
Each agent has its own payload format; see Agent details for the others. If nothing happens, rerun the command with --verbose to see which plugins and hooks Symposium considered, and check the logs in ~/.symposium/logs/.
When you are done, remove the test enablement with cargo agents use my-crate --remove. Before you publish, restore the source = "cargo" installation and publish the hook or subcommand crate first: your users’ agents install it from crates.io.
A path dependency reads your source tree, so it doesn’t catch files missing from the published package; check those with cargo package --list (see step 2 of Embedding skills in your crate).
Inside your crate’s own repository, a Symposium.toml or skills/ directory is already active as a workspace plugin ([package.metadata.symposium] is not read there), which is handy while writing skills. Its hooks and MCP servers are active there too; gate a component with predicates = ["not(workspace-member())"] to keep it for your users only.
Writing a hook handler
This guide walks through writing a symposium hook handler in Rust using the symposium-sdk crate.
Step 1. Create a new binary crate
Create your new crate. A separate crate next to your library (for example my-crate-hooks) keeps the SDK out of your library’s dependencies:
cargo new my-crate-hooks
cd my-crate-hooks
And then add symposium-sdk to your dependencies, along with anyhow, which the handler methods return:
cargo add symposium-sdk anyhow
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-sdk 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_sdk::hook::{HookHandler, PreToolUseInput, PreToolUseOutput, run};
// The shell tool's name in Claude Code and Codex, Copilot, Antigravity, and Kiro.
const SHELL_TOOLS: &[&str] = &["Bash", "bash", "run_command", "execute_bash"];
struct MyHook;
impl HookHandler for MyHook {
async fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result<PreToolUseOutput> {
if SHELL_TOOLS.contains(&event.tool_name.as_str()) {
Ok(PreToolUseOutput::context("Remember: prefer non-destructive commands"))
} else {
Ok(PreToolUseOutput::default())
}
}
}
fn main() -> ExitCode {
run(MyHook)
}
The run() function:
- Reads symposium canonical JSON from stdin.
- Deserializes it into an
Inputevent. - Calls
handler.handle_event(), which dispatches to the appropriate method. - 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.
tool_name arrives exactly as the agent sent it, and tool_input keeps the agent’s own argument names, so check every name you want to handle (Antigravity’s shell, for example, takes CommandLine rather than command); see Matcher.
Step 3. Register it in your plugin manifest
Publish the handler to crates.io, then reference it as a hook command in your plugin’s Symposium.toml:
[[hooks]]
name = "check-usage"
event = "PreToolUse"
matcher = "^(Bash|bash|run_command|execute_bash)$"
command = { source = "cargo", crate = "my-crate-hooks", executable = "my-crate-hooks" }
Symposium installs the binary (with cargo binstall or cargo install) the first time the hook runs. A manifest shipped in your crate needs nothing else; a recommendations-repository entry also sets name and depends-on (see Authoring a plugin).
Output types
Each handler method returns its event-specific output type:
| Method | Return type | Key fields |
|---|---|---|
pre_tool_use | PreToolUseOutput | decision, additional_context, updated_input |
post_tool_use | PostToolUseOutput | additional_context |
user_prompt_submit | UserPromptSubmitOutput | additional_context |
session_start | SessionStartOutput | additional_context |
stop | StopOutput | additional_context |
Each output type has convenience constructors:
::default(): empty output, no-op.::context("..."): inject text into the agent’s context. OnStop, agents treat this output differently; seeStopoutput.PreToolUseOutput::with_updated_input(value): replace the tool input. Not every agent can apply a rewrite; see how each agent receives the decision.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 {
async fn handle_event(&self, input: &Input) -> anyhow::Result<Output> { /* dispatches */ }
async fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result<PreToolUseOutput> { /* default */ }
async fn post_tool_use(&self, event: &PostToolUseInput) -> anyhow::Result<PostToolUseOutput> { /* default */ }
async fn user_prompt_submit(&self, event: &UserPromptSubmitInput) -> anyhow::Result<UserPromptSubmitOutput> { /* default */ }
async fn session_start(&self, event: &SessionStartInput) -> anyhow::Result<SessionStartOutput> { /* default */ }
async fn stop(&self, event: &StopInput) -> anyhow::Result<StopOutput> { /* 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-crate-hooks
To run it through Symposium the way an agent does, see Try it in a project.
Example: blocking destructive commands
This example reads Claude Code’s Bash tool; to cover other agents, check their tool names as in Step 2 and read Antigravity’s CommandLine as well.
//! A `PreToolUse` handler that refuses destructive shell commands.
//!
//! ```bash
//! echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":null,"cwd":"/tmp"}}' \
//! | cargo run --example block_destructive
//! ```
use std::process::ExitCode;
use symposium_sdk::hook::{HookHandler, PreToolUseInput, PreToolUseOutput, run};
/// Fragments that are never worth running, whatever the surrounding command.
const DESTRUCTIVE: &[&str] = &["rm -rf /", "mkfs", "dd if=", ":(){ :|:& };:"];
struct BlockDestructive;
impl HookHandler for BlockDestructive {
async fn pre_tool_use(&self, event: &PreToolUseInput) -> anyhow::Result<PreToolUseOutput> {
if event.tool_name != "Bash" {
return Ok(PreToolUseOutput::default());
}
let command = event
.tool_input
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default();
match DESTRUCTIVE.iter().find(|needle| command.contains(**needle)) {
Some(needle) => Ok(PreToolUseOutput::deny(format!(
"refusing a command containing `{needle}`"
))),
None => Ok(PreToolUseOutput::default()),
}
}
}
fn main() -> ExitCode {
run(BlockDestructive)
}
Example: injecting context on session start
//! A `SessionStart` handler that puts a project's notes in front of the agent.
//!
//! ```bash
//! echo '{"SessionStart":{"session_id":null,"cwd":"/path/to/project"}}' \
//! | cargo run --example inject_context
//! ```
use std::path::Path;
use std::process::ExitCode;
use symposium_sdk::hook::{HookHandler, SessionStartInput, SessionStartOutput, run};
struct InjectContext;
impl HookHandler for InjectContext {
async fn session_start(&self, event: &SessionStartInput) -> anyhow::Result<SessionStartOutput> {
let Some(cwd) = event.cwd.as_deref() else {
return Ok(SessionStartOutput::default());
};
// A missing file is the ordinary case, not an error: most sessions
// start somewhere with no notes to offer.
match std::fs::read_to_string(Path::new(cwd).join("NOTES.md")) {
Ok(notes) => Ok(SessionStartOutput::context(notes)),
Err(_) => Ok(SessionStartOutput::default()),
}
}
}
fn main() -> ExitCode {
run(InjectContext)
}
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
| Command | Description |
|---|---|
cargo agents init | Set up user-wide configuration |
cargo agents sync | Synchronize skills with workspace dependencies |
cargo agents search | Search configured registries for plugins |
cargo agents use | Enable a plugin by name (--remove to disable) |
cargo agents status | Show which plugins are enabled for this workspace, and why |
cargo agents plugin | Manage plugin sources |
cargo agents self-update | Update symposium to the latest version |
cargo agents crate-info | Find crate sources (agent-facing) |
Global options
| Flag | Description |
|---|---|
-v, --verbose | Print detailed decision trace (which plugins matched, which skills were considered, etc.) |
--json | Output 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, --quiet | Suppress status output |
--help | Print help |
--version | Print 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., Antigravity CLI, Claude Code, Copilot) 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
| Flag | Description |
|---|---|
--add-agent <name> | Add an agent (e.g., antigravity, claude, copilot). 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 antigravity
Adding an agent to an existing config:
cargo agents init --add-agent copilot
Removing an agent:
cargo agents init --remove-agent antigravity
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:
-
Find workspace root: runs
cargo metadatato locate the workspace. -
Scan dependencies: reads the full dependency graph from the workspace.
-
Discover applicable skills: loads plugin sources (from user config) and matches skill predicates against workspace dependencies.
-
Install skills: for each configured agent, copies applicable
SKILL.mdfiles into the agent’s expected skill directory (e.g.,.claude/skills/for Claude Code,.agents/skills/for Antigravity/Copilot/Codex). A.gitignorecontaining*is written into every new skill directory, and an empty.symposiummarker file is dropped into each installed skill directory. -
Mirror workspace skills: if
agents-syncingis 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. -
Clean up stale skills: scans every agent’s skills parent directory and removes any subdirectory containing the
.symposiummarker that wasn’t installed (or propagated) this sync. Directories without the marker (user-managed) are left untouched. If a registry cannot be read (apaththat is missing, unreadable or itself a plugin, or agitregistry that has not been fetched yet), sync warns, naming the registry, and skips this step: nothing is removed until it can be read again. -
Register hooks: ensures hooks and MCP servers are registered for all configured agents, at the scope set by
hook-scope: the user’s home directory for"global"(the default), the project for"project"(MCP servers fall back to the user level for agents without a project-level file). Unregisters hooks for agents no longer in the config. An agent config file symposium cannot parse is left untouched: sync names it in a warning and carries on with the skills and the other agents, whilecargo agents initstops with that error.
Consent prompt
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. Instead, your agent is told
so it can point you here: pending candidates are named when a session starts,
and when your dependencies change mid-session the agent hears that there may be
new ones. 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
| Flag | Description |
|---|---|
<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:
- Already loaded — plugin names in the plugin registry (a bare
SKILL.mdis loaded as a plugin, so it appears here too). A configured registry is a trust root, so a hit here is available now, with nouseneeded (unless the plugin is dormant, which is noted). - 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
| Flag | Description |
|---|---|
<NAME> | Plugin or crate name to enable |
--global | Enable for every workspace instead of just the current one |
--remove | Drop 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.
useis the by-name way to say so ([plugins] auto-enableis 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
useentry 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:
| State | Meaning |
|---|---|
active | Enabled and its predicates hold here. The root names the trust root: workspace membership, a configured registry, [plugins] auto-enable, or a [plugins] use entry. |
dormant | Loaded but contributing nothing: a registry plugin awaiting cargo agents use, or one whose predicates don’t currently hold. |
candidate | Discovered in a dependency and awaiting consent. These are exactly what an interactive cargo agents sync asks about. |
declined | Recorded 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
-
Check for updates — runs
cargo searchagainst the configured registry to find the latest published version of symposium. If the installed version is already current, prints a message and exits. -
Install — runs
cargo install symposium --forceto 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
| Value | Behavior |
|---|---|
"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 a plugin: its id and its effective configuration as TOML, after validation has filled in defaults and promoted inline installations.
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.
| Flag | Description |
|---|---|
<PATH> | Path to a directory or a single .toml file |
--no-check-crates | Skip 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
| Flag | Description |
|---|---|
<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:
-
Auto-sync (if enabled) — when
auto-sync = truein the user config, runscargo agents syncto ensure skills are current for the workspace. The workspace root is resolved from the hook payload’scwdfield; 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. -
Dispatches to plugin hooks — runs any hook handlers defined by plugins for the given event.
Output
Stdout carries only the response for your agent, in its own format. Sync progress goes to the log file, and whatever a plugin’s install_commands print goes to stderr. To see the progress when running a hook by hand, pass -v: cargo agents -v hook <AGENT> <EVENT> prints it to stderr.
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.
Antigravity CLI
Config name: antigravity
Google’s Antigravity CLI, invoked as agy. Antigravity ships three surfaces —
the CLI, the IDE and the web app — which share the same configuration roots, so
what symposium writes applies to all of them.
Skills
| Scope | Path |
|---|---|
| Project | .agents/skills/<name>/SKILL.md |
| Global | ~/.gemini/config/skills/<name>/SKILL.md |
Hooks
Symposium merges a single named entry, symposium, into Antigravity’s
hooks.json. Other entries in the file are left alone, and only symposium’s own
is removed when the agent is unconfigured.
| Scope | File |
|---|---|
| Project | .agents/hooks.json |
| Global | ~/.gemini/config/hooks.json |
Events registered: PreToolUse, PostToolUse, PreInvocation, SessionStart,
Stop.
Output format: JSON. Timeouts are in seconds (30 by default). Exit codes are
ignored — only what a hook writes to stdout affects the agent. Context reaches the
model at session start and on the first PreInvocation of a turn; after a tool
call it does not (PostToolUse output carries nothing back), and on PreToolUse
only a denial’s reason is shown.
Caveat: PreInvocation stands in for symposium’s user-prompt-submit
because Antigravity has no prompt event. It fires before every model call, so
symposium runs the prompt event only on the first invocation of a turn.
Caveat: in headless print mode (agy -p), Antigravity adopts no workspace
unless it is given --add-dir <absolute path> — it cannot read project files and
loads no project .agents/ configuration. A relative path is ignored. Ordinary
interactive use is unaffected; this only matters for automation and CI.
MCP servers
| Scope | File | Key |
|---|---|---|
| Project | .agents/mcp_config.json | mcpServers.<name> |
| Global | ~/.gemini/config/mcp_config.json | mcpServers.<name> |
Unlike Gemini CLI, MCP configuration lives in its own file rather than sharing
one with hooks. agy mcp add has no scope flag and writes the global file.
Claude Code
Config name: claude
Skills
| Scope | Path |
|---|---|
| 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.
| Scope | File |
|---|---|
| Project | .claude/settings.json |
| Global | ~/.claude/settings.json ($CLAUDE_CONFIG_DIR/settings.json when set) |
Events registered: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop (PascalCase).
Output format: JSON with hookSpecificOutput wrapper. Exit code 2 blocks tool use.
MCP servers
| Scope | File | Key |
|---|---|---|
| Project | .mcp.json | mcpServers.<name> |
| Global | ~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json when set) | mcpServers.<name> |
MCP servers do not go in settings.json, which holds only the hooks.
GitHub Copilot
Config name: copilot
Skills
| Scope | Path |
|---|---|
| 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 hooks key of the global settings.
| Scope | File |
|---|---|
| Project | .github/hooks/symposium.json |
| Global | ~/.copilot/settings.json ($COPILOT_HOME/settings.json when set) |
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).
Caveat: Copilot loads a project’s hooks only once you trust the folder (it asks the first time you open it).
Caveat: Copilot also runs the hooks in .claude/settings.json. In a project where Claude Code is configured too, each Copilot event runs cargo agents hook twice, once as each agent.
MCP servers
| Scope | File | Key |
|---|---|---|
| Global | ~/.copilot/mcp-config.json ($COPILOT_HOME/mcp-config.json when set) | mcpServers.<name> |
Copilot reads no project-level MCP file, so a project-scoped registration goes to the global file.
Codex CLI
Config name: codex
Skills
| Scope | Path |
|---|---|
| Project | .agents/skills/<name>/SKILL.md |
| Global | ~/.agents/skills/<name>/SKILL.md |
Hooks
Symposium merges hook entries into Codex’s hooks.json.
| Scope | File |
|---|---|
| Project | .codex/hooks.json |
| Global | ~/.codex/hooks.json ($CODEX_HOME/hooks.json when set) |
Events registered: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart (PascalCase).
Output format: JSON. Exit code 2 blocks tool use.
Caveat: Codex runs a hook only after you trust it. Until then it skips the hook, warning at startup; open /hooks in Codex to review and trust symposium’s entries. A changed entry needs trusting again. Project hooks also require the project itself to be trusted.
MCP servers
| Scope | File | Key |
|---|---|---|
| Global | ~/.codex/config.toml ($CODEX_HOME/config.toml when set) | [mcp_servers.<name>] |
Codex reads no project-level MCP file, so a project-scoped registration goes to the global file.
Kiro
Config name: kiro
Skills
| Scope | Path |
|---|---|
| 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.
| Scope | File |
|---|---|
| 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/Codex. Unregistration deletes the symposium.json file entirely.
MCP servers
| Scope | File | Key |
|---|---|---|
| Project | .kiro/settings/mcp.json | mcpServers.<name> |
| Global | ~/.kiro/settings/mcp.json | mcpServers.<name> |
OpenCode
Config name: opencode
Skills
| Scope | Path |
|---|---|
| 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
| Scope | File | Key |
|---|---|---|
| Project | opencode.json | mcp.<name> |
| Global | ~/.config/opencode/opencode.json | mcp.<name> |
Goose
Config name: goose
Skills
| Scope | Path |
|---|---|
| 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
| Scope | File | Key |
|---|---|---|
| Global | ~/.config/goose/config.yaml ($GOOSE_PATH_ROOT/config/config.yaml when set) | extensions.<name> |
Goose reads no project-level MCP file, so a project-scoped registration goes to the global file.
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 = "antigravity"
[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
| Key | Type | Default | Description |
|---|---|---|---|
auto-sync | bool | true | Automatically run cargo agents sync during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention. |
agents-syncing | bool | true | Include 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-scope | string | "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-update | string | "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 Antigravity, Copilot, 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.
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Agent name: antigravity, claude, codex, copilot, goose, kiro, or opencode. |
[logging]
| Key | Type | Default | Description |
|---|---|---|---|
level | string | "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.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Record 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.
| Key | Type | Default | Description |
|---|---|---|---|
symposium-recommendations | bool | true | Fetch plugins from the symposium-dev/recommendations repository. |
user-plugins | bool | true | Scan ~/.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.
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | A name for this registry. Used in logs and cache paths, and to attribute the plugins loaded from it. |
git | string | — | Repository URL. Fetched and cached under ~/.symposium/cache/plugin-sources/, then read as a local directory. |
path | string | — | Local directory containing plugins. Relative paths are resolved from ~/.symposium/. |
auto-update | bool | true | Check 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.
| Key | Type | Default | Description |
|---|---|---|---|
auto-enable | array of strings | [] | Dependency names whose embedded plugins load without being asked about. "*" pre-consents to every dependency. |
use | array | [] | 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). |
disable | array 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:
| Config | Cache | Logs | |
|---|---|---|---|
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
| Path | Purpose |
|---|---|
~/.symposium/config.toml | User configuration |
~/.symposium/state.toml | Persistent 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, configured as a [[registry]] entry and called a registry in the rest of these docs, is a local directory or a GitHub repository containing plugins and standalone skills that Symposium discovers automatically. Symposium searches it recursively to find all available extensions.
Discovery rules
Symposium scans a registry recursively to find plugins and standalone skills:
- A plugin is a directory that contains a
Symposium.tomlfile. A registry plugin gets no defaultskills/group: its skills load only through the[[skills]]groups its manifest declares (for examplesource.path = "skills"). - A directory that contains a
SKILL.mdand noSymposium.tomlis loaded as a plugin with default values: named for the skill’s frontmattername(required, as for every registry skill), with the skill’s frontmatterdepends-onandpredicatesacting as the plugin’s activation gate.depends-onis optional: a skill that names no dependency loads dormant (it activates once you enable it by name withcargo agents use), exactly like a gatelessSymposium.tomlplugin.
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. The registry root itself must not be an entry: a root that contains a Symposium.toml or a SKILL.md is an error (cargo agents plugin validate fails, and a configured registry loads nothing). Put each plugin or skill in a subdirectory instead.
Example structure
plugin-source/
my-plugin/
Symposium.toml # plugin
skills/ # not a separate entry (loaded through my-plugin's [[skills]] groups)
basic/
SKILL.md
serde-skill/
SKILL.md # standalone skill
nested/
deep/
tokio-skill/
SKILL.md # standalone skill (found recursively)
mixed/
Symposium.toml # plugin
SKILL.md # ignored (the plugin takes precedence)
Configuration
Registries are configured in your config.toml file. See the Configuration reference for details on setting up local directories, GitHub repositories, and built-in sources.
Validation
You can validate a registry directory:
# Validate all plugins and skills in a directory, checking that crate names exist on crates.io
cargo agents plugin validate path/to/plugin-source/
# Same, but skip the crates.io check
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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | registry manifests only | Plugin name. Used in logs and CLI output. Required in a registry manifest. A crate manifest defaults it to the crate name, and a workspace manifest to the name of its directory. |
depends-on | string or array | no | Which crates this plugin applies to. Use ["*"] for all crates. See Plugin-level filtering. |
predicates | array of strings | no | Predicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the plugin to apply. See Predicates. |
installations | array of tables | no | Named installation declarations ([[installations]]). Hooks, subcommands, and custom predicates reference these by name. See Installations. |
skills | array of tables | no | Skill groups ([[skills]]). See [[skills]] groups. |
hooks | array of tables | no | Hooks ([[hooks]]). See [[hooks]]. |
predicate | array of tables | no | Custom predicate definitions ([[predicate]]). See Custom predicates. |
mcp_servers | array of tables | no | MCP server registrations ([[mcp_servers]]). See [[mcp_servers]]. |
plugins | array of tables | no | Chained plugin references ([[plugins]]): other plugins that load whenever this one is active. See Chained plugins. |
subcommand | table of tables | no | Commands added to cargo agents ([subcommand.<name>]). See Subcommands. |
defaults | table | no | [defaults] skills = <bool> (default true): whether to append the default skill groups: source.path = "skills", plus the .agents/skills group in a workspace manifest. Accepted only in crate and workspace manifests; a registry manifest that contains [defaults] is rejected. |
A registry plugin gets no default skill group: its skills load only through the [[skills]] groups it declares, so a registry plugin that ships a skills/ directory needs [[skills]] source.path = "skills". Crate and workspace plugins get the default skills/ group unless [defaults] skills = false (workspace plugins also get a .agents/skills group by default, see Workspace skills).
Note: A registry plugin that references no dependency anywhere is dormant: it loads, but it never activates until the user enables it by name in the [plugins] use config (for example with cargo agents use). Use depends-on = ["*"] for a plugin that should always be active. These count as referencing a dependency:
- a
depends-onfield, or adepends-on(...)predicate in apredicateslist, on the plugin itself, a[[skills]]group, an[[mcp_servers]]entry, or a[[plugins]]edge; - a
depends-on(...)predicate in a[[hooks]]entry’spredicates; - a custom predicate used directly in the plugin-level
predicates.
A depends-on(...) predicate counts wherever it sits in the list, including depends-on(*) and one nested inside not, any, or all.
These do not count: the crate named by a [[plugins]] edge’s source.cargo, a [subcommand.<name>]’s depends-on or predicates, and the frontmatter of a SKILL.md inside a [[skills]] group. Crate and workspace plugins never go dormant: what loaded the crate (a chained reference or the user’s enablement), or membership in the active workspace, is their 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
Use the wildcard depends-on = ["*"] instead for a plugin that always applies.
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.
| Field | Type | Description |
|---|---|---|
depends-on | string or array | Which crates this group advises on. Accepts a single string ("serde") or array (["serde", "tokio>=1.0"]). See Dependency predicates for syntax. |
predicates | array of strings | Predicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the group to install. See Predicates. |
source.path | string | Local directory containing skill subdirectories. Resolved relative to the manifest file. |
source.git | string | GitHub 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 not 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). The referenced crate loads without a consent step of its own: the plugin that names it vouches for it (see Configuration).
| Field | Type | Description |
|---|---|---|
source.cargo | string or table | The crate carrying the plugin. A dependency-atom string ("serde", "serde>=1") or a { name = "...", version = "..." } table. |
depends-on | string or array | Gate for this edge — the referenced plugin loads only when these hold (in addition to the owning plugin’s own gate). |
predicates | array of strings | Additional 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. List several [[plugins]] entries to load several crates.
Only
source.cargois supported today;source.git/source.pathchained 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: [[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). A merged manifest that parses but fails validation (for example a hook naming an unknown installation) disables the crate’s whole plugin, default skills/ group included.
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:
fooships nothing → itsskills/directory loads.foodeclares[[skills]] source.path = "guidance"→ bothguidance/andskills/load (combined with defaults).foodeclares[defaults] skills = falseplus a custom group → only the custom group loads.foodeclares[defaults] skills = falseand nothing else → nothing loads.foocarries its own[[plugins]] source.cargo = "bar"→barresolves the same way, recursively.
Everything a crate manifest declares loads like a registry plugin’s: skills, hooks, MCP servers, subcommands, custom predicates, and further [[plugins]] references.
Delegating to another crate
A crate can delegate to another crate with a [[plugins]] chained reference of its own:
# In the referenced crate's Cargo.toml (in its Symposium.toml, write the table as [[plugins]])
[[package.metadata.symposium.plugins]]
source.cargo = "companion-crate"
Chained references are followed until no new crate is reached; there is no depth limit. A crate reached through several references loads only once (crates are identified by name, hyphen/underscore-insensitively), which also makes cycles harmless, and a SKILL.md reached two ways installs once, since a skill is identified by the path of its SKILL.md. See Supporting your crate for the full crate-author walkthrough.
Installations
An installation describes how to obtain (and optionally pre-configure) something a hook, subcommand, or custom predicate will run. They reference an installation as their command, either by name (command = "mytool") or inline at the use site (command = { script = "scripts/x.sh" }).
A [[installations]] entry has a name plus any of:
| Field | Type | Description |
|---|---|---|
source | string | Optional. How to acquire bits onto disk: cargo or github. Each source takes its own fields, listed under Installation sources. When omitted, no acquisition step runs. |
install_commands | array of strings | Optional. 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. |
requirements | array | Optional. Other installations to acquire whenever this one is referenced. Strings name [[installations]] entries; tables are inline declarations. |
executable | string | Optional. Path to a binary to run. For cargo, the binary name (looked up in the install’s bin/ dir). For github, a path inside the acquired tree. With no source, a path on disk, relative to the directory of the manifest that declares it. |
script | string | Optional. Same resolution rules as executable, but invoked as sh <path> <args>. |
args | array of strings | Optional. 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
commandor as a requirement entry) accept the same fields, includingrequirements.
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
| Field | Required | Description |
|---|---|---|
crate | yes | Name of the crate to install. |
version | no | Exact version to install ("13.0.0"). Defaults to the latest stable release on crates.io. |
git | no | Install from this git repository (cargo install --git) instead of crates.io. Requires executable on the installation. |
global | no | true installs into the user’s global cargo location instead of Symposium’s cache (see below). Requires executable on the installation. |
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. A global = true installation must set executable.
[[installations]]
name = "rg"
source = "cargo"
crate = "ripgrep"
executable = "rg"
global = true
github
[[installations]]
name = "mytool-hooks"
source = "github"
url = "https://github.com/example/mytool-hooks"
script = "hooks/claude/mytool-rewrite.sh" # optional; see below
args = ["--format"]
| Field | Required | Description |
|---|---|---|
url | yes | https://github.com/<owner>/<repo>, or https://github.com/<owner>/<repo>/tree/<ref>/<path> for one directory of the repository. git is accepted as another name for this field. |
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.
| Field | Type | Description |
|---|---|---|
name | string | Descriptive name for the hook (used in logs). |
event | string | Event type to match: one of the supported hook events (e.g., PreToolUse). |
matcher | string (optional) | Regex over the tool name, as the agent names it (e.g., Bash in Claude Code, bash in Copilot; see Matcher). The regex is unanchored, so Bash also matches BashOutput; anchor it (^Bash$) to match one tool. Omit to match all. |
command | string or table | What to run. A string names a [[installations]] entry; a table is an inline installation (promoted to a synthetic entry named after the hook). |
executable | string (optional) | Path to a binary inside (or relative to) the installation. At most one of executable/script set across hook + installation. |
script | string (optional) | Path to a shell script to run via sh. Same exclusivity rule as executable. |
args | array (optional) | Invocation arguments. Forbidden when the installation also declares args. |
requirements | array (optional) | Installations to acquire before running. Same shape as command (string name or inline declaration). |
agent | string (optional) | Restrict the hook to a specific agent (antigravity, claude, copilot, kiro, …). It does not change the input; format still decides that (canonical JSON by default). |
format | string | Wire format the handler expects on stdin. symposium (default): symposium converts the agent’s event to its canonical format before delivering. Any agent name (antigravity, claude, codex, copilot, kiro): the hook fires only when Symposium runs for that agent, and receives that agent’s native input unchanged; on every other agent it does not fire, and nothing is converted. Pair it with a format = "symposium" hook to cover the other agents (see Agent-specific hooks). Symposium always intermediates: it never registers plugin hooks directly into agent configs. See Hooks. |
predicates | array (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. A hook has no depends-on field: gate it on a dependency with predicates = ["depends-on(serde)"]. 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 a tool as a side requirement and run a hook script from a separate github source:
[[installations]]
name = "mytool"
source = "cargo"
crate = "mytool"
[[installations]]
name = "mytool-hooks"
source = "github"
url = "https://github.com/example/mytool-hooks"
[[hooks]]
name = "rewrite"
event = "PreToolUse"
requirements = ["mytool"]
command = "mytool-hooks"
script = "hooks/claude/mytool-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 = "mytool"
source = "cargo"
crate = "mytool"
install_commands = [
"ln -sf $HOME/.symposium/cache/binaries/mytool/*/bin/mytool $HOME/.local/bin/mytool",
]
[[hooks]]
name = "mytool-rewrite"
event = "PreToolUse"
command = "mytool"
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, but an agent-specific hook fires only when Symposium runs for that agent, and receives that agent’s input unchanged. On every other agent it does not fire, and nothing is converted. To cover the other agents, pair it with a format = "symposium" hook.
A plugin with a Claude-specific hook and a symposium fallback:
[[installations]]
name = "my-hook-binary"
source = "cargo"
crate = "my-hook-binary"
[[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).
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).
[[installations]]
name = "mytool"
source = "cargo"
crate = "mytool"
[[hooks]]
name = "uses-mytool-via-script"
event = "PreToolUse"
requirements = ["mytool", { source = "cargo", crate = "ripgrep" }]
command = { script = "scripts/uses-mytool.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 = "mytool"
source = "cargo"
crate = "mytool"
[[installations]]
name = "mytool-hooks"
source = "github"
url = "https://github.com/example/mytool-hooks"
requirements = ["mytool"] # mytool gets installed whenever mytool-hooks is used
[[hooks]]
name = "rewrite"
event = "PreToolUse"
command = "mytool-hooks"
script = "hooks/claude/mytool-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:
| Variable | When set | Value |
|---|---|---|
$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 path | Absolute path to the resolved executable / script. |
$PATH | One or more dependencies contribute a runnable with an absolute path | Each 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. mytool-hooks -> SYMPOSIUM_DIR_mytool_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_commandsruns before env vars are set. The$SYMPOSIUM_*vars and the augmented$PATHare only available to the hook’s spawned process.install_commandsruns earlier, inside the symposium dispatch process, so it cannot reference its own (or any other) installation’s env vars. Use absolute paths ininstall_commandsinstead.
Supported hook events
| Hook event | Description | CLI usage |
|---|---|---|
PreToolUse | Before a tool (e.g., Bash) is invoked by the agent. | pre-tool-use |
PostToolUse | After a tool completes. | post-tool-use |
UserPromptSubmit | When the user submits a prompt. | user-prompt-submit |
SessionStart | When an agent session starts. | session-start |
Stop | When the agent’s turn ends. | stop |
Not every agent delivers every event, and agents name them differently. See Agent support for which events each agent delivers.
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): block. The action is blocked, dispatch stops, and the hook’s stderr becomes the reason the agent is given. See exit codes for how each agent receives the block.- 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. The exception is
additionalContextonSessionStart,UserPromptSubmitandPostToolUse: symposium’s own context and every hook’s are joined, in order, so none replaces another. -
Stderr handling: If a hook exits with code
2(or no exit code), dispatch returns immediately with the hook’s stderr as the block reason. Otherwise stderr is captured but not returned on success.
Testing hooks
To exercise a hook end to end, pipe a real agent payload into cargo agents hook <agent> <event>, using the event’s CLI name from the table above. For Claude Code:
echo '{"session_id":"s","cwd":"/path/to/project","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test"}}' \
| cargo agents hook claude pre-tool-use
This runs the full dispatch (including the automatic sync, when auto-sync is on) for the workspace at cwd, so the plugin must be installed and active there; the output is in the agent’s own format. The payload must be the named agent’s native JSON: you can also use antigravity, copilot, codex, or kiro as the agent name, each with that agent’s payload. To test a symposium-format handler on its own, pipe canonical JSON straight into it (see Testing).
[[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.
| Field | Type | Description |
|---|---|---|
name | string | The 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). |
command | string or table | The installation to run. Same shape as hook command (a string naming a [[installations]] entry or an inline table). |
args | array of strings | Optional. 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 if the command exits 0. Its stdout does not affect the result; it may carry watch events that control caching.
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
Symposium caches a custom predicate’s result on disk, per workspace (under ~/.symposium/cache/predicates/), keyed by the call as written (battery_pack(cli>=0.3)). Later syncs reuse the cached result without spawning the command until one of the inputs the predicate reported changes. Within one run, the same call is spawned at most once.
The predicate reports its inputs by printing watch events on stdout as JSON Lines, one object per line:
{"watchFile":"/path/to/project/Config"}
{"watchEnv":"LAMBDA_ENV"}
{"watchTime":60000}
| Event | The cached result is discarded when |
|---|---|
{"watchFile":"<path>"} | The file’s size or modification time changes, or the file appears or disappears. Prefer an absolute path: a relative one resolves against the directory Symposium runs in. |
{"watchEnv":"<NAME>"} | The variable’s value changes, or it becomes set or unset. |
{"watchTime":<milliseconds>} | That much time has passed. The shortest watchTime wins, and {"watchTime":0} turns caching off for the call. |
The events from one run are combined, and a line that is not a known event is skipped with a warning. A predicate that exits non-zero is cached the same way as one that exits 0. A predicate that prints no events is cached forever, so report every input the result depends on, or print {"watchTime":0}. The cache is also discarded when a Symposium upgrade changes its format; delete ~/.symposium/cache/predicates/ to force every predicate to run again.
In Rust, the symposium-sdk crate prints these events for you: symposium_sdk::env::var and symposium_sdk::fs::read_to_string read a value and report it as an input, and symposium_sdk::predicate::PredicateEmitter writes events directly (watch_file, watch_env, watch_time).
[[mcp_servers]]
Each [[mcp_servers]] entry declares an MCP server that Symposium registers into the agent’s configuration during cargo agents sync (and the automatic sync that hooks run).
Symposium does not install MCP servers: unlike hooks and subcommands, they do not use [[installations]]. A stdio server’s command is written into the agent’s MCP configuration verbatim, so it must be an absolute path, or a command name the agent can find on the user’s PATH.
There are multiple MCP transports:
Stdio
[[mcp_servers]]
name = "my-server"
command = "/usr/local/bin/my-server"
args = ["--stdio"]
env = [{ name = "RUST_LOG", value = "info" }]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Server name as it appears in the agent’s MCP config. |
command | string | yes | Absolute path to the server binary, or a command name on the user’s PATH. Written into the agent’s MCP config verbatim; Symposium does not install it. |
args | array of strings | yes | Arguments passed to the binary. Use [] when there are none. |
env | array of tables | yes | Environment variables to set when launching the server, each { name = "...", value = "..." }. Use [] when there are none. |
depends-on | string or array | no | Which crates this server applies to. ANDed with the plugin’s gate. |
predicates | array of strings | no | Predicates (depends-on, shell, path_exists, env, workspace-member, not, any, all) that must all hold for the server to register. See Predicates. |
Stdio entries do not need a type field.
HTTP
[[mcp_servers]]
type = "http"
name = "my-server"
url = "http://localhost:8080/mcp"
headers = []
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Must be "http". |
name | string | yes | Server name as it appears in the agent’s MCP config. |
url | string | yes | HTTP endpoint URL. |
headers | array of tables | yes | HTTP headers to set when making requests, each { name = "...", value = "..." }. Use [] when there are none. |
depends-on | string or array | no | Which crates this server applies to. ANDed with the plugin’s gate. |
predicates | array of strings | no | Predicates that must all hold for the server to register. See Predicates. |
SSE
[[mcp_servers]]
type = "sse"
name = "my-server"
url = "http://localhost:8080/sse"
headers = []
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Must be "sse". |
name | string | yes | Server name as it appears in the agent’s MCP config. |
url | string | yes | SSE endpoint URL. |
headers | array of tables | yes | HTTP headers to set when making requests, each { name = "...", value = "..." }. Use [] when there are none. |
depends-on | string or array | no | Which crates this server applies to. ANDed with the plugin’s gate. |
predicates | array of strings | no | Predicates that must all hold for the server to register. See Predicates. |
How registration works
During cargo agents sync, each MCP server entry is written into each configured 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:
- Collects
[[mcp_servers]]entries from all enabled plugins. - 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.
| Agent | Config location (project scope) | Key |
|---|---|---|
| Antigravity | .agents/mcp_config.json | mcpServers.<name> |
| Claude Code | .mcp.json | mcpServers.<name> |
| GitHub Copilot | ~/.copilot/mcp-config.json (no project file) | mcpServers.<name> |
| Codex CLI | ~/.codex/config.toml (no project file) | [mcp_servers.<name>] |
| Kiro | .kiro/settings/mcp.json | mcpServers.<name> |
| OpenCode | opencode.json | mcp.<name> |
| Goose | ~/.config/goose/config.yaml (no project file) | extensions.<name> |
Servers are registered at the scope chosen by the hook-scope setting (see Configuration): with "project", in the files above; with "global" (the default), in each agent’s user-level configuration. Agents without a project-level file always get the user-level one. See each agent’s page under Supported agents for the global locations.
[subcommand.<name>]
A [subcommand.<name>] table adds a command that users and agents run as cargo agents <name>. By convention, <name> is the crate’s name, so an agent can run cargo agents <crate> --help to discover the crate’s own CLI. Symposium resolves command through the same installation pipeline hooks use, runs it with the installation’s args followed by the arguments typed after <name>, passes its output through, and exits with its exit code. The command owns its own arguments, validation, and --help.
# A recommendations entry; in a crate's own Symposium.toml, leave out name and depends-on.
name = "widgetlib"
depends-on = ["widgetlib"]
[[installations]]
name = "widgetlib-cli"
source = "cargo"
crate = "widgetlib-cli"
executable = "widgetlib-cli"
# `cargo agents widgetlib check --strict` runs `widgetlib-cli check --strict`
[subcommand.widgetlib]
description = "Check and inspect the widget definitions in this workspace"
audience = "agents"
command = "widgetlib-cli"
| Field | Type | Required | Description |
|---|---|---|---|
description | string | yes | Shown next to the command in cargo agents --help. At most 1024 bytes. |
audience | string | no | "agents" (default) or "humans". Only chooses the cargo agents --help section the command is listed under (“Commands for agents” or “Commands for humans”); it does not restrict who can run it. |
command | string or table | yes | What to run. A string names an [[installations]] entry; a table is an inline installation (promoted to a synthetic entry named after the subcommand). A subcommand has no executable, script, or args fields of its own: set them on the installation. |
depends-on | string or array | no | Which crates this subcommand applies to. ANDed with the plugin’s gate. |
predicates | array of strings | no | Predicates that must all hold for the subcommand to be available. See Predicates. |
<name> is the word typed after cargo agents. It may contain only ASCII letters, digits, -, and _, and it must not be the name of a built-in command: init, sync, search, use, status, hook, plugin, self-update, crate-info, telemetry, or help.
A subcommand is listed in cargo agents --help, and can be run, only while its plugin is active and its own depends-on and predicates hold. If two active plugins define the same name, running it fails with an error naming both plugins. A subcommand’s gate does not keep its plugin out of dormancy.
Example: full manifest
# A recommendations entry; in a crate's own Symposium.toml, leave out name and depends-on.
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 (the recommendations repository does not accept git sources)
[[skills]]
depends-on = ["widgetlib^1.0"]
source.git = "https://github.com/org/widgetlib/tree/main/symposium/skills"
[[hooks]]
name = "check-widget-usage"
event = "PreToolUse"
matcher = "^(Bash|bash|run_command|execute_bash)$"
command = { script = "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. The file must be named Symposium.toml for Symposium to discover it; to validate every entry in a registry directory, pass the directory instead (see Plugin sources). 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
| Event | Description |
|---|---|
PreToolUse | Before the agent invokes a tool. Can inject context, modify the tool input, or block the call. |
PostToolUse | After a tool completes. Can inject context. |
UserPromptSubmit | When the user submits a prompt. Can inject context. |
SessionStart | When an agent session begins. Can inject context. |
Stop | When the agent’s turn ends. |
Agent support
Symposium registers one handler per event with each agent, under the agent’s own name for that event. A “not delivered” cell means Symposium registers no handler for that event on that agent. The table gives each agent’s native event name; the name in parentheses is the agent’s name in a hook’s format and in cargo agents hook <agent>.
| Agent | PreToolUse | PostToolUse | UserPromptSubmit | SessionStart | Stop |
|---|---|---|---|---|---|
Claude Code (claude) | PreToolUse | PostToolUse | UserPromptSubmit | SessionStart | Stop |
Antigravity CLI (antigravity) | PreToolUse | PostToolUse | PreInvocation (first invocation of each turn only) | SessionStart | Stop |
Codex CLI (codex) | PreToolUse | PostToolUse | UserPromptSubmit | SessionStart | not delivered |
GitHub Copilot (copilot) | preToolUse | postToolUse | userPromptSubmitted | sessionStart | not delivered |
Kiro (kiro) | preToolUse | postToolUse | userPromptSubmit | agentSpawn | not delivered |
| Goose | not delivered | not delivered | not delivered | not delivered | not delivered |
| OpenCode | not delivered | not delivered | not delivered | not delivered | not delivered |
Goose and OpenCode have no shell hooks, so no plugin hook ever runs there; they receive skills and MCP servers only. A hook for an event an agent does not deliver simply never fires on that agent. Agents also differ in what they do with a hook’s output; for example, Antigravity shows a PreToolUse hook’s output only when the hook blocks the call. Each agent’s page under Supported agents has the details.
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"
}
}
| Field | Type | Description |
|---|---|---|
tool_name | string | Name of the tool being invoked. |
tool_input | object | Arguments the agent is passing to the tool. |
session_id | string or null | Agent session identifier, if available. |
cwd | string or null | Working 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"
}
}
| Field | Type | Description |
|---|---|---|
tool_name | string | Name of the tool that was invoked. |
tool_input | object | Arguments passed to the tool. |
tool_response | object | The tool’s response/output. |
session_id | string or null | Agent session identifier, if available. |
cwd | string or null | Working directory of the agent. |
UserPromptSubmit
{
"UserPromptSubmit": {
"prompt": "Fix the failing test in src/lib.rs",
"session_id": "abc-123",
"cwd": "/home/user/project"
}
}
| Field | Type | Description |
|---|---|---|
prompt | string | The text the user submitted. |
session_id | string or null | Agent session identifier, if available. |
cwd | string or null | Working directory of the agent. |
SessionStart
{
"SessionStart": {
"session_id": "abc-123",
"cwd": "/home/user/project"
}
}
| Field | Type | Description |
|---|---|---|
session_id | string or null | Agent session identifier, if available. |
cwd | string or null | Working directory of the agent. |
Stop
{
"Stop": {
"session_id": "abc-123",
"cwd": "/home/user/project"
}
}
| Field | Type | Description |
|---|---|---|
session_id | string or null | Agent session identifier, if available. |
cwd | string or null | Working 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" }
}
}
| Field | Type | Description |
|---|---|---|
decision | "allow" or "deny" | Whether to allow or block the tool call. Defaults to "allow" and may be omitted. The SDK’s PreToolUseOutput::deny(reason) sets it together with the reason. |
additionalContext | string or null | Text injected into the agent’s context for this tool call. With "deny", the reason the agent is given. |
updatedInput | object or null | Replacement tool input. If set, overrides the original tool_input. |
To block the call:
{
"PreToolUse": {
"decision": "deny",
"additionalContext": "Refusing to run a command that deletes the filesystem root"
}
}
Each agent receives the decision in the form it honors:
| Agent | "deny" | updatedInput |
|---|---|---|
| Claude Code | permissionDecision: "deny", reason shown to Claude | Sent with permissionDecision: "ask": the user confirms the rewritten call. Claude applies a rewrite only alongside a decision, and "allow" would skip the user’s permission prompt. In a headless run (claude -p) there is nobody to ask, so the rewritten call is blocked. |
| Codex CLI | permissionDecision: "deny" | Not applied. Codex rewrites only with "allow" and has no "ask"; the original call runs and a warning is logged. |
| GitHub Copilot | permissionDecision: "deny" | Sent as modifiedArgs. |
| Kiro | Exit 2, reason on stderr (Kiro reads no decision from stdout) | Not applied; the original call runs and a warning is logged. |
| Antigravity | decision: "deny" | Sent as overwrite. |
Tool input is the agent’s own: write updatedInput in the shape that agent’s tool takes.
PostToolUse output
{
"PostToolUse": {
"additionalContext": "Note: 3 tests were skipped due to missing fixtures"
}
}
| Field | Type | Description |
|---|---|---|
additionalContext | string or null | Text injected into the agent’s context after the tool result. |
UserPromptSubmit output
{
"UserPromptSubmit": {
"additionalContext": "Relevant context: this project uses tokio 1.x"
}
}
| Field | Type | Description |
|---|---|---|
additionalContext | string or null | Text 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."
}
}
| Field | Type | Description |
|---|---|---|
additionalContext | string or null | Text injected into the agent’s context at session start. |
Stop output
{
"Stop": {
"additionalContext": "Things look good!"
}
}
| Field | Type | Description |
|---|---|---|
additionalContext | string or null | Text for the agent when the turn ends. Only Antigravity and Claude Code run Stop hooks, and neither treats this as plain context: Claude Code ignores it, while Antigravity takes it as a reason to keep going and starts another turn. |
Exit codes
| Code | Meaning |
|---|---|
0 | Success. Stdout is parsed as JSON and merged into the hook result. |
2 | Block. The action is blocked and stderr is returned to the agent as the reason. On PreToolUse this is a "deny" decision, delivered to each agent as in the table above. On other events cargo agents hook exits 2 with the reason on stderr, and the agent applies its own semantics (Claude Code, for instance, erases a blocked prompt). |
| Other non-zero | Warning. 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.
The regex is unanchored: it matches if it is found anywhere in the tool name, so Bash also matches BashOutput. Anchor it with ^...$ (^Bash$) to match one tool exactly.
tool_name is passed through exactly as the agent sends it, and tool_input keeps the agent’s own argument names. Agents name the same tool differently: the shell tool is Bash in Claude Code and Codex, bash in Copilot, execute_bash in Kiro, and run_command in Antigravity, whose input carries CommandLine rather than command. A symposium-format hook meant to fire on several agents must match each name, for example matcher = "^(Bash|bash|run_command|execute_bash)$", and read the input in each shape.
Testing
To unit-test a symposium-format handler, pipe canonical JSON straight into it, the way Symposium does (a script handler runs under sh):
echo '{"PreToolUse":{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":null,"cwd":"/tmp"}}' \
| sh scripts/check.sh
To test end to end, pipe the agent’s own native JSON into cargo agents hook <agent> <event>, where <agent> is one of antigravity, claude, codex, copilot, or kiro and <event> is pre-tool-use, post-tool-use, user-prompt-submit, session-start, or stop. For Claude Code:
echo '{"session_id":"s","cwd":"/path/to/project","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test"}}' \
| cargo agents hook claude pre-tool-use
This runs the full dispatch, including the automatic sync when auto-sync is on, for the workspace at cwd, so the plugin must be installed and active there. Symposium converts the payload to canonical JSON for your hook and prints the result in the agent’s format (for Claude Code, a hookSpecificOutput object). There is no symposium agent name: the canonical format is only what your handler sees.
Skill definitions
A skill is a SKILL.md file inside a skill directory. Skills follow the agentskills.io format, with one restriction: every frontmatter value must be a string, so the spec’s metadata map (or any other list or map) is rejected. See Frontmatter fields.
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
Symlinks in skill content
A skill directory may contain symlinks, and installing a skill follows them: a link to a file arrives as a real file, a link to a directory arrives as a real directory holding a copy of its contents. That is what lets a workspace keep one copy of a shared script and link it into the several skills that use it.
Links are followed wherever they point, including outside the skill directory and outside the crate or registry entry that ships it. Two alternatives were considered and rejected:
- Copying the link itself. The installed skill lives in the agent’s skill directory, a different tree, so a relative link pointing outside the skill directory dangles there — which is exactly the missing-file error this is meant to avoid.
- Following only links that stay inside the source. This breaks layouts that
already exist, such as a skill linking a library out of a sibling crate in the
same workspace, and it would make installing from a source tree stricter than
installing the same crate from crates.io:
cargo packagefollows links, including ones whose target escapes the package root, so the published crate carries that content as a real file either way.
The consequence is worth stating plainly: a skill you install can bring any file its links reach into the directory your agent reads. The boundary that governs that is enablement — a plugin embedded in a dependency does not run without your consent, and a registry is a trust root you added yourself. Symposium does not put a second, filesystem-shaped boundary on top of it.
Two links are skipped rather than followed, each with a warning, and neither fails the sync:
- a broken link, whose target does not exist;
- a link naming a directory the copy is already inside, which would otherwise nest forever.
SKILL.md format
A SKILL.md file has YAML frontmatter followed by a markdown body:
---
name: serde-basics
description: Basics of the serde crate. Use when adding Serialize or Deserialize support to Rust types.
depends-on: serde
---
Prefer deriving `Serialize` and `Deserialize` on data types.
Frontmatter fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Skill identifier, also the name of the directory the skill is installed into. Follow the agentskills.io naming rules (lowercase letters, digits, and hyphens), and prefix it with your crate’s name (for example widgetlib-basics). |
description | string | yes | What the skill covers and when to use it. Must not be empty; at most 1024 bytes. Agents decide whether to load a skill from its description alone, so name the crate and the tasks the skill helps with (for example “Use when building or theming widgets with the widgetlib crate”). |
depends-on | string | no | Comma-separated dependency atoms this skill is about (e.g., depends-on: serde, tokio>=1.0). Narrows the enclosing [[skills]] group scope; it cannot widen it. |
predicates | string | no | Comma-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. |
Other string fields from the agentskills.io spec, such as license, are accepted and kept. These rules apply to every field:
- Every value must be a YAML string. Lists, maps (including the agentskills.io
metadatamap), numbers, booleans, and empty values are rejected, so quote anything YAML would read otherwise (version: "1.0"). - Quote a value that contains
:or#. Unquoted, YAML rejects the first and treats everything after#as a comment, silently shortening the value. - The
cratesfield is rejected; usedepends-on. - In workspace skills the frontmatter is optional:
namedefaults to the name of the skill’s directory, anddescriptionmay be omitted. Skills from a registry or a crate need both.
Security: a skill’s
namecomes from plugin content and becomes a directory on disk, so Symposium treats it as untrusted input. A name that would resolve outside the agent’s skills directory is rejected: the skill fails to load instead of being installed, andcargo agents plugin validatereports it.
Crate atoms
Crate atoms specify a crate name with an optional version constraint:
serde— any versiontokio>=1.40— 1.40 or newertokio>1.40— strictly above 1.40regex<2.0— below 2.0regex<=2.0— 2.0 or belowserde^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
Write the atom without spaces: serde >=1.0, serde>= 1.0, and serde>=1.0, <2.0 are all rejected. In frontmatter, depends-on is split on commas, so a range with a comma (serde>=1.0,<2.0) cannot be written there; use predicates: depends-on(serde>=1.0,<2.0) instead, since commas inside parentheses do not separate predicates.
See Dependency 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 and their components 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 accepted on the plugin itself, on [[skills]] groups, [[mcp_servers]] entries, [[plugins]] edges, and [subcommand.<name>] tables (see Plugin definitions), and in SKILL.md frontmatter. [[hooks]] and [[predicate]] entries have no depends-on field; gate a hook on a dependency with predicates = ["depends-on(serde)"].
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:
serdeserde>=1.0tokio^1.40regex<2.0serde=1.0serde==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
=is not Cargo’s=. In a dependency atom,=1.0means^1.0(1.0 or any later 1.x release), which is what a bare1.0means in Cargo. To pin an exact version, use==:serde==1.0.219matches only 1.0.219.
Further rules:
- Write the atom without spaces.
serde >=1.0is rejected; writeserde>=1.0. - Names match exactly, as the crate is published: a
serde-jsonatom does not match theserde_jsoncrate. - In TOML, an atom may carry a comma-separated range, every part of which must hold:
"serde>=1.0,<2.0". SKILL.md frontmatter splitsdepends-onon commas, so a range cannot be written there; usepredicates: depends-on(serde>=1.0,<2.0)instead.
Predicates match against direct workspace dependencies only, not transitive ones.
Usage in different contexts
Plugin manifests (TOML)
The depends-on field accepts a single atom string or an array of them:
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: serdedepends-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-onat 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-onin 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.
The crates field (in manifests and in SKILL.md frontmatter) and the crate(...) predicate are rejected; use depends-on.
Predicates
A predicate decides whether a plugin, skill group, skill, hook, MCP server, [[plugins]] edge, 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-onfield uses dependency-atom syntax (see dependency predicates) and is sugar:depends-on = ["serde", "tokio"]lowers to a singleany(depends-on(serde), depends-on(tokio))predicate. - The
predicatesfield uses the function-call syntax below.
Where each field is accepted:
predicates: the plugin itself,[[skills]]groups,[[hooks]]entries,[[mcp_servers]]entries,[[plugins]]edges,[subcommand.<name>]tables, and SKILL.md frontmatter.depends-on: all of the above except[[hooks]], which has nodepends-onfield. Gate a hook on a dependency withpredicates = ["depends-on(serde)"].- Neither:
[[predicate]]entries, which define a predicate rather than being gated by one.
Where both fields are set, they 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:
| Predicate | Holds 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))).
A plugin can also define its own predicate functions with [[predicate]] and use them in any predicates list.
crate(...) is rejected; use depends-on(...).
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:
| Level | Evaluated |
|---|---|
Plugin predicates | At sync (gates skills & MCP), at every hook dispatch, and at subcommand lookup and cargo agents --help |
[[plugins]] edge predicates | Whenever the active plugin set is built: at sync, at every hook dispatch, at subcommand lookup, and at cargo agents --help |
Skill group predicates | At sync, before the group’s skills are loaded (so before a source.git is fetched) |
Skill frontmatter predicates | At sync, after the skill loads |
Hook predicates | At hook dispatch, after the matcher passes |
MCP server predicates | At sync, when collecting servers to register |
Subcommand predicates | At subcommand lookup (cargo agents <name>) and at cargo agents --help |
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-level,[[plugins]]edge, 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" }
This plugin’s hooks dispatch only while jq is on the user’s $PATH. Symposium never registers plugin hooks into agent configs; it checks the plugin’s predicates each time an event arrives. 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. Each of these is a predicates value for any item that accepts one:
- Opt-in, only when a flag is set:
predicates = ["env(SYMPOSIUM_EXPERIMENTAL)"] - Opt-out or escape hatch, skipped when a marker file is present or in CI:
predicates = ["not(path_exists(.skip-hooks))", "not(env(CI))"] - A 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))"] - 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.
What to read next
- Key repositories — the repos that make up Symposium
- Key modules — the main pieces of the codebase
- Important flows — key paths through the code
- Testing guidelines — how to write and run tests
- Governance — how we work together
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, so symposium should make it easy to audit, constrain, and revoke. Plugins embedded in dependencies stay off until the user enables them, and plugins in the central repository are reviewed before they load without asking; both exist 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, curated by the Symposium project. 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.
symposium-sdk: the shared plugin vocabulary
Depended on by both Symposium and anything that extends it. Beyond the hook handler SDK, it carries what a package manager needs to speak:
manifest: the unvalidatedSymposium.tomlschema (theRaw*types),Serializeas well asDeserializeso a manifest can travel on the wire. Retired fields (crates,source.crate, git/path chained sources) still parse, so validation can reject them with a migration hint rather than an unknown-field error.pm:PackageId,PluginInfo, andUnvalidatedPlugin(what a package manager answers with: Symposium validates it into aPlugin), re-exported from Symposium’spmmodule.predicate::syntax: the predicate tree and its parsing.hook::HookAgent,manifest::HookFormat,manifest::Audience: named by manifests, so they travel with the schema. Mapping aHookAgentto its wire-format handler needs Symposium’s agent modules, so that stays behind ashook_schema::agent_event.
What deliberately stays in Symposium is validation: defaults, inline-installation promotion, dormancy, trust. The schema says what a manifest can contain; Symposium decides what it means.
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. Agent relocation variables (CLAUDE_CONFIG_DIR, …) are read through env_dir, which consults the process environment only for a from_environment() instance, so a test can never reach a developer’s real agent directories; tests inject one with set_env.
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 (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses .claude/skills/ and Kiro uses .kiro/skills/, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral .agents/skills/. OpenCode and Goose are skills-only agents (no hook registration).
Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes .agents/hooks.json but ~/.gemini/config/hooks.json; Copilot .github/hooks/ but ~/.copilot/settings.json), so sync calls the project-scoped functions at project scope rather than rooting the global path at the workspace.
MCP registration is its own axis, and the one place where guessing is expensive: an agent’s MCP file is usually not its hooks file, so a wrong target fails silently - symposium reports success for a file the agent never reads. Agent::mcp_config_path(scope, project_root, sym) is the single seam holding that per-agent truth (the table lives in agent details), with register_mcp_servers / unregister_mcp_servers as the only entry points; there is no separate project/global pair of functions to keep in sync. McpScope is deliberately distinct from HookScope: Antigravity, Claude, OpenCode and Kiro have a project-level MCP file, while Codex, the Copilot CLI and Goose read only a user-level one, so supports_project_mcp_scope reports when project scope is downgraded rather than writing a file nobody reads. Entry shape varies too - OpenCode needs {type, command: [bin, ...args], enabled} and rejects its whole config file otherwise, which is why it has its own serializer (opencode_server_to_json) instead of the shared {command, args} one.
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. Copying and comparing share one traversal, walk_tree — they have to agree entry for entry, since a comparison scan that omitted something the copier writes would see a difference on every run and turn each sync into a full recopy. That traversal follows symlinks (see dir_walk.rs), so a linked file installs as a real file and a linked directory as its contents; a broken link and a link back into the tree being copied are reported and skipped rather than failing the sync. 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.
While PluginRegistry::unreadable_registries is not empty, sync skips that reap and warns once per registry: an unreadable registry offered nothing, so everything it installed would look stale.
One entry point, sync(sym, deps, update). The caller supplies the WorkspaceDeps, so the hook pipeline hands over the resolver its other stages already used rather than resolving the workspace a second time. Callers: the CLI’s sync arm (after the consent prompt), use_command once an enablement is recorded, and the hook pipeline’s auto-sync.
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 validates them into Plugins. 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 returns each entry through entry_plugin: a Symposium.toml manifest read as-is, or a bare SKILL.md synthesized into a manifest (standalone_skill_manifest); 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.
load_registry reads each registry through PmInstance::registry_plugins, which fails where active_plugins would come back empty because the source cannot be read (a PathPm whose directory cannot be listed or is itself an entry, and a GitPm that has not been fetched). Such a registry is reported and recorded in PluginRegistry::unreadable_registries (see common issues); plugin list and plugin show read through the same call.
Validation here turns the raw TOML (parsed into the SDK’s RawPluginManifest) into:
Installationentries (optionalsource, optionalexecutable/script, optionalargs, plusrequirementsandinstall_commands) collected onPluginManifest.installations. Inline installation references on hooks or other installations are promoted into syntheticInstallationentries with derived names (<hook>for an inlinecommand,<owner>__req_<i>for an inline requirement), so all references in the validated form are plain names.Hookentries withcommand: String(the name of anInstallation) plus optional hook-levelexecutable/script/args. Validation guarantees at most one ofexecutable/scriptis set across hook + installation, and at most one layer setsargs.SkillGroupandPluginMcpServerentries whosedepends-onsugar andpredicateslist are merged into one runtimePredicateSet. Skill groupsourcesyntax is deserialized as raw string/table forms, then validated intoPluginSource.ChainedPluginentries from[[plugins]]: a per-edgePredicateSetplus asource.cargoreference (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 inskills.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 ownSymposium.tomlif 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.
merge_crate_manifest(metadata, file, crate_name) builds a crate’s unvalidated manifest. 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). When the plugin reaches validate_plugin, the result runs through the same validate_manifest pipeline under ManifestOrigin::Crate: 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. Plugin 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, workspace_plugin_for_dir, and validate_plugin) runs resolve_group_sources before returning, so each [[skills]] source.path group carries an absolute directory plus a display source_label — a Plugin needs no base/manifest dir. A Plugin carries no manifest file path 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 synthesized by standalone_skill_manifest into a manifest with default values, which ordinary registry validation turns into a plugin — 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 PluginManifest::requires_use = true, i.e. dormant. PluginManifest::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::WorkspaceMember — name 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.
dir_walk.rs — following symlinks without looping
The two rules every source-tree walk needs once symlinks are followed, in one place.
A live source tree — a workspace member, a path or git dependency, a [[registry]] path entry, ~/.symposium/plugins/, a git-cloned registry cache — can contain symlinks, and what sits behind them is skill content like any other. Such a tree is dereferenced rather than copied link-for-link: the destination is a different tree, so a preserved relative link pointing outside the skill directory would dangle there. Dereferencing also makes a source-tree install agree with a crates.io install, which already delivers real files (cargo package follows links, including ones escaping the package root). Links are followed wherever they point — there is no containment check against the source root; the skill reference records that decision and its rationale.
resolved_kind(path, file_type)classifies a directory entry with links resolved.DirEntry::file_typereports the link, which is neither a directory nor a regular file, andDirEntry::metadatadoes not follow it either;fs::metadatadoes.Errcan only come from that follow, so it means a broken link.Ancestorsrecords the directories a walk is currently inside, by canonical path, and refuses to enter one twice. A tree of real directories cannot contain itself, so this only fires once a directory link has been followed — but every directory on the way down is recorded, since the link that closes the loop may sit several real directories below the one it names.
Four walks use it: the copy/compare traversal in sync.rs (both rules), and the cycle guard alone in skill discovery (skills::find_skill_files_recursive), registry enumeration (pm::layout::walk), and crate content detection (pm::cargo::contains_skill_md) — those three already followed directory links through Path::is_dir.
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
currentpointer (<cache>/binaries/<crate>/current).Nonereads the pointer and serves that version with no crates.io query — so a per-event dispatch never hits the registry.Check/Fetchquery 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). OnlyFetchforces 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/Fetchresolve the remoteHEADwith a cheapgit ls-remoteand compare against the commit recorded in a.commit-shafile in the cache dir; the binary is reinstalled (cargo install --force) only when the SHA changed (orFetch, or the binary is missing).Nonenever 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 UnvalidatedPlugins 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. An UnvalidatedPlugin is the resolved id, the content root, and an unvalidated RawPluginManifest (plus an optional label_root, the directory resolved skill paths are displayed relative to, so a registry entry reads as path:<entry>/skills). The manifest is the point: a PM may have parsed it from a Symposium.toml, translated another ecosystem’s manifest, or synthesized one for a package that describes itself not at all, and Symposium cannot tell.
Turning an UnvalidatedPlugin into a Plugin is PmInstance’s job, via plugins::validate_plugin: schema validation, inline-installation promotion, default content, dormancy, and resolving source.path groups against the plugin’s root all happen there. Each PmInstance carries two flags, both Symposium’s to set and neither anything a PM says about itself:
trusted: whether consent is needed. Registries and the workspace are trust roots; the cargo transport (over dependencies) is not. This is the distinction consumers branch on (registry loading takes only trusted instances;discovertakes only the untrusted cargo transport).kind: PluginKind: which validation policy applies.Registry(must name itself,[defaults]rejected, dormant with no gate) orPackage(id supplies the name, the reference that reached it supplies the gate, defaultskills/group appended).
So a package manager cannot make itself trusted or wake a dormant plugin by what it returns. A package’s manifest can still turn off the default skills/ group with [defaults] skills = false, since that is part of the package schema. A registry plugin that fails validation is reported, naming its directory, since fixing it is the user’s job; a package’s failure is only logged, since it is its author’s, and a report on the hook path would land next to the agent’s JSON. 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) returns (via the shared plugin_from_fetched) 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) returns 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:
plugin_from_fetched(fetched) -> UnvalidatedPluginbuilds the unvalidated plugin for a fetched crate from its manifest sources ([package.metadata.symposium]inCargo.tomland aSymposium.tomlat the source root) layered byplugins::merge_crate_manifest(see important flows). The offer’s id is the resolved crate. A crate with no manifest sources still yields one carrying an empty manifest, which validation turns into a plugin whose only content is the defaultskills/group, soload_pluginreturns a plugin for any fetchable crate (a failed fetch is logged and yields nothing). The default group is appended by validation, not by the PM: defaults are policy.
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 aSymposium.tomlis a plugin entry, one with aSKILL.mdis a bare-skill entry — loaded as a default plugin — manifest wins) andenumerate(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 manifestdepends-on. Interpreting an entry’s manifest stays inplugins.rs.pm/path.rs—PathPm— one local directory in the flat layout:~/.symposium/plugins/, a[[registry]]pathentry, or the git cache directory a git registry unpacks into (serving as aGitPm’s inner reader). Its ids name the entry’s subpath within the source.active_pluginsreturns every entry (viaplugins::entry_plugin),load_plugin(id)the entry an id names,fetchjoins the subpath back onto the directory,searchsubstring-matches entry names, andregistry_sourcereports it as aPath. A registry is a trust root, so its plugins activate without consent; entry-load failures surface as report warnings.pm/git.rs—GitPm— one[[registry]]gitentry. Once fetched, a git repo is just a directory, so the reads (active_plugins/load_plugin/search/fetch) delegate to an innerPathPmover the cache directory; the git-specific part isrefresh— pull the repository (honoring the entry’sauto-updateunless the caller forces it) — andregistry_sourcereports it as aGit. The builtinsymposium-recommendationsrepo is such a registry (an ordinary flat registry — each entry names the crates it advises with its owndepends-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. plugins::merge_crate_manifest deserializes the table and merges it with any Symposium.toml; validation happens when the resulting plugin reaches plugins::validate_plugin. 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: predicate evaluation
The predicate syntax (the Predicate tree, both surface spellings, parsing, Display, and serde) lives in symposium_sdk::predicate::syntax and is re-exported here, so crate::predicate::Predicate still names the same type. The split lets a manifest carry predicates without linking evaluation. Evaluation is the PredicateEval extension trait, an extension trait because the types are foreign.
The syntax covers both dependency-graph matching and runtime/environment gating: one Predicate enum, PredicateSet (a list ANDed together), and this module’s 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-onfield uses dependency-atom syntax (serde,serde>=1.0,*) and lowers, viaDependsOnList, todepends-on(...)/depends-on(*)predicates OR-combined into a singleany(...)that is appended to the same list. Sodepends-onis sugar — there is no separate dependency-predicate type. - The
predicatesfield uses function-call syntax:depends-on(<atom>),shell(<cmd>)(verbatim arg,sh -c, exit 0 holds),path_exists(<arg>)(disk, then$PATHfor bare names),env(<name>[=<value>]),workspace-member()(the plugin is defined by a member of the active workspace — provenance stamped per plugin intoPredicateContextviaPlugin::applies; registry loading stamps false, workspace-plugin loading stamps true), and the combinatorsnot(<p>),any(<p>, …),all(<p>, …). The retiredcrate(...)spelling is rejected with a migration hint, as are the oldcratesfields.
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 Plugin 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.
The skill name comes from plugin content and becomes that directory name, so it is untrusted input: load_skill rejects any name is_safe_dir_name refuses (one that could resolve outside the skills directory), and the skill fails to load instead of being installed. Agent::project_skill_dir and global_skill_dir assert the same check before joining the name.
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 + PluginManifest::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_command.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 Plugin::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 &[Plugin] 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 → respond. The response is a HookResponse (stdout, stderr, exit code), since some decisions travel only through the exit code: a plugin hook’s exit 2 on PreToolUse becomes a canonical deny (blocked_response), and Kiro receives any denial as exit 2. 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.
On SessionStart, 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. handle_post_tool_use and handle_user_prompt_submit add one generic notice (there may be new plugins, run cargo agents sync) when run_auto_sync reports that it re-synced because Cargo.lock changed. 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/ — 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.
The replacement recording contract is being built behind private schema, identity, and state submodules before it is connected to production callers. Within schema, mod.rs owns shared row primitives and version dispatch, macros.rs owns strict versioned-row boilerplate for rows with and without cross-field validation while each row module keeps its validation rules, metrics.rs owns the fixed latency histogram and complete-session validation shared by hook aggregate rows, and hook.rs owns the strict hook-metrics row and read-side validation. Its hook/surface.rs, hook/outcome.rs, hook/key.rs, and hook/update.rs children keep SDK surface mapping, outcome accounting, the typed hook_subject dimension and aggregate key, and atomic write updates with their respective tests. plugin_hook.rs owns the strict plugin-hook aggregate row and read-side validation. Its plugin_hook/outcome.rs, plugin_hook/bucket.rs, and plugin_hook/update.rs children keep outcome accounting, validated public identity and pre-admission attribution, and atomic write updates with their respective tests. name.rs owns validation shared by public name types, agent.rs owns agent vocabulary and agent-originated rows, command.rs owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, extension.rs owns validated public extension coordinates and vocabulary shared across event kinds, and resolution.rs owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in resolution/package.rs; resolution/extension.rs owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, the skill-only attribution persisted by the installation index, and the versioned row that derives extension_subject from its target and path. Timestamped rows validate during deserialization that day is the UTC calendar day containing at; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives package_subject from its coordinate, agent configuration derives agent_subject from its agent, command derives command_subject from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and cohort_day from one bound session transition. Within state, mod.rs owns the versioned private-state file shape, state/lifecycle.rs owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions, state/open_day.rs centralizes the monotonic day transition used by daily state while persistence remains responsible for sourcing it from the one latest-opened-day high-water mark, state/session_counts.rs owns bounded private hook session sets plus contribution-count reconciliation, state/hook.rs owns the deterministic daily map that selects those trackers by aggregate key, and state/plugin_hook.rs keeps each plugin-hook row’s admitted identity, stable event ID, and session tracker behind one key-checked selection. Its state/plugin_hook/admission.rs child owns the deterministic daily map and the separate 128-public-row allowance for this aggregate family. Only this private-state boundary can create an overflow bucket; row constructors receive the coupled identity from the selected entry rather than accepting its scope, coordinate, and subject separately. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. Each hook row and private tracker share a key made from the UTC day and hook_subject, so equally sized trackers for different aggregates cannot be exchanged. Hook-metrics updates derive their row and session identities from one bound recording, reject impossible per-observation plugin counts, stage every row counter before changing private session state, and publish the two objects only after every checked update succeeds. The private storage submodule owns the new contract’s canonical paths and exclusive filesystem access: StoragePaths keeps inspectable data and its permanent .lock under <config-dir>/telemetry/ while keeping telemetry-state.toml beside that directory, and TelemetryLock makes one non-blocking acquisition whose file handle releases the lock on drop or unwind. The eventual recorder will stage rows and aggregate changes in memory, successfully replace the private state containing their anchors and entries, and only then expose those rows for publication. Their tests remain beside the responsibility they exercise.
state/codec.rs owns canonical private-state serialization, version dispatch,
schema decoding, and secret-safe diagnostics. The private storage module
owns the bounded file I/O that calls that codec.
Resolution summaries derive an optional session_id from a paired agent and
raw vendor session identifier rather than accepting a loose scoped identifier.
state/codec.rs owns canonical private-state encoding and content-free decode
errors, so malformed state can never expose the identity key in diagnostics.
schema/extension_invocation.rs is the extension-invocation aggregate boundary. Its extension_invocation/vocabulary.rs child keeps the closed version 1 agent, phase, target-scope, and unnamed-reason vocabulary; extension_invocation/bucket.rs accepts only safe public attribution or a fixed unnamed reason, leaving overflow assignment to private state; and extension_invocation/row.rs owns the strict versioned row and its read-side scope, counter, and session validation. The row’s update.rs child applies one phase observation atomically from a bound recording and an admitted private-state selection, so the event id, agent, target identity, identifier epoch, and session tracker cannot be supplied independently. The independent attempted/completed session-count validator lives with the other aggregate primitives in schema/metrics.rs. state/extension_invocation.rs owns the matching bounded private sets and reconciles their independent contribution counts with the stored row. Failed observations add no session id, but still perform this reconciliation so a lost snapshot row cannot leave published counts inconsistent with private state. Its extension_invocation/admission.rs child derives coupled row identity, keeps the stable row identifier and session tracker behind one epoch-aware aggregate key, and sends new public targets beyond the daily allowance to overflow. state/public_row_budget.rs owns the shared 128-row daily allowance used independently by each named aggregate family: day rollover and clear restore it, while identifier reset does not.
Storage acquisition also tightens a pre-existing telemetry directory and lock
file to owner-only modes on Unix instead of relying only on their creation
modes. storage/atomic.rs writes a complete byte buffer through a
same-directory temporary file before atomically replacing its destination; it
does not fsync, and callers must finish serialization before replacement. Every
telemetry-owned temporary uses the shared .telemetry-tmp- prefix so lazy
cleanup and status recognize the same namespace. Activating the replacement
recorder must remove the legacy record and roll_off paths in the same
change, so two schemas and retention policies never share the telemetry
directory.
LockedStorage owns both the canonical paths and TelemetryLock, so private
state cannot be loaded or replaced outside the lock. Its private_state.rs
child performs a bounded byte read, distinguishes TOML syntax errors, invalid
state content, and unsupported versions without retaining parser source text,
serializes the complete state before replacement, and requires mutable access
to replace it. Its daily_files.rs sibling recognizes only canonical event
and metric filenames. When private state is absent, storage initializes the
high-water mark and identifier-window anchor from the later of the current UTC
day and the newest surviving daily filename; malformed and unsupported state
remain errors rather than entering this path.
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— printsformat_human()to stdout (default for most commands)Verbose(-v) — prints all events (info + debug) to stderrJson(--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 name | Agent |
|---|---|
antigravity | Antigravity CLI |
claude | Claude Code |
copilot | GitHub Copilot |
codex | Codex CLI |
kiro | Kiro |
opencode | OpenCode |
goose | Goose |
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:
- Register hooks — write the hook configuration so the agent calls
cargo-agents hookon the right events. - 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:
| Scope | Path | Supported by |
|---|---|---|
| Project skills | .agents/skills/<skill-name>/SKILL.md | Antigravity, Copilot, Codex, OpenCode, Goose |
| Project skills | .claude/skills/<skill-name>/SKILL.md | Claude Code (does not support .agents/skills/) |
| Project skills | .kiro/skills/<skill-name>/SKILL.md | Kiro (uses its own path) |
At the project level, Claude Code requires .claude/skills/, Kiro requires .kiro/skills/, while Antigravity, Copilot, 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:
| Agent | Global skills path |
|---|---|
| Antigravity CLI | ~/.gemini/config/skills/<skill-name>/SKILL.md |
| Claude Code | ~/.claude/skills/<skill-name>/SKILL.md |
| Copilot | (no global skills path) |
| 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.
| Scope | File |
|---|---|
| Global | ~/.claude/settings.json ($CLAUDE_CONFIG_DIR/settings.json when set) |
| 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. Symposium registers these:
| Event | Description |
|---|---|
PreToolUse | Before a tool is invoked. Can allow, block, or modify the tool call. |
PostToolUse | After a tool completes. |
UserPromptSubmit | When the user submits a prompt. |
SessionStart | Session starts, resumes, or is cleared or compacted. Where symposium adds its own context. |
Stop | Agent turn completes. Claude Code reads no context from it. |
Other events include 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.
| Scope | File |
|---|---|
| Global | ~/.copilot/settings.json ($COPILOT_HOME/settings.json when set), under the 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
| Event | Description |
|---|---|
preToolUse | Before a tool is invoked. Can allow, deny, or modify tool args. |
postToolUse | After a tool completes. |
sessionStart | New session begins. Supports command and prompt types. |
sessionEnd | Session completes. |
userPromptSubmitted | When the user submits a prompt. |
errorOccurred | When 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".
Antigravity CLI
Hook registration
Antigravity hooks live in a dedicated hooks.json under the customization root —
.agents/ in a workspace, ~/.gemini/config/ globally. Entries are keyed by a
hook name, so symposium owns a single symposium key and leaves anything
else in the file untouched.
| Scope | Path |
|---|---|
| Global | ~/.gemini/config/hooks.json |
| Project | .agents/hooks.json |
{
"symposium": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "cargo-agents hook antigravity pre-tool-use",
"timeout": 30
}
]
}
],
"SessionStart": [
{
"type": "command",
"command": "cargo-agents hook antigravity session-start",
"timeout": 30
}
]
}
}
Note the two shapes: tool events (PreToolUse, PostToolUse) wrap their
handlers in a matcher group, while lifecycle events (PreInvocation,
SessionStart, Stop) take a flat list. Timeouts are in seconds, not
milliseconds. An unrecognised shape or an unknown event name is accepted
silently and simply never fires.
SessionStart is missing from Antigravity’s published documentation but present
in its hook proto. It fires with the first turn of a new conversation (in the
TUI, at the first prompt rather than at launch) and never on resume, so a
resumed conversation skips the SessionStart refresh. There is no prompt event,
so PreInvocation stands in for user-prompt-submit; because it fires before
every model call, only invocationNum == 0 dispatches
(AgentHookInput::should_dispatch).
Hook output
Exit codes are ignored entirely — only stdout matters. On PreToolUse, an object
without a valid decision (including {}) is treated as a denial, so
symposium always emits {"decision": "allow"} unless a plugin actually denied.
Context is returned as an injected step rather than an additionalContext field:
{ "injectSteps": [{ "ephemeralMessage": "..." }] }
Kiro
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.
| Scope | File |
|---|---|
| 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
| Event | Description |
|---|---|
preToolUse | Before a tool is invoked. Can block (exit code 2). |
postToolUse | After a tool completes. |
userPromptSubmit | When the user submits a prompt. |
agentSpawn | Session starts (maps to session-start internally). |
stop | Agent finishes. |
Hook payload/output
Kiro uses exit-code-based control flow:
- Exit 0: stdout captured as additional context
- Exit 2: block (
preToolUseonly), 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
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.
| Scope | File |
|---|---|
| Global | ~/.codex/hooks.json ($CODEX_HOME/hooks.json when set) |
| 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
| Event | Description |
|---|---|
PreToolUse | Before a tool is invoked. Can block. |
PostToolUse | After a tool completes. Can stop session (continue: false). |
UserPromptSubmit | When the user submits a prompt. |
SessionStart | Session starts or resumes. |
Stop | Agent turn completes. |
Hook payload/output
Codex uses a protocol similar to Claude Code, with two methods to block:
- JSON output:
{ "decision": "block", "reason": "..." } - 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
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 event | Symposium event | Description |
|---|---|---|
tool.execute.before | pre-tool-use | Before a built-in tool runs. Can block by throwing Error, or mutate output.args. |
tool.execute.after | post-tool-use | After a built-in tool completes. |
message.updated | user-prompt-submit | Filtered to role === "user" messages. |
session.created | session-start | When a new session begins. |
Goose
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; not registered means symposium does not hook that event there.
| Symposium event | Antigravity | Claude | Copilot | Codex | Kiro | OpenCode | Goose |
|---|---|---|---|---|---|---|---|
pre-tool-use | PreToolUse | PreToolUse | preToolUse | PreToolUse | preToolUse | — | — |
post-tool-use | PostToolUse | PostToolUse | postToolUse | PostToolUse | postToolUse | — | — |
user-prompt-submit | PreInvocation | UserPromptSubmit | userPromptSubmitted | UserPromptSubmit | userPromptSubmit | — | — |
session-start | SessionStart | SessionStart | sessionStart | SessionStart | agentSpawn | — | — |
stop | Stop | Stop | not registered | not registered | not registered | — | — |
Tool names are each agent’s own, and symposium passes them to plugin hooks unchanged: the same shell tool is Bash in Claude Code and Codex, bash in Copilot, execute_bash in Kiro and run_command in Antigravity. A matcher that should fire on several agents has to name each of them.
Adding a new agent
To add support for a new agent:
- Add a variant to the
HookAgentenum inhook_schema.rs. - Create an agent module (e.g.,
hook_schema/newagent.rs) implementing theAgenttrait and the event-specific payload/output types. - Implement the
AgentHookPayloadandAgentHookOutputtraits to convert between the agent’s wire format and the internalHookPayload/HookOutputtypes. - 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 = "antigravity"/"claude"/"copilot"/"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:
- If the plugin declares a hook with
formatmatching agent A → deliver the input unmodified (the handler already expects this agent’s native format). - Otherwise, if the plugin declares a symposium-format hook → convert to symposium canonical and deliver.
- 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 Antigravity. If you want cross-agent coverage, provide a symposium-format hook as a fallback.
Example
A plugin with hooks for claude, antigravity, and symposium:
- On Claude: the
format = "claude"hook receives Claude’s native JSON. - On Antigravity: the
format = "antigravity"hook receives Antigravity’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
.gitignoresymposium-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-infois 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"
| Field | Type | Required | Description |
|---|---|---|---|
description | string | yes | Shown in cargo agents --help. Capped at 1024 chars. |
audience | "humans" | "agents" | no, defaults to "agents" | Controls grouping in cargo agents --help. |
command | string or table | yes | A 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-on | string or array | no | Subcommand-level dependency predicate, AND-combined with the plugin-level depends-on. |
Reserved names that cannot be used as subcommand keys: every built-in command (init, sync, search, use, status, hook, plugin, self-update, crate-info, telemetry) and 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 PluginManifest 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:
- Name registration and lookup.
- Workspace-aware filtering (the subcommand only appears for projects matching the plugin’s dependency predicates).
- A short description shown in
cargo agents --help. - Resolution of
commandthrough the installation pipeline to a concrete(executable, base_args). - 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:
- Loads the plugin registry and the active workspace’s crates.
- Walks active plugins for one whose
subcommandsmap contains the typed name and whose subcommand-leveldepends-onpredicate (if any) also matches. - Resolves the subcommand’s
commandthrough the installation pipeline — acquiring the binary if it isn’t already cached, running anyinstall_commands, processingrequirements. - Execs the resolved
(executable, base_args ++ user_args), inheriting stdio. - Returns the child’s exit code as the
cargo agentsexit 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 withaudience = "humans". - Commands for agents — discovery and analysis tools for the agent to invoke:
crate-infoand plugin-vended subcommands withaudience = "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
| Concern | Owned by |
|---|---|
| Subcommand name | Manifest |
| Short description | Manifest |
audience | Manifest |
Argument grammar, flags, <subcommand> --help | Plugin’s binary |
| Argument validation | Plugin’s binary |
| Exit codes | Plugin’s binary, propagated by symposium |
| Binary acquisition, caching, post-install setup | Shared installation framework |
| Workspace-aware filtering | Symposium |
Resolution of command → (executable, args) | Shared installation framework |
| Stdio forwarding | Symposium (inherited) |
cargo agents --help rendering | Symposium |
| Conflict resolution | Symposium |
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 { ... }
)
- Command code emits a tracing event at
info(actions) ordebug(decisions) level, carrying a singlereportfield whose value is aReportEventformatted viaDisplay(which serializes to JSON). - The
ReportLayerchecks: does this event have areportfield? Is its level withinmax_level? - If yes, it deserializes the JSON string back into a
ReportEventand renders it based on mode.
Modes
| Mode | CLI flags | Output target | Level filter |
|---|---|---|---|
Normal | (none) | stdout | INFO only |
Verbose | -v | stderr | INFO + DEBUG |
Json | --json | buffered → stdout at exit | INFO (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
| Level | When to use | Visible in |
|---|---|---|
info | Actions taken (installed, removed, validated) | Normal, Verbose, Json |
debug | Decisions (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
Displayimpl onReportEventserializes to JSON — this is how the value passes through tracing’s%formatter into the visitor - The layer’s visitor checks
record_debug(notrecord_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:
skills::active_pluginsseeds a worklist from the trust-root plugins the registry loaded: each active plugin’splugin.chainededges whose predicates hold (evaluated against the owning plugin’s provenance) contribute asource.cargocrate id.- For each id the fixed-point calls
pms.load_plugin(id)on the package-manager setactive_pluginswas handed (built once bypackage_managers(deps)). The id’spmroutes it to the cargo transport, which:CargoPm::fetchresolves the source viaRustCrateFetch(path overrides for local path deps, then the cargo registry cache, then crates.io) withUpdateLevel::None— cache-only, so this is safe on the per-event hook path. The fetched id carries the exact resolved version.CargoPm::plugin_from_fetchedreturns the crate as an unvalidated plugin, with a manifestplugins::merge_crate_manifestbuilds by layering two sources (merge order:[package.metadata.symposium]fromCargo.toml→Symposium.tomlfile). Both use the ordinary plugin-manifest schema and are parsed leniently (a malformed layer is logged and dropped).- The cargo instance validates it (
plugins::validate_pluginwithPluginKind::Package), underManifestOrigin::Crate(name defaults to the crate,depends-onis waived,[defaults]accepted, defaultskills/group appended unless[defaults] skills = false). The result is aPluginwhosecanonicalid is the resolved crate. A crate with no manifest sources still yields one whose only content is that defaultskills/group.
record_activehonors 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.cargochained reference to the target crate) is followed. Avisitedset keyed on(pm, normalized name)— global across the wholeactive_pluginscall — 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.- Facet extraction then walks the active set.
collect_skillsruns each plugin’s skill groups through the ordinaryload_skills_for_grouppipeline — honoring named groups, group predicates, andsource.path/source.git, with each discovered skill’s origin hashed from its on-diskSKILL.mdpath (this is where git skill sources are fetched, hence theupdatelevel). 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, andwarn_undispatched_crate_featuresnotes 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, plugin_from_fetched), pm/mod.rs (PmInstance, PluginKind), plugins.rs (merge_crate_manifest, validate_plugin, RawPluginManifest::merge, ManifestOrigin::Crate, Plugin::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.
discovery::discoverasks the untrusted cargo transport for itsactive_plugins(dep_ids): the plugins embedded in the workspace’s dependencies.CargoPm::active_pluginsfetches each dependency cache-only and inspects it — a workspace dep resolves into the sourcecargo metadataalready 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 byuse, auto-enabled, declined, or an undecided candidate. Nothing is prompted or written.- At sync time,
skills::active_pluginsasksdiscovery::enabled_dependencieswhich crate names[plugins] auto-enableor an applicableuseentry covers — workspace deps, plusused crates that aren’t deps at all — and seeds each as a cargo id on the same worklist a chained reference feeds, sopms.load_pluginhonors the crate’s manifest sources, skill groups, and its own[[plugins]]edges. This reads config rather than the offer list, socargo 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::searchis what letsusename such a crate; a name a configured registry already provides is skipped here so it isn’t double-loaded.) - Independently, a registry plugin with no dependency gate anywhere loads dormant (
PluginManifest::requires_use) and activates only when auseentry names it. The gate rides thePredicateContext(with_used_names/is_used), so skill resolution, hook dispatch, subcommand lookup, help, and MCP filtering all agree.
Decisions are recorded by the consent prompt in an interactive cargo agents sync (discovery::prompt_for_consent, which writes auto-enable and disable) and by cargo agents use (use entries); cargo agents status reports what [plugins] currently decides. A hook never prompts: SessionStart names the pending candidates as context instead.
The key code paths are in discovery.rs, config.rs (PluginsConfig, UseEntry), pm/cargo/mod.rs (active_plugins, load_plugin), plugins.rs (PluginManifest::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.
- The binary and the test harness parse argv with
Cli::try_parse_from, then callhelp_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. - For no subcommand,
--help/-h, or the barehelpkeyword,help_textreturns the top-level grouped help:renderslices 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). - For
<built-in> --help,help_textre-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. - A plugin-vended
<name> --helpis left alone:help_textreturnsNone, and dispatch forwards--helpto 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).
- The binary (or library
cli::run) callssubcommand_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. find_subcommandwalks that set. For each plugin it applies the plugin-leveldepends-onpredicate against the workspace, then looks upargv[0]inplugin.subcommands. If the entry has its owndepends-onpredicate, that must also match. Two or more matches → error.- The matched subcommand’s
commandfield names anInstallationon the same plugin.installation::resolve_runnableacquires the source if any, runsinstall_commands, and picks theRunnable(Execfor binaries,Scriptfor shell scripts). - The child is spawned with stdio inherited. Its exit code is collapsed to a
u8— the binary wraps it inExitCode::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
-
Prompt for agents — ask which agents the user uses (e.g., Antigravity CLI, Claude Code, Copilot). Multiple agents can be selected.
-
Write user config — create
~/.symposium/config.tomlwith the[[agent]]entries populated:[[agent]] name = "claude" [[agent]] name = "antigravity" -
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
-
Consent prompt (interactive
cargo agents synconly) — 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 onOutput::is_interactive(): the hook-triggered auto-sync callssync::syncdirectly and never reaches this step. See dependency discovery. -
Find workspace root — run
cargo metadatato locate the workspace manifest directory. -
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. -
Scan dependencies — read the full dependency graph from the workspace.
-
Match skills to dependencies — for each plugin, parse
SKILL.mdYAML frontmatter, reject malformed or non-string metadata, warn about skipped invalid skills, then evaluate skill group dependency predicates and individual skilldepends-onfrontmatter against the workspace dependencies. -
Install skills per agent — for each configured agent:
- Copy applicable
SKILL.mdfiles into the agent’s expected skill directory. - Drop a
.symposiummarker 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.gitignorecontaining a single*so symposium-managed files stay out of version control.
- Copy applicable
-
Workspace
.agents/skills/(agents-syncing) — not a separate step: whenagents-syncingis enabled, each workspace plugin carries aworkspace-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). -
Reap stale skills — across every known agent’s skills parent directory, remove any subdirectory that contains the
.symposiummarker but wasn’t installed this sync. Directories without the marker (user-managed) are left untouched.While a registry cannot be read, this step is skipped and a warning names the registry: it offered nothing this time, so everything it installed would look stale.
-
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/) 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
The payload is parsed first, and the agent’s parser can declare it not an occurrence of the symposium event (AgentHookInput::should_dispatch; false for an Antigravity PreInvocation after the first of a turn). Such a payload is answered with the agent’s neutral output and skips every step below.
-
Auto-sync (if enabled) — when
auto-sync = truein the user config, runscargo agents syncto ensure skills are current. The workspace root is resolved from the payload’scwdfield; 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.SessionStartis the refresh point. Because it fires once per agent session, it does the expensive work that other events skip: it bypasses theCargo.lockfreshness gate (so skills re-sync even when the workspace’s dependencies are unchanged) and passesUpdateLevel::Checkso git registries andsource.gitskill groups are re-fetched if their upstream moved. Every other event keeps the cheap,Cargo.lock-gated path withUpdateLevel::None(debounced) to avoid per-event network andcargo metadatacost. The registry refresh onSessionStart(ensure_registrieswithCheck, decided in the binary entry point from the event) still honors each registry’sauto-updatetoggle.SessionStartalso runsprewarm_hook_sources, which refreshes already-installed hook binaries/scripts (thecargo/githubsources backing plugin hooks) — refresh-only, so it never eagerly installs a tool a hook may never use; first install still happens lazily at dispatch. -
Built-in dispatch — symposium’s own handling, before plugin hooks.
PreToolUseproduces nothing. OnSessionStartthree fragments are computed independently and, when present, joined into oneadditionalContext:- 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 runcargo agents --helpto find them. Computed independently of the update-check throttle, so it fires whenever there is something to discover. - Consent hint: the dependency plugins awaiting consent, telling the agent to point the user at
cargo agents syncorcargo agents use. - Update nudge — when
auto-update = "warn", the 24-hour check throttle has elapsed, and the registry reports a newer version: a line suggestingcargo agents self-update.
On
PostToolUseandUserPromptSubmit, when auto-sync just re-synced becauseCargo.lockchanged, a generic notice says there may be new plugins and points atcargo agents sync. That is right after a tool call that changed the dependencies, or the user’s next prompt when they changed them.run_auto_syncreturns that as a bool, so an event with an unchangedCargo.lockdoes no extra work. The notice is lost when the agent ignores context on that event (Antigravity after a tool call), or when the change is first seen onPreToolUseorStop, which sync but carry no notice.Agents without hook registration (OpenCode, Goose) never receive this; for them the only discovery surface is
cargo agents --helpitself. - Discovery hint — when the active workspace exposes plugin-vended subcommands (the same workspace-filtered set listed by
-
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
requirementsfor 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 theexecutable/scriptagainst the cached location. Dispatch acquires withUpdateLevel::None— it serves the cache (git checks debounced) rather than hitting the network on every event. Freshness comes from theSessionStartprewarm (step 1), which re-acquires every applicable hook’s source withCheckonce per session. - If no source, the
executable/scriptis 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 forExec, orsh path args…forScript.
- If the installation declares a
- 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.
mergelets a later value replace an earlier one, so onSessionStart,UserPromptSubmitandPostToolUseaJoinedContextkeeps every source’s context (the builtin fragments first, then each hook) and writes the joined text back after each merge.PreToolUseandStopare left out: there some agents keep context in the field that holds the decision. - Return the merged result to the agent.
- Ensure any
- A hook that exits 2 blocks: dispatch stops, and
blocked_responseturns its stderr into the reason. OnPreToolUsethat is a canonical deny, translated like any other so each agent receives it in the form it honors (Antigravity ignores exit codes, Copilot drops the reason of a failing hook). On other events the process exits 2 with the reason on stderr.
-
Respond: the agent’s handler renders the final output as a
HookResponse(stdout, stderr, exit code). Most agents read everything from stdout; Kiro, whose stdout is plain context, is told to deny a tool through exit 2.
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:
| Name | Backend | Notes |
|---|---|---|
claude-sdk | Claude Agent SDK (Python) | Requires uv + ANTHROPIC_API_KEY |
kiro-cli-acp | Kiro CLI via ACP | Requires kiro-cli in PATH |
Any name from acpr --list | ACP registry via acpr | Auto-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
shis onPATH. Git ships it atC:\Program Files\Git\usr\bin. Several tests spawnshto run script-based hooks and predicates, so a missingshshows up as unrelated-looking hook failures. - The repo’s
.gitattributesnormalizes checked-out text files to LF. This keeps shebang’d fixtures and shell scripts runnable regardless ofcore.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 thecargo-agentsbinary.
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:
| Category | Policy |
|---|---|
| New contributors | PRs need review from a maintainer |
| Maintainer team member | PRs should be reviewed by another maintainer before landing |
| Core team member | Review 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.
A tool decision has to reach the agent in the form it honors
A deny or rewrite that the translation drops fails open: the tool runs, and since the reason still reaches the model as context, the agent can even report the call as blocked. That was the state of Claude, Codex and Copilot until each from_symposium mapped decision and updatedInput, so check the wire output per agent (tests/hook_decisions.rs) whenever a PreToolUse field is added. Two agents need more than a field:
- Claude and Codex apply
updatedInputonly alongside apermissionDecision, and"allow"skips the user’s permission prompt. Claude gets"ask"; Codex has no"ask", so a rewrite is not applied there. - Kiro reads no decision from stdout. A denial becomes exit 2 in the Kiro handler’s
respond, which is why the pipeline returns aHookResponserather than bytes.
Copilot also runs Claude Code’s hooks
Copilot CLI reads .claude/settings.json as well as its own hook files. In a project where both agents are configured, every Copilot event runs cargo agents hook twice: as copilot from .github/hooks/symposium.json, and as claude from Claude’s settings. Plugin hooks with side effects run twice, and whatever symposium tells the agent once (such as a notice) is taken by whichever invocation runs first.
Hook stdout belongs to the agent
An agent parses everything cargo agents hook writes to stdout. One line ahead of the JSON and Claude Code falls back to treating it all as plain text (which reaches the model only on SessionStart and UserPromptSubmit), while Copilot drops the output entirely. The in-process test pipeline cannot see this, because the leaks come from around it: the report layer, which the binary installs (and leaves out for hooks), and child processes that inherit stdout (plugin install_commands send theirs to stderr). Anything new that prints during a hook, or spawns a child that might, needs the same care; tests/hook_stdout.rs runs the real binary to catch it.
Antigravity footguns
Two Antigravity behaviours fail silently rather than loudly, so they are worth knowing before debugging a hook that “does nothing”.
{} on PreToolUse denies the tool call
Antigravity ignores hook exit codes entirely; only stdout decides. On
PreToolUse, an object without a valid decision — {} included, as well as
{"decision": ""} — is treated as a denial, while writing nothing at all
allows. Symposium’s dispatcher would otherwise return {} whenever no plugin contributed, which
is the common case, so AntigravityPreToolUseOutput keeps decision as a plain
always-serialized field defaulting to allow. Making it Option or adding
skip_serializing_if would block every tool call.
Unknown event names and shapes are accepted and never fire
An unrecognised event key in hooks.json, or the wrong structure for a known one
(a flat handler list where a matcher group is expected, or vice versa), produces
no error — the hook simply never runs. ANTIGRAVITY_EVENTS and
antigravity_is_tool_event in agents/mod.rs are the single source of truth for
both, and the unit tests assert the shape per event for exactly this reason.
Symlinks in source trees
DirEntry::file_type() reports the entry, so a symlink is neither is_dir() nor is_file() and falls through the gap between the two branches — silently, with no error and no warning. That is how installing a skill used to drop every symlinked file in it: sync’s copier had exactly that shape, and so did the scan it compares against. fs::metadata follows the link; DirEntry::metadata does not. Reach for dir_walk::resolved_kind instead of either.
Path::is_dir() does follow links, which is the other half of the trap: a walk written that way already descends into linked directories, so it can already loop on a link naming a directory it is inside. Guard any such walk with dir_walk::Ancestors.
And when two walks over one tree feed each other — a copy, and the comparison that decides whether to copy — they have to be the same traversal. Fixing one and not the other does not show up as a wrong file; it shows up as a sync that reinstalls the same skill forever.
Plugin-supplied names are untrusted input
Plugin content must not decide where Symposium writes. A name it supplies, such as a skill’s name, can resolve outside the directory it is joined onto (Path::join does not confine it), so it never becomes part of a path unchecked. Validate it with skills::is_safe_dir_name, which also covers Windows-specific path forms, rather than with a check of your own.
A source that reads as empty is not an empty source
A listing that swallows read errors returns the same empty list for “nothing there” and “could not look”, and anything that removes what a source stopped offering turns the second into data loss: a [[registry]] path on an unmounted share would make sync delete every skill installed from it. So PackageManager::registry_plugins fails when a registry cannot be read, PluginRegistry::unreadable_registries carries that to sync, and sync skips its stale-skill cleanup while that list is not empty. Any new pass that removes things because a source stopped offering them belongs behind the same condition. The opposite mistake is as bad: a source that is legitimately absent must not count as unreadable, or cleanup never runs again, which is why the built-in ~/.symposium/plugins/ is left out when nothing exists at that path.
Windows portability (tests)
The test suite runs on windows-latest, where CI passes --no-fail-fast so that one failing test binary does not hide the failures in every binary cargo would otherwise skip. 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. Seesetup_fixtureinsymposium-testlib. - Paths inside
shscript bodies. On Windowsshis git-bash’s MSYS shell, which readsC:\a\bas escapes plus an illegal:. Rewrite to the/c/a/bform and quote the value. Seesh_pathinpredicate.rstests. .shfiles must usescript, notexecutable. A shell script cannot be spawned directly as a process on Windows (no shebang support). In fixtures, reference it viascript = "..."so it is run throughsh, neverexecutable = "...".- Canonicalized paths carry a
\\?\prefix.fs::canonicalizeon Windows returns an extended-length path thatcargo’s output lacks. Canonicalize both sides before comparing. - Paths inside asserted messages. A report event or error that embeds a path renders it with backslashes on Windows, so an assertion matching a
/-separated substring fails there. Normalize withreplace('\\', "/")before matching, the waynormalize_pathsdoes for snapshots. - Snapshot tests and home-abbreviated paths.
display_path(inoutput.rs) abbreviates$HOMEto~/. On Windows the test temp dir lives under$HOME, so printed config paths come out home-relative, not absolute.normalize_paths(insymposium-testlib) replaces both the absolute and the~/form; a snapshot leaking a random.tmpXXXX/path means one form was missed. Do notUPDATE_EXPECTyour way past it: that bakes the volatile temp path into the snapshot and it fails on the next run.
Capturing report events in tests
A test that asserts on ReportEvents installs a ReportLayer with tracing::subscriber::set_default, which is thread-local. tracing caches callsite interest and the global max level process-wide, recomputing both from whichever dispatchers are alive the first time a callsite is reached. Tests run in parallel, so a thread with no subscriber can reach a reporting callsite first and pin it to Interest::never() for every thread; the capturing test then drains zero events, which looks like the command reporting nothing. The failure is timing-dependent, so it shows up as a platform-specific flake rather than a reproducible failure.
install_tracing_baseline (in symposium-testlib) installs a permissive global default subscriber once per test process to keep that cache open, and a thread-local set_default still takes precedence over it. setup_fixture calls it, and so must any helper that installs its own report layer.
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:
- Hook registration — where and how to write config so the agent calls
cargo-agents hook - Hook I/O protocol — event names, input/output field names, exit code semantics
- Extension installation — where skill files go (project and global)
- 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
| Agent | Project config path | Global config path | Format |
|---|---|---|---|
| Antigravity CLI | .agents/hooks.json | ~/.gemini/config/hooks.json | JSON, named entries per hook |
| Claude Code | .claude/settings.json | ~/.claude/settings.json | JSON, hooks key with matcher groups |
| GitHub Copilot | .github/hooks/*.json | ~/.copilot/settings.json | JSON, version: 1 with hooks key |
| Codex CLI | .codex/hooks.json | ~/.codex/hooks.json | JSON, hooks key with matcher groups |
| Kiro | .kiro/agents/*.json | ~/.kiro/agents/*.json | JSON, 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
| Agent | Command field | Platform-specific? |
|---|---|---|
| Antigravity CLI | command | No |
| Claude Code | command | No |
| GitHub Copilot | bash / powershell | Yes |
| Codex CLI | command | No |
| Kiro | command | No |
| OpenCode | N/A (JS function) | N/A |
| Goose | N/A | N/A |
Timeout defaults
| Agent | Default timeout | Unit |
|---|---|---|
| Antigravity CLI | 30 | seconds (timeout) |
| Claude Code | 600 | seconds |
| GitHub Copilot | 30 | seconds (timeoutSec) |
| Codex CLI | 600 | seconds (timeout or timeoutSec) |
| Kiro | 30,000 | milliseconds (timeout_ms) |
| OpenCode | 60,000 | milliseconds (community hooks plugin) |
| Goose | N/A | N/A |
Event names
Symposium registers hooks for four events on every agent with shell hooks, plus stop on Antigravity and Claude Code. Each agent uses different names and casing conventions; not registered marks an event the agent has but symposium does not hook.
| Symposium event | Antigravity CLI | Claude Code | Copilot | Codex CLI | Kiro CLI | OpenCode | Goose |
|---|---|---|---|---|---|---|---|
| pre-tool-use | PreToolUse | PreToolUse | preToolUse | PreToolUse | preToolUse | tool.execute.before | N/A |
| post-tool-use | PostToolUse | PostToolUse | postToolUse | PostToolUse | postToolUse | tool.execute.after | N/A |
| user-prompt-submit | PreInvocation | UserPromptSubmit | userPromptSubmitted | UserPromptSubmit | userPromptSubmit | message.updated (filter by role) | N/A |
| session-start | SessionStart | SessionStart | sessionStart | SessionStart | agentSpawn | session.created | N/A |
| stop | Stop | Stop | not registered (agentStop) | not registered (Stop) | not registered (stop) | N/A | N/A |
Blocking support
Not all events can block the action in all agents.
| Agent | Pre-tool-use can block? | Post-tool-use can block? | User-prompt can block? | Session-start can block? |
|---|---|---|---|---|
| Antigravity CLI | Yes | No | No | No |
| Claude Code | Yes | No | Yes (exit 2) | No |
| GitHub Copilot | Yes | No | No | No |
| Codex CLI | Yes | Yes (continue: false) | Yes (continue: false) | Yes (continue: false) |
| Kiro | Yes (exit 2) | No | No | No |
| OpenCode | Yes (throw Error) | No | No (observe only) | No (observe only) |
| Goose | N/A | N/A | N/A | N/A |
Hook I/O protocol
Input fields (pre-tool-use)
| Agent | Tool name field | Tool args field | Session/context fields |
|---|---|---|---|
| Antigravity CLI | toolCall.name | toolCall.args (object) | conversationId, workspacePaths (array), stepIdx |
| Claude Code | tool_name | tool_input (object) | session_id, cwd, hook_event_name |
| GitHub Copilot | toolName | toolArgs (JSON string) | timestamp, cwd |
| Codex CLI | tool_name | tool_input (object) | session_id, cwd, hook_event_name, model |
| Kiro | tool_name | tool_input (object) | hook_event_name, cwd |
| OpenCode | tool | args (mutable output object) | sessionID, callID |
| Goose | N/A | N/A | N/A |
Output structure (pre-tool-use)
| Agent | Permission decision field | Decision values | Modified input field | Nesting |
|---|---|---|---|---|
| Antigravity CLI | decision (required) | allow, deny, ask, force_ask | overwrite | flat |
| Claude Code | permissionDecision | allow, deny, ask, defer | updatedInput | nested in hookSpecificOutput |
| GitHub Copilot | permissionDecision | allow, deny, ask | modifiedArgs | flat |
| Codex CLI | decision or permissionDecision | block/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.args | JS mutation |
| Goose | N/A | N/A | N/A | N/A |
Exit codes
All shell-based agents use the same convention (where applicable):
| Code | Meaning |
|---|---|
0 | Success; stdout parsed as JSON |
2 | Block/deny; stderr used as reason |
| Other | Non-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. Antigravity ignores exit codes entirely — only stdout decides, and on PreToolUse an object without a valid decision ({} included) is a denial.
Extension installation
Skill file paths
| Agent | Project skills path | Global skills path |
|---|---|---|
| Antigravity CLI | .agents/skills/<name>/SKILL.md | ~/.gemini/config/skills/<name>/SKILL.md |
| Claude Code | .claude/skills/<name>/SKILL.md | ~/.claude/skills/<name>/SKILL.md |
| GitHub Copilot | .agents/skills/<name>/SKILL.md | (none) |
| 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
| Agent | Project instructions | Global instructions |
|---|---|---|
| Antigravity CLI | AGENTS.md, GEMINI.md, .agents/rules/*.md | (none) |
| Claude Code | CLAUDE.md, .claude/CLAUDE.md | ~/.claude/CLAUDE.md |
| GitHub Copilot | .github/copilot-instructions.md, AGENTS.md | ~/.copilot/copilot-instructions.md |
| Codex CLI | AGENTS.md (each dir level) | ~/.codex/AGENTS.md |
| Kiro | .kiro/steering/*.md, AGENTS.md | ~/.kiro/steering/*.md |
| OpenCode | AGENTS.md, CLAUDE.md | ~/.config/opencode/AGENTS.md |
| Goose | .goosehints, AGENTS.md | ~/.config/goose/.goosehints |
MCP server configuration
Relevant if symposium exposes functionality via MCP.
An agent’s MCP file is usually not the file its hooks live in, and the
per-agent entry shapes differ more than they look. Agent::mcp_config_path
encodes this table; every row marked verified was confirmed by asking the tool
(<tool> mcp list reporting an entry symposium wrote, and <tool> mcp add
showing which file it chooses), because a wrong guess here fails silently -
symposium reports success for a file the agent never reads.
| Agent | Project scope | User scope | Entry shape | Verified |
|---|---|---|---|---|
| Claude Code | <project>/.mcp.json | ~/.claude.json | mcpServers.<name> = {command, args} | yes |
| Antigravity CLI | <project>/.agents/mcp_config.json | ~/.gemini/config/mcp_config.json | mcpServers.<name> = {command, args} | yes (agy mcp add) |
| OpenCode | <project>/opencode.json | ~/.config/opencode/opencode.json | mcp.<name> = {type: "local", command: [bin, ...args], enabled, environment} | yes |
| Codex CLI | (none - user scope only) | ~/.codex/config.toml | [mcp_servers.<name>] = command, args | yes |
| GitHub Copilot CLI | (none - user scope only) | ~/.copilot/mcp-config.json | mcpServers.<name> = {command, args} | yes |
| Kiro | .kiro/settings/mcp.json | ~/.kiro/settings/mcp.json | mcpServers.<name> = {command, args} | no (GUI only) |
| Goose | (none - user scope only) | ~/.config/goose/config.yaml | extensions.<name> = {name, type: stdio, cmd, args, enabled, envs} | yes |
Notes that cost real debugging time:
- Claude Code ignores
mcpServersinsettings.jsonat both scopes - that file is hooks only. See Claude Code. - The Copilot CLI requires the
mcpServerswrapper; entries written bare at the top level make it reject the whole file (mcpServers: Required), taking the user’s own servers down with it..vscode/mcp.jsonbelongs to the VS Code extension, a different product symposium does not currently target. - OpenCode rejects the entire config file for a wrong entry shape, so its
serializer is separate: the command is one array, and env vars go under
environment(anenvkey parses but never reaches the child). - Codex, Copilot CLI and Goose have no project-level MCP config, so project scope resolves to their user-level file rather than to a file nobody reads.
- Antigravity keeps MCP in its own
mcp_config.json, not in thehooks.jsonbeside it, so a broken hooks file cannot take MCP down with it.agy mcp addhas no scope flag and always writes the user-level file. - Goose uses
cmd, notcommand, and requires atype; the nestedprovider: mcp/config:form it once got is rejected outright. Remote istype: streamable_httpwithuri- Goose has nossevariant. env/headersmust be maps. ACP models them as[{name, value}]pairs, and a list is skipped or rejected - silently, for an entry that differs from a working one only by carrying env. Codex takes env as a TOML table, Goose asenvs, OpenCode asenvironment, everyone else asenv.- The Copilot CLI also needs an explicit
type(local/http/sse), or a remote entry never appears.
Antigravity CLI Hooks Reference
Disclaimer: This document reflects our current understanding of Antigravity 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 · Plugins & Skills · GitHub repo · the bundled
agy-customizationsskill under~/.gemini/antigravity-cli/builtin/skills/, which is more precise than the website
Google’s Antigravity CLI (agy) exposes shell-command hooks through a dedicated
hooks.json. Antigravity ships three surfaces — the CLI, the IDE, and the web
app — which share the same configuration roots.
Configuration
| File | Scope |
|---|---|
~/.gemini/config/hooks.json | User-global |
<repo>/.agents/hooks.json | Project-scoped |
Both are additive; all matching hooks run, each with its working directory set to
the directory containing its own hooks.json.
Project scope works in ordinary interactive use: agy walks up from the working
directory to find .agents/, and loads project hooks on a second pass once it has
adopted the workspace.
Headless print mode is the exception. agy -p adopts no workspace unless
given --add-dir <absolute path> — it cannot read project files and loads no
project .agents/ configuration. A relative --add-dir . is ignored. Automation
driving agy -p against a project must pass an absolute path.
Configuration structure
Each top-level key is a hook name mapping to its events. PreToolUse and
PostToolUse wrap handlers in a matcher group; PreInvocation,
PostInvocation and Stop take flat handler lists.
{
"symposium": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "cargo-agents hook antigravity pre-tool-use", "timeout": 30 }
]
}
],
"PreInvocation": [
{ "type": "command", "command": "cargo-agents hook antigravity user-prompt-submit", "timeout": 30 }
]
}
}
enabled: false on a named hook disables all its handlers. timeout is in
seconds and defaults to 30. Only type: "command" is supported; commands run
via sh -c (cmd /c on Windows).
Events
| Event | When it fires | Matcher |
|---|---|---|
PreToolUse | before a tool step executes | tool name |
PostToolUse | after a tool step completes | tool name |
PreInvocation | before the model is called | n/a |
PostInvocation | after each model invocation completes | n/a |
Stop | when the execution loop terminates | n/a |
SessionStart | with the first turn of a new conversation | n/a |
SessionStart is absent from the official documentation but present in the
binary’s hook proto (the HookArgs oneof has exactly six variants: PreTool,
PostTool, PreInvocation, PostInvocation, SessionStart, Stop), and it
works: registered in hooks.json it loads and carries a populated
workspacePaths. It fires with the first turn of a new conversation, in the
TUI at the first prompt rather than at launch. Resuming a conversation (-c,
--conversation, or the TUI) starts a new process that never fires it, so a
resumed conversation skips symposium’s SessionStart refresh until a new
conversation starts. PreInvocation by contrast fires before every model call,
and its invocationNum restarts at 0 each turn, so it marks the start of a turn
rather than of a session. That is why symposium maps it to user-prompt-submit
and dispatches only invocationNum == 0 (AgentHookInput::should_dispatch).
Verified on agy 1.2.7.
Unknown event keys are accepted silently and never fire, so a misspelled event name fails with no error at all.
Matchers are regexes over tool names, which are the lowercased step type without
its CORTEX_STEP_TYPE_ prefix — run_command, view_file, browser_.*. A
matcher written for another agent’s tool names (Bash, Edit) will not match.
Input Schema (stdin)
All keys are camelCase (protojson).
Base fields (all events)
{
"conversationId": "5e4f131c-…",
"workspacePaths": ["/path/to/workspace"],
"transcriptPath": "…/transcript_full.jsonl",
"artifactDirectoryPath": "…",
"modelName": "gemini-3.7-flash-high"
}
workspacePaths is populated once the workspace is adopted, and empty under
agy -p without --add-dir. A hook’s working directory is always the directory
holding its own hooks.json, never the user’s project.
PreToolUse / PostToolUse additions
{
"toolCall": { "name": "run_command", "args": { "CommandLine": "npm test" } },
"stepIdx": 2,
"error": ""
}
error is present on PostToolUse only. Both events carry toolCall.
PreInvocation / PostInvocation additions
{ "invocationNum": 0, "initialNumSteps": 1 }
Stop additions
{ "executionNum": 0, "terminationReason": "NO_TOOL_CALL", "error": "", "fullyIdle": true }
Output Schema (stdout)
PreToolUse
{ "decision": "allow", "reason": "optional", "permissionOverrides": [] }
decision is required: allow, deny, ask, force_ask, or
deny_unless_prior_grant. An overwrite object shallow-merges into the tool
call’s arguments before it runs.
Writing {} denies the call. So do {"decision": ""} and objects carrying
only other fields. Writing nothing at all allows. Any hook that does not intend to
block must emit an explicit {"decision": "allow"}.
PostToolUse
Expects {}.
PreInvocation — inject context
{ "injectSteps": [{ "ephemeralMessage": "..." }] }
Each step accepts one of toolCall, userMessage, or ephemeralMessage. This is
the equivalent of Claude Code’s additionalContext.
PostInvocation
injectSteps as above, plus terminationBehavior: force_continue, terminate,
or omitted.
Stop
{ "decision": "continue", "reason": "required when continuing" }
Any value other than continue lets the agent stop.
Exit Codes
Ignored. Exit 1 and exit 2 behave exactly as exit 0; only stdout decides the outcome. This differs from every other agent symposium supports, where exit 2 blocks.
Skills
| Scope | Path |
|---|---|
| Project | <repo>/.agents/skills/<name>/SKILL.md |
| Global | ~/.gemini/config/skills/<name>/SKILL.md |
A skill is a directory containing SKILL.md with name and description
frontmatter. Additional files and subdirectories (scripts/, examples/,
resources/, references/) are supported, so symposium’s .symposium marker and
.gitignore are preserved. The CLI also reads
~/.gemini/antigravity-cli/skills/ and ~/.gemini/skills/, but
~/.gemini/config/skills/ is the location all three surfaces recognize.
Registering skills from another location
~/.gemini/config/skills.json registers skill directories stored outside the
default locations, and is read on every run:
{ "entries": [{ "path": "/abs/path/to/repo/.agents/skills", "exclude": ["experimental-.*"] }] }
Paths must be absolute. The schema also documents workspace-relative paths,
but an entry of .agents/skills resolves to nothing in practice.
The list is global with no notion of the active repository, so every indexed
directory loads in every session; include_only and exclude filter by skill
directory name, not by workspace. Symposium does not use this file — project
skills are found by ordinary discovery — but it is the mechanism for skills kept
outside the standard locations.
Symlinks are followed, both a symlinked skill directory inside a skills folder
and a symlink of the folder itself. A separate report of symlinked skills being
ignored concerns the IDE and ~/.gemini/antigravity/skills/.
plugins.json follows the same schema for plugin directories.
MCP server configuration
| Scope | Path |
|---|---|
| Project | <repo>/.agents/mcp_config.json |
| Global | ~/.gemini/config/mcp_config.json |
Standard mcpServers object; stdio entries use command/args/env, remote
entries use serverUrl. Unlike Gemini CLI, MCP configuration does not share a
file with hooks. agy mcp add has no scope flag and writes the global file.
Custom instructions
GEMINI.md and AGENTS.md at the workspace root, plus .agents/rules/*.md,
loaded by walking up to the repository root.
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
| Type | Description |
|---|---|
command | Shell script; communicates via stdin/stdout/exit codes |
http | POSTs JSON to a URL endpoint; supports header interpolation with $VAR_NAME |
prompt | Single-turn LLM evaluation returning {ok: true/false, reason} |
agent | Spawns a subagent with tool access (Read, Grep, Glob) for up to 50 turns |
Events
| Event | Trigger | Can block? | Matcher target |
|---|---|---|---|
SessionStart | Session begins/resumes | No | startup, resume, clear, compact |
SessionEnd | Session terminates (1.5s default timeout) | No | clear, resume, logout, etc. |
UserPromptSubmit | User submits prompt, before processing | Yes (exit 2) | None |
PreToolUse | Before tool call | Yes | Tool name regex (Bash, Edit|Write, mcp__.*) |
PostToolUse | After tool succeeds | No | Tool name regex |
PostToolUseFailure | Tool fails | No | Tool name regex |
PermissionRequest | Permission dialog appears | Yes | Tool name regex |
PermissionDenied | Auto-mode classifier denial | No (retry: true available) | Tool name regex |
Stop | Main agent finishes responding | Yes | None |
StopFailure | Turn ends on API error | No (output ignored) | rate_limit, authentication_failed, etc. |
SubagentStart | Subagent spawned | No | Agent type |
SubagentStop | Subagent finishes | Yes | Agent type |
Notification | System notification | No | permission_prompt, idle_prompt, etc. |
TaskCreated | Task created | Yes (exit 2 rolls back) | None |
TaskCompleted | Task completed | Yes (exit 2 rolls back) | None |
TeammateIdle | Teammate about to go idle | Yes | None |
ConfigChange | Config file changes during session | Yes (except policy_settings) | Config source |
CwdChanged | Directory change | No | None |
FileChanged | Watched file changes | No | Basename |
WorktreeCreate | Git worktree created | Yes (non-zero fails) | None |
WorktreeRemove | Git worktree removed | No | None |
PreCompact | Before compaction | No | manual, auto |
PostCompact | After compaction | No | manual, auto |
InstructionsLoaded | CLAUDE.md loaded | No | Load reason |
Elicitation | MCP server requests user input | Yes | MCP server name |
ElicitationResult | MCP elicitation result | Yes | MCP server name |
Configuration
Settings merge with precedence (highest first): Managed → Command line → Local → Project → User.
| File | Scope |
|---|---|
Managed policy (MDM, registry, server, /etc/claude-code/) | Organization-wide |
.claude/settings.local.json | Single project, gitignored |
.claude/settings.json | Single project, committable |
~/.claude/settings.json | All projects (user) |
CLAUDE_CONFIG_DIR moves the user level as a whole: when it is set, Claude Code
reads $CLAUDE_CONFIG_DIR/settings.json and $CLAUDE_CONFIG_DIR/.claude.json
and ignores ~/.claude/settings.json and ~/.claude.json, so symposium writes
its global hooks and user-level MCP servers there. Verified against Claude Code
2.1.284 with the variable set: a hook and an MCP server left in the ~/ files
were ignored, while symposium’s hooks under $CLAUDE_CONFIG_DIR fired
(hook_started/hook_response for SessionStart in
claude -p --output-format stream-json --verbose) and claude mcp list showed
its server.
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: stringtool_input: object with tool-specific fields (commandfor Bash,file_path/contentfor 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: booleanlast_assistant_message: string
Output Schema (stdout)
Output is capped at 10,000 characters.
Universal fields
| Field | Type | Description |
|---|---|---|
continue | boolean | false stops Claude entirely |
stopReason | string | Message for user when continue is false |
systemMessage | string | Warning shown to user |
suppressOutput | boolean | Omits 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
| Code | Meaning |
|---|---|
0 | Success; stdout parsed as JSON |
2 | Blocking error — action blocked, stderr fed to Claude |
| Other | Non-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
| Variable | Description |
|---|---|
CLAUDE_PROJECT_DIR | Absolute path to project root |
CLAUDE_ENV_FILE | File 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
bypassPermissionsmode.
MCP Server Registration
In addition to hooks, symposium registers itself as an MCP server in the agent’s MCP configuration. This provides an alternative integration path alongside the hook-based approach.
Configuration structure
The entry goes under mcpServers - but not in the settings.json that
carries hooks. Claude Code does not read mcpServers from a settings file at
either scope; entries written there are silently ignored (claude mcp list
shows nothing, and a session sees no tools). MCP has its own two files:
{
"mcpServers": {
"symposium": {
"command": "/path/to/cargo-agents",
"args": ["mcp"]
}
}
}
- Project-level:
<project>/.mcp.json - User-level:
~/.claude.json(or$CLAUDE_CONFIG_DIR/.claude.json)
Confirmed against the CLI: claude mcp add -s project writes the former,
-s user the latter.
A server in a project .mcp.json starts out pending approval - Claude asks
before trusting a server a repository supplies. Symposium deliberately does not
pre-approve its own entries (that would mean overriding a trust prompt on the
user’s behalf); approve once via /mcp, or add the name to
enabledMcpjsonServers in your own settings if you want it standing.
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)
| Event | Trigger | Can block? |
|---|---|---|
sessionStart | New or resumed session | No |
sessionEnd | Session completes or terminates | No |
userPromptSubmitted | User submits a prompt | No |
preToolUse | Before tool call | Yes |
postToolUse | After tool completes (success or failure) | No |
errorOccurred | Error during agent execution | No |
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, though they can still add context (see what the CLI actually reads).
Configuration
Cloud Agent and CLI
Hooks defined in .github/hooks/*.json. For the Cloud Agent, files must be on the repository’s default branch.
The CLI combines hooks from several places: repository .github/hooks/*.json, user-level ~/.copilot/hooks/*.json or a hooks key in ~/.copilot/settings.json (where symposium registers globally; config.json is managed by the CLI), and also .claude/settings.json. It loads a repository’s hooks only once the folder is trusted: it asks interactively, or COPILOT_ALLOW_ALL=true trusts the working directory. Because it reads Claude’s settings too, a project with both agents configured runs symposium’s hook twice per Copilot event.
COPILOT_HOME moves the user level as a whole: when it is set, the CLI reads settings.json and mcp-config.json from $COPILOT_HOME instead of ~/.copilot/, so symposium writes its global hooks and MCP servers there. Verified against the Copilot CLI 1.0.89 with the variable set: symposium’s hooks and MCP server left in ~/.copilot/ were ignored (copilot mcp list reported no server and no hook ran), while with them under $COPILOT_HOME each event ran cargo-agents hook copilot, and copilot mcp list and the /mcp dashboard showed the server, which a session started and offered as a tool. The CLI still keeps its cache of each MCP server’s tools in ~/Library/Caches/copilot/mcp-tools/ on macOS, which the variable does not move.
{
"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"
}
]
}
}
| Field | Type | Description |
|---|---|---|
type | string | Must be "command" |
bash | string | Command for Linux/macOS |
powershell | string | Command for Windows |
cwd | string | Working directory relative to repo root |
env | object | Environment variables |
timeoutSec | number | Default 30 seconds |
comment | string | Documentation, 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 bash→osx/linux, powershell→windows.
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"
}
| Value | Meaning |
|---|---|
"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.
What the CLI actually reads
Observed on Copilot CLI 1.0.80 (2026-09-29), with a hook per event returning a different marker and the model asked which markers it saw:
- A top-level
additionalContextreached the model fromsessionStart,userPromptSubmitted,preToolUseandpostToolUse. GitHub’s hooks reference says otherwise for two of them: thatuserPromptSubmittedoutput from config-file hooks is dropped, and thatpreToolUsetakes no context. - Output that is not pure JSON (for example, a progress line ahead of the object) is logged and dropped, on every event. Nothing reaches the model, not even at session start.
preToolUsehonorspermissionDecisionandpermissionDecisionReason, andmodifiedArgsrewrites the call.- The shell tool is named
bash(lowercase) in the hook input.
VS Code output (preview, extended fields)
| Field | Type | Description |
|---|---|---|
continue | boolean | false stops agent |
stopReason | string | Message when continue is false |
systemMessage | string | Warning shown to user |
hookSpecificOutput.permissionDecision | string | allow, deny, ask |
hookSpecificOutput.updatedInput | object | Replace tool arguments |
hookSpecificOutput.additionalContext | string | Extra 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.
Additional Extension Surfaces (not hooks, but related)
Custom instructions (soft/probabilistic)
| File | Scope |
|---|---|
.github/copilot-instructions.md | Repository-wide instructions |
.github/instructions/**/*.instructions.md | Path-specific instructions (with applyTo globs) |
AGENTS.md | Agent-mode instructions |
~/.copilot/copilot-instructions.md | User-level (personal) |
| Organization-level instructions | Admin-configured |
Priority: Personal (user) > Repository (workspace) > Organization.
MCP server configuration
| Scope | Config path | Root key |
|---|---|---|
| VS Code (workspace) | .vscode/mcp.json | servers |
| VS Code (user) | Via “MCP: Open User Configuration” command | servers |
| CLI | ~/.copilot/mcp-config.json | mcpServers |
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($COPILOT_HOME/mcp-config.jsonwhen set)
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 returnmodifiedArgsonPostToolUse— can returnmodifiedResultonSessionStart,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.
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 first shipped in v0.114 (March 2026), behind an opt-in codex_hooks flag; hooks are now on by default, and [features] hooks = false in ~/.codex/config.toml turns them off.
Trust
Codex runs a non-managed hook only after the user trusts that exact definition. Trust is recorded against the hook’s hash, so a new or changed hook is skipped until reviewed: Codex prints a startup warning pointing at /hooks, where hooks are reviewed, trusted or disabled. Project hooks additionally load only when the project’s .codex/ layer is trusted. For automation that vets hooks itself, codex exec --dangerously-bypass-hook-trust runs enabled hooks for one invocation. Observed on Codex 0.147: until trusted, symposium’s hooks simply do not run.
Configuration
| File | Scope |
|---|---|
~/.codex/hooks.json | User-global |
<repo>/.codex/hooks.json | Project-scoped |
Both are additive — all matching hooks from all files run. Project hooks follow the untrusted-project trust model.
CODEX_HOME moves the user level as a whole: when it is set, Codex reads
$CODEX_HOME/hooks.json and $CODEX_HOME/config.toml, and ignores
~/.codex/hooks.json and ~/.codex/config.toml. Symposium writes its global
hooks and MCP servers there too. Verified against Codex CLI 0.159.3 with the
variable set: the TUI’s hook review listed symposium’s four hooks with
$CODEX_HOME/hooks.json as their source, and codex mcp list showed its
server.
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
| Event | Trigger | Matcher filters on | Can block? |
|---|---|---|---|
SessionStart | Session starts or resumes | source ("startup" or "resume") | Yes (continue: false) |
PreToolUse | Before tool execution | tool_name ("Bash", "apply_patch", MCP tools) | Yes |
PostToolUse | After tool execution | tool_name ("Bash", "apply_patch", MCP tools) | Yes (continue: false) |
UserPromptSubmit | User submits a prompt | N/A | Yes (continue: false) |
Stop | Agent turn completes | N/A | Yes (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: stringtool_use_id: stringtool_input: object withcommandfield
PostToolUse additions
tool_name,tool_use_id,tool_input(same as PreToolUse)tool_response: string
UserPromptSubmit additions
prompt: string
Stop additions
stop_hook_active: booleanlast_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
| Code | Meaning |
|---|---|
0 | Success; stdout parsed. No output = continue normally. |
2 | Block/deny; stderr used as reason |
| Other | Non-blocking warning |
Execution Behavior
- Multiple matching hooks run concurrently — no ordering guarantees.
- Commands run with session
cwdas working directory. - Shell expansion works.
Rewriting tool input
Per the current docs, PreToolUse can rewrite a call with hookSpecificOutput.updatedInput, but only together with permissionDecision: "allow", which also approves the call: for Bash and apply_patch it is a string command, for MCP and other tools the replacement arguments object. There is no "ask" to leave the rewritten call to the user. Earlier releases accepted updatedInput without applying it.
Current Limitations
- 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
| Scope | Path |
|---|---|
| Global | ~/.codex/AGENTS.md (or AGENTS.override.md) |
| Project | AGENTS.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
| Scope | Path |
|---|---|
| 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 |
| System | Bundled 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($CODEX_HOME/config.tomlwhen set)
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
notifyin 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.
| Mode | Behavior |
|---|---|
auto | Tools execute without approval (default) |
approve | Every tool call requires manual confirmation |
smart_approve | AI risk assessment auto-approves low-risk, prompts for high-risk |
chat | No 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.
| File | Scope |
|---|---|
~/.config/goose/.goosehints | Global |
.goosehints (project root) | Project |
AGENTS.md | Project |
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. Goose’s
schema has its own vocabulary - a type discriminant, the binary under cmd
(not command), and env as an envs map:
extensions:
symposium:
name: symposium
type: stdio
cmd: /path/to/cargo-agents
args: [mcp]
enabled: true
envs:
TOKEN: abc
A remote server is type: streamable_http with the endpoint under uri. There
is no sse variant - the accepted set is stdio, builtin, platform,
streamable_http, frontend, inline_python, which goose recipe validate
will list back at you for a wrong one:
extensions:
remote:
name: remote
type: streamable_http
uri: https://example.com/mcp
enabled: true
headers:
Authorization: Bearer t
- User-level:
~/.config/goose/config.yaml($GOOSE_PATH_ROOT/config/config.yamlwhen that variable is set, else$XDG_CONFIG_HOME/goose/config.yamlwhen that one is) - Project-level: none - Goose reads only the user-level file, so project-scoped registration resolves there.
GOOSE_PATH_ROOT moves that file: when it is set, Goose reads
$GOOSE_PATH_ROOT/config/config.yaml, whatever XDG_CONFIG_HOME says, and an
empty value counts as unset. Symposium writes its MCP servers there too.
Verified against Goose 1.54.0 with a different MCP server in each candidate
file, by which one goose run --text hi started: the one under
$GOOSE_PATH_ROOT when both variables were set, and the XDG_CONFIG_HOME one
when GOOSE_PATH_ROOT was empty. Through cargo agents sync with the variable
set, a server left in ~/.config/goose/config.yaml never started, while the
one symposium wrote under $GOOSE_PATH_ROOT started and was offered to the
model as a tool.
Two ways to check a change without a configured LLM provider: goose recipe validate on a recipe holding the entry (it deserializes the same
ExtensionConfig), and goose run --text hi, whose startup warnings name each
extension it tried to launch. goose info -v only echoes the raw YAML, so it
cannot tell an accepted entry from a rejected one.
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.
| File | Scope |
|---|---|
.kiro/agents/*.json | Project |
~/.kiro/agents/*.json | Global |
Agent Definition Fields
| Field | Type | Default if omitted |
|---|---|---|
name | string | Derived from filename |
description | string | (none) |
prompt | string or file:// URI | No custom system context |
tools | array of strings | No tools available |
allowedTools | array of strings/globs | All tools require confirmation |
toolAliases | object | (none) |
resources | array of URIs/objects | (none) |
hooks | object | (none) |
mcpServers | object | (none) |
toolsSettings | object | (none) |
includeMcpJson | boolean | (none) |
model | string | System default |
keyboardShortcut | string | (none) |
welcomeMessage | string | (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
| Event | Trigger | Matcher? | Can block? |
|---|---|---|---|
agentSpawn | Session starts | No | No |
userPromptSubmit | User submits prompt | No | No |
preToolUse | Before tool execution | Yes | Yes (exit 2) |
postToolUse | After tool execution | Yes | No |
stop | Agent finishes | No | No |
Input Schema (stdin)
All events include hook_event_name and cwd.
userPromptSubmit adds:
prompt: string
preToolUse adds:
tool_name: stringtool_input: object (full tool arguments)
postToolUse adds:
tool_name: stringtool_input: objecttool_response: string
MCP tools use @server/tool naming (e.g., @postgres/query).
Exit Codes
| Code | Meaning |
|---|---|
0 | Success; stdout captured as context |
2 | Block (preToolUse only); stderr sent to LLM as reason |
| Other | Warning; 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).agentSpawnhooks 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)
| Type | Trigger |
|---|---|
promptSubmit | User submits a prompt |
agentStop | Agent finishes responding |
preToolUse | Before tool execution |
postToolUse | After tool execution |
fileCreate | File created |
fileEdit | File saved |
fileDelete | File deleted |
preTaskExecution | Before spec task runs |
postTaskExecution | After spec task runs |
userTriggered | Manual invocation |
The IDE adds file-event and spec-task triggers not available in the CLI.
Action Types (2)
| Type | Description |
|---|---|
askAgent | Sends a natural language prompt to the agent (consumes credits) |
shellCommand | Runs 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_PROMPTenv var is available forpromptSubmitshell 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:
| Scope | Path |
|---|---|
| Workspace | .kiro/steering/*.md |
| Global | ~/.kiro/steering/*.md |
| Standard | AGENTS.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
| Scope | Path |
|---|---|
| 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
| Scope | Path |
|---|---|
| Workspace | .kiro/settings/mcp.json |
| Global | ~/.kiro/settings/mcp.json |
| Agent-level | mcpServers 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:
- Global config plugins (
~/.config/opencode/opencode.json→"plugin": [...]) - Project config plugins (
opencode.json) - Global plugin directory (
~/.config/opencode/plugins/) - 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
| Hook | Trigger | Control Flow |
|---|---|---|
event | Every system event (~30 types including session.idle, session.created, tool.execute.before, file.edited, permission.asked) | Observe only |
tool.execute.before | Before any built-in tool runs | Throw Error → block. Mutate output.args → modify tool arguments. Return normally → allow. |
tool.execute.after | After a built-in tool completes | Mutate output.title, output.output, output.metadata |
shell.env | Before any shell execution | Mutate output.env to inject environment variables |
stop | Agent attempts to stop | Call client.session.prompt() to prevent stopping and send more work |
config | During configuration loading | Mutate config object directly |
tool | Plugin load time (declarative) | Registers custom tools via tool() definitions; overrides built-ins with same name |
auth | Auth initialization | Object with provider, loader, methods |
chat.message | Chat message processing | Mutate message and parts via output object |
chat.params | Before LLM API call | Mutate temperature, topP, options via output object |
permission.ask | Permission requested | Set output.status to 'allow' or 'deny' — reportedly never called (bug #7006) |
Experimental Hooks (prefix experimental.)
| Hook | Description |
|---|---|
chat.system.transform | Push strings to output.system array to augment system prompt |
chat.messages.transform | Mutate output.messages array |
session.compacting | Push 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.beforeortool.execute.after. - Plugin-level syntax errors prevent loading entirely.
tool.execute.beforeerrors 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 identifierOPENCODE_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
| Scope | Path |
|---|---|
| Project | AGENTS.md at project root |
| Global | ~/.config/opencode/AGENTS.md |
| Legacy compat | CLAUDE.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
| Scope | Path |
|---|---|
| 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
Evaluating agent tasks with and without Symposium
TL;DR
- Add an internal
cargo xtask agent-evalrunner. It starts a real Claude Code or Codex CLI session on a small Rust task, with or without Symposium, then checks the code the agent left behind and what Symposium did along the way. - Start with two tasks.
formattingchecks that a skill and a hook reach a brand-new session.store-durabilitycompares the agent with and without a skill for a crate it cannot already know. - Each trial runs inside
srt, so the agent cannot read your files, other trials, or the tests used to grade it. - Designing this surfaced a suspected bug: Claude Code may not see skills that Symposium installs during the first session, because Symposium never asks it to reload them. We reproduce it first, then fix it.
Motivation
We want to measure whether Symposium changes how an agent performs a task. Three gaps prevent that today.
First, our agent-mode fixtures always initialize and synchronize Symposium before the agent starts (with_fixture in symposium-testlib/src/lib.rs runs init then sync). Skills are already installed when the session begins, so no test exercises the path a new user hits: the first SessionStart hook installing skills on its own. That path has a suspected gap: according to the Claude Code hooks documentation, Claude Code discovers skills before SessionStart hooks finish, so a skill a hook installs would be missing from the first prompt unless the hook asks for a rescan, and Symposium does not ask today.
Second, there is no baseline. Every agent-mode run has Symposium present, so we cannot compare an ordinary agent run against one with Symposium on the same task.
Third, delivering a skill is not the same as the agent using it. In Vercel’s evaluation, an agent never invoked an available skill in 56% of cases, and with the skill available but unprompted, results matched having no documentation at all. So we need to check whether a skill was used, not only whether it was installed.
This RFD builds the smallest runner that closes these gaps.
Change in a nutshell
| Task | Clients | Configurations |
|---|---|---|
formatting | Claude Code and Codex CLI | With Symposium |
store-durability | Claude Code and Codex CLI | With and without Symposium |
- What can I run? The two tasks above, with
cargo xtask agent-eval <task>. Developers run them by hand when changing how Symposium delivers skills and hooks; they call a model, so they never run in CI. - What happens? Each trial gets a fresh environment and its own agent session, then the runner checks what Symposium did and tests the agent’s code, and keeps everything.
- What must I write? A starter project, a prompt, the plugins to offer, what Symposium should do, and hidden tests.
- What are we building? An internal runner in
xtask, two client adapters, isolation withsrt, the checks, and the report. - What changes in Symposium? Reproduce and fix first-session skill loading on Claude Code, and add
Stophook support for Codex.
Out of scope for now: model-graded or human checks, more clients, parallel trials, and importing evals.json files.
The rest of this RFD follows the runner from the user’s side: what happens when you run an eval, using formatting, then comparing with and without Symposium, using store-durability, and what you do when you write an eval. The precise rules are on a reference page; you should not need them to follow the proposal.
A few terms, from Anthropic’s guide to agent evals:
- A task is one test: a starter project, a prompt, and what counts as success.
- A trial is one attempt at a task in one configuration, with or without Symposium.
- The transcript is the client’s full event log for a trial.
- A hidden test is a test stored with the task in
evals/and never copied into the agent’s project. The agent cannot see or change it.
Detailed plans
What happens when you run an eval
This proposal adds a command for running agent evaluations from the Symposium checkout. The command does not exist yet. You choose a task, a client (claude or codex), and a model; the runner prepares the projects, starts the agent, checks the results, and saves the evidence. The formatting task checks Symposium’s integration, while store-durability compares outcomes with and without Symposium.
Take the formatting task on Claude Code:
cargo xtask agent-eval formatting --client claude --model <model>
The task asks the agent to add a small function to a crate. Its plugin carries a rust-formatting skill and a Stop hook that runs cargo fmt when the agent finishes. Here is what the runner does.
1. Prepare a fresh trial. The runner builds cargo-agents from your checkout (or uses --symposium-bin <path>). It creates a trial directory with an empty home/, a copy of the starter in project/, and a bin/ holding claude and cargo-agents. Every process in the trial uses that home/ as HOME, so your own Claude Code, Codex, and Symposium configuration never leaks in.
It then runs cargo agents init with global hooks and a registry holding only the task’s plugins. It does not run cargo agents sync, so the skill directory starts empty. Installing the skill is Symposium’s job during the session, and that is part of what the trial checks.
2. Start the agent. The runner launches claude inside srt with the task’s prompt. The agent can write only inside its trial directory and can reach only the model API. It cannot see your home directory or evals/, where the hidden tests live; it can still read system and toolchain files. The reference gives the exact boundary.
3. Symposium syncs. When the session starts, Claude Code runs Symposium’s SessionStart hook. Symposium sees the project, installs the rust-formatting skill, and asks Claude Code to reload its skills so the first prompt can use it. That last part is the suspected bug described below: Symposium does not ask today.
4. The agent works. The prompt names the skill, so Claude should load it. When the agent finishes, the plugin’s Stop hook records whether the code was already formatted and then runs cargo fmt.
5. Check what Symposium did. The runner checks that:
- the
SessionStarthook ran and succeeded, and Symposium recorded a successful sync; - the skill was installed, with Symposium’s
.symposiummarker and content matching the plugin; - the agent successfully loaded the skill, according to the transcript;
- the
Stophook left a successful record.
A successful load shows the skill reached the agent in that same session. It does not show the agent followed it, and it does not show whether the client listed the skill on its own.
6. Check the code. Grading never runs in the agent’s project. The runner builds a fresh copy of the starter and takes only the agent’s src/ from the submission, so the agent cannot weaken a test by editing Cargo.toml or a build script. If that copy does not build, the trial fails, the remaining code checks are skipped, and its evidence is still kept. Otherwise the runner runs the agent’s own cargo test --lib, each hidden test, and cargo fmt --check.
The agent’s own tests alone prove little, since they could pass with nothing meaningful in them. The hidden tests are what check the behavior the task asked for.
7. Report the result. A formatting trial passes when every check in steps 5 and 6 passes, and fails when any of them fails. It is inconclusive when the evidence cannot decide a check and nothing failed: for example, the runner could not interpret the transcript, or the hook evidence is missing or contradicts the transcript. A timeout or client error is a failure. The reference has the exact rules.
The trial’s result directory keeps the transcript, the submission as the agent left it, the grading output, the hook traces, and Symposium’s log, so any result can be checked by hand. A rerun creates a new batch; earlier results are never overwritten.
Comparing with and without Symposium
formatting only runs with Symposium, because it asks whether Symposium delivered. The interesting question for a capability task is whether Symposium helps at all, which needs a baseline:
cargo xtask agent-eval store-durability --client claude --model <model> --trials 5
Each round now runs two trials. The Symposium trial is the one above. The baseline trial is prepared the same way, except that bin/ has no cargo-agents, init never runs, and srt makes the staged binary unreadable. Your own installed cargo-agents stays on your machine the whole time; no trial can reach it. The order alternates between rounds, so neither configuration always goes second.
For each configuration, the report shows:
- the success rate: trials that succeeded, out of trials that say something about the agent. Timeouts and client errors count as failures, so a configuration cannot look better by failing early. Setup failures and grading infrastructure errors say nothing about the agent; they are counted separately, and any of them is a reason to inspect the batch;
- how often the agent loaded each skill;
- the cost: mean tokens and agent time, over all those trials and over the successful ones only, so a configuration that is cheaper only because it gives up early does not look like a saving;
- the difference between the two configurations.
Five trials per configuration are limited evidence. The report gives numbers and claims nothing more.
Without srt, --exploratory runs trials inside each client’s own, narrower sandbox. Those results are labeled exploratory, are useful for developing tasks, and are never compared.
What you do when you write an eval
Here is the whole formatting task. A plugin can be shared between tasks, so it lives on its own:
evals/
plugins/
format-on-stop/
Symposium.toml # depends-on = ["*"], one skill, one Stop hook
skills/rust-formatting/SKILL.md
scripts/format-on-stop.sh
tasks/
formatting/
task.toml
starter/ # a small crate with no dependencies
checks/
double.rs # hidden test
kind = "regression"
plugins = ["format-on-stop"]
fmt_check = true
prompt = """
Use the rust-formatting skill. Add `pub fn double(x: i32) -> i32` to src/lib.rs.
"""
expected_output = "double returns twice its argument, and the crate is formatted."
[expected]
hooks = ["format-on-stop"]
[expected.skills]
installed = ["rust-formatting"]
Writing it takes these steps.
1. Pick the kind. A regression task asks whether Symposium still delivers; its expectations decide whether a trial succeeds. A capability task asks whether Symposium improves the outcome; it runs both configurations and compares their code checks, while its expectations are graded and reported but do not change the comparison. For example, in a capability trial:
- the code passes and the skill was not loaded: the trial succeeds, and its
usedexpectation fails; - the code fails and the skill was loaded: the trial fails, and its
usedexpectation passes.
2. Write the starter and the prompt. Ask for a public function, so a hidden test can call it. This prompt names the skill. That is fine here, because the task checks delivery, not whether the agent picks a skill on its own. Our capability comparisons are unprompted, so they never name the skill; a prompted comparison would be valid, but it answers a different question.
3. Choose the plugins from evals/plugins/.
4. Declare what Symposium should do. hooks lists plugin hooks that must leave a successful record. [expected.skills] holds four lists:
installed: skills that must be installed during the session;not_installed: skills that must not be;used: skills the agent must successfully load, according to the transcript. A load is evidence the skill reached the agent, not proof it followed the skill;not_used: skills the agent must not load.
When used is omitted, it defaults to installed minus not_used, which is why the example above needs only installed. An explicit used = [] makes no claim about use. A skill can be installed but irrelevant to the task:
[expected.skills]
installed = ["rust-formatting", "release-notes"]
not_used = ["release-notes"] # installed, but nothing in this task calls for it
Lists that contradict each other, such as a skill in both used and not_used, are rejected.
5. Write the hidden tests in checks/. Each file holds exactly one test, and the runner checks that exactly one test ran for it, which catches a typo in a test name or a test that was never discovered. checks/double.rs:
#![allow(unused)]
fn main() {
#[test]
fn doubles() {
assert_eq!(formatting::double(21), 42);
}
}
6. Optionally add a solution/. These are files copied over the starter that should pass every code check. cargo xtask agent-eval --check-tasks always validates every task.toml, and runs in CI without a model or API key. When a task has a solution/, it also grades the solution, which catches a broken hidden test before it shows up as a 0% success rate. Passing --check-tasks without a solution does not show that the hidden tests work.
7. Run it. Run --check-tasks, then the task itself.
Writing a capability task
A capability task needs knowledge the model cannot already have, or both configurations succeed and the comparison shows nothing. store-durability uses fixture-store, a crate that exists only in this repository. Its Store::put buffers writes in memory, and they reach disk only on Store::flush. A Store dropped without flushing loses its writes silently. The code compiles and runs either way.
The prompt, which does not mention the skill:
Add a function
pub fn save_settings(dir: &Path, settings: &[(&str, &str)]) -> std::io::Result<()>to src/lib.rs that saves each key/value pair with fixture-store, using aStoreopened atdir.
A registry plugin, gated on the dependency, carries a skill that states the rule:
name = "fixture-store"
depends-on = ["fixture-store"]
[[skills]]
source.path = "skills"
---
name: fixture-store
description: Use when reading or writing data with the fixture-store crate.
---
`Store::put` buffers writes in memory. They reach disk only when `Store::flush`
is called, and a `Store` dropped without flushing loses them without an error.
Call `flush()` before returning from any function that writes.
The hidden test calls save_settings, opens a new Store at the same directory, and checks that every value is there. Without the flush, it fails. Its task.toml differs from formatting in three ways:
kind = "capability"
plugins = ["fixture-store"]
fixtures = ["fixture-store"] # copied into the trial read-only, outside the project
editable = ["src/**"] # the only files taken from the submission; the default
[expected.skills]
installed = ["fixture-store"] # graded and reported, but not part of the comparison
The made-up crate reduces the chance that the model already knows the rule. A baseline may still infer it from familiar buffering conventions or read it in the crate’s source, so pilot runs will tell us whether the task separates the two configurations. If the baseline finds the rule, that is a fair result: the success rates converge, and the cost difference shows what the skill saved.
An illustrative result:
| Without Symposium | With Symposium | |
|---|---|---|
| Skill installed | (none) | at SessionStart |
| Skill loaded | (none) | 4 of 5 trials |
| Task succeeded | 2 of 5 | 4 of 5 |
| Mean tokens and time | baseline | difference reported |
Changes to Symposium
Reload skills after the first sync
Installing a skill during SessionStart is not enough for the first prompt to see it. Claude Code runs skill discovery before SessionStart hooks finish, and rescans only when a hook returns "reloadSkills": true in its SessionStart output. Symposium’s SessionStart handler (handle_session_start in src/hook.rs) never sets it, so we suspect a skill installed in the first session is invisible until the next one.
This is a suspected bug in its own right, so it is tracked separately from the runner. We first write a test that shows it (a skill installed by SessionStart that the first prompt cannot load), then fix it: after a successful auto-sync on SessionStart, the Claude Code response always includes reloadSkills: true. Reloading only when skills changed would need sync::sync to report what it changed, which it does not today; that can come later if rescans prove costly.
For Codex, third-party sources indicate skills are scanned only at startup, and its documentation describes no reload mechanism. Step 7 determines what Codex actually does. If it cannot load a skill installed during its own session, that is a measured limitation on Codex, reported as such, not hidden by syncing in advance.
Stop hooks on Codex
Codex supports Stop hooks, but Symposium does not register or map the event for Codex today: register_codex_hooks in src/agents/mod.rs registers four events, and src/hook_schema/codex.rs has no Stop arm. This proposal adds it, following the Claude mapping. Until it lands, formatting on Codex fails its hook expectation.
Frequently asked questions
Will tasks support grader prompts?
Not in the first version. Every check in these two tasks has an objective answer: a test passes, a file exists, a transcript shows a load. A check that needs judgment, such as whether code follows a DSL’s idioms, needs a model-graded check calibrated against human review. The formality evaluation will likely be the first task that needs one.
Why don’t skill expectations decide a capability task?
A capability task compares the two configurations on the same code checks. The baseline has no skills, so if “the skill was used” decided success, the Symposium configuration would face a check the baseline never does. Expectations are still graded and reported next to the result, so a trial that passed without loading the skill is visible.
Why check discoverability only through use?
No client is known to list its available skills reliably in the transcript, so “the client could see the skill” cannot be observed directly. A successful load is the evidence we can get, and formatting names its skill so a working delivery produces one. Reading a client’s skill listing is follow-up work.
Why is solution/ optional?
It is most useful when the hidden test is subtle, as in store-durability, where a broken test would look like the agent failing in both configurations. For formatting, it adds little. Without it, --check-tasks still validates the task, but cannot show the hidden tests work.
Why no negative tasks yet?
Whether a skill is installed in a project it does not apply to is decided by Symposium’s sync, without the agent, and the existing in-process tests already cover it. Whether an agent leaves an installed but irrelevant skill alone is an agent question, and any task can ask it with not_used.
What did we take from existing evaluation practice?
Isolated trials, checks on the outcome rather than the steps, a baseline over several trials, tasks the model cannot already solve, a check on whether the skill was used, and checks the agent cannot reach. These recur across Anthropic’s guide, the Agent Skills guide, Vercel’s evaluation, and Terminal-Bench. task.toml uses the Agent Skills evals.json field names where they overlap, so that format can be imported later.
Why srt?
It restricts reads, writes, and network for the client and every process it starts, with no container image to maintain, and puts the same boundary around both clients. The clients’ own sandboxes differ: Codex documents no way to restrict reads, and Claude Code’s sandbox does not cover its file tools. See Isolation.
Why grade only the editable files?
So the agent cannot make a hidden test pass by editing Cargo.toml, a build script, or the fixture crate. See Grading the code.
Why count timeouts as failures?
Excluding them would let a configuration look better by failing early. See Outcomes and counting.
Why several trials?
Agent results vary between runs, by more than many differences worth detecting. Five trials per configuration is a starting point; regression tasks, which should succeed every time, run once by default.
Why is formatting a regression task and not a comparison?
Models already format Rust well, so a baseline would likely succeed every time. The task is still the simplest end-to-end check that SessionStart installs a skill the agent can load and that a plugin’s Stop hook fires. A Stop hook rather than PostToolUse keeps the agent from always reading already-formatted files, so the hook’s record shows what the agent wrote itself.
Why not only use simulation tests?
Simulation tests invoke hooks by hand with prepared input. They confirm Symposium handles an event correctly, not that a real client sends that event, at that time, or sees the skills a hook installed. The suspected first-session reload bug is exactly the kind of problem simulation cannot find.
Why not use Inspect?
Inspect provides scoring, logs, and sandboxing, and its Agent Bridge supports Claude and Codex. It is a viable alternative. For two tasks with code checks, a Rust runner fits the existing tests and calls the clients directly. If model-graded scoring becomes most of the work, we should revisit this.
What are the costs and limitations?
Real trials cost model usage and take minutes each, so a five-trial comparison on two clients is twenty sessions. Results from different machines are comparable only when the recorded client versions match. Isolation needs macOS or Linux today. A load shows the agent saw a skill, not that it followed it.
What happens if we do nothing?
Comparisons stay manual and hard to reproduce, no test covers first-session synchronization with a real client, and we have no measure of whether delivered skills are used at all.
Implementation plan and status
Step 1: Task format and grading
Add the evals/ layout, task.toml parsing and validation, the fixture-store crate, and the two tasks with their starters, hidden tests, and the store-durability solution. Add the trusted grading copy, the one-test-per-hidden-test check, and --check-tasks, and run it in CI. Test that the solution passes, that extra agent-written unit tests do not change a grade, that an edit to Cargo.toml or the fixture crate does not either, and that contradictory expectations are rejected.
- Task format, grading, and
--check-tasks.
Step 2: Isolated trial setup
Pin an srt version. Build the trial directories, tool staging, the cleared environment, both setup modes with their pre-launch checks, the generated srt policy, and the exploratory fallback. Results are written on every path, and each phase has a deadline.
Add a no-model smoke test under the exact generated policy. It launches the real clients and cargo-agents with --version, builds and tests a project, and fails to read the developer’s home (including cargo-agents by absolute path), another trial, and evals/; in a baseline trial, it also fails to run the staged cargo-agents. This is the first thing to build: it decides whether the runner can launch at all.
- Trial setup,
srtboundary, and smoke test.
Step 3: Reproduce and fix the reload bug
In its own issue and pull request: first a test showing that a skill installed by SessionStart is invisible to the first prompt on Claude Code, then return reloadSkills: true after a successful auto-sync on SessionStart, with hook schema tests.
- Bug reproduced, then fixed.
Step 4: Claude Code adapter and formatting
Add ClientRunner, the Claude adapter, the expectation checks, and the evidence cross-checks. Test transcript parsing, client errors, timeouts, and inconclusive results against a stand-in client and recorded transcripts. Then run formatting and confirm the skill is installed and loaded in the same session and the Stop hook fires. Record the minimum supported Claude Code version.
- Claude adapter and
formatting.
Step 5: Comparisons on Claude Code
Add --trials, alternating order, and benchmark.json. Test the counting against recorded results, including timeouts, setup failures, exclusions, inconclusive trials, and a configuration with no eligible trials. Then run store-durability with five trials and record the results here.
- Trials, reporting, and
store-durability.
Step 6: Codex Stop hook support
Map and register Stop for Codex in src/hook_schema/codex.rs and src/agents/mod.rs, following Claude, with the existing hook schema and registration tests.
- Codex
Stopsupport.
Step 7: Codex CLI adapter
Add the Codex adapter and its skill-load detection, reusing the tasks, checks, and isolation unchanged. Test parsing and failures without model calls, then run both tasks. Determine whether Codex can load a skill installed during its own SessionStart, and record the answer here. Record the minimum supported Codex version. Update the running-tests and testing-guidelines chapters.
- Codex adapter.
Reference: evaluating agent tasks
This page holds the precise rules behind the skill evaluation RFD. Read the RFD’s walkthroughs first; this page assumes them.
Task format
evals/
plugins/<name>/ # a Symposium plugin, offered through the trial's registry
fixtures/<name>/ # a crate a task depends on, copied in read-only
tasks/<name>/
task.toml
starter/ # project copied into each trial
checks/ # hidden tests, one file per test
solution/ # optional; files copied over the starter
task.toml fields:
| Field | Default | Meaning |
|---|---|---|
kind | required | regression or capability. |
plugins | [] | Plugins from evals/plugins/ placed in the trial’s registry. |
fixtures | [] | Crates from evals/fixtures/ copied to <run>/fixtures/, read-only. |
editable | ["src/**"] | The only paths taken from the submission for grading. |
fmt_check | false | Run cargo fmt --check on the grading copy. |
prompt | required | Sent to the client unchanged. |
expected_output | optional | A human-readable description of success; not graded. |
[expected] hooks | [] | Plugin hooks that must leave a successful record. |
[expected.skills] installed | [] | Skills that must be installed during the session. |
[expected.skills] not_installed | [] | Skills that must not be installed. |
[expected.skills] used | installed minus not_used | Skills the agent must successfully load. [] makes no claim. |
[expected.skills] not_used | [] | Skills the agent must not load. |
--check-tasks rejects a task when:
- a skill appears in both
installedandnot_installed, or bothusedandnot_used; - a skill in
usedalso appears innot_installed; - a
checks/file does not hold exactly one test, or a named plugin or fixture does not exist.
Fixture crates are not workspace members and have no skills/ directory, so the only way their rule reaches the agent through Symposium is a registry plugin.
Preparing a trial
All batches live under one evaluation root:
<eval-root>/<batch>/
tools/
common/ # staged clients, readable by every trial
symposium/ # staged cargo-agents, readable by Symposium trials only
trial-1-with-symposium/ # <run>
home/ # HOME for every process in the trial
project/ # copy of the starter
fixtures/ # read-only copies of the task's fixture crates
bin/ # links to staged tools: the client, sh, and (Symposium only) cargo-agents
cargo-home/ # CARGO_HOME, empty at start
tmp/ # TMPDIR
evidence/ # EVAL_EVIDENCE_DIR and SYMPOSIUM_HOOK_TRACE, outside the project
registry/ # the task's plugins (Symposium configuration only)
The executables a trial runs are staged once per batch: the client into tools/common/ (a single binary for native installs; for npm installs, the package directory and the Node.js runtime), and the cargo-agents under evaluation into tools/symposium/. They are copied, not linked: on Linux, srt matches read rules by the name a path is opened under, so a link to a client under the developer’s home would not launch. Staging also fixes the binaries for the whole batch, and their hashes go into each result.
Claude Code (~/.claude), Codex (~/.codex), and Symposium (~/.symposium) keep their configuration and state under the home directory, so pointing HOME (and USERPROFILE on Windows) at <run>/home gives each trial fresh state.
The client starts with a cleared environment holding only HOME, PATH, TMPDIR, CARGO_HOME, EVAL_EVIDENCE_DIR, SYMPOSIUM_HOOK_TRACE, and the model API key. PATH is, in order:
<run>/bin;- the active toolchain’s own
bin/directory (the directory ofrustup which cargo), which holds only Rust tools; - the system directories (
/usr/bin,/bin), which hold the linker and the tools the client needs.
The developer’s ~/.cargo/bin is never on PATH, since it can hold their own cargo-agents.
In the Symposium configuration, the runner copies the task’s plugins into <run>/registry, writes this config, and runs cargo agents init --add-agent <client>:
auto-sync = true
hook-scope = "global"
auto-update = "off"
[defaults]
symposium-recommendations = false
user-plugins = false
[[registry]]
name = "eval"
path = "<run>/registry"
init registers hooks without syncing. Global scope matters: project-scoped hooks only exist after a sync, which would install skills before the behavior under test. The prompt never mentions Symposium, and telemetry stays at its default (off).
Before launch, the runner checks that the skill directory is empty and that the client’s machine-wide managed-settings locations are empty. For a baseline, it also checks that cargo-agents resolves nowhere on PATH and that there is no ~/.symposium and no Symposium entry in the client’s hook or MCP configuration. A failed check is a setup failure.
The starter has no git history, so one trial cannot read another’s work through it. Each trial starts a new conversation. The API key is passed to the client process only and never retained. The runner refuses clients older than a pinned minimum version, since hook and transcript formats change between versions.
Isolation
What “without Symposium” means
cargo-agents stays installed on the developer’s machine throughout. “Without Symposium” means no process in the trial can reach it. Nothing is installed or uninstalled between configurations.
| Without Symposium | With Symposium | |
|---|---|---|
<run>/bin/cargo-agents | absent | link to the staged binary |
Staged binary in tools/symposium/ | unreadable | readable |
Hooks in <run>/home | none | registered by cargo agents init |
| Developer’s installed copies | unreadable | unreadable |
Symposium registers its hooks as cargo-agents hook <agent> <event>, a bare name the client resolves on PATH. The trial’s PATH holds no developer directories, and srt makes the developer’s home unreadable, so an installed copy cannot be reached by any path.
Enforced: srt
srt is the open-source sandbox runtime Claude Code’s own sandbox is built on, packaged as a command that wraps a program and every process it starts. It uses Seatbelt on macOS and bubblewrap on Linux. The runner pins an srt version, records it in each result, and launches each client as srt --settings <run>/srt.json <client> ....
srt reads are deny-then-allow: everything is readable unless a denyRead region covers it, and allowRead reopens paths inside a denied region. Writes and network are denied unless allowed. The runner generates this policy per trial and keeps it with the results:
{
"filesystem": {
"denyRead": ["<user data roots>", "<eval-root>", "<symposium checkout>"],
"allowRead": ["<run>", "<batch>/tools/common", "<toolchain>"],
"allowWrite": ["<run>/project", "<run>/home", "<run>/cargo-home", "<run>/tmp", "<run>/evidence"]
},
"network": {
"allowedDomains": ["<the client's model API host>"]
}
}
The denied regions are:
- user data roots: every user’s home and the usual mount points (
/home,/root,/mnt,/mediaon Linux;/Usersand/Volumeson macOS); - the evaluation root, so other trials and earlier batches are unreadable;
- the Symposium checkout, which holds
evals/, so hidden tests, solutions, and other tasks’ plugins are unreadable.
A Symposium trial’s policy also lists <batch>/tools/symposium in allowRead; a baseline’s never does. Everything else stays readable, notably the system directories and shared temporary directories outside the evaluation root. A machine with user data elsewhere needs those paths added to denyRead, which the runner accepts as configuration.
Because srt is the boundary, each client’s own sandbox is off inside it and the client runs without permission prompts: Claude Code with --permission-mode bypassPermissions, Codex with --sandbox danger-full-access. Both clients document those modes for environments that are already isolated. Inside srt, prompts would only duplicate the boundary, and in non-interactive mode they would stop the agent from running cargo test.
Enforced mode is the default on macOS and Linux and requires srt and its dependencies (ripgrep, plus bubblewrap and socat on Linux). Only enforced trials are compared.
Exploratory: without srt
When srt is unavailable, or on Windows until its support leaves alpha, --exploratory runs trials inside each client’s own sandbox, as restrictive as it allows:
- Claude Code: through
--settings,sandbox.enabled,failIfUnavailable,allowUnsandboxedCommands: false,filesystem.denyReadfor the developer’s home withallowReadfor the trial and toolchain,allowWritefor<run>/cargo-home, andpermissions.blockReadsOutsideWorkingDirectories, with--permission-mode acceptEdits. - Codex:
--sandbox workspace-write, with<run>/cargo-home,<run>/home, and<run>/evidenceassandbox_workspace_write.writable_roots. Codex documents no way to restrict reads.
An escape audit scans each transcript for paths outside the trial and for any cargo-agents call in a baseline. It parses command text, so it can be evaded and an empty audit proves nothing; that is why exploratory results are labeled and never compared. In enforced mode, the same audit runs as a secondary check.
Running the client
Both adapters run, in enforced mode:
srt --settings "$RUN/srt.json" claude -p "$PROMPT" --output-format stream-json --verbose \
--model "$MODEL" --permission-mode bypassPermissions
srt --settings "$RUN/srt.json" codex exec --json --model "$MODEL" \
--sandbox danger-full-access --skip-git-repo-check --dangerously-bypass-hook-trust "$PROMPT"
Codex runs a hook only after it is trusted. The trial’s fresh home contains no hooks except Symposium’s, so --dangerously-bypass-hook-trust applies to exactly those, for that one invocation. Codex requires a git repository unless told otherwise, hence --skip-git-repo-check.
A missing completion event or a client-reported error is a client error, even when the process exits 0. When the agent’s deadline expires, the runner kills the process tree, including any hooks, and keeps whatever evidence exists.
Grading the code
The runner first keeps the submission as the agent left it, then builds a grading copy: a fresh copy of the starter and fixtures from evals/, with only the editable paths taken from the submission.
- Within
editable, the submission replaces the starter exactly: the starter’s editable files are removed first, so a file the agent deleted stays deleted. - Only regular files and directories are accepted. A symlink or special file under an editable path invalidates the submission, which is a failed trial. Every destination is checked to resolve inside the grading copy before it is written.
- Edits outside
editableare listed in the result and never graded.
Grading runs under srt with its own fresh writable paths, read-only fixtures, no network, and no credentials. Then, in order:
cargo build. If it fails, the trial fails and the remaining checks are skipped.cargo test --lib, however many tests the agent wrote.- Each hidden test, as an explicit target (
cargo test --test <name>). It must pass, and exactly one test must have run, with none ignored or filtered out. cargo fmt --check, whenfmt_checkis set.
Every step has a deadline. A failure is either the submission’s or the runner’s:
- Submission failure: the code does not build, a test fails or crashes, or a test exceeds its deadline. The trial fails.
- Grading infrastructure error: the sandbox could not launch, a fixture was unavailable, or the runner could not collect results. The trial is excluded and reported, and its kept submission can be graded again.
Expectations
An expectation is the task author’s assertion, in [expected]. The runner decides each one from what it observed, as pass, fail, or inconclusive. Inconclusive means the evidence cannot decide, and it is never silently turned into a pass or a fail. Expectations apply to the Symposium configuration only.
Skills installed
installed passes when the skill directory holds the skill with Symposium’s .symposium marker and content matching the plugin in evals/. not_installed passes when it is absent.
Skills used
From the transcript, the adapter observes each skill’s load as one of:
loaded: a successfulSkilltool call naming it, or a successful read of its installedSKILL.md;attempted: such a call that failed or was denied;not_observed: neither appears in a transcript the adapter fully interpreted;unknown: the adapter could not interpret the transcript.
| Assertion | pass | fail | inconclusive |
|---|---|---|---|
used | loaded | attempted, not_observed | unknown |
not_used | not_observed, attempted | loaded | unknown |
A failed or denied attempt never delivered the skill’s content, so it satisfies not_used; it is still reported. A client could supply a skill in a way its adapter does not see, so each adapter documents what it detects.
Synchronization and hooks
Every Symposium trial also requires:
- the skill directory was empty before launch;
- a
SessionStarthook trace with a successful exit; - Symposium recorded a successful sync for the project (it records one only after a sync succeeds).
A missing SessionStart invocation fails. Evidence too incomplete to tell whether a sync completed is inconclusive. Auto-sync failures do not fail the agent’s session, so a successful client exit is not evidence that a sync happened.
Each hook in [expected] hooks must leave at least one successful record. Stop can fire more than once, so its first record is the one checked.
What the evidence can and cannot show
Hook records, hook traces, and installed skills are written by processes inside the trial: Symposium’s hooks, which the client starts. The same boundary that lets them write lets the agent write there too, so the agent could, in principle, alter a record, edit an installed skill, or change the hook configuration. They are supporting evidence, cross-checked from outside the trial:
- installed skill contents are compared with the plugin in
evals/, which the trial cannot read or write; - the client’s hook and Symposium configuration are snapshotted after
initand compared after the trial, and any change is recorded; - the transcript, captured from the client’s output stream rather than from a file, is the primary record of what the agent did, including any write to
evidence/orhome/.
A hook record contradicted by the transcript, or a changed configuration, makes the affected expectations inconclusive. Independently verified hook execution would need a recording channel outside the agent’s writable paths; that is follow-up work if tampering is ever observed.
Outcomes and counting
A trial can succeed only if its client session completed. A timeout or client error is a failure even when the code it left would pass; that code is still graded and reported, and a grading infrastructure error during it does not move the trial out of the count. For a completed trial:
| Kind | Succeeds when | Inconclusive when |
|---|---|---|
| Regression | Every code check and every expectation passes. | An expectation is inconclusive and nothing failed. |
| Capability | Every code check passes. | Never. Expectations are reported but do not decide the outcome. |
A missing hook or skill is a Symposium failure, not a point in the baseline’s favor.
Each planned trial ends in exactly one place:
| Count | Meaning |
|---|---|
| setup failures | the trial never launched its client |
| cancelled | the batch stopped before the trial ran |
| isolation exclusions | the client launched, but the trial violated isolation |
| grading exclusions | the trial completed, but grading needed to decide it hit an infrastructure error |
| succeeded, failed, inconclusive | everything else, by outcome |
planned = setup failures + cancelled + started
eligible = started - isolation exclusions - grading exclusions
= succeeded + failed + inconclusive
For each configuration, benchmark.json reports:
- success rate: succeeded divided by eligible, or
nullwith no eligible trials, never 0 or 1. For regression tasks, the succeeded, failed, and inconclusive counts are reported alongside; - setup failures, cancellations, and exclusions, by cause;
- pass_all: whether every eligible trial succeeded, or
nullwith none; - observed loads, per skill;
- cost: mean tokens (input, output, and cache) and mean agent time, over eligible trials and over succeeded trials, each with its own count of contributing trials, since a client may report time but not tokens. Agent time excludes setup and grading. Usage a client does not report is
nulland left out of the means; - delta: the Symposium configuration minus the baseline, for each rate and mean.
The order of configurations alternates between rounds (baseline first, then Symposium first, and so on). Failed trials are never retried or discarded; a rerun is a new batch.
Retained results
results/store-durability/claude/<batch>/
benchmark.json
trial-1-without-symposium/
result.json
srt.json
submission/ # the project as the agent left it
grading/ # the grading copy's output
transcript.jsonl
response.txt
stderr.log
trial-1-with-symposium/
... # as above, plus:
hook-trace.jsonl
evidence/
symposium.log
A trial’s result.json:
{
"task": { "name": "store-durability", "kind": "capability", "content_hash": "<sha256 of task, fixture, and plugin dirs>" },
"client": { "name": "claude", "version": "<version>" },
"model": { "name": "<model>", "settings": {} },
"mode": "with-symposium",
"trial": 3,
"order": 2,
"symposium": { "version": "<cargo-agents --version>", "sha256": "<binary hash>", "revision": "<git sha or null>" },
"os": "<os and arch>",
"toolchain": "<rustc version>",
"deadlines_secs": { "setup": 120, "agent": 600, "grading": 300 },
"elapsed_secs": 94,
"usage": { "input_tokens": 30100, "output_tokens": 4100, "cache_read_tokens": 7000, "cache_write_tokens": 0 },
"execution": "completed",
"isolation": { "mode": "enforced", "srt_version": "<version>", "audit_findings": [], "config_changes": [] },
"code": {
"build": "pass",
"cargo_test_lib": "pass",
"hidden": { "settings_persist": "pass" },
"cargo_fmt_check": null,
"edits_outside_editable": []
},
"expectations": {
"skills": { "fixture-store": { "installed": "pass", "used": "pass", "observed_load": "loaded" } },
"hooks": {},
"sync": { "skill_dir_empty_before": "pass", "session_start": "pass", "sync_recorded": "pass" }
},
"success": "pass"
}
The batch’s benchmark.json:
{
"task": "store-durability",
"client": "claude",
"isolation": "enforced",
"configurations": {
"without-symposium": {
"planned": 5, "setup_failures": 0, "cancelled": 0, "started": 5,
"excluded": { "isolation": 0, "grading": 0 },
"eligible": 5, "succeeded": 2, "failed": 3, "inconclusive": 0,
"success_rate": 0.4, "pass_all": false,
"cost": {
"eligible": { "tokens": { "mean": 38900, "trials": 5 }, "agent_secs": { "mean": 81, "trials": 5 } },
"succeeded": { "tokens": { "mean": 44100, "trials": 2 }, "agent_secs": { "mean": 92, "trials": 2 } }
}
},
"with-symposium": {
"planned": 5, "setup_failures": 0, "cancelled": 0, "started": 5,
"excluded": { "isolation": 0, "grading": 0 },
"eligible": 5, "succeeded": 4, "failed": 1, "inconclusive": 0,
"success_rate": 0.8, "pass_all": false,
"observed_load": { "fixture-store": { "loaded": 4, "attempted": 0, "not_observed": 1, "unknown": 0 } },
"cost": {
"eligible": { "tokens": { "mean": 35100, "trials": 5 }, "agent_secs": { "mean": 70, "trials": 5 } },
"succeeded": { "tokens": { "mean": 36000, "trials": 4 }, "agent_secs": { "mean": 72, "trials": 4 } }
}
}
},
"delta": { "success_rate": 0.4 }
}
The runner in code
The runner lives in xtask and is synchronous: trials run one after another and only spawn subprocesses, so async would add a runtime without adding concurrency. It reuses symposium-testlib’s fixture preparation and hook trace types, but adds its own two setup modes instead of routing through with_fixture, whose unconditional init and sync are exactly what this evaluation must avoid. Each trial’s environment is set on the client’s Command, never on the runner’s own process.
Err means the runner itself broke, such as a full disk. Everything that can go wrong in a trial is an outcome. A trial allocates its result directory first and finalizes a result on every path, so no failure loses evidence:
#![allow(unused)]
fn main() {
fn run_trial(
task: &Task,
client: &impl ClientRunner,
plan: &TrialPlan,
) -> anyhow::Result<TrialResult> {
let mut result = TrialResult::started(task, plan, ResultDir::create(plan)?);
let run = match RunDir::prepare(task, plan, plan.setup_deadline) {
Ok(run) => run,
Err(err) => return result.finalize_setup_error(err),
};
result.execution = client.run(run.env(), task.prompt(), plan.agent_deadline);
result.grading = grade(task, &run, plan.grading_deadline);
// `run` is still alive here, so its evidence is copied before the directory is removed.
result.finalize(run)
}
}
grade returns a value, not a Result: a submission that fails or hangs produces failed checks, and only an infrastructure problem becomes Grading::InfrastructureError.
A client adapter only builds its command line. Isolation lives in RunEnv, whose command clears the environment, sets the trial’s HOME, PATH, and working directory, and wraps the program in srt, so adding a client means adding one small adapter:
#![allow(unused)]
fn main() {
pub trait ClientRunner {
fn run(&self, env: &RunEnv, prompt: &str, deadline: Duration) -> Execution;
}
}
Everything except the model call is testable without a client or API key. For example, the guarantee behind “without Symposium”:
#![allow(unused)]
fn main() {
#[test]
fn baseline_cannot_reach_cargo_agents() -> anyhow::Result<()> {
let opts = RunOpts::for_test();
let task = Task::load(&opts.evals_root, "store-durability")?;
let run = RunDir::prepare(&task, &opts.plan(Mode::WithoutSymposium), opts.setup_deadline)?;
let status = run
.env()
.command("sh")
.args(["-c", "command -v cargo-agents || cat \"$DEVELOPER_CARGO_BIN/cargo-agents\""])
.env("DEVELOPER_CARGO_BIN", opts.developer_cargo_bin())
.status()?;
assert!(!status.success());
Ok(())
}
}
The second half of that command reads the developer’s copy by absolute path, which srt must refuse.
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_toolsandexecute— following the progressive disclosure pattern. list_toolsreturns TypeScript declarations describing available tools.executeruns 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:
-
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.
-
Config pollution. Writing entries into
.claude/settings.local.json(or equivalent) leaves artifacts that are visible to the user, hard to.gitignorecleanly, and create merge friction in shared repos. -
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:
- Resolves the workspace (same
WorkspaceDepslogic as sync/hooks). - Collects all applicable
[[mcp_servers]]from the plugin registry. - Exposes two tools:
list_toolsandexecute. - 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 Schema | TypeScript |
|---|---|
{ "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 required | field?: T |
| anything else | any |
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
| Agent | MCP config location | Transport |
|---|---|---|
| Claude Code | .claude/settings.local.json | stdio |
| Gemini CLI | .gemini/settings.json | stdio |
| Copilot | .github/copilot-mcp.json | stdio |
| Codex CLI | codex.json | stdio |
| Kiro | .kiro/mcp.json | stdio |
| OpenCode | .opencode/config.json | stdio |
| Goose | .goose/mcp.json | stdio |
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__statusresource 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.
Related work
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
executetool 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_toolsfiltering. 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/listsupports cursor-based pagination (for large result sets, not progressive disclosure).notifications/tools/list_changedlets servers signal that their tool set changed mid-session.inputSchemais always JSON Schema withtype: "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.rswith a single"symposium"entry - Update existing MCP integration tests to expect the new behavior
- Verify:
cargo testpasses,.claude/settings.jsoncontains 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
rmcpdependency - Add
McpServevariant toCommands, wire handler - Implement stdio MCP server with
list_toolsandexecutestubs - 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
rquickjsdependency - Register namespace globals from backing server tool lists
- Implement async dispatch: JS
await→ MCPtools/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.lockmtime at startup - On
list_tools, check mtime; if changed and auto-sync enabled, re-resolve - Integration test: modify fixture’s
Cargo.lockmid-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)), anddepends-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 (toBAR).path_exists(<arg>), true if the argument resolves to an existing path — checked on the filesystem, then on$PATHfor a bare name (so it matches a local file or an installed binary).shell(<command>), true if<command>run viash -cexits0.- 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-onand the workspace has (depends-on = ["*"]names every workspace). - An explicit
useentry, 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:
| Operation | Input | Output | Used by |
|---|---|---|---|
active_plugins | the workspace’s dependency ids | set of unvalidated plugins | discovery, sync |
load_plugin | package-id | set of unvalidated plugins | chained references, use |
search | partial query string | set of package-ids + metadata | symposium use, symposium search |
fetch | package-id | directory with plugin content | sync/install |
list_deps | (none) | set of package-ids | auto-discovery |
workspace_info | (none) | workspace root and members | workspace plugins, scoping |
refresh | update level | whether content was pulled | registry sync |
What a PM returns is an unvalidated plugin: 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 plugin 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:
| Operation | Definition |
|---|---|
resolve | accepts a object like {foo = "1"} using the same format as expected by cargo; resolves per cargo algorithm |
search | if PM = cargo, search cargo registry for matching crates; otherwise, return empty |
fetch | creates a dummy project to populate the cargo cache and returns the crate source directory from there |
list-deps | returns 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:
| Operation | Definition |
|---|---|
resolve | accepts an object like {url = "...", branch = "...", rev = "..." } and resolves to a commit SHA |
search | returns empty (git repos aren’t a searchable registry) |
fetch | clones/fetches the repo at the specified commit SHA and returns the directory |
list-deps | returns 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.tomlstructure, 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:
resolveschema,fetchvia cargo toolchain,list-depsfromCargo.lock. - Discovery & sync — the two-phase discovery algorithm (
list-depson all PMs, thensearchon all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration. - User-managed plugins —
symposium use/remove/statuscommands, 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:
pathandgitregistries 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
gitPM yet (git sources for skill groups and installations exist, but a chainedsource.gitis 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: auseentry can be scoped to one workspace; adisableentry cannot, so turning a plugin off in one project turns it off everywhere. The scoping machinery already exists on theuseside, 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
- Plugin model. Plugins,
[defaults], predicates, chained plugins, activation roots. - PM interface and the cargo PM. The identity tuple, the operation set, the JSON-RPC transport (
symposium_sdk::pm::protocolandpm::serveron the PM’s side,pm::RemotePmon Symposium’s), andsymposium-pm-cargoas a standalone crate and binary. A PM answers with anUnvalidatedPlugin: 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. - Discovery and sync. Dependency-embedded plugin discovery, the consent prompt, and the
[plugins]config. Recommendations are a flat registry rather than asearchresult (see the note under the recommendations registry). - User-managed plugins.
use/remove/status, workspace vs. global scope. - 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.tomlprovides 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 manifestskills/— 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 byworkspace()).
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
| State | Meaning | Where |
|---|---|---|
| Installed | Content is in cache, ready to activate | ~/.symposium/cache/ |
| Active | Predicates pass, content wired into agent dirs | .claude/skills/, etc. |
| Inactive | Installed but predicates don’t pass | Cache 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.
search
// 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:
| Concern | Owner |
|---|---|
| Producing a manifest (parse, synthesize, translate) | PM |
| Schema validation, inline-installation promotion | Symposium |
Defaults (skills/, .agents/skills/), [defaults] handling | Symposium |
| Activation roots, trust, consent | Symposium |
Resolving source.path against root | Symposium |
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:
| Code | Meaning | Symposium behavior |
|---|---|---|
| -32001 | Not found | Skip gracefully, report in status |
| -32002 | Network error | Retry with backoff, fall back to cache |
| -32003 | Invalid input | Hard error at parse time |
| -32004 | Auth required | Report 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:
- On first use, Symposium spawns the PM binary, connects via stdio, and sends
initialize. - The PM stays alive for the rest of the Symposium invocation, and is shut down when
PmRegistrydrops. - Spawning is lazy: a PM whose operations are never needed is never started.
- Symposium may have several requests in flight (the PM handles this or serializes internally).
A PM binary is found one of three ways:
- Built in. The PMs Symposium ships with are located by name, with no configuration required.
- 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. - Plugin-vended. A plugin registers a new PM type, per the parent RFD’s future work. The
initializehandshake 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 plugin 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: unvalidated-plugin 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:
- Split the cargo PM into a library the binary wraps, so unit tests can keep driving it in-process through the trait.
- 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.
- Forward
SYMPOSIUM_CARGOinto 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
cargoPM 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_plugintakes a crate name and version requirement in cargo’s format.fetchleverages the existing cargo toolchain to obtain crate sources.list_depsreports 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):
- A path dependency resolves to its local directory directly.
- 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. - Otherwise use the existing cargo toolchain:
~/.cargo/registry/src/(the unpacked source cache), falling back to a crates.io download. - The crate root directory is the plugin directory (defaults apply to discover skills, etc.).
- 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.lockmtime. - If
Cargo.lockhasn’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 syncresolves 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, thenactive_pluginsover 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:
-
Call
list-depson 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. -
Call
active_pluginson 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 askills/directory or aSymposium.tomlof 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. -
Split the plugins 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.
-
Classify each remaining plugin against
[plugins]. A name may be enabled byuse, pre-consented byauto-enable, previously declined viadisable, or undecided, which makes it a candidate. -
Prompt the user about the candidates, and record the answers: approvals into
auto-enable, declines intodisable.
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’slist-depsinclude 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:
- Use cached
list-depsresults (from lockfile mtime — no network calls). - Run discovery over them, cache-only.
- 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:
| Configuration | Result |
|---|---|
use only | enabled, subject to its predicates |
auto-enable only | enabled if a dependency embeds it |
use + auto-enable | enabled; use additionally reaches a plugin no dependency embeds |
anything + disable | off |
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-depsresults are cached based on lockfile mtime. No cargo invocation ifCargo.lockhasn’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-deps → search 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] Xsearches PMs, installs a plugin, records it in config.symposium use --remove Xremoves that record from config.symposium statusshows 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:
- 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
searchhits from every PM, which is what letsusename a package the workspace does not depend on yet. Matches are deduplicated on(pm, canonical-name). - 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>` - Record the
(pm, canonical-name)pair in config, so the entry round-trips to the same plugin. - Fetch into cache.
- 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:
usecommand
Step 3: symposium use --remove
Matching, removal from config, cleanup on next sync.
- PR:
removecommand
Step 4: symposium status
Display installed/active/inactive plugins with provenance and predicate status.
- PR:
statuscommand
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.
- Look up the predicate in the cache.
- If all watched inputs match and no
WatchTimehas elapsed, use the cached result. An empty watch set always matches. - Otherwise, evaluate the predicate and obtain its result from the exit status.
- 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)emitsWatchFile(path).env(FOO=BAR)emitsWatchEnv("FOO").shell(cmd)emitsWatchTime(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
- Add and parse
WatchFile,WatchEnv, andWatchTimeevents while keeping the exit status as the predicate result. - Union watch events, cache an empty watch set indefinitely, and treat
WatchTime(0)as immediate staleness. - Add cache storage and fingerprint comparison for files, environment variables, and expiry times.
- Wire
path_exists,env, andshellto emit their watch events. - Add
symposium_sdk::env::var()to emit environment watch events.
Telemetry: recording events
TL;DR
- Replace the experimental event list with a question-driven, closed schema for local telemetry.
- Keep recording off by default, per-user, local only, and gated by versioned consent. Existing unversioned opt-ins must consent again.
- Record observed sessions, configured agents, public package/extension resolution, aggregate agent skill invocation, aggregate hook reliability, command use, and known storage gaps.
- Never record individual prompt/tool activity rows, prompt or tool details, paths, private names, dependency snapshots, or a global installation/workspace id.
- Use purpose-scoped pseudonyms and non-waiting, best-effort recording. Telemetry failure must not disrupt hooks, sync, or commands.
- Defer upload, server-side handling, and subjective feedback to separate follow-up efforts tracked under #246.
Supporting pages: data contract and exclusions, telemetry command reference and consent disclosure, and configuration and consent states.
Motivation
Symposium has no production evidence about which integrations people reach, which public packages resolve to plugins and skills, whether agents activate those skills, or whether hooks are slow or failing.
The existing experimental telemetry was never wired into production. Its proposed prompt and tool rows would be high-volume without answering those questions.
The team instead needs a low-volume, inspectable record of reach, resolution, skill activation, and reliability. That evidence can guide agent support, recommendation work, and investigation of hook cost. The schema must be agreed before collection begins so every field has a stated use and privacy boundary.
This telemetry can show that a public skill resolved and that an agent activated it. It cannot show that the agent followed the skill or that the skill improved the task outcome. Version 1 observes structured skill activation only for Claude, but the event model and attribution boundary are agent-neutral.
Controlled evaluation and explicit feedback remain separate follow-up efforts tracked under #246.
Change in a nutshell
From the user’s perspective, telemetry follows one inspectable lifecycle:
- Telemetry begins disabled.
cargo agents initandcargo agents telemetry enablepresent the same team-approved disclosure and default to no. - After versioned consent, recording-capable Symposium operations write only the disclosed local events and aggregates.
cargo agents telemetry statusexplains the effective state, whileshowexposes the stored bytes.disablestops future recording without deleting existing files;clearremoves recorded data, andreset-identifiersrotates future identifiers.- Nothing is uploaded under this RFD.
Recording uses a JSONL schema built around those questions. A full sync emits one summary plus safe package and extension relationships. Hook and agent skill observations update bounded daily snapshots:
observed session -> session_start
full sync -> resolution_summary
-> package_resolution*
-> extension_resolution*
completed hook -> hook_metrics snapshot
-> plugin_hook_metrics snapshot
agent skill use -> extension_invocation_metrics snapshot
command -> command
The exhaustive data contract defines the fields, enums, exclusions, and complete JSONL examples. The main design has these invariants:
- Only
Enabledrecording can create telemetry state or files. - The producer accepts closed typed data, never arbitrary metadata or raw errors.
- Public names require allowlisted provenance and validation; everything else is unnamed or opaque.
- Pseudonyms are scoped to one measurement and normally rotate every 30 days.
- Routine hook and skill invocations update aggregates; they never become activity rows.
- A non-waiting process lock may drop a whole batch or observation but never delays another recorder.
- Files remain through D30 and first become eligible for lazy deletion on D31.
- Nothing is uploaded by this RFD.
Detailed plans
Measurement questions
All measures describe opted-in installations, not the whole user population. Reports must state that selection bias. This RFD proposes the questions below to make the activity goals in #246 and #243 measurable.
| # | Question | Operational definition |
|---|---|---|
| Q1 | Is another session observed after an installation’s first session? | Deduplicate session_start by retention_subject and measure observed cohort days D1, D7, and D30. |
| Q2 | Which plugins and skills resolve? | Count extension_resolution occurrences and scoped subjects by public extension and safe witnessed path. |
| Q3 | Which public packages and versions occur, and what do they resolve? | Count package_resolution by public coordinate and extension_match; use paths carried by extension_resolution. |
| Q4 | Are Symposium and plugin hooks failing or slow? | Use daily invocation/attempt counters, outcomes, fixed latency histograms, and complete identified-session impact counts. |
| Q5 | Which agents, versions, and platforms have reach? | Keep configured-agent observations separate from observed hook sessions. |
| Q6 | Which command surfaces are used? | Count completed built-ins and eligible public plugin commands without arguments. |
| Q7 | Which resolved public skills do agents actually activate? | Count completed extension_invocation_metrics and complete identified-session counts by public skill and safe resolution subject. |
These questions have specific limits:
- For Q1, a stored D0
session_startfor aretention_subjectadmits that cohort to analysis. D1, D7, or D30 is present when at least one later session is observed on that cohort day, from the same or a different agent. Later rows without a stored D0 are ignored. Multiple sessions on one day count once. This measures a later observed session, not one long session or continued value; session start runs automatically once Symposium is installed. - Q2 proves resolution, not activation.
- Q3 records relationship edges, not a complete dependency set.
- Q4 counts completed observations, so host termination can be invisible.
- Q7 proves activation, not that the agent followed the skill or completed the task better. Version 1 can answer Q7 only for Claude.
Scope and boundaries
This RFD defines the complete scope of local recording: consent, provenance, resolution evidence, event families, identifiers, agent capability gaps, storage, controls, rollout, documentation, and tests.
It covers #242 and the activity-metric portion of #243.
It does not cover #243’s experimental outcome signals, #244 uploading, or #245 feedback collection. It also leaves out new host-hook timeouts, public-identity inference from arbitrary git URLs, private-source names, and concurrent full-sync mutation of installed skills. Telemetry locking protects telemetry only.
Vocabulary
Discovery finds candidate packages, plugins, and skills before consent is known. It never records telemetry.
Resolution selects the packages and extensions that actually apply. Resolution events describe the final set and the evidence that selected it.
An observed session is one whose registered SessionStart hook ran. It is narrower than a configured agent.
A skill activation is a completed, structured agent invocation of a particular installed skill. It does not mean the agent followed the skill or that the skill improved the result.
Consent and activation
Telemetry remains a per-user setting:
[telemetry]
enabled = true
consent-version = 1
| Effective state | Condition | Recording |
|---|---|---|
Disabled | enabled absent or false | None; create no telemetry directory or state. |
ConsentRequired | enabled but consent version absent/stale | None until current disclosure is accepted. |
Enabled | enabled with current consent version | Events defined by this contract. |
Existing users with an unversioned enabled = true must consent again. Interactive init and telemetry enable present the same team-approved disclosure and default to no. Non-interactive calls require an explicit acknowledgement; editing the boolean alone cannot upgrade consent.
The version 1 disclosure requirements define what the disclosure must cover. The command reference includes a complete example for review, but its wording is not fixed implementation text. The team approves the final wording before recording is activated. init and telemetry enable then use the same snapshot-tested string.
Editorial changes that preserve coverage and meaning do not require renewed consent. Increase the consent version when collection expands categories, user-derived fields, linkability, timestamp precision, public-name eligibility, or retention, or weakens a normative exclusion. Narrowing collection does not require renewed consent. The telemetry configuration reference defines the effective-state semantics.
Adding an agent enum value creates a new schema version for each affected event kind because existing typed readers cannot parse the new value. It does not by itself require renewed consent when the fields, categories, timestamp precision, and correlation boundaries remain inside the accepted disclosure.
A new field, hook surface, or linkage for that agent does require a consent-version increase.
No production event is wired until the typed catalogue, privacy contract, controls, documentation, and current consent flow all land. Earlier implementation steps remain inert behind ConsentRequired.
Event contract
The schema is closed and typed. It accepts fixed variants, enums, counters, and bounded structures. It rejects arbitrary metadata maps, raw PM or agent payloads, errors, debug strings, command arguments, and sanitization fallbacks.
Counters use checked unsigned arithmetic. Overflow drops the batch or observation.
Every row has a per-kind schema version, fixed kind, random row id, UTC day, and Symposium version. Only completed session_start and command rows carry a wall-clock timestamp, truncated to one second. Millisecond durations remain because Q4 needs mergeable distributions.
| Kind | Purpose and emission |
|---|---|
session_start | A registered session-start hook completed; optional agent-scoped session id and D0-D30 return subject. |
agent_configuration | Daily configured/not-configured observation for each supported agent. |
resolution_summary | One completed full-sync result with public/unnamed inputs, reason counts, resolved artifacts, changes, and duration. |
package_resolution | One eligible public input coordinate with public, unnamed_only, or none extension match. |
extension_resolution | One public plugin/skill and one bounded safe path that selected it. |
hook_metrics | Cumulative daily counters and latency histogram per agent, hook, and identifier epoch. |
plugin_hook_metrics | Cumulative daily counters/histograms per agent, hook, epoch, and bounded plugin bucket. |
extension_invocation_metrics | Cumulative daily agent skill-attempt, completion, and failure counters per bounded public or unnamed bucket. |
command | One completed eligible top-level command without arguments. |
storage_limit | At most one daily marker naming the low-volume operation whose whole batch did not fit. |
The session_start event is authoritative for observed-session and return measurements (Q1 and Q5). A hook_metrics row whose hook is session_start measures only that hook surface’s reliability and latency for Q4; its invocation count is not a session count.
Ordinary hook lookup emits no resolution summary. A structured agent skill-use observation may update extension_invocation_metrics without emitting a resolution event. Version 1 obtains that observation from Claude’s Skill signal.
Internal hook dispatch, telemetry management commands, and ineligible external commands emit no command event. See What Symposium records for exact fields and invariants.
Event producers
Producers return typed observations or reports. They never serialize telemetry or write files. The Recorder checks consent, applies public-identity and correlation rules, derives identifiers, and sends accepted event batches or aggregate updates to storage.
| Rows | Producer boundary |
|---|---|
session_start | The outer hook wrapper, after a registered SessionStart completes successfully. |
agent_configuration | The first non-telemetry recording-capable invocation each UTC day, as one all-agent snapshot derived from Symposium’s per-user agent list. |
resolution_summary, package_resolution, extension_resolution | One structured full-sync report assembled while resolving and installing; telemetry does not rerun predicates or rediscover relationships. |
hook_metrics | The outer hook wrapper, after the hook’s final outcome and duration are known. |
plugin_hook_metrics | The plugin dispatcher, around each applicable plugin’s preparation and execution. |
extension_invocation_metrics | The Claude Skill adapter normalizes targeted pre/success/failure hooks, then the generated installation index supplies safe attribution. |
command | The top-level CLI dispatcher, after an eligible built-in or public plugin command reaches an outcome. |
storage_limit | The JSONL sink itself, when a complete low-volume batch cannot fit. |
Agent and package-manager payloads provide input to existing Symposium operations; they do not emit arbitrary telemetry. If a producer cannot construct a complete typed observation, it records nothing for that observation.
Identifiers and correlation boundaries
When enabled recording first needs identity state, Symposium atomically creates a random 32-byte key in private <config-dir>/telemetry-state.toml (default ~/.symposium/telemetry-state.toml). This file is separate from the inspectable <config-dir>/telemetry/ data directory. Symposium creates and replaces it with owner-only permissions where the platform supports them. If the state file is absent while daily files survive, the initial identifier-window anchor and latest-opened-day high-water mark use the later of the current UTC day and the newest canonical event or metric filename. Malformed or unsupported state is not treated as absent.
Identifiers use the first 128 bits of HMAC-SHA-256 over a frozen domain, locally anchored 30-day window, and length-framed dimension fields. The data contract fixes the byte construction, domain strings, prefixes, and field order.
The dimension limits what an identifier can link. Identity code constructs it from typed coordinates; producers do not concatenate strings. It represents one installation for one package, agent, or command dimension, never the installation globally.
Private state keeps the identity key and the current identifier-window anchor. It adds a return-cohort anchor when the first session is observed. Every recorder reads that state under the telemetry lock, so the same domain, window, and dimension produce the same subject across processes and restarts.
An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation. This normal rollover changes the window input without replacing the key. disable and clear preserve the key and anchors. Renewed consent and reset-identifiers replace the key, set the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clear the return-cohort anchor. The next observed session starts a new cohort at D0.
Identifier-window age uses that same later day. An anchor later than the wall-clock day is valid after clock rollback and does not by itself make state malformed.
The key is private state, not anonymized telemetry. Someone who has it can recompute candidate identifiers. Telemetry commands therefore never print it, and it remains outside the inspectable telemetry data directory.
| Identifier | Scope |
|---|---|
event_id | One row; random rather than derived. |
session_id | Agent + vendor session id + 30-day window; optional. |
retention_subject | One observed-session D0-D30 cohort; session_start only. |
agent_subject | One agent + 30-day window. |
package_subject | One safe public package coordinate + 30-day window. |
extension_subject | One safe target/path + 30-day window; shared by its resolution and public invocation aggregates. |
hook_subject | One agent/hook surface + 30-day window. |
plugin_subject | One safe public plugin + 30-day window. |
command_subject | One safe command coordinate + 30-day window. |
The return subject is the sole cross-agent exception: it deduplicates Q1 but cannot link to other event kinds. A cohort remains stable through D30; the next observed session starts a new cohort. Accepting new consent or resetting identifiers rotates the key, resets the identifier-window anchor without moving it behind the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session becomes D0 of another cohort.
session_id is absent when the agent supplies none, including Copilot. Raw vendor ids never enter events or an unkeyed hash. There is no global installation/workspace id, and future analysis or upload must not reconstruct one. Missing identity state is created only when enabled; malformed existing state stops recording until explicit identifier reset. A syntactically valid unsupported state version is reported separately and is not replaced by an older binary.
Public package and extension identity
Each package manager must return typed provenance with the resolved coordinate: registry URL, git, path, workspace, or unknown. Core, rather than the package manager, maps allowlisted public registries to stable ecosystem labels.
Raw URLs and provenance never enter an event. Sources outside the allowlist are private and unnamed by default.
A named package requires allowlisted provenance, a valid public name, and an exact resolved version. Other inputs increment unnamed_packages and exactly one reason: private_registry, git, path, workspace, unknown_source, or invalid_coordinate.
Source reasons take precedence. invalid_coordinate applies only to an otherwise public source with an invalid coordinate. Exact versions let the team isolate version-specific recommendation gaps.
package_resolution.extension_match distinguishes:
public: at least one eligible public extension matched;unnamed_only: extension content matched, but none was safe to name;none: no resolved extension matched.
Plugin, skill, and command names follow the same public-by-allowlist rule. This requires extending the current PM representation, which loses source provenance after loading metadata.
Complete safe resolution paths
extension_resolution records the actual successful package-to-plugin-to-skill path. Predicate evaluation produces evidence in its original pass; telemetry never re-evaluates a predicate.
Safe nodes are public package and extension coordinates, all contributors, the successful any branch, an opaque not marker, or opaque with a fixed reason: private_source, non_package_predicate, or limit.
Shell commands, paths, environment values, custom predicate details, workspace members, wildcards, private names, and a negated child never enter the path.
The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, not, or opaque node. A subtree that would exceed depth 8 becomes opaque: limit. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. These limits do not count filesystem path components; filesystem paths are never recorded.
Full sync builds safe evidence for successful installations because the generated attribution index needs it even when telemetry is disabled. Only an enabled recorder serializes that evidence as telemetry.
Cached booleans for non-package predicates may synthesize opaque: non_package_predicate. Caching an entire PredicateSet would discard the successful branches and witnesses. Supporting such a cache would require a different witness design.
This preserves actionable resolution relationships without recording a complete dependency snapshot. It proves that an extension resolved. A matching extension_invocation_metrics row separately proves that an agent activated the installed skill; version 1 can produce that row only for Claude.
Hook aggregation and version 1 agent coverage
Each completed hook merges into one daily hook_metrics row per agent, surface, and active identifier epoch. Per-plugin preparation and execution merge into plugin_hook_metrics. An identifier reset can create another epoch row on the same day.
Histogram bounds are fixed at [5, 10, 25, 50, 100, 250, 500, 1000] milliseconds so rows remain mergeable.
Outcome counters and histograms have the exact sum invariants defined in the data contract. Identified-session counts are all-or-nothing: every observation must supply an id, and each keyed set is limited to 256.
On a missing id, overflow, or state/snapshot mismatch, Symposium discards the sets. The row remains incomplete for that day rather than publishing a plausible partial count.
Private plugins merge into unnamed buckets. At most 128 public-plugin rows are named per UTC day across agents, hooks, and identifier epochs; later names merge into overflow buckets. Extension invocation metrics have a separate daily limit of 128 public skills.
Each named subset is first-observed. Earlier-in-day plugins or skills are therefore overrepresented when the limit is reached. Analysis must report overflow and must not treat named rows as a random sample. The combined aggregate snapshot may occupy at most 512 KiB of the shared daily allowance.
Hook rows disclose exact daily counts. pre_tool_use, post_tool_use, and user_prompt_submit therefore approximate daily tool/prompt activity even without individual records. Extension invocation rows disclose exact daily skill-attempt, completion, and failure counts. The disclosure states both explicitly.
This matrix defines which agent signals version 1 records. It is part of the producer contract, not implementation status or telemetry priority. Unsupported absence means unknown, not zero.
| Agent | Configuration | SessionStart | Session id | Fresh/resume | Stop | Skill invocation |
|---|---|---|---|---|---|---|
| Claude Code | yes | yes | yes | yes | yes | attempted/completed/failed |
| Codex CLI | yes | yes | yes | yes | no | unsupported |
| GitHub Copilot | yes | yes | no | no | no | unsupported |
| Antigravity | yes | yes | yes | no | no | unsupported |
| Kiro | yes | yes | yes | no | no | unsupported |
| OpenCode | yes | no | n/a | n/a | no | unsupported |
| Goose | yes | no | n/a | n/a | no | unsupported |
Configured reach covers all seven agents; observed-session measures cover only registered hook integrations. Stop and structured skill invocation remain Claude-only in version 1. An unsupported agent produces no invocation row, which means unknown rather than zero. Stop is not required by Q1-Q7.
Skill invocation attribution
Agent-specific parsing ends at a normalized extension-use observation. The Claude adapter accepts only a fixture-tested Skill tool shape and produces a skill identifier plus one phase: attempted, completed, or failed. Raw Claude input and ephemeral invocation ids do not enter the recorder.
Full sync writes a versioned installation index under the agent skills parent at .symposium/index-v1.json. This generated, gitignored file is installation state, not configuration or telemetry.
The index maps the actual agent-facing identifier to the installed directory, marker fingerprint, eligibility, and safe public coordinate or path when one exists. Private identifiers may remain in the local index because they already exist in installed skill content. They are never serialized as telemetry.
The index is atomically replaced after sync determines which installations succeeded. Before naming a public invocation, lookup verifies the Symposium marker and fingerprint. A hook sees an old or new complete index; a missing, corrupt, or stale mapping never falls back to a path or content guess.
Public matches reuse the extension_subject derived for the selected safe resolution path. Other observations merge into unnamed rows with one fixed reason: ineligible, not_indexed, attribution_unavailable, ambiguous, or invalid_signal.
not_indexed means a valid attribution index has no matching agent-facing identifier. attribution_unavailable means the index is missing, corrupt, or stale. After 128 named public-skill rows in one UTC day, further public matches merge into overflow.
Claude’s existing PreToolUse and PostToolUse hooks increment attempts and completions. A PostToolUseFailure registration matched only to Skill increments failures; it does not create generic failure-surface metrics.
Each phase update is an independent lower bound. If one update is lost, completed plus failed need not equal attempted and may exceed it.
The same all-or-nothing 256-id rule applies to attempted and completed distinct-session sets. A missing id, overflow, or state/snapshot mismatch makes both counts incomplete for that row and day. Completion proves activation only; it does not show whether the agent followed the instructions or improved the result.
Recording architecture
| Unit | Responsibility |
|---|---|
Recorder | Enforce consent, buffer typed batches, and isolate recording failures. |
IdentityDeriver | Own identity state and derive scoped pseudonyms. |
PublicIdentityPolicy | Convert PM provenance into safe coordinates or unnamed counts. |
ResolutionWitness | Carry evidence through one-pass resolution. |
InstalledExtensionIndex | Persist actual agent-facing skill attribution after successful installation. |
ObservationRouter | Normalize agent-native extension-use signals and send them only to active sinks. |
MetricAggregator | Merge hook, plugin-hook, and extension-invocation observations into bounded rows. |
JsonlSink | Lock, cap, retain, append/replace, inspect, and clear files. |
ArchiveReader | Preserve raw inspection and return validated records from immutable closed days. |
Only an enabled Recorder owns the telemetry sink and identity components. Call sites cannot write directly. The generated installation index is functional sync state and exists independently of telemetry consent.
Keep the schema, recorder, identity, storage writer/reader, retention, limits, and controls in focused submodules beneath telemetry. Only narrow recording and control entry points are crate-visible. A future upload module depends on ArchiveReader; recording and storage never depend on upload.
A sync returns a structured report with provenance and witnesses. That report drives skill installation, atomic index replacement, and, when recording is enabled, one sanitized relationship batch.
A hook prepares its agent response before converting timings and outcomes into aggregate updates. When telemetry is disabled and no other sink is active, the observation router does not load the index or construct an extension-use observation.
Commands are measured once at top-level dispatch. Raw errors never enter telemetry.
Storage, concurrency, and retention
Files and state
Low-volume rows append to events-YYYY-MM-DD.jsonl. Current daily hook, plugin-hook, and extension-invocation aggregates live in a bounded, atomically replaced metrics-YYYY-MM-DD.jsonl snapshot under the inspectable telemetry data directory.
The sibling private telemetry-state.toml holds the identity key, cohort and cleanup metadata, the latest opened UTC day, marker state, and temporary keyed aggregate entries. Each plugin-hook entry keeps its stable row event_id, session-count sets, and contribution count together. The identifier also appears in the metric row and is not secret; the session sets are never emitted. Complete entries expire at day rollover and are removed by clear or reset-identifiers. The telemetry lock remains in the data directory and guards data and private state mutations.
Closed days
The latest opened day is a durable high-water mark and never moves backward. Observing a later UTC day advances it and permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than reopening a closed day. A forward clock correction can therefore cause recording to remain dropped until the actual date catches up, but a clock rollback cannot modify data already considered closed.
Raw inspection remains byte-preserving. A separate typed reader returns only recognized, valid rows from closed, unexpired days and reports malformed, invalid, and unknown-version lines separately. Validation includes file/day consistency and other cross-field invariants. The reader loads at most the daily allowance into owned memory under the telemetry lock, then releases the lock before a consumer does further work. An oversized or incompletely read day produces no validated result rather than a partial result presented as complete.
Concurrent writes and failure
Recorders make one non-waiting exclusive-lock attempt. Contention drops the entire buffered batch or aggregate observation. Event batches serialize before one append so concurrent lines cannot interleave. Snapshot updates use same-directory temporary replacement.
While holding that lock, session recording rejects a day before the latest-opened-day high-water mark. It calculates the identifier-window and return-cohort transitions before mutating either anchor. It then applies both transitions and any high-water advancement to one in-memory state, atomically replaces private state once, and only then derives the row identifiers and appends the session_start row. If the append fails after a new cohort is stored, later rows for that cohort are ignored by Q1 unless a D0 row was stored. The partial failure therefore causes undercounting rather than unstable identity.
Private-state replacement uses a temporary file beside telemetry-state.toml in the config directory. Abandoned state and snapshot temporaries are ignored and cleaned lazily under the telemetry lock. No fsync is promised, so a crash can still lose the latest update. Contribution counts detect state and snapshot divergence and permanently mark affected daily session counts incomplete.
Aggregate counters are lower bounds. No durable counter can quantify observations lost to lock contention, process termination, or I/O failure because those conditions can also prevent writing the counter. A cap-only counter would not measure total loss.
Size and retention
The event file, aggregate snapshot, and reserved maximum-size storage_limit row share 8 MiB per day. This is a safety ceiling, not expected volume or preallocation. It bounds damage from a producer bug or unexpectedly large resolution batch; normal recording should remain well below it.
Together with D31 expiry, the daily allowance bounds ordinary retained telemetry near 248 MiB, excluding temporary files and private state. Aggregate metrics receive at most 512 KiB. An oversized metric update is dropped without stopping low-volume events. An ordinary batch that cannot fit is replaced by the daily marker, and ordinary recording stops for that day. Relationship batches are never split. Private-state reads have a separate 16 MiB safety ceiling.
Files survive D30 and become eligible for lazy deletion when current_day - file_day > 30, first on D31. clear deletes event and metric files plus pending count sets, but preserves consent, identity/cohort state, and the latest-opened-day high-water mark. reset-identifiers rotates future identifiers without rewriting old files or moving the high-water mark backward.
disable stops recording but keeps files by default. Uninstalling Symposium also leaves them. The telemetry CLI reference defines the exact command behavior.
Multiple agents
Concurrent agents produce separate session and agent/hook rows. The same public package/path derives the same dimension subject for unique-install deduplication, but no project id links the agents. A simultaneous flush may be dropped; concurrent mutation of installed skills remains a separate problem.
Schema evolution
Schema versions are per event kind. Semantic or correlation changes create a new version. Privacy expansions also require a new consent version.
Readers retain malformed and unknown lines, count them separately, and exclude them from typed analysis. Raw show preserves their bytes.
The “What is never recorded” section of the data contract is a producer rule. In summary, telemetry excludes prompt and tool content, per-invocation rows, raw errors and payloads, paths and workspace identity, environment/machine/account values, private-source names, arbitrary URLs, global identifiers, and timestamps finer than one second.
Here, the per-invocation exclusion includes individual skill activations and raw agent-facing skill identifiers.
Drawbacks and limitations
This design accepts the following costs and limits:
- Opt-in measurements describe participating installations, not the complete user population. Reports must state this selection bias.
- A completed skill activation does not prove that the skill was followed or improved the task. Version 1 also obtains structured skill-use observations only from Claude.
osandarchdescribe the Symposium binary’s compilation target, not physical hardware or a host outside a compatibility layer. Anx86_64binary running under Rosetta recordsx86_64, and a Linux binary under WSL recordslinux.- Scoped pseudonyms do not make a local directory anonymous. File/day/order and one buffered sync can expose co-occurrence; unusual public versions, public skill-use counts, agent/platform combinations, and exact counts can fingerprint an installation.
- Best-effort recording undercounts activity. Busy multi-agent sessions contend more, terminated hooks lose final observations, and unsupported agents have configuration but not session or skill-invocation observations. A failure may also prevent writing a durable dropped-update counter, so the missing data cannot be measured completely.
- Recording performs bounded in-process work and one non-waiting lock attempt. It does not promise zero latency, although failure and contention never change the user operation’s result.
- A large forward system-clock correction can advance the durable day high-water mark. Recording then drops observations dated before that mark rather than risk reopening data already treated as closed.
- The daily safety cap permits ordinary retained data near 248 MiB through D30. The implementation also adds cross-cutting provenance, witness, attribution, aggregation, consent, and schema-maintenance work.
Rationale and alternatives
Record no production telemetry
This avoids the privacy, storage, and implementation costs above, but leaves Q1-Q7 unanswered. Controlled evaluation can determine whether a skill helps in a test scenario; it cannot show production reach, resolution gaps, or hook reliability.
Record every hook and skill invocation
Hooks are high-volume; Q4 needs rates and distributions, not traces. Resolution edges are low-volume and Q2/Q3 need the actual safe package-to-plugin-to-skill relationship. Aggregating hook and skill activity while retaining safe resolution events preserves that product signal without creating per-invocation rows.
Defer scoped identifiers, provenance, and witnesses until upload
These mechanisms already serve local measurement and privacy. Q1-Q7 need narrow deduplication and relationship evidence. Provenance prevents private coordinates from reaching an inspectable or shareable telemetry directory.
Recording plain coordinates now would create weaker local files and permanently lose evidence needed by the measurement questions. event_id also gives cumulative metric rows stable identity across snapshot replacement and reset epochs.
Omit exact package versions
A package name alone cannot distinguish a missing recommendation from a version-specific resolution gap or regression. Exact versions provide that diagnostic only after the PM proves an allowlisted public origin and validates the coordinate; private and local versions remain unnamed.
Record a complete dependency snapshot
Per-package frequency and safe extension paths answer Q3 without an explicit project fingerprint. Same-file/day/batch order can still reveal co-occurrence locally, but a dependency-set or resolution id would make that linkage direct and persistent.
Use SQLite instead of JSONL
Low-volume events append, while hook metrics form a small bounded daily snapshot. JSONL keeps both byte-inspectable, preserves unknown versions and malformed lines, and needs no database. The process lock plus atomic snapshot replacement supplies the required concurrency behavior.
Unresolved questions
No telemetry-contract question is intentionally left open for acceptance. Two implementation inputs remain: Step 7 requires team approval of the exact disclosure wording, and Step 5 requires before-and-after hook measurements.
If either requires changing collection, privacy, or failure semantics, the RFD must be amended before recording is activated.
Future possibilities
The event catalogue is closed for consent version 1, but new measurements can be added as typed, versioned event families. Every family goes through Recorder and remains subject to consent, public-identity rules, storage caps, retention, failure behavior, and the never-record exclusions.
Additional agent adapters
Agent-native extension-use payloads remain behind the normalized observation boundary. Version 1 implements only the Claude Skill adapter. Supporting another agent requires a thin adapter, native-signal conformance fixtures, capability documentation, and an event schema update; it does not duplicate attribution, aggregation, or storage.
Plugin telemetry
The same model can later support eligible public plugins. A plugin would declare bounded feature names in its manifest and report only core-defined aggregates. Symposium would own and validate the schema, record and use the data, and optionally provide aggregate reports to plugin authors.
Plugins could not add arbitrary fields, bypass consent, or write directly to storage. This RFD implements only the event families listed above. Plugin reporting requires a separate implementation and disclosure before collection begins.
Controlled evaluation
Production telemetry and a future evaluation harness may consume the same normalized extension-use observation, but they must use separate sinks and contracts. The telemetry sink writes only the daily aggregates defined here.
An explicitly started harness may retain detailed per-run evidence in its isolated evaluation workspace. It does not read telemetry JSONL or rely on rotating telemetry identifiers.
This RFD preserves that internal seam but does not define a harness, scenario format, trace format, outcome grader, or token accounting. Controlled with/without comparisons answer whether a skill improved a task; production telemetry does not.
Upload
Accepting this RFD is not consent to upload. A future RFD must define transport/authentication, renewed consent and scheduling, retry/idempotency, server retention/access/deletion, reporting thresholds, and incident handling.
Upload may use only accepted local fields and must preserve scoped-correlation boundaries. It cannot create a global subject from identifiers, file order, batch membership, request grouping, or transport metadata. event_id and per-kind versions enable retry and mixed-version handling; they pre-approve no transport.
The local archive is the durable handoff for that future work. A future uploader consumes only recognized, validated rows from closed UTC days; it does not receive live observations or opaque JSONL files. Malformed, invalid, and unknown-version lines remain locally inspectable but are not uploadable by a reader that does not understand them.
The uploader remains downstream of storage. It reads a bounded day under the telemetry lock, releases the lock before network work, and retries from unchanged local files. Upload failure does not extend local retention: data becomes ineligible on D31 whether it was acknowledged, failed, or never attempted. The uploader owns separate acknowledgement and retry state so transport concerns do not enter identity state or recording paths. Acknowledging all currently recognized rows is not a whole-day acknowledgement while unknown-version rows remain.
This RFD supplies the closed-day and validated-reader boundary, not an uploader, upload state, network dependency, endpoint, authentication scheme, schedule, or upload setting. Using the archive avoids both a live second sink, which would couple recording to network behavior, and a duplicate upload outbox containing another copy of the data.
Proposed documentation
- What Symposium records: normative fields, enums, examples, and exclusions.
cargo agents telemetry: controls, files, inspection, and concurrency.- Telemetry configuration: consent and effective-state semantics.
These remain proposed pages until implementation lands; shipped design/reference chapters continue to describe the current binary.
Frequently asked questions
What does pseudonymous mean here?
A scoped identifier comes from a random local secret rather than machine identity. It still links observations inside one stated purpose and window. Rotation and domain separation limit that linkage; they do not make the local files anonymous.
The secret key is private state, not anonymized telemetry. It is not included in telemetry because someone who has it can recompute candidate identifiers.
Does a completed skill activation mean the skill helped?
No. It means an agent successfully activated the installed skill; version 1 can observe this only for Claude. It does not show whether the agent followed the instructions or whether the task result improved. That causal question requires a controlled evaluation comparing equivalent runs with and without the skill.
Implementation plan and status
The seven steps below are PR-sized. Steps 1-6 keep production collection inert. Step 7 activates the current consent version only after every producer and control is present.
Tests and benchmarks may construct an enabled recorder only through a test-only API bound to a temporary telemetry home. There is no runtime environment-variable or configuration bypass.
1. Contract and identity
|
2. Storage and controls
/ \
3. PM provenance 6. Reach/commands
|
4. Resolution witnesses/index/events
|
5. Hook and extension-use metrics
\ /
7. Consent and activation
After Step 2, Steps 3 and 6 may proceed in parallel. Step 4 follows Step 3, Step 5 follows Step 4, and Step 7 waits for Steps 5 and 6.
Step 3 isolates the risky core PM type seam from telemetry instrumentation. Steps 4 and 5 remain end-to-end measurement slices. The plan remains seven PRs.
Step 1: Telemetry contract and identity
Replace the dormant event types with the closed producer schema: common fields, bounded witnesses, fixed outcomes and histograms, extension-invocation counters and buckets, and per-kind version dispatch.
Add private telemetry-owned <config-dir>/telemetry-state.toml, atomic owner-only key creation where supported, scoped HMAC derivation, local 30-day windows, D0-D30 cohorts, and reset primitives. Keep this state outside the inspectable telemetry data directory. Do not add storage or emission.
Verify:
-
All contract examples round-trip, and bounds and unknown versions behave as specified.
-
Arbitrary or private data cannot serialize.
-
Identities separate domains, dimensions, agents, and windows. The same active-window inputs remain stable across recorders and restarts, while normal rollover changes subjects without replacing the key.
-
disableandclearpreserve identity state; renewed consent and reset rotate it. -
Disabled paths create nothing.
-
PR: telemetry contract and scoped identity
Step 2: Storage and local controls
Add whole-batch event appends, non-waiting process locking, atomically replaced aggregate snapshots, daily caps and reservations, storage_limit, lazy D31 cleanup, and a durable latest-opened-day high-water mark. Add typed status, byte-preserving show, a validated closed-day reader, clear, and reset-identifiers commands.
Expose a test-only enabled recorder bound to a caller-supplied temporary telemetry home for integration tests and benchmarks. No production caller writes through the sink yet, and no runtime bypass is added.
Verify:
-
Concurrent complete lines, old-or-new snapshots, whole-operation drops, and cap/marker accounting.
-
D30/D31 cleanup, raw inspection, validated reads, and abandoned state/snapshot temporary cleanup.
-
Day advancement, identifier-window rollover, and return-cohort transition share one locked state replacement before a session append. A failed D0 append cannot admit later rows as a return cohort.
-
Observations before the high-water mark are dropped before cohort mutation; clock rollback cannot reopen earlier files, and forward-correction drops are non-disruptive.
-
Malformed, invalid, and unknown-version lines remain inspectable, are reported separately, and cannot enter validated output.
-
Validated output contains no lock, temporary, or private-state file; an oversized or incompletely read day is rejected as a whole.
-
Private-state permissions and separation, clear/reset semantics, and test-only recorder isolation.
-
Management commands never record themselves.
-
PR: telemetry storage and local controls
Step 3: PM provenance and public-identity policy
Extend every package-manager result with typed provenance and exact coordinates. Add core-owned public allowlists and coordinate validation. If an out-of-process package-manager protocol lands first, carry provenance through it as part of this step.
All supported package managers must provide provenance before events are wired. This step emits no telemetry.
Verify every package manager and source class, malformed and wildcard coordinates, public allowlist behavior, unnamed-reason precedence, and that raw URLs and private or local coordinates cannot become public identities.
- PR: package provenance and public identity policy
Step 4: Resolution witnesses, attribution index, and recording
Return safe evidence from the original predicate evaluation while preserving short-circuit and accepted cache behavior. Whole-PredicateSet caching remains disallowed.
Carry evidence through plugin and skill selection, return one structured full-sync report, and construct coherent resolution_summary, package_resolution, and extension_resolution batches. After installation, atomically replace the generated agent-facing attribution index with successful managed skills and their marker fingerprints. Ordinary read-only plugin lookup remains silent.
Verify:
-
all,any,not, and opaque/limit paths, plus cache hits without reevaluation. -
Every
extension_matchcase and complete package-to-plugin-to-skill paths. -
Whole-batch failure, successful and failed installation indexing, and atomic old-or-new index reads.
-
Stale, corrupt, fingerprint-mismatch, and collision handling.
-
Raw paths, private names, and dependency snapshots stay out of telemetry.
-
PR: resolution witnesses, installed attribution, and recording
Step 5: Hook and extension-invocation telemetry
Measure completed Symposium and plugin-hook handling. Merge top-level observations into hook_metrics and plugin preparation/execution into plugin_hook_metrics, with fixed outcomes/histograms, scoped subjects, public/unnamed/overflow buckets, the 128-named-row limit, and all-or-nothing identified-session counts.
Add the sink-neutral extension-use observation and Claude Skill adapter. Existing pre/post-tool hooks and a targeted failure hook update extension_invocation_metrics through the installed attribution index, with independent phase counters, fixed unnamed reasons, a separate 128-public-skill limit, and all-or-nothing attempted/completed session counts. Never emit a per-invocation row.
Measure the hook path before and after. Verify:
-
Sanitized automatic and manual activation, lifecycle fixtures, and unknown schemas.
-
Public, private, missing, ambiguous, and stale attribution; marker validation; and raw-input exclusion.
-
Counter, histogram, and cross-row invariants; independently dropped phases; boundary buckets; process merging; and rollover/reset.
-
Bounded behavior across 500 lifecycles, private and overflow buckets, and missing, mixed, or over-256 session ids.
-
Crash recovery, a worst-case snapshot within 512 KiB, and integer-overflow drops.
-
A fake second sink, disabled-path short-circuiting, and unchanged agent output on recording failure.
-
PR: bounded hook and extension-invocation telemetry
Step 6: Session, configuration, and command telemetry
Add capability-aware session_start, daily agent_configuration, and eligible top-level command rows over the shared recorder.
The first non-telemetry recording-capable invocation of each UTC day attempts one all-agent configuration batch from the per-user agent list. It does not inspect agent-owned files. Hook internals, telemetry controls, arguments, and unsafe plugin command names remain excluded.
Verify:
-
The capability matrix, Copilot without a session id, and Claude-only
Stop. -
Configuration-list semantics, same-day configuration deduplication, and retry after a dropped configuration batch.
-
Fixed command vocabulary and public eligibility, plus failures before or after command dispatch.
-
PR: session reach and command telemetry
Step 7: Consent, activation, and documentation
Add consent-version, one shared team-approved disclosure for init and telemetry enable, re-consent, explicit non-interactive acknowledgement, enable and disable, and final production wiring for Steps 4-6.
Review the final wording against the version 1 coverage requirements. The proposed prompt is a complete example, not the implementation string. There is no event-file migration because the dormant recorder was never called. Publish the proposed pages and update the current design and flow chapters plus md/SUMMARY.md.
Verify:
-
New and existing configuration states, plus documented review of the final disclosure against every required coverage point.
-
A snapshot proving that
initandtelemetry enableuse identical approved text. -
Interactive and non-interactive flows, no partial or disabled collection, and no runtime consent bypass.
-
Raw inspection and expiry, the full CLI and integration suite, and the hook-path benchmark.
-
Formatting, clippy, workspace tests, mdBook, and orphan checks.
-
PR: telemetry consent and recording activation
What Symposium records
Symposium records telemetry only after you opt in. The records stay on your machine; nothing described on this page is uploaded. cargo agents telemetry show displays the stored bytes, and cargo agents telemetry clear deletes event and aggregate-metric files. The never-record list summarizes the exclusions.
This page is the complete producer contract for consent version 1. If a field is not listed here, Symposium does not write it as telemetry.
Common fields
Every JSONL row has:
| Field | Example | Meaning |
|---|---|---|
v | 1 | Schema version for this row kind. |
kind | session_start | Row kind from the list below. |
event_id | 9f2c41b6-... | Identifier for this row; random when minted. |
day | 2026-08-03 | UTC calendar day. |
symposium | 0.4.0 | Symposium version that wrote the event. |
Completed operational events (session_start and command) also have at, an RFC3339 UTC timestamp truncated to one second. Their day is the UTC calendar day containing at; a mismatch makes the row invalid. Resolution, configuration, and aggregate metric rows have only day.
Counters and durations are non-negative JSON integers that fit an unsigned 64-bit value. Symposium checks arithmetic and drops an overflowing batch or observation instead of wrapping the value.
Durations are converted to whole milliseconds by truncating any sub-millisecond remainder. A duration above the unsigned 64-bit millisecond range is recorded as the maximum value instead of wrapping.
event_id exists to deduplicate a future retry of the same event or identify one cumulative metric row. A metric row keeps the event_id minted when its dimension first appears that UTC day as the row is rewritten. It is not an installation, session, account, or project identifier.
Example JSONL for every row kind
These are independent row-shape examples, not one coherent operation or batch. The identifiers and coordinates are illustrative. An actual day may contain repeated low-volume events and one cumulative row per aggregate-metric dimension.
{"v":1,"kind":"session_start","event_id":"9f2c41b6-495e-4c88-a22b-c597f8102aed","day":"2026-08-03","at":"2026-08-03T09:14:02Z","symposium":"0.4.0","agent":"claude","os":"linux","arch":"x86_64","start":"fresh","session_id":"sess_31d8b1916028f65a0c0521dc1f4c86fb","retention_subject":"ret_74ddf26f80ad8b58de7f03e6c632e654","cohort_day":0}
{"v":1,"kind":"agent_configuration","event_id":"1a77f7c8-2c45-4de2-8cc6-b507ac3605f8","day":"2026-08-03","symposium":"0.4.0","agent":"claude","configured":true,"os":"linux","arch":"x86_64","agent_subject":"agt_9255770e1679cb789796a9f9e86325c5"}
{"v":1,"kind":"resolution_summary","event_id":"6c3ef7a1-f782-4b47-9a25-829dd64e1ba2","day":"2026-08-03","symposium":"0.4.0","trigger":"session_start","outcome":"ok","duration_ms":142,"public_packages":2,"unnamed_packages":1,"unnamed_package_reasons":{"private_registry":1,"git":0,"path":0,"workspace":0,"unknown_source":0,"invalid_coordinate":0},"plugins":1,"skills":1,"installed":2,"updated":0,"reaped":0,"session_id":"sess_31d8b1916028f65a0c0521dc1f4c86fb"}
{"v":1,"kind":"package_resolution","event_id":"c03e7390-52b1-4e11-b7e7-9573c84e555a","day":"2026-08-03","symposium":"0.4.0","package":{"ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},"extension_match":"public","package_subject":"pkg_f6db813c87209816ae4896f3e60dd774"}
{"v":1,"kind":"extension_resolution","event_id":"fa21a1d7-64a7-4e3e-9f6a-4572cf8f1939","day":"2026-08-03","symposium":"0.4.0","target":{"type":"skill","source":"symposium-recommendations","name":"example-debugging"},"path":[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"extension","extension_type":"plugin","source":"symposium-recommendations","name":"example-tools"},{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}],"extension_subject":"ext_6e68a75b9701bbad86cf32cd876e994a"}
{"v":1,"kind":"hook_metrics","event_id":"b563dd02-0301-4e2c-aac4-2e0d5dfaa977","day":"2026-08-03","symposium":"0.4.0","agent":"claude","hook":"pre_tool_use","invocations":500,"outcomes":{"ok":498,"blocked":1,"plugin_error":1,"internal_error":0},"plugins_attempted":500,"plugins_completed":500,"duration_ms":{"bounds":[5,10,25,50,100,250,500,1000],"counts":[40,80,180,130,55,12,2,1,0]},"identified_sessions":1,"identified_sessions_non_ok":1,"session_counts_complete":true,"hook_subject":"hok_51f4f4143ce32704e96086899dc66a27"}
{"v":1,"kind":"plugin_hook_metrics","event_id":"70dcad2a-fd19-4b5d-97cb-a7c7b52a81f1","day":"2026-08-03","symposium":"0.4.0","agent":"claude","hook":"pre_tool_use","plugin_scope":"public","plugin":{"source":"symposium-recommendations","name":"example-tools"},"attempts":500,"executions":500,"outcomes":{"ok":499,"blocked":0,"error":1},"prepare_ms":{"bounds":[5,10,25,50,100,250,500,1000],"counts":[400,90,10,0,0,0,0,0,0]},"execute_ms":{"bounds":[5,10,25,50,100,250,500,1000],"counts":[10,50,200,180,50,8,2,0,0]},"identified_sessions":1,"identified_sessions_non_ok":1,"session_counts_complete":true,"plugin_subject":"plg_d42e3f1fbac5bb001e1258a1e70b0d77"}
{"v":1,"kind":"command","event_id":"5d18caa8-84f7-4aa3-846c-99ea810ccd85","day":"2026-08-03","at":"2026-08-03T10:02:11Z","symposium":"0.4.0","command":{"type":"builtin","name":"use"},"duration_ms":820,"outcome":"ok","command_subject":"cmd_adf0c14ddc35b97762b5daae6f4119ce"}
{"v":1,"kind":"storage_limit","event_id":"8430f7f3-7ec5-4ca5-9c65-8f6e83eaa3de","day":"2026-08-03","symposium":"0.4.0","dropped_operation":"manual_sync"}
{"v":1,"kind":"extension_invocation_metrics","event_id":"7b3bda33-1fd7-4657-9637-4057268370dc","day":"2026-08-03","symposium":"0.4.0","agent":"claude","target_scope":"public","target":{"type":"skill","source":"symposium-recommendations","name":"example-debugging"},"attempted":14,"completed":12,"failed":2,"session_counts_complete":true,"identified_sessions":4,"identified_sessions_completed":3,"extension_subject":"ext_6e68a75b9701bbad86cf32cd876e994a"}
Scoped identifiers
Key and rotation
When an enabled recorder first needs identity state, Symposium stores a random secret key in private <config-dir>/telemetry-state.toml (default ~/.symposium/telemetry-state.toml). The same state holds the current identifier-window anchor and, after the first observed session, the return-cohort anchor. Every recorder reads it under the telemetry lock, so identical domain, window, and dimension inputs produce the same subject across processes and restarts.
If private state is absent while daily files survive, Symposium initializes the latest-opened-day high-water mark and identifier-window anchor to the later of the current UTC day and the newest day named by a canonical events-YYYY-MM-DD.jsonl or metrics-YYYY-MM-DD.jsonl file. Other names are ignored. Malformed state and a syntactically valid unsupported state version are not treated as absence and are never replaced by this initialization path.
An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation. This normal rollover changes the window input without replacing the key. Renewed consent or telemetry reset-identifiers replaces the key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session starts a new cohort at D0. telemetry disable and telemetry clear preserve the key and whichever anchors exist.
Identifier-window age uses that same later day. An anchor later than the wall-clock day is valid after clock rollback and is not malformed for that reason alone.
This file is separate from the inspectable <config-dir>/telemetry/ data directory and has owner-only permissions where the platform supports them. The key is private state, not anonymized telemetry. It is not written into events, printed by telemetry commands, or derived from your machine. Someone who has the key can recompute candidate identifiers.
The implementation prevents accidental formatting or serialization of the key. It does not promise to scrub every in-memory copy: the security boundary is the private state file and keeping the key out of telemetry and diagnostics.
Derivation format
Version 1 uses the first 128 bits of HMAC-SHA-256. Each variable-length window or dimension value has an eight-byte unsigned big-endian byte length followed by its bytes:
frame(value) = u64_be(byte_length(value)) || value
HMAC(
key,
"telemetry:<domain>:v1\0"
|| frame(window)
|| frame(dimension field 1)
|| frame(dimension field 2)
|| ...
)
The window is the anchor’s ASCII YYYY-MM-DD representation from telemetry-state.toml. Dimension fields use the exact UTF-8 bytes of their stable labels and validated strings, without case folding or Unicode normalization. Length framing keeps field boundaries unambiguous even when a value contains a NUL byte. Domains with no dimension fields end after the framed window.
The domain strings, wire prefixes, and ordered dimension fields are frozen for consent version 1:
| Identifier | HMAC domain | Wire prefix | Window anchor | Ordered dimension fields |
|---|---|---|---|---|
session_id | session_id | sess_ | identifier-window | Agent, vendor session id. |
retention_subject | retention_subject | ret_ | return-cohort | None. |
agent_subject | agent_subject | agt_ | identifier-window | Agent. |
package_subject | package_subject | pkg_ | identifier-window | Package ecosystem, name, exact version. |
extension_subject | extension_subject | ext_ | identifier-window | Target type, source, name, then the complete safe resolution path. |
hook_subject | hook_subject | hok_ | identifier-window | Agent, hook surface. |
plugin_subject | plugin_subject | plg_ | identifier-window | Public source, plugin name. |
command_subject | command_subject | cmd_ | identifier-window | Command type, then its typed coordinate fields in event order. |
Structured values such as an extension path use the same framing recursively. A sequence starts with its eight-byte unsigned big-endian item count. Each variant starts with its framed type label, followed by its fields in the order used by the corresponding event schema. Identity code owns this encoding; telemetry producers pass typed coordinates rather than concatenating strings.
What identifiers can link
Symposium derives each identifier for one narrow purpose:
| Identifier | What it can link | Rotation |
|---|---|---|
session_id | Events carrying the same vendor session id for one agent | 30 days; omitted when unavailable |
retention_subject | Observed session days in one D0-D30 cohort | After cohort day 30 |
agent_subject | Repeated configuration observations for one agent | 30 days |
package_subject | Repeated observations of one public package/version | 30 days |
extension_subject | Resolution and public invocation aggregates for one safe path | 30 days |
hook_subject | Daily hook aggregates for one agent and hook surface | 30 days |
plugin_subject | Daily hook aggregates for one eligible public plugin | 30 days |
command_subject | Repeated use of one eligible command | 30 days |
These values are pseudonymous, not anonymous: they deliberately permit limited linking inside the stated scope. retention_subject can link observed sessions across agents for D0-D30; this is the single exception needed for return measurement. No identifier links that cohort, a package subject, and a command subject, and there is no workspace id.
All lines in your local telemetry directory still come from your Symposium home. File order and same-day events can therefore suggest which observations happened together.
Version 1 public identity allowlists
Only the following stable labels can make package, plugin, skill, or plugin-command names eligible for recording under consent version 1:
| Enum | Version 1 values | Used by |
|---|---|---|
| Package ecosystem | cargo | package.ecosystem and package path nodes. |
| Public extension source | symposium-recommendations, crates-io | Resolution/invocation target.source, extension path nodes, plugin.source, and plugin-command source. |
crates-io applies only when core proves allowlisted crates.io provenance; symposium-recommendations identifies the built-in registry. user-plugins, configured registries, paths, workspaces, and arbitrary git sources remain unnamed. Raw registry names and URLs are never enum values. Adding an ecosystem or public-source label expands name eligibility and requires a new consent version.
Public plugin and skill names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, -, or _. Unlike public package names, extension names may begin with a digit because they are authored extension identifiers rather than crates.io package coordinates. Symposium preserves the spelling without case folding or Unicode normalization. An otherwise valid extension whose name does not fit this telemetry grammar remains usable but is treated as unnamed by telemetry.
Event kinds
session_start
This row records a completed registered Symposium session-start hook.
| Field | Values | Meaning |
|---|---|---|
at | UTC second | When Symposium completed the session-start handling. |
agent | antigravity, claude, codex, copilot, kiro | Agent that invoked the registered hook. |
os | linux, macos, windows, other | OS class for the running Symposium build. |
arch | x86_64, aarch64, other | Architecture class for the running build. |
start | fresh, resumed, unknown | Agent-supplied lifecycle classification when available. |
session_id | scoped id, optional | Omitted when the agent supplies no session id. |
retention_subject | scoped id | Deduplicates this D0-D30 observed-session cohort. |
cohort_day | integer 0 through 30 | UTC days since the cohort’s first observed session. |
GitHub Copilot does not currently supply a session id. OpenCode and Goose do not currently call Symposium through a registered session-start hook, so they do not produce this event.
These rows, not hook_metrics rows whose hook is session_start, are authoritative for observed-session and return measurements. A stored D0 row admits its retention_subject cohort to analysis. D1, D7, or D30 is present when at least one later session-start row has that cohort_day, regardless of agent or vendor session id. A later row without a stored D0 for the same subject is ignored. Multiple rows on the same cohort day count once.
The producer captures at once when session-start handling completes. That timestamp determines day and the session observation from which the producer derives session_id, retention_subject, and cohort_day.
The aggregate hook rows measure only session-start hook reliability and latency.
agent_configuration
This row records whether a supported agent is configured for Symposium that day.
| Field | Values | Meaning |
|---|---|---|
agent | antigravity, claude, codex, copilot, kiro, opencode, goose | Agent being checked. |
configured | boolean | Whether the agent is listed in per-user Symposium configuration. |
os | linux, macos, windows, other | OS class for the running Symposium build. |
arch | x86_64, aarch64, other | Architecture class for the running build. |
agent_subject | scoped id | Deduplicates one installation for this agent and window. |
On the first non-telemetry recording-capable invocation each UTC day, Symposium attempts one whole batch containing all seven agents. configured means the agent is present in Symposium’s per-user configuration at that moment; Symposium does not inspect agent-owned configuration files.
A successful batch is the day’s snapshot, so later configuration changes appear on the next UTC day’s snapshot. If locking, storage, or I/O drops the batch, it remains outstanding and a later eligible invocation retries it.
configured: true does not claim that an agent session ran or that the agent-owned integration files remain intact.
resolution_summary
This row records the result of a full sync after session start, manual sync, use, or removal. Package, plugin, and skill counts are distinct coordinates within the sync, not duplicate declarations.
| Field | Values | Meaning |
|---|---|---|
trigger | session_start, manual_sync, use, remove | Why full sync ran. |
outcome | ok, partial, error | Closed result classification. |
duration_ms | integer | Full resolution/sync duration. |
public_packages | integer | Eligible public resolution-input packages. |
unnamed_packages | integer | Private, local, unknown, invalid, or otherwise ineligible inputs. |
unnamed_package_reasons | fixed counters | Mutually exclusive reasons that sum to unnamed_packages. |
plugins | integer | Plugins in the final resolved set. |
skills | integer | Skills in the final resolved set. |
installed | integer | Artifacts newly installed by this sync. |
updated | integer | Existing artifacts changed by this sync. |
reaped | integer | Obsolete artifacts removed by this sync. |
session_id | scoped id, optional | Present only for a sync inside an identified agent session. |
unnamed_package_reasons has exactly private_registry, git, path, workspace, unknown_source, and invalid_coordinate counters. Each unnamed package increments exactly one. Source provenance takes precedence; invalid_coordinate is used only for an allowlisted public source with a malformed name or a missing, wildcard, or invalid exact version. The counters sum to unnamed_packages.
When a resolution runs inside an identified agent session, the producer derives
session_id from that agent and its raw vendor session identifier using the
same bound recording context that supplies the row’s day. Producers never pass
an already-derived session identifier into the row constructor.
Read-only extension lookup on ordinary hook calls does not produce this event.
package_resolution
This row records one eligible public package used as resolution input during a full sync.
| Field | Values | Meaning |
|---|---|---|
package.ecosystem | cargo | Stable public ecosystem label. |
package.name | validated string | Public package name using the fixed version 1 grammar. |
package.version | validated exact version | Exact resolved public version, never a range or *. |
extension_match | public, unnamed_only, none | What kind of resolved extension, if any, the package contributed to. |
package_subject | scoped id | Deduplicates this exact coordinate for 30 days. |
A package is named only when its package manager reports provenance matching a reviewed public-registry allowlist. Registry URLs themselves are not recorded.
The version 1 package-name grammar is 1 through 64 ASCII bytes. The first byte is an ASCII letter; the remaining bytes are ASCII letters, digits, -, or _. Symposium preserves the spelling without case folding or treating hyphens and underscores as equivalent. This stable telemetry grammar does not copy a registry’s changing reserved-name list.
An exact package version has three numeric semantic-version components and may include prerelease or build metadata. Missing versions, ranges, and wildcards are invalid coordinates.
extension_match describes what the package contributed:
public: at least one eligible public extension matched, including when unnamed content also matched.unnamed_only: extension content matched, but none was eligible to name.none: no resolved extension matched.
Events measure packages independently. Symposium does not record a workspace/resolution id, but the summary and contiguous relationship batch can still make the public package set for one sync recoverable, especially when only one sync occurred that day. Review the whole file before sharing it.
extension_resolution
This row records one public plugin or skill and one safe path that selected it.
| Field | Values | Meaning |
|---|---|---|
target.type | plugin, skill | Resolved extension type. |
target.source | symposium-recommendations, crates-io | Stable label, never a configured URL or local name. |
target.name | public extension name | Name defined by eligible public content. |
path | bounded typed nodes | Actual safe package/predicate/extension chain. |
extension_subject | scoped id | Deduplicates this safe target/path for 30 days. |
Path nodes are limited to:
Node type | Recorded content |
|---|---|
package | Eligible public ecosystem, name, and exact version. |
extension | Eligible public plugin/skill source and name. |
all | All safe children that contributed to success. |
any | The branch that actually made the expression succeed. |
not | Marker only; the child is not recorded. |
opaque | Fixed reason: private_source, non_package_predicate, or limit. |
The exact version 1 node shapes are shown below. These examples use public placeholder coordinates; the same package and extension validation rules described above apply inside path nodes.
{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"}
{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}
{"type":"all","children":[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"any","child":{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}},{"type":"not"},{"type":"opaque","reason":"limit"}]}
{"type":"any","child":{"type":"not"}}
{"type":"not"}
{"type":"opaque","reason":"non_package_predicate"}
all.children contains one or more nodes. any.child contains the one branch
that made the expression succeed. Each object is strict: missing fields,
additional fields, unknown node types, and unknown opaque reasons are invalid.
Shell commands, paths, environment variables, custom predicate names or arguments, and private package or extension names never enter a path. An opaque marker can represent their position.
The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, not, or opaque node. A subtree that would exceed depth 8 becomes opaque: limit. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. Evidence depth does not count filesystem components; filesystem paths are never recorded.
This event says an extension resolved. It does not say that an agent read or used the extension; a matching extension_invocation_metrics aggregate separately reports observed agent activation. Version 1 can produce that aggregate only for Claude.
hook_metrics
This cumulative row combines completed hook observations for one UTC day, agent, hook surface, and active identifier epoch. An epoch normally lasts 30 days but ends early after identifier reset or renewed consent. A reset can therefore leave two rows for the same agent, hook, and day with different hook_subject values. Symposium updates the current row instead of appending one telemetry row per invocation.
| Field | Values | Meaning |
|---|---|---|
agent | antigravity, claude, codex, copilot, kiro | Invoking agent. |
hook | pre_tool_use, post_tool_use, user_prompt_submit, session_start, stop | Symposium hook surface. |
invocations | integer | Completed hook observations merged into the row. |
outcomes | hook outcome counters | Exact counters named ok, blocked, plugin_error, and internal_error. |
plugins_attempted | integer | Plugin hooks whose preparation began. |
plugins_completed | integer | Plugin hooks with an observed terminal result. |
duration_ms | latency histogram | Parsed-input-to-response-ready latency; telemetry update time is excluded. |
identified_sessions | integer, optional | Distinct identified sessions represented; present only when session_counts_complete=true. |
identified_sessions_non_ok | integer, optional | Those sessions with at least one non-ok observation; present only when counts are complete. |
session_counts_complete | boolean | Whether the two distinct identified-session counts are complete for every observation. |
hook_subject | scoped id | Links this agent/hook dimension inside its 30-day identifier window. |
The row is written only after its first completed observation, so invocations is greater than zero. outcomes and the duration histogram each sum to invocations. Exactly one outcome counter advances for each completed observation. Precedence is internal_error, then blocked, then plugin_error, then ok.
Within each observation, and therefore across the cumulative row, plugins_completed cannot exceed plugins_attempted.
Session counts remain complete only when every contributing observation supplies a session id and neither set exceeds 256 distinct ids for the row. When complete, identified_sessions is at least 1 and cannot exceed invocations. identified_sessions_non_ok is at least 1 when outcomes.ok < invocations, and cannot exceed either identified_sessions or invocations - outcomes.ok. The remaining all-ok sessions cannot exceed outcomes.ok. On the first missing id or overflow, Symposium discards both sets, writes session_counts_complete: false, and omits both counts for the rest of that day. Raw and keyed session ids are never written into the aggregate file.
plugin_hook_metrics
This cumulative row combines plugin-hook observations for one UTC day, agent, hook surface, active identifier epoch, and bounded plugin bucket. A reset can leave otherwise identical unnamed or overflow rows distinguished only by event_id; public rows also receive a new plugin_subject.
| Field | Values | Meaning |
|---|---|---|
agent | antigravity, claude, codex, copilot, kiro | Invoking agent. |
hook | pre_tool_use, post_tool_use, user_prompt_submit, session_start, stop | Symposium hook surface. |
plugin_scope | public, unnamed, overflow | Whether the bucket names an eligible public plugin. |
plugin.source | symposium-recommendations, crates-io, conditional | Present only when plugin_scope=public. |
plugin.name | public extension name, conditional | Present only when plugin_scope=public. |
attempts | integer | Plugin-hook attempts that reached an observed terminal result. |
executions | integer | Those completed attempts that reached child execution. |
outcomes | plugin outcome counters | Exact counters named ok, blocked, and error. |
prepare_ms | latency histogram | Preparation time for every attempt. |
execute_ms | latency histogram | Child execution time for attempts counted by executions. |
identified_sessions | integer, optional | Distinct identified sessions represented; present only when session_counts_complete=true. |
identified_sessions_non_ok | integer, optional | Those sessions with at least one non-ok attempt; present only when counts are complete. |
session_counts_complete | boolean | Whether the two distinct identified-session counts are complete for every attempt. |
plugin_subject | scoped id, conditional | Present only with an eligible public plugin. |
Counting rules
attempts counts terminal results. The row exists only after its first terminal result, so attempts is greater than zero. executions counts the subset that started child execution. A preparation failure is terminal error with no execution. Every attempt that does not execute has an error outcome, so attempts - executions cannot exceed outcomes.error. outcomes and prepare_ms each sum to attempts; execute_ms sums to executions. Across every epoch and bucket for one agent, hook, and day, plugin attempts sum to the corresponding top-level plugins_completed.
blocked records exit code 2 or a block decision in successfully parsed output. error records input serialization, preparation, spawn, wait, or output-parsing failure; termination by signal; or any other non-zero exit. When both signals apply, error takes precedence over blocked. ok is every other completed attempt. The same 256-id all-or-nothing session rule used by hook_metrics applies, with attempts in place of invocations and outcomes.ok as the ok observation count.
Plugin identity and row limits
Eligible public plugins use {source, name} coordinates from the reviewed allowlist. Private, local, invalid, or otherwise ineligible plugins merge into one unnamed row per agent, hook, epoch, and day and expose no identity.
At most 128 named public-plugin rows may appear across all agents, hooks, and identifier epochs in one UTC day. Attempts for later rows merge into overflow rows per agent, hook, and epoch and expose no identity. This daily allowance survives identifier reset and telemetry disable. UTC-day rollover starts a new allowance; telemetry clear resets it because clear deletes the snapshot rows it accounts for.
Named rows are first-observed, not sampled, so earlier-in-day public plugins are overrepresented when the limit is reached. Analysis must report overflow and must not treat the named subset as random.
Rules shared by hook aggregates
A latency histogram is exactly:
{"bounds":[5,10,25,50,100,250,500,1000],"counts":[0,0,0,0,0,0,0,0,0]}
The nine non-overlapping millisecond buckets mean <=5, (5,10], (10,25], (25,50], (50,100], (100,250], (250,500], (500,1000], and >1000. Bounds and count-array length cannot vary. Fixed histograms can be merged correctly across installations; locally calculated percentiles cannot.
Neither aggregate kind has at, an invocation id, a raw or scoped session id, or an individual invocation’s time or outcome. No hook input, output, stdout, stderr, error text, or numeric exit detail is recorded. Symposium adds no timeout as part of telemetry.
A host termination, lock conflict, storage limit, or I/O failure can omit an observation, so aggregate counts are lower bounds. No durable dropped-update counter can cover every loss mode because lock contention, termination, and I/O failure can also prevent writing that counter. Counting only cap rejections would imply false completeness.
These rows do reveal exact daily counts for each hook surface. In particular, pre_tool_use, post_tool_use, and user_prompt_submit counts are proxies for daily tool and prompt activity even though no individual activity record exists.
extension_invocation_metrics
This cumulative row combines skill-invocation observations for one UTC day, supported agent, active identifier epoch, and bounded skill bucket. Version 1 supports only Claude Code and accepts only its fixture-tested Skill signal. Symposium does not append one row per invocation.
| Field | Values | Meaning |
|---|---|---|
agent | claude | The only version 1 agent with a supported structured skill-invocation signal. |
target_scope | public, unnamed, overflow | Whether the bucket names an eligible public skill. |
target.type | skill, conditional | Present only when target_scope=public. |
target.source | symposium-recommendations, crates-io, conditional | Reviewed public source; present only when target_scope=public. |
target.name | public extension name, conditional | Public skill name; present only when target_scope=public. |
unnamed_reason | ineligible, not_indexed, attribution_unavailable, ambiguous, invalid_signal, conditional | Present only when target_scope=unnamed. |
attempted | integer | Valid Claude PreToolUse:Skill observations merged into the row. |
completed | integer | Successful Claude PostToolUse:Skill observations merged into the row. |
failed | integer | Terminal Claude PostToolUseFailure:Skill observations merged into the row. |
session_counts_complete | boolean | Whether both distinct identified-session counts are complete for all attempted and completed observations. |
identified_sessions | integer, optional | Distinct sessions with an attempted observation; present only when session counts are complete. |
identified_sessions_completed | integer, optional | Distinct sessions with a completed observation; present only when session counts are complete. |
extension_subject | scoped id, conditional | Present only for a public bucket; matches the selected safe resolution path for 30 days. |
Count semantics
attempted, completed, and failed are independent lower bounds. Each hook phase updates the snapshot separately. If one update is lost, completed plus failed need not equal attempted and may exceed it. The two session counts have the same independence. A stored row has at least one non-zero phase counter.
failed advances only when the supported agent emits its targeted failure signal. For version 1, that is Claude’s PostToolUseFailure:Skill event. Symposium does not infer failure from denial, termination, or a missing terminal observation. Completion means that the agent activated the skill; it does not show whether the agent followed its instructions or improved the task result.
Attribution
Public attribution comes only from the generated installation index and a matching Symposium marker fingerprint. Unnamed reasons have these meanings:
ineligible: the installed skill was private, local, invalid, or otherwise unsafe to name.not_indexed: the index was valid and readable but contained no matching agent-facing identifier.attribution_unavailable: the index was missing, corrupt, or stale.ambiguous: more than one entry matched.invalid_signal: the Claude payload did not match the validated schema.
No reason exposes the raw identifier.
Row and session limits
At most 128 public-skill rows may be named across identifier epochs in one UTC day. Later public skills merge into one overflow row per agent and epoch. Each fixed unnamed reason produces at most one row per agent and epoch. This daily allowance survives identifier reset and telemetry disable. UTC-day rollover starts a new allowance; telemetry clear resets it because clear deletes the snapshot rows it accounts for. The public subset is first-observed, not sampled; analysis must report overflow and include unnamed counts when interpreting the public denominator.
The same 256-id all-or-nothing rule applies to the attempted and completed session sets. Only attempted and completed observations contribute to those sets. A failed observation does not contribute an id or make counts incomplete because its id is missing, but it still reconciles private contribution counts with the stored row. When counts are complete, identified_sessions is at most attempted, identified_sessions_completed is at most completed, and each count is at most 256. A positive attempted or completed counter has at least one session in its matching set. On the first missing id for an attempted or completed observation, overflow, or state/snapshot mismatch, Symposium discards both sets, writes session_counts_complete: false, and omits both counts for the rest of that row and day.
This aggregate has no at, duration, raw or scoped session id, invocation id, tool name, tool input/output, prompt, transcript, or individual outcome row. Unsupported agents emit no row, which means unknown rather than zero.
command
This row records one completed eligible top-level user command.
| Field | Values | Meaning |
|---|---|---|
at | UTC second | Completion time. |
command | typed coordinate | Fixed built-in, or eligible public plugin command coordinate. |
duration_ms | integer | Top-level command duration. |
outcome | ok, error | Closed result. |
command_subject | scoped id | Deduplicates this command for 30 days. |
Arguments are never recorded. Internal hook, all telemetry commands, and ineligible external/plugin commands do not produce command events.
Built-in command operations are init, sync, search, use, remove, status, plugin_sync, plugin_list, plugin_show, plugin_validate, self_update, and crate_info. remove is the telemetry name for cargo agents use --remove; it is not a separate CLI subcommand.
{"type":"builtin","name":"use"}
An eligible plugin command contains only its reviewed public-source label, public plugin name, and declared command name:
{"type":"plugin","source":"symposium-recommendations","plugin":"example-tools","name":"example-check"}
Public plugin-command names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, -, or _. Symposium preserves the spelling without case folding or Unicode normalization. A plugin command outside this telemetry grammar remains usable but does not produce a command event.
storage_limit
This row means that the next complete low-volume event batch did not fit in the shared daily 8 MiB allowance. In addition to the common fields, dropped_operation is session_start, manual_sync, use, remove, init, configuration, or command. It identifies the top-level operation whose batch was rejected.
The row appears at most once per UTC day, and the marker itself counts toward 8 MiB. An aggregate-metric update that would exceed its separate 512 KiB maximum or the remaining shared allowance is dropped without this marker and does not stop low-volume recording. The marker does not report lock-contention, aggregate-update, or crash losses.
What is never recorded
- Individual prompt/tool activity records, per-invocation hook records, or individual skill-invocation records.
- Prompt text or any substring of it.
- Tool names, arguments, responses, or results.
- Shell commands, file contents, patches, or URLs from user/agent data.
- Error messages, debug strings, hook stdout/stderr, numeric exit details, or raw agent/package-manager payloads.
- Raw agent-facing or private skill identifiers and ephemeral invocation identifiers.
- File paths, workspace roots, project names, repository ids, or dependency-set snapshots.
- Environment names/values, hostname, username, home directory, IP address, model name, or account/vendor identifiers.
- Names or versions from private registries, git/path/workspace sources, or sources whose public identity is not allowlisted.
- Raw configured registry names/URLs or arbitrary git URLs.
- Raw vendor session ids, a global installation id, a workspace id, or one identifier shared across measurement purposes.
- Wall-clock timestamps more precise than one second.
Storage and expiry
Data files and recording
Low-volume events are appended as JSON lines in events-YYYY-MM-DD.jsonl under the inspectable <config-dir>/telemetry/ data directory (default ~/.symposium/telemetry/). Current cumulative hook, plugin-hook, and extension-invocation aggregates are JSON lines in metrics-YYYY-MM-DD.jsonl. Symposium rewrites this bounded snapshot atomically after a merge.
The lock in the telemetry directory also guards sibling private state. A recorder makes one non-waiting lock attempt. It may drop a complete buffered event batch or aggregate observation rather than delay your hook or command. Recording failures never change the user operation’s result.
Under that lock, a recording operation captures its completion timestamp once, uses its UTC day, and rejects it when it precedes the latest-opened-day high-water mark. It calculates the identifier-window transition, applies it with any high-water advancement to one in-memory state, atomically replaces private state once, and only then binds the timestamp, day, and identifier window for row construction. The replacement also occurs when the selected window remains current, so every operation has the same persist-before-bind boundary. Every row in one operation reuses that bound recording context. In particular, a full resolution uses one context for its summary, package, and extension rows.
Session recording also calculates the return-cohort transition before mutating either anchor. The identifier-window transition, return-cohort transition, and high-water advancement are persisted together. The session_start row is then derived from the same bound session context. If its append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating unstable identity.
Private state keeps the latest opened UTC day as a high-water mark. Observing a later day permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than modifying a closed day. Raw inspection still preserves every stored line. Typed reading of a closed day returns only recognized rows that pass their versioned schema and file/day invariants, and reports malformed, invalid, and unknown-version lines separately. It rejects an oversized or incompletely read day as a whole rather than returning a partial validated result.
Daily limits and retention
The event file, aggregate-metric snapshot, and reserved maximum-size storage_limit line share an 8 MiB daily allowance. This allowance is a safety ceiling, not expected volume or preallocation. It bounds damage from a producer bug or unexpectedly large resolution batch.
Aggregate metrics may use at most 512 KiB, so high-volume hook and skill activity cannot consume the allowance reserved for resolution and configuration events.
A file is eligible for deletion only when current_utc_day - file_utc_day > 30. A D0 file remains throughout D30 and is first eligible on D31. Together with D31 expiry, the daily limit bounds ordinary retained telemetry near 248 MiB, excluding temporary files and private state. Cleanup is lazy, so an old file remains until a recording-capable invocation or telemetry command runs.
Private state
The sibling private <config-dir>/telemetry-state.toml holds the identity key, current identifier-window anchor, optional return-cohort anchor, the latest opened UTC day, cleanup and marker metadata, bounded keyed session sets, snapshot contribution counts used to calculate complete distinct-session counts, the stable row event_id in each aggregate entry that cannot reconstruct its identity from the row, and separate per-day public-row admission counts for plugin-hook and extension-invocation metrics. The row identifier is not secret; the corresponding metric row contains the same value.
Symposium creates and replaces it atomically with owner-only permissions where supported. Replacement uses a same-directory temporary file beside config.toml; abandoned state temporaries are ignored and cleaned lazily under the telemetry lock.
A recorder reads at most 16 MiB of private state. Invalid UTF-8, invalid TOML, TOML that violates the state schema, and an oversized file are malformed state; diagnostics retain at most the error location and never the source line or parser error that could contain the identity key. A syntactically valid unsupported state version stops recording but is reported separately from malformed state, so an older binary does not treat newer state as safe to replace.
The session sets are not printed or copied into metric rows. Symposium discards each plugin-hook or extension-invocation entry, including its row identifier, session sets, and contribution counts, at UTC-day rollover and removes it when telemetry clear or telemetry reset-identifiers runs. clear also deletes the row the entry identifies; reset removes entries whose keys belong to the previous identifier epoch. State is replaced before the corresponding metric snapshot. If a later snapshot write fails, a contribution-count mismatch on the next update discards the sets and permanently marks the row’s session counts incomplete for that day.
Loading private state with an existing metric snapshot reconciles the two before selecting an aggregate. Symposium rebuilds each daily public-row allowance from every surviving public row for that family and day, including rows from earlier identifier epochs, and saturates the count at 128.
When selecting a public aggregate with no private entry, Symposium adopts a surviving row’s event_id without consuming another allowance slot only when the row’s day, agent, public coordinate, and, for plugin hooks, hook surface match the observation and the recorded subject equals the subject derived from that observation. Adoption runs before overflow selection. A surviving row that receives no later observation needs no private entry. An unnamed or overflow row cannot establish this relationship, so a missing private entry starts a separate row with a new event_id.
telemetry clear deletes event and aggregate-metric files and rewrites private state to remove pending sets while preserving the identity key, current anchors, and latest-opened-day high-water mark. telemetry reset-identifiers rotates future identifiers, sets the identifier-window anchor to the later of the current UTC day and that high-water mark, and clears the return-cohort anchor without moving the high-water mark backward. The next observed session starts a new retention cohort at D0. telemetry disable stops recording; existing files remain unless the user accepts its interactive clear offer or runs telemetry clear later.
Installation index
The generated <agent-skills-parent>/.symposium/index-v1.json file is installation state, not telemetry. It may contain local agent-facing identifiers, installed-directory fingerprints, eligibility, and safe public attribution needed to identify the skill Symposium installed. It is gitignored, atomically replaced after sync, excluded from telemetry show and telemetry clear, and not uploaded by this RFD.
Limits of interpretation
Public names, versions, and exact counts can be identifying when they are unusual. Exact daily hook counts disclose approximate prompt/tool activity by surface. Extension-invocation rows disclose exact skill-attempt, completion, and failure counts.
Lock contention, process termination, full files, and I/O failures can make the log incomplete. The records are therefore pseudonymous, locally associated, and best-effort. They must not be described as anonymous or as proof that an unrecorded action did not happen.
cargo agents telemetry
Use this command to manage per-user local telemetry: check whether it is enabled, opt in, inspect what is stored, stop recording, delete recorded data, or rotate future identifiers. See What Symposium records for the exhaustive field list and Telemetry configuration for consent semantics.
Telemetry is off by default. Nothing is uploaded.
Usage
cargo agents telemetry [status]
cargo agents telemetry enable [--acknowledge]
cargo agents telemetry disable
cargo agents telemetry show [--count N]
cargo agents telemetry clear
cargo agents telemetry reset-identifiers
Start with status. If you choose to participate, run enable and review the disclosure. Use show whenever you want to inspect the stored records. disable stops future recording, while clear deletes recorded data.
Telemetry management commands are never recorded as command telemetry.
status
Use status to see the effective consent state and a summary of the local event and aggregate-metric files:
$ cargo agents telemetry status
Telemetry: enabled (consent version 1)
data directory: ~/.symposium/telemetry/
stored: 3 file(s), 28.4 KiB
physical lines: 72
supported rows: 68
unknown schemas: 2
invalid rows: 1
malformed lines: 1
range: 2026-08-01 through 2026-08-03
Possible states are disabled, consent required, and enabled. Consent required means the config contains an earlier or unversioned opt-in; Symposium records nothing until you accept the current disclosure.
Stored files/bytes and physical lines cover daily event and aggregate-metric files; they exclude .lock, temporary files, and sibling private telemetry-state.toml. Supported rows are lines the current binary recognizes by kind and schema version and that satisfy the complete schema. An unknown schema has an unrecognized kind or version. An invalid row names a recognized schema but violates it. A malformed line has no usable {v, kind} JSON envelope. Unknown, invalid, and malformed lines stay on disk and remain visible through show. status never prints the secret identity key or pending keyed session sets. When the latest-opened-day high-water mark is later than the current UTC day, status reports that recording is paused until the clock catches up.
enable
enable presents the current disclosure and asks whether to begin local recording. It does not enable telemetry unless the user explicitly accepts.
Disclosure requirements
Before asking for consent, the team-approved version 1 disclosure must make these points clear:
- Telemetry is off by default, requires an explicit opt-in, and defaults to no in both interactive entry points.
- The recorded categories are:
- observed sessions and configured agents, including Symposium version, agent labels, and platform classes;
- public package names and exact versions, public resolution relationships, aggregate sync results, and reasons packages remain unnamed;
- exact daily hook, plugin-hook, and agent skill-activation metrics, with structured skill activation available only for Claude in version 1; and
- completed eligible commands without arguments, plus storage-limit markers.
- Exact hook-surface counts can approximate daily prompt/tool activity. The disclosure distinguishes second-precision session/command timestamps from day-only aggregate and resolution rows.
- Purpose-scoped pseudonymous identifiers permit the stated links for up to 30 days, including one D0-D30 observed-session cohort across agents; they are not anonymous or a global project/workspace identity.
- The main exclusions are stated plainly, including prompt/tool content, paths, project/workspace identity, environment and machine identity, raw errors and agent/package-manager payloads, private package/plugin/skill names, and individual hook or skill-invocation rows.
- Recorded data and private identifier/counting state remain local in their stated locations, nothing is uploaded, data remains through D30 and is eligible for deletion on D31, and the disclosure names the inspection and deletion commands.
The exhaustive field list and never-record list define the details behind these requirements. The team may revise structure, tone, and wording before implementation, provided the final disclosure preserves every required point and its meaning.
cargo agents init and cargo agents telemetry enable must use the same approved text and both default to no.
Example version 1 disclosure
The following non-normative example demonstrates complete coverage. It is a review aid and starting point for the final product copy, not the string the implementation must reproduce.
Symposium telemetry is off by default. If enabled, Symposium records:
- observed session starts and configured agents, with fresh/resumed status when
available, Symposium version, operating-system class, and architecture;
- public package names and exact versions, public plugin/skill resolution paths,
aggregate sync results, reasons packages remain unnamed, and whether only
unnamed extensions matched;
- exact daily Claude skill-activation attempts, completions, and failures,
public skill names, distinct-session counts when complete, fixed unnamed
reasons, and overflow counts;
- exact daily hook and plugin-hook counts, outcomes, and latency histograms;
- completed built-in and eligible public plugin commands, with outcome and
duration but without arguments;
- storage-limit markers naming only the affected operation; and
- purpose-scoped pseudonymous identifiers that link repeated observations for
up to 30 days, plus one D0-D30 observed-session cohort across agents.
Counts for pre-tool-use, post-tool-use, and user-prompt-submit approximate daily
tool and prompt activity. Session starts and commands include UTC timestamps
truncated to one second; other rows include only the UTC day.
Pseudonymous does not mean anonymous: the scoped identifiers permit only the
links described above, and no identifier links all telemetry or identifies a
project or workspace.
Symposium never records prompt or tool content, tool names or arguments, file
paths, project or workspace identity, environment values, hostname, username,
model/account/vendor identifiers, raw errors or agent/package-manager payloads,
private package, plugin, or skill names, raw agent-facing skill identifiers,
individual hook-invocation rows, or individual skill-invocation rows.
Recorded data stays on this machine in ~/.symposium/telemetry/. A private
identifier key and bounded counting state stay in
~/.symposium/telemetry-state.toml; telemetry commands never print them.
Nothing is uploaded.
Files remain through day 30 and become eligible for deletion on day 31. Inspect
them with cargo agents telemetry show and delete them with
cargo agents telemetry clear.
Enable telemetry under consent version 1? [y/N]
In a non-interactive environment, enable does not change config unless --acknowledge is supplied explicitly. This prevents scripts or a manually retained unversioned boolean from upgrading consent silently.
Enabling telemetry does not rewrite or assign new identifiers to old stored lines. Accepting a new consent version rotates the secret identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor, severing old and new scoped identifiers. The next observed session starts a new retention cohort at D0.
disable
disable stops future recording by setting enabled = false. Existing event and aggregate-metric files remain on disk. In an interactive terminal, disable offers to clear them and defaults to keeping them. In a non-interactive environment, it prints the clear command instead of deleting data.
show
show prints stored event and current aggregate-snapshot JSONL lines. The ordering is deterministic:
- UTC days appear in ascending order.
- Within a day, append-only event lines appear first in physical order.
- Aggregate rows follow in kind, agent, hook or target scope, public source/name or unnamed reason, and event-id order.
The event id breaks ties when an identifier reset creates two aggregate epochs in one day. --count N returns the last N lines in this ordering:
$ cargo agents telemetry show --count 2
{"v":1,"kind":"command","event_id":"5d18caa8-84f7-4aa3-846c-99ea810ccd85","day":"2026-08-03","at":"2026-08-03T10:02:11Z","symposium":"0.4.0","command":{"type":"builtin","name":"use"},"duration_ms":820,"outcome":"ok","command_subject":"cmd_adf0c14ddc35b97762b5daae6f4119ce"}
{"v":1,"kind":"hook_metrics","event_id":"b563dd02-0301-4e2c-aac4-2e0d5dfaa977","day":"2026-08-03","symposium":"0.4.0","agent":"claude","hook":"pre_tool_use","invocations":500,"outcomes":{"ok":496,"blocked":1,"plugin_error":3,"internal_error":0},"plugins_attempted":500,"plugins_completed":500,"duration_ms":{"bounds":[5,10,25,50,100,250,500,1000],"counts":[8,17,76,144,181,68,6,0,0]},"session_counts_complete":true,"identified_sessions":4,"identified_sessions_non_ok":2,"hook_subject":"hok_b5b707841de7695912bec9b8bca382e8"}
The command copies stored line bytes; it does not parse, normalize, repair, or pretty-print them. Unknown-version and malformed lines are shown as stored. The current day’s aggregate file is a cumulative snapshot, not a history of its replaced versions.
Aggregate rows have no at, so storage order is not chronology. All output comes from the same Symposium home, and line order or day can expose co-occurrence even though the rows have no global installation or workspace id. Review the complete output before sharing it.
Redirect the output to create a local copy:
cargo agents telemetry show --count 100000 > telemetry.jsonl
No separate export command is part of this RFD.
clear
clear deletes every events-YYYY-MM-DD.jsonl and metrics-YYYY-MM-DD.jsonl file. It acquires the telemetry lock and also discards pending aggregate session-count sets:
$ cargo agents telemetry clear
Deleted 12 telemetry data file(s) from ~/.symposium/telemetry/.
clear preserves the telemetry directory, lock, identity/cohort state, identity key, latest-opened-day high-water mark, and consent setting. New rows in the same identifier window can therefore carry the same scoped subjects as cleared rows. Severing that future linkage also requires reset-identifiers.
reset-identifiers
reset-identifiers severs identifier linkage between future and existing rows. It acquires the telemetry lock, replaces the secret identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, clears the return-cohort anchor, and discards pending aggregate session-count sets. The next observed session starts a new retention cohort at D0:
$ cargo agents telemetry reset-identifiers
Telemetry identifiers reset. Existing telemetry data files were not changed.
The command neither deletes nor rewrites old event or aggregate-metric rows. Identifiers before and after the reset cannot be derived into each other from the data files.
If no identity state exists, the command reports that there is nothing to reset instead of creating a key. If existing state is unreadable or malformed, recording remains stopped until this command explicitly replaces it. If the file has a syntactically valid unsupported version, the command refuses to replace it and reports that a binary supporting that version must manage it.
Files and private state
~/.symposium/
|-- telemetry-state.toml
`-- telemetry/
|-- .lock
|-- metrics-2026-08-03.jsonl
`-- events-2026-08-03.jsonl
Each project skills parent may also contain a generated .symposium/index-v1.json installation index. It maps agent-facing skill identifiers to Symposium-managed installations so a later hook can attribute a skill activation. The index is gitignored installation state, not telemetry: show, clear, retention, and identifier reset do not read or delete it, and this RFD does not upload it.
telemetry-state.toml is private Symposium state outside the inspectable telemetry data directory. It contains the secret identity key, current identifier-window anchor, optional return-cohort anchor, the latest opened UTC day, cleanup and marker metadata, bounded keyed session sets plus contribution counts used for complete aggregate session counts, the stable row event_id in each plugin-hook and extension-invocation aggregate entry, and their separate daily public-row admission counts. A stored row identifier is also present in its metric row and is not secret. All recorders read this state under the telemetry lock.
An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation without replacing the key. Renewed consent or reset-identifiers replaces the key, resets the identifier-window anchor, and clears the return-cohort anchor; disable and clear preserve them. None of these operations moves the latest-opened-day high-water mark backward.
For a session, high-water advancement, identifier-window rollover, and return-cohort transition form one state transition under the telemetry lock. Symposium writes them with one atomic private-state replacement before deriving identifiers or appending the session_start row.
Symposium atomically creates and replaces the file with owner-only permissions where supported. Replacement uses a same-directory temporary file beside config.toml; abandoned state temporaries are ignored and cleaned lazily under the telemetry lock.
show, status, data retention, and clear do not expose or delete the state file. clear rewrites it only to remove pending sets, preserving the key and current anchors.
Session sets and contribution counts are never copied into metric rows. Symposium discards complete plugin-hook and extension-invocation entries, including their row identifiers, at day rollover or when clear or reset-identifiers runs. clear also deletes the identified rows and resets the daily public-row allowances, while reset removes entries keyed to the previous identifier epoch without restoring those allowances. show and status do not lock writers, so a summary spanning several files is not an atomic snapshot.
Concurrent recording
Recorders make one non-waiting attempt on the lock in the telemetry data directory. The lock guards data and private state mutations. On contention, the recorder drops the entire event batch or aggregate observation rather than delaying the agent or command.
Event batches are appended. Hook, plugin-hook, and extension-invocation observations are merged into a bounded, canonically ordered snapshot using a same-directory temporary write and atomic replace. A crash leaves either the old or new complete snapshot; abandoned temporary files are ignored and cleaned lazily.
Session-count state is atomically replaced first and carries the snapshot contribution count. After a failed snapshot write, a mismatch discards the sets and makes the row’s session counts incomplete for that day.
On load, Symposium rebuilds each daily public-row allowance from every surviving public row for that family and day, saturating the count at 128. When selecting a public aggregate with no private entry, it adopts a matching surviving row without spending another slot only when the row’s recorded subject equals the subject derived from the observation. Adoption runs before overflow selection. An unnamed or overflow row with no private entry starts a separate row.
Management commands can wait for the lock. A crash can still lose the last batch or metric update, or leave a partial final event line; status reports that line as malformed and show preserves it.
Hook and extension-invocation counts are lower bounds. There is no durable all-cause dropped-update counter because contention, termination, and I/O failure can also prevent writing that counter.
Size and expiry
Each UTC day’s event file, aggregate-metric snapshot, and reserved maximum-size storage_limit line share an 8 MiB allowance. This is a safety ceiling, not expected volume or preallocation. It bounds damage from a producer bug or unexpectedly large resolution batch.
Aggregate metrics may use at most 512 KiB. An update that would exceed that maximum or the remaining shared allowance is dropped without stopping low-volume event recording.
Telemetry files remain through D30 and become eligible for deletion on D31, when current_utc_day - file_utc_day > 30. Together with D31 expiry, the daily allowance bounds ordinary retained telemetry near 248 MiB, excluding temporary files and private state.
Cleanup runs lazily, at most once per day, when a recording-capable or telemetry command next runs. Uninstalling Symposium does not delete these files.
Telemetry configuration
Telemetry consent belongs to the current user. Symposium reads it from the user configuration file, normally ~/.symposium/config.toml. Project configuration cannot enable, disable, or grant consent for telemetry.
Consent states
[telemetry]
enabled = true
consent-version = 1
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | boolean | false | The user’s stored choice to permit local recording. |
consent-version | positive integer | absent | Disclosure version the user acknowledged. |
Symposium records telemetry only when enabled is true and consent-version matches the disclosure used by the current binary. For consent version 1:
| Configuration | Effective state |
|---|---|
absent section or enabled = false | Disabled |
enabled = true with absent/non-1 version | Consent required; record nothing |
enabled = true and consent-version = 1 | Enabled |
The binary chooses the current consent version. The configuration value records which disclosure the user accepted; it cannot select an arbitrary version. The version 1 disclosure requirements define what that disclosure covers.
The complete prompt on that page is an example, not fixed implementation text. Interactive init and telemetry enable present the same final text approved by the team.
When consent must be renewed
A future release may require a higher consent version after changing the recorded categories, linkability, timestamp precision, public-name eligibility, retention, or a normative exclusion. Until the user accepts that version, the effective state is Consent required and Symposium records nothing.
Adding an agent enum value creates a new schema version for each affected event kind so typed readers do not reinterpret old schemas. It does not by itself require renewed consent when the recorded fields, categories, timestamp precision, and correlation boundaries remain unchanged. New fields, hook surfaces, or linkage for that agent do require a higher consent version.
Accepting a newer consent version rotates the telemetry identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session starts a new D0-D30 return cohort. Existing event and aggregate-metric files are neither rewritten nor deleted, but their scoped identifiers cannot link to later rows.
Changing telemetry state
cargo agents telemetry enable presents the team-approved disclosure for the current version before writing these values. Interactive cargo agents init presents the same text for new users and existing unversioned opt-ins, defaulting to no. Non-interactive init does not grant or upgrade consent.
cargo agents telemetry disable sets enabled = false without deleting existing telemetry data files.
Related private and installation state
Consent configuration is separate from the random identity key, current identifier-window anchor, optional return-cohort anchor, and latest opened UTC day in private <config-dir>/telemetry-state.toml (default ~/.symposium/telemetry-state.toml). This state sits outside the inspectable <config-dir>/telemetry/ data directory and uses owner-only permissions where supported.
It persists across disable and clear, keeping identifiers consistent inside the active window. It is not configuration, and Symposium never reads it from project configuration.
Symposium-managed project skills may also have a generated <agent-skills-parent>/.symposium/index-v1.json installation index. It associates the agent-facing skill identifier with the resolved installation that sync produced. This file is gitignored installation state, not project configuration or telemetry; changing it cannot grant consent, and telemetry commands do not inspect or delete it.
Agent Plugins interoperability
TL;DR
- Compile each applicable Symposium plugin into an agent plugin directory and install that directory, rather than writing skill files into a different path for each agent that has a plugin unit.
- Read externally authored agent plugins as ordinary Symposium plugins, so a crate can publish a
plugin.jsonat its root instead of aSymposium.toml. - Prefer the Agent Plugins format where an agent reads it, but do not treat it as the only way to register extensions: one compiled directory carries the Agent Plugins, Claude Code and Gemini CLI manifests at once, and the per-skill install path stays for the agents with no plugin unit, and for any project-scoped plugin on an agent that cannot express one.
- Write each directory once and register its path with an agent where the agent accepts one, copying where it does not. Only Claude Code accepts one today, so most agents get a copy.
- Install through
cargo agents sync, at the same scope as the enablement that selected the plugin. No new command. - Cover the format’s skills component in version one. MCP servers are a follow-up that belongs to the meta-server design.
Motivation
Symposium installs skills with one mechanism per agent. A skill directory goes to .claude/skills/ for one agent, .agents/skills/ for five, and .kiro/skills/ for another, each with a separate global path. Symposium carries that knowledge itself, and supporting a new agent means learning another placement rule.
The agents that grew a plugin system converged on the same unit, not on one format: a directory holding a manifest and a skills/ folder. Codex CLI and the Copilot CLI read the Agent Plugins format itself. Claude Code and Gemini CLI read formats of their own that differ mainly in the manifest filename. OpenCode and Goose have no plugin unit at all. Agent Plugins is therefore the format Symposium prefers to emit where an agent reads it rather than the single mechanism everything routes through, and the per-skill install path stays for the agents that do not read it. Either way, producing a directory lets the agent’s own loader perform the placement Symposium performs by hand.
A directory is also a better unit to own. Symposium writes a .gitignore containing * into each installed skill directory and marks each with a .symposium file so that it can later remove what it no longer owns. Applied per plugin rather than per skill, this becomes one hidden directory to write and one directory to remove when a plugin stops applying.
The reading direction is currently worse than absent. Registry enumeration claims a directory that holds a Symposium.toml or a SKILL.md and otherwise descends into it. A registry of agent plugins therefore yields one claimed entry per skills/<name>/SKILL.md. The package name, its version, and its identity are discarded, and its skills arrive as unrelated dormant plugins.
The same content is treated three different ways depending on where it sits. A crate that ships a plugin.json beside a skills/ directory already has its skills collected, because crate defaults scan skills/ regardless of the manifest beside it; such a crate is missing only its metadata and validation. Registries and workspace members receive nothing at all.
Accepting the format is also consistent with how Symposium approaches distribution. The project routes plugin delivery through existing package registries rather than building its own. Letting a crate describe its extensions with a published open format is the same decision applied to packaging.
Install contract
Symposium guarantees to an agent that it:
- selects the extensions relevant to the current workspace, evaluating every gate before anything is written;
- delivers the selected skills in the unit that agent understands;
- installs at the scope of the enablement that selected the plugin, and never wider;
- writes only into locations it marks as its own, leaving user-managed content untouched; and
- removes what it previously installed and no longer owns, on the next sync, whenever a plugin stops applying rather than only when Symposium itself is removed.
What Symposium does not delegate is equally definite. Predicates and depends-on are evaluated before a directory is produced, so an agent never receives a gate and never resolves one. Hooks remain registered and dispatched by Symposium, which is what allows a hook to be authored once in a vendor-neutral format and evaluated per dispatch. Installations, custom predicates, and subcommands have no portable representation and stay where they are.
Version one
Version one delivers:
- a compiled plugin directory produced from an already-gated Symposium plugin, carrying its manifest and its resolved skills;
- installation of that directory for the agents that have a plugin unit, carrying every manifest dialect at once: globally for all of them, and project-scoped for Claude Code, the one agent that can express it;
- recognition of an externally authored
plugin.jsonpackage wherever a plugin is already found, and resolution of its skills; - the
dev.symposiumextensions namespace, through which a portable package can carry Symposium gating; and - coverage of these packages by
plugin validate,search,status, anduse.
Version one does not change:
- MCP server registration, which continues through the existing path;
- hook registration and dispatch, which remain Symposium’s; or
- how plugins are distributed or enabled.
MCP servers are a follow-up rather than an omission. The format defines two component types and requires a conforming client to support at least one, naming a skills-only client as an example, so a directory with no mcp.json is valid and an incoming one is reported as unsupported. The reason to sequence it separately is that emitting an mcp.json makes each agent connect to every backing server itself and load all of their tool declarations at startup, which is the cost the MCP meta-server exists to avoid. That trade is a decision about the meta-server, and it is settled there before it is applied here. Skills raise no equivalent question, because a skill directory is the same content however it arrives.
Change in a nutshell
Installing one plugin that carries one skill currently produces a separate write for each configured agent, each in a different location. In its place, Symposium compiles the plugin once:
pdf-tools/
plugin.json Codex CLI, Copilot CLI, VS Code
.claude-plugin/plugin.json Claude Code
gemini-extension.json Gemini CLI
.gitignore contains *
skills/
extract-tables/
SKILL.md
One directory serves every agent, because each agent’s loader ignores the manifests that are not its own. That directory is then installed for every agent that accepts one; Agent backends covers which mechanism each gets. The change is built around three replaceable boundaries: a compiled plugin model derived from an already-gated Symposium plugin; a set of manifest renderers and install mechanisms over that model; and a loader that recognizes an externally authored package as an ordinary plugin.
Detailed plans
Walkthrough
A user working on a Rust project wants the table-extraction guidance that a configured registry publishes as an agent plugin. Nothing about the steps is new; only what lands on disk changes.
$ cargo agents search pdf
user-plugins
pdf-tools Table extraction guidance agent plugin
$ cargo agents use pdf-tools
enabled pdf-tools for /home/alex/work/reporter
installed pdf-tools
claude registered .symposium/plugins/pdf-tools
codex skills only .agents/skills/extract-tables
gemini skills only .agents/skills/extract-tables
use records the enablement, workspace-scoped unless --global is given, and then syncs. The compiled directory is written once under .symposium/plugins/ for a workspace-scoped plugin, or under ~/.symposium/installed/ for a global one. Each agent is then given it by whichever mechanism it supports, which is why the lines differ: this enablement is workspace-scoped, and Claude Code is the only agent that can express that, so it is pointed at the directory while Codex CLI and Gemini CLI fall back to per-project skill directories. Had the same plugin been enabled with --global, all three would have received the compiled directory.
$ cargo agents status
active
pdf-tools agent plugin used in this workspace
The agent is then started as usual and the skill is available. Whether it truly arrived is confirmed by asking the agent what it can see, since a file in a plausible location can still be ignored.
$ cargo agents remove pdf-tools
disabled pdf-tools for /home/alex/work/reporter
removed pdf-tools from claude, codex, gemini
Two other routes reach the same place without naming a plugin. Adding a dependency whose source carries plugin content produces a consent question on the next sync, and answering yes installs it exactly as above. Placing a plugin.json beside a skills/ directory in a workspace member requires no command at all, because workspace membership is itself the gate.
Compiled plugin model
The compiled model is a mapping rather than a rename, because a Symposium plugin holds more than the shared format can express. Skill groups from any source, including source.git groups that must be fetched first, are resolved into a self-contained skills/ directory. The plugin name and version become the manifest. Everything else either stays with Symposium or has already been consumed.
Gating is the significant part. Because predicates are evaluated before compilation, the emitted directory contains only what applies to the current workspace. Nothing is lost by the format’s lack of an activation vocabulary, because the format never needs one.
Installation
Symposium writes each compiled directory once, into a location it owns, and prefers to tell an agent where that directory is rather than copy it into the agent’s own plugin folder. Registering a path keeps one copy per plugin instead of one copy per plugin per agent, and it is also what makes a project-scoped installation possible on an agent whose plugin folder is user-level.
Two mechanisms therefore exist, and which one applies is a property of the agent. Claude Code accepts a local directory marketplace, so for it the directory stays where Symposium wrote it and the agent is pointed at it. Codex CLI and the Copilot CLI take a marketplace plus a per-package enablement entry and then copy the package into a cache of their own, and Gemini CLI discovers a package purely by its presence in a known folder, so those receive a copy. Agent backends records which agent falls where, and what that was verified against.
In both cases Symposium writes the agent’s configuration itself rather than driving the agent’s own plugin install command. This is what it already does for hook registration and MCP entries, and it is the only option on a path that runs during a hook, where there is no terminal and no user to answer a prompt. Because a well-formed file in a plausible location can still be ignored, the check that an installation worked is to ask the agent what it can see, not to inspect the file that was written.
Nothing about how a plugin is chosen changes. cargo agents sync compiles and installs whatever currently applies, use and remove record an enablement decision and then sync, and auto-sync performs the same work at session start. Installation remains a consequence of what applies to the workspace rather than a separate action, so this introduces no new command and no per-plugin install step.
Scope of an installation
An installation is scoped to the enablement that produced it. A workspace-scoped use entry, a workspace member, and a workspace dependency all install for that project; a global entry installs for the user. This is the scoping use already has, applied to a different artifact.
Agents differ in whether they can honor it, and most cannot. An agent that offers only a user-level plugin folder and no way to register a path cannot express a project-scoped installation, and a workspace-scoped plugin must not become visible in unrelated projects. For such an agent, a workspace-scoped plugin continues to arrive through the existing per-project skills path, and only a global enablement is installed as a plugin. Of the agents probed, Claude Code is the only one that can scope a plugin to a project; see Only Claude Code can scope a plugin to a project for what each of the others does instead. That is what makes global delivery part of the first increment rather than follow-up work.
Directory collisions
Several packages may sit side by side under one parent. Each is its own entry, which is what a registry already is, and no relationship between siblings is implied.
A plugin.json inside a directory that has already been claimed is ignored, because a claimed directory is not descended into. Nesting a package inside a package is therefore not a way to ship two, and the outer manifest is the one that describes the entry. A source root that is itself a package is an error, as it already is for the other two manifests: a source contains packages rather than being one.
On the install side, two plugins from different origins can resolve to the same directory name. The rule that already governs skill directories governs these: the plain name is used when the slot is free, and a name suffixed with a short hash of the package’s origin is used when more than one origin claims the name or when a user-managed directory already occupies it. Cleanup keys on the ownership marker rather than on the shape of the name, so a package that moves between the two forms self-heals on the next sync.
Agent backends
There is one backend, not one per agent. It renders the compiled model into the manifests described in Change in a nutshell and installs the result by whichever mechanism the agent supports: pointing the agent at the directory where it was written, or copying it into a folder the agent discovers. Claude Code and Gemini CLI are manifest renderers over that one model rather than a separate design, and a package that Claude Code records as installed but not enabled also gets its enablement entry written.
Which agent gets which mechanism, at which path, and what each of those was verified against belongs with the user-facing documentation; see How extensions are installed, and Only Claude Code can scope a plugin to a project for the scoping each agent can express.
OpenCode extends through TypeScript modules and Goose through MCP servers. Neither offers a directory-shaped plugin unit, so both retain the current per-skill install path. That path is not narrowed much by this change: because only Claude Code can scope a plugin to a project, it remains how a workspace-scoped plugin reaches the other six agents.
External packages
Symposium recognizes two kinds of plugin directory today, one holding a Symposium.toml and one holding a bare SKILL.md. A directory holding a plugin.json becomes a third. Precedence runs Symposium.toml, then plugin.json, then SKILL.md. A directory carrying both a TOML and a JSON manifest loads as a Symposium plugin and takes its name and version from plugin.json where the TOML omits them.
Such a package is recognized in the three positions a plugin already occupies, and each position retains its existing meaning. A registry entry is curated and trusted. A workspace member is gated by membership. A dependency is an untrusted offer subject to consent.
Skills map without adaptation: skills/ holds one skill per immediate child, in the agentskills.io format Symposium already uses, and deeper directories are not searched.
Activation of external packages
The format cannot express when a package applies, so an externally authored package arrives without a gate. The existing enablement rules already resolve this. A plugin with nothing from which to infer a gate is dormant and is woken by a use entry; membership gates a workspace plugin; consent gates a dependency-embedded one. No new activation state is introduced, and use, search, and status describe these packages in the vocabulary they already use.
An author who wants a portable package to be dependency-scoped under Symposium declares depends-on or predicates beneath a dev.symposium key in the manifest’s extensions object. The format requires a client to ignore namespaces it does not implement, and to do so without inspecting their contents, so the package remains portable.
Failure containment
The format requires failures to be contained to the smallest affected unit and reported rather than suppressed, which matches how the report layer behaves. A manifest that violates its schema rejects that package alone and leaves the rest of the registry loading. An unrecognized top-level manifest field is reported and ignored. An invalid skill is skipped. A path that resolves outside the package directory is denied at the narrowest applicable boundary.
Ordering
Compilation comes first because it changes how everything installs and is therefore the baseline the rest builds on. Global delivery goes with it rather than after it: since only Claude Code can scope a plugin to a project, a compile step that handled workspace scope alone would leave the per-skill path carrying almost everything. Accepting external packages adds a source of plugins, and that addition is considerably less valuable while those plugins would still be installed by the older per-skill mechanism.
Frequently asked questions
Which command installs a plugin?
None that is new. cargo agents sync compiles and installs whatever applies, use and remove record an enablement decision and then sync, and auto-sync does the same work at session start. A plugin is installed because it applies, not because it was installed by name.
Why not drive each agent’s own plugin install command?
Symposium already writes agent configuration directly for hooks and MCP entries, and using one mechanism keeps that consistent. An agent’s install command also assumes a marketplace and a user at a terminal, neither of which is available on the auto-sync path, which runs inside a hook.
Why register a path instead of copying into each agent’s folder?
Copying reproduces the problem this change is meant to remove, one copy per agent, one level higher. Registration also decides scope: an agent whose plugin folder is user-level can still be given a project-scoped package if it accepts a path. In practice registration is the exception. Claude Code is the only agent probed that accepts one, so copying is what most agents get.
Why compile a directory rather than continue writing skill files directly?
Because the agents already implement a loader for that directory, and using it removes work instead of adding it. Symposium currently maintains a skills path per agent and per scope. Afterwards it maintains one fact per agent: how that agent is given a plugin directory.
Is compilation lossy?
No. Predicates are evaluated before compilation, so an agent receives only what applies. Hooks, installations, custom predicates, and subcommands remain with Symposium and are dispatched by Symposium. What is handed over is the skills, which is precisely what is scattered today.
Does Claude Code need a separate emitter?
No. Its plugin format predates the standard and differs mainly in the manifest path and in supporting components the standard omits, and Gemini CLI’s extensions are the same case. Because each agent ignores the manifests that are not its own, one compiled directory can carry all three at once, so these are manifest renderers over one model rather than emitters of their own. See One directory, several manifests.
What happens to OpenCode and Goose?
Neither has a plugin unit to target, so both continue to receive skills as individual directories. That path is not narrowed much in any case: only Claude Code can scope a plugin to a project, so it stays how a workspace-scoped plugin reaches the other six agents.
Why dev.symposium for the extensions namespace?
The format asks a client to base its namespace on a domain it controls and to keep that namespace stable, since published identifiers should not move. dev.symposium corresponds to the organization the project publishes from, and control of the matching domain is confirmed before the identifier is published.
Is the format stable enough to build on?
Version 1.0.0 was published in August 2026 by a committee spanning Amazon, Cursor, Microsoft, OpenAI, Vercel, and Google. Its schemas are versioned with the specification text, and published schema identifiers cannot be reassigned to different contents. The surface is two component types and a closed manifest, which bounds the cost of being wrong.
Implementation plan
- Confirm each agent’s plugin location, manifest dialect, enablement mechanism, and the scoping it can express, by observing what a running agent reports rather than by inspecting the files written to it. Done for Claude Code, Codex CLI, the Copilot CLI, Gemini CLI, and VS Code; the results are in Implementation details below. Kiro remains to be probed.
- Produce the compiled plugin directory from an already-gated plugin, carrying all three manifests and validated against the published manifest schema, including the ownership marker, the hidden-directory rule, and name disambiguation on collision.
- Install global plugins for every agent with a plugin unit, copying into the folder each discovers and writing the marketplace and enablement entries the agent requires, restricted to the plugins whose gate cannot vary by workspace.
- Install workspace-scoped plugins for Claude Code, the one agent that can express them, and retire its per-skill writes and the directories they left behind.
- Recognize a
plugin.jsondirectory as a plugin entry in every position a plugin is already found, including the nesting and source-root rules. - Resolve an external package’s skills, reporting an
mcp.jsonas unsupported, and contain malformed packages, skills, and escaping paths at their proper boundaries. - Honor the
dev.symposiumextensions namespace, and extendplugin validate,search,status, anduseto cover these packages.
Future work
Emitting and reading mcp.json removes the last per-agent configuration format Symposium maintains, and follows the meta-server decision described above. It also introduces the format’s subprocess contract, a PLUGIN_ROOT and a PLUGIN_DATA directory that survives updates, and requires a rule separating Symposium’s own ${VAR} expansion from the format’s two placeholders.
Beyond that, Claude Code and Gemini CLI can both carry hooks inside a plugin directory, which would trade the vendor-neutral hook format and per-dispatch predicates for native dispatch. Publishing a compiled Symposium plugin as a portable package for other clients is a short step once compilation exists. A package’s version can drive update checks and cache freshness, which the format explicitly permits.
Implementation details
What probing the installed agents and mapping this proposal onto the current code turned up. The first two points constrain the implementation; the rest corrected claims in the sections above, which now read as amended.
Where a global compiled directory goes
Installation writes a global plugin’s directory under the user configuration directory. That directory already holds one: ~/.symposium/plugins/ is the builtin user-plugins registry (Symposium::registry_instances in src/config.rs), a directory symposium reads entries from.
Compiling into it makes symposium read its own output. A compiled directory carries a plugin.json, which registry enumeration claims as an entry once external packages are recognized, so every global install reappears as a plugin of the user-plugins registry on the next load.
Global compiled directories go in a sibling instead:
~/.symposium/plugins/ registry symposium reads
~/.symposium/installed/ compiled directories symposium writes
Workspace scope is unaffected (<project root>/.symposium/plugins/, as described above), and the whole .symposium/ directory is symposium’s own, so one .gitignore containing * sits at its root rather than one per compiled directory.
A global installation needs a workspace-independent gate
Scope of an installation matches an installation to the enablement that produced it, so a use --global entry installs for the user. Predicates, though, are evaluated against the workspace being synced, while a user-level directory is visible from every workspace. The two do not compose:
$ cargo agents use --global pdf-tools # gated depends-on = ["lopdf"]
Run where lopdf is a dependency, the gate holds and the directory is written under the user configuration directory. Every other project on the machine now sees the plugin, without the dependency that gates it.
Removal has the mirror problem. Cleanup removes marked directories it did not install on this run, so a global set that varies by workspace makes two projects undo each other: syncing the project without lopdf removes the directory, syncing the one with it writes it back, on every session start.
Both follow from evaluating a workspace-dependent gate once and keeping the result somewhere workspace-independent. A plugin therefore installs globally only when its gate cannot vary by workspace: an empty gate (a dormant plugin woken by a global use entry, which is the case the walkthrough shows) or depends-on = ["*"]. A global entry on any other plugin installs per project, and the install report names the scope it got.
The test is conservative. depends-on(<name>) and workspace-member() are workspace-dependent by definition, shell(...) and a relative path_exists(...) resolve against the workspace as their working directory, and a custom predicate is opaque. Only the empty set and depends-on(*) qualify; widening the test per predicate kind later is additive.
One directory, several manifests
This is the evidence for the single directory Change in a nutshell shows. An earlier draft treated Claude Code and Gemini CLI as separate emitters; probing the installed agents shows they do not need to be. Claude Code ignores a plugin.json at a package root and falls back to the directory name for identity, Agent Plugins clients ignore .claude-plugin/, and Gemini CLI reads only its own file. So one compiled directory carrying all three manifests, with the same content in each, satisfies every one of them:
pdf-tools/
plugin.json Codex CLI, Copilot CLI, VS Code
.claude-plugin/plugin.json Claude Code
gemini-extension.json Gemini CLI
skills/extract-tables/SKILL.md every one of them
Verified against Claude Code 2.1.237, Codex CLI 0.147.0, Copilot CLI 1.0.79 and Gemini CLI 0.55.1: both validators accept that directory, and Claude Code resolves the declared name from it rather than the directory name.
The staging root needs one manifest of its own, because Claude Code, Codex CLI and Copilot CLI all take a marketplace (a directory of packages) plus a per-package enablement entry, rather than a path to one package. .claude-plugin/marketplace.json is the one file all three read. Codex CLI also accepts .agents/plugins/marketplace.json and prefers it when both are present; Claude Code rejects it.
So there is one emitter with several manifest renderers, not one emitter per agent.
Only Claude Code can scope a plugin to a project
This is the evidence for Scope of an installation. An earlier draft expected Codex CLI, Kiro and Claude Code to offer a project-scoped location, and VS Code to accept a workspace-scoped path registration. Of the agents installed here, only Claude Code does:
| Agent | Result |
|---|---|
| Claude Code | plugin install -s project |
| Codex CLI | a project .codex/config.toml marketplace entry is ignored, and there is no folder discovery at .codex/plugins/ or $CODEX_HOME/plugins/ |
| Copilot CLI | a project .copilot/settings.json is ignored, a working-directory marketplace manifest is not discovered, and plugin install has no scope flag |
| Gemini CLI | a project .gemini/extensions/ is ignored |
| VS Code | chat.pluginLocations is machine-scoped and workspace-trust restricted, so it cannot be set per workspace |
Kiro is not installed here and remains unconfirmed.
The consequence is that a workspace-scoped plugin can be delivered as a compiled directory to Claude Code alone. On the others it arrives as individual skill directories, as it does today. So the per-skill path is not reduced in scope: it stays the mechanism for workspace-scoped plugins on six of the seven agents, and compilation pays off for global installations plus Claude Code project installations. That is why Ordering puts global delivery in the first increment rather than in follow-up work.
Two mechanics worth recording
Codex CLI’s install step copies a package into a version-keyed cache under $CODEX_HOME/plugins/cache/. Writing its config.toml entries alone registers enablement against an empty cache, so the copy has to happen too. Re-running the install after editing the source at the same version does re-copy, so there is no stale-content trap. Its removal step clears both config tables but leaves the cache directory behind, which symposium has to delete itself.
Gemini CLI needs no configuration write at all: a package present at ~/.gemini/extensions/<name>/ is listed and enabled. Its own extensions link command blocks waiting on input, which is another reason the file is written directly rather than shelling out.
Implementation status
This RFD describes proposed work. Implementation has not begun.
See Proposed: Agent Plugins packages for the intended authoring reference and Proposed: How extensions are installed for the resulting install locations.
Agent Plugins packages
Proposed reference page for the Agent Plugins interoperability RFD. This is how
md/reference/would read once support lands.
Symposium can load packages in the Agent Plugins 1.0.0 format, next to its own plugin definitions. One of these packages is a folder with a plugin.json manifest and a skills/ folder.
Loading one gives you its skills in every agent you have set up, including the agents that cannot read the format themselves.
Symposium reads the skills half of the format. The format’s other component type, MCP servers, is reported as unsupported. Declare those in a Symposium.toml plugin.
Layout
pdf-tools/
plugin.json required
skills/ optional, fixed location
extract-tables/
SKILL.md
The folder and file names are fixed by the format. plugin.json cannot point somewhere else, and cannot declare components inline.
Where symposium looks
These packages are picked up in the same three places as a symposium plugin:
| Where it sits | When it turns on |
|---|---|
An entry in a configured [[registry]] | Dormant until a use entry names it |
| A workspace member’s folder | On whenever that project is in your workspace |
| A dependency’s source | Offered for consent, like any plugin found in a dependency |
A folder holding both a Symposium.toml and a plugin.json is read as a symposium plugin. Symposium.toml is the richer manifest and wins.
Manifest
The manifest is closed, meaning only known fields are allowed. $schema and name are required. The only other permitted fields are version, description, author, homepage, repository, license, keywords, and extensions. A name is 1 to 64 characters, lowercase letters and digits plus hyphens and periods, starting and ending with a letter or digit.
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "pdf-tools",
"version": "1.2.0",
"description": "Table extraction guidance",
"license": "MIT"
}
Telling symposium when it applies
The manifest has no way to say when a package applies, so one that comes from a registry stays dormant until you use it. To tie it to a dependency or a predicate while keeping it portable, say so under the dev.symposium key:
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "pdf-tools",
"extensions": {
"dev.symposium": {
"depends-on": ["lopdf"],
"predicates": ["path_exists(pdftotext)"]
}
}
}
Other clients must ignore keys they do not know, so this costs you nothing in portability. The fields take the same syntax as a Symposium.toml plugin gate. See Predicates.
Skills
Every direct child of skills/ that holds a SKILL.md is one skill, in the usual skill format. Deeper folders are not searched. A broken skill (malformed frontmatter, or missing a field the skill format requires) is skipped and reported, and the rest of the package still loads.
Where those skills end up per agent is covered in How extensions are installed.
What the format cannot carry
Hooks, subcommands, installations, and custom predicates are not in the format, so one of these packages cannot declare them. Use a Symposium.toml plugin when you need those.
What happens when something is wrong
Problems are contained to the smallest part affected, and reported rather than hidden:
| Problem | Result |
|---|---|
plugin.json breaks its schema | That package is rejected. Others in the registry still load. |
| An unknown top-level field in the manifest | Reported and ignored. The package loads. |
| One skill is broken | That skill is skipped. The others load. |
| A path that resolves outside the package folder | Access is denied, at the smallest level that applies. |
An mcp.json is present | Reported as unsupported. Skills still load. |
Validation
cargo agents plugin validate path/to/pdf-tools
Reports each problem at the level where it would take effect, so you can tell a rejected package from a skipped skill.
How extensions are installed
Proposed page for the Agent Plugins interoperability RFD. This is how the install-locations part of Agents would read once the compile step lands.
Symposium decides which plugins apply to your workspace, then hands each agent the result. For most agents it hands over a plugin directory, which is the unit agents themselves use.
Which command installs a plugin
There isn’t one. A plugin is installed because it applies, not because you install it by name.
cargo agents sync # installs whatever currently applies
cargo agents use <name> # enables a plugin, then syncs
cargo agents remove <name> # disables it, then syncs
cargo agents status # shows each plugin, why it applies, and where it went
With auto-sync on, the same work happens when an agent session starts, so usually there is nothing to run at all.
Installing a plugin, start to finish
Three routes, depending on where the plugin comes from.
A plugin you ask for
Find it, then enable it. use scopes to the current workspace unless you pass --global, and syncs straight away.
$ cargo agents search pdf
user-plugins
pdf-tools Table extraction guidance agent plugin
$ cargo agents use pdf-tools
enabled pdf-tools for /home/alex/work/reporter
installed pdf-tools
claude registered .symposium/plugins/pdf-tools
codex skills only .agents/skills/extract-tables
gemini skills only .agents/skills/extract-tables
The lines differ because agents accept a plugin differently, and because this enablement is scoped to one project. Claude Code is the only agent that can scope a plugin to a project, so it is pointed at the directory where it was written; Codex CLI and Gemini CLI can only install a plugin for you everywhere, so for this project their skills are installed the older way instead. Run use --global and all three get the directory.
Start your agent as usual and the skill is there. To be sure, ask the agent what skills it can see.
Undo it the same way:
$ cargo agents remove pdf-tools
disabled pdf-tools for /home/alex/work/reporter
removed pdf-tools from claude, codex, gemini
A plugin that comes with a dependency
Add the dependency and sync. Symposium asks before installing anything a dependency carries, because a dependency is not something you chose to trust for this.
$ cargo add lopdf
$ cargo agents sync
lopdf offers a plugin. Enable it?
> enable
ask me later
never ask again
Answering enable installs it exactly as above and remembers the answer. never ask again records the refusal, and the default answer records nothing, so pressing Enter never decides anything permanently.
A plugin in your own project
Put a plugin.json beside a skills/ directory in your workspace, or in any member of it:
reporter/
Cargo.toml
plugin.json
skills/
house-style/
SKILL.md
Nothing to run and nothing to enable. Being part of the workspace you are working in is what makes it active, so it applies to everyone working in this project and to no one else.
What symposium builds
For each plugin that applies, symposium compiles a directory:
pdf-tools/
plugin.json who this plugin is
.claude-plugin/plugin.json the same, for Claude Code
gemini-extension.json the same, for Gemini CLI
.gitignore contains *, so the directory stays out of your commits
.symposium marks the directory as ours, so we can clean it up later
skills/
extract-tables/
SKILL.md
One directory serves every agent. Each one reads the manifest it knows and ignores the others.
Only what applies is in there. Predicates and depends-on are evaluated before this directory is built, so the agent never sees a gate and never loads something it should not have.
Skills from a source.git group are fetched and resolved first, so the directory is self-contained.
How each agent is given the directory
The directory is written once. Agents that can be pointed at a path are pointed at it; agents that only discover plugins by location get a copy.
| Agent | Manifest it reads | How it is given | Verified against |
|---|---|---|---|
| Claude Code | .claude-plugin/plugin.json | Registered as a local marketplace, plus an enablement entry in settings | 2.1.237 |
| Codex CLI | plugin.json | A marketplace entry plus an enablement entry, which copies the package into its own cache | 0.147.0 |
| GitHub Copilot CLI | plugin.json | A marketplace entry plus an enablement entry | 1.0.79 |
| Gemini CLI | gemini-extension.json | Copied into ~/.gemini/extensions/<name>/, which needs no configuration write at all | 0.55.1 |
| VS Code | plugin.json | Path registered through the chat.pluginLocations setting, which is per-machine | |
| Kiro | plugin.json | Not yet confirmed |
Symposium writes each agent’s configuration itself rather than running that agent’s own plugin install command, the same way it registers hooks. Some of those commands also block waiting for input, which is no use on the auto-sync path.
Project or global
An installation matches the scope of whatever enabled the plugin. A workspace use entry, a workspace member, or one of your dependencies installs for that project only. A use --global entry installs for you everywhere.
Most agents cannot express a project-scoped plugin: their plugin folder is per-user and they offer no way to register a workspace path. Claude Code is currently the only one that can. For all the others, a project-scoped plugin is installed the older way instead, as individual skill directories under the project, so that a plugin enabled in one project never shows up in another.
A global installation carries one extra restriction. A user-level directory is visible from every project, but a gate like depends-on is evaluated against the project being synced, so a plugin is installed globally only when its gate cannot vary by project. Anything else is installed per project instead, and cargo agents status names the scope each plugin got.
Agents without a plugin unit
OpenCode and Goose have no plugin directory to install into. OpenCode extends through TypeScript modules, and Goose through MCP servers. For these two, symposium installs skills the old way, into .agents/skills/<skill-name>/. So does every other agent for a project-scoped plugin, since only Claude Code can scope one.
When two plugins want the same name
The plain plugin name is used when it is free. When two plugins from different sources want the same name, or when a directory you manage yourself already sits there, symposium adds a short hash of the plugin’s origin to the directory name instead.
Cleanup keys on the .symposium marker rather than the directory name, so a plugin that moves between the two forms sorts itself out on the next sync.
What is not in the directory
MCP servers. Registered separately. See plugin definitions for how registration works and what [mcp] enabled controls.
Hooks. Symposium registers only its own hook handler with an agent, and dispatches plugin hooks itself. This is what lets a hook be written once in a vendor-neutral format and still run on every agent that supports hooks, and it is why a hook’s predicates are checked on each dispatch rather than once at install time.
What happens to old files
Every directory symposium installs carries a .symposium marker. On each sync it removes marked directories it no longer owns, and leaves anything unmarked alone, so your own skills and plugins are never touched.
Directories written by a configuration you have switched away from are removed in the same pass. Moving between the plugin-directory install and the older per-skill install does not leave anything behind.
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
Deserializewhere 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:
| Type | Current role | Decision |
|---|---|---|
CrateList | Accepts 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. |
Predicate | Parses 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. |
PredicateSet | Deserializes 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. |
PluginMcpServer | Reads 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. |
PluginSource | Accepts 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. |
SkillGroup | Reads 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.