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:

  1. Reads a single InitMessage from stdin.
  2. Dispatches by command field.
  3. Writes structured OutboundMessages to stdout.
  4. 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

  1. Add the plugin to the workspace Cargo.toml:

    [workspace]
    members = [
        # ...
        "plugins/fledge-plugin-myplugin",
    ]
  2. Build and install:

    cargo run -p merlin-cli -- init

    This rebuilds all plugins, copies plugin.toml into the fledge data directory, symlinks the binary, and registers the plugin.

  3. Verify it shows up:

    fledge plugins list --json | jq '.plugins[].name'
  4. Run it directly:

    fledge plugins run myplugin-do --ni hello 3
    # → hellohellohello
  5. Use it from the agent:

    merlin "use myplugin-do to repeat 'hi' five times"

    The LLM will see myplugin-do in 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::Output is 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 = true for 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.