Plugin Authoring Guide
Anything Merlin does, it does through a fledge plugin. Adding a new capability — say, an HTTP fetcher, a database query tool, a custom linter wrapper — is just writing one.
What You’re Building
A fledge plugin is a binary that:
- Reads a single
InitMessagefrom stdin. - Dispatches by
commandfield. - Writes structured
OutboundMessages to stdout. - Exits 0 on success, non-zero on failure.
The wire format is documented in
The fledge-v1 Protocol. The fledge-protocol Rust
crate gives you typed helpers.
Anatomy of a Plugin
plugins/fledge-plugin-myplugin/
├── Cargo.toml
├── plugin.toml ← manifest: commands + per-command arg schemas
└── src/
└── main.rs ← single binary, dispatches by command
Cargo.toml
[package]
name = "fledge-plugin-myplugin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[[bin]]
name = "fledge-plugin-myplugin"
path = "src/main.rs"
[dependencies]
fledge-protocol = { path = "../../crates/fledge-protocol" }
# whatever else you need
plugin.toml
[plugin]
name = "fledge-plugin-myplugin"
version = "0.1.0"
description = "Does the thing"
author = "CorvidLabs"
protocol = "fledge-v1"
[[commands]]
name = "myplugin-do"
description = "Do the thing"
binary = "target/release/fledge-plugin-myplugin"
args = [
{ name = "input", type = "string", required = true,
description = "What to do" },
{ name = "count", type = "integer", required = false,
description = "How many times (default 1)" },
]
# Set dangerous = true if the command can have side effects worth confirming.
# dangerous = true
Argument order in args is the positional argv order when the
agent invokes your command. The type field generates JSON-Schema
property types for the LLM’s tool input.
src/main.rs
use fledge_protocol::PluginIO;
fn main() {
let mut io = PluginIO::new();
let init = io.recv_init();
let command = init.command.as_deref().unwrap_or("");
let result = match command {
"myplugin-do" => cmd_do(&init.args),
_ => {
io.output(&format!("Unknown command: {command}\n"));
return;
}
};
match result {
Ok(output) => io.output(&output),
Err(error) => {
io.log("error", &error);
std::process::exit(1);
}
}
}
fn cmd_do(args: &[String]) -> Result<String, String> {
let input = args.first().ok_or("missing 'input' argument")?;
let count: usize = args
.get(1)
.map(|raw| raw.parse().unwrap_or(1))
.unwrap_or(1);
let output = input.repeat(count);
Ok(format!("{output}\n"))
}
Wiring It In
-
Add the plugin to the workspace
Cargo.toml:[workspace] members = [ # ... "plugins/fledge-plugin-myplugin", ] -
Build and install:
cargo run -p merlin-cli -- initThis rebuilds all plugins, copies
plugin.tomlinto the fledge data directory, symlinks the binary, and registers the plugin. -
Verify it shows up:
fledge plugins list --json | jq '.plugins[].name' -
Run it directly:
fledge plugins run myplugin-do --ni hello 3 # → hellohellohello -
Use it from the agent:
merlin "use myplugin-do to repeat 'hi' five times"The LLM will see
myplugin-doin its tool catalog with the schema you declared and call it with{"input": "hi", "count": 5}.
Conventions
- Naming. Commands should be
<plugin>-<verb>:files-read,git-status,myplugin-do. The agent’s tool catalog is flat; prefixes prevent collisions. - Arg order. Required positional args first, then optional. The agent passes args in declaration order.
- One arg = one argv slot. Don’t make plugins re-parse joined strings. The argv translator preserves whitespace per slot.
- Strings are the lingua franca. Numbers and bools are stringified before being passed; the plugin parses if needed.
- Stdout is the result.
OutboundMessage::Outputis what the LLM sees as the tool result. Keep it short and structured (JSON when appropriate). - Stderr is for fledge.
io.log("info", ...)for diagnostics that shouldn’t end up in the LLM context. dangerous = truefor commands with destructive side effects. The CLI uses this flag to decide whether to confirm before running.
Testing
A plugin’s cmd_* functions are pure-ish — they take &[String] and
return Result<String, String>. Unit-test them directly:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doubles_input() {
let result = cmd_do(&["hi".into(), "2".into()]).unwrap();
assert_eq!(result, "hihi\n");
}
}
For end-to-end tests through the agent, see the integration test harness
(crates/merlin-core/tests/integration_agent.rs)
— ScriptedProvider + a temp project is the pattern.
Existing Plugins as Examples
Look at the simplest one first:
fledge-plugin-shell (plugins/fledge-plugin-shell/src/main.rs)
is ~30 lines and shows the basic pattern. Then
fledge-plugin-files (plugins/fledge-plugin-files/src/main.rs)
for a multi-command plugin with several arg shapes.