The fledge-v1 Protocol

Plugins talk to fledge via newline-delimited JSON over stdin/stdout. The Rust crate fledge-protocol (crates/fledge-protocol) provides typed structs and a PluginIO helper.

Lifecycle

   fledge spawns plugin binary


  ────── stdin ──────▶
   InitMessage  {protocol, command, args, project, plugin, fledge}

  ◀───── stdout ──────
   OutboundMessage::Output  {text}
   OutboundMessage::Log     {level, message}
   OutboundMessage::Progress{...}
   ...

  ────── stdin ──────▶
   InboundMessage  {type, id, value}   ← responses to Prompt/Confirm/Select/etc.


   plugin exits

Init

Sent once at startup:

{
  "protocol": "fledge-v1",
  "command": "files-read",
  "args": ["src/main.rs"],
  "project": {
    "name": "merlin",
    "root": "/path/to/merlin",
    "language": "rust",
    "git": { "branch": "main", "dirty": false }
  },
  "plugin": {
    "name": "fledge-plugin-files",
    "version": "0.1.0",
    "dir": "/path/to/plugin"
  },
  "fledge": { "version": "1.3.1" }
}

In Rust:

use fledge_protocol::PluginIO;

let mut io = PluginIO::new();
let init = io.recv_init();
let command = init.command.as_deref().unwrap_or("");
let args = &init.args;

Outbound (plugin → fledge)

Plugins emit one JSON object per line. The discriminator is the "type" field.

TypeFieldsUse
outputtextTool output that bubbles up to the LLM
loglevel ("info" / "warn" / "error"), messageDiagnostics — captured by fledge but not the LLM
progressmessage?, current?, total?, done?Long-running progress
promptid, message, default?, validate?Ask user for free-form input
confirmid, message, default?Yes/no
selectid, message, options, default?Pick from a list
storekey, valuePersist a key/value (no response)
loadid, keyRetrieve a stored value (response on stdin)
execid, command, cwd?, timeout?Have fledge run a sub-command (response on stdin)
metadataid, keysQuery project metadata (response on stdin)

Plugins can emit any number of output, log, and progress messages without expecting a response. Messages with an id field (prompt, confirm, select, load, exec, metadata) expect a matching inbound response.

Inbound (fledge → plugin)

User responses to id-bearing outbound messages:

{
  "type": "response",
  "id": "1",
  "value": "yes"
}

Or a cancellation:

{
  "type": "cancel",
  "id": "1",
  "reason": "user-requested"
}

The PluginIO::wait_for_response(id) helper blocks on stdin until the matching response arrives.

Helpers

The PluginIO struct in fledge-protocol wraps stdin/stdout with typed helpers:

io.recv_init()                  → InitMessage
io.output(text)                 // emit Output
io.log(level, message)          // emit Log
io.progress(message, cur, tot)  // emit Progress
io.prompt(message, default)     → String   (blocks)
io.confirm(message, default)    → bool     (blocks)
io.select(message, options)     → usize    (blocks)
io.exec(command, cwd, timeout)  → ExecResult (blocks)

The non-interactive flag --ni (used by Merlin’s agent) tells fledge to fail any prompt rather than blocking. Plugins should not assume prompts will succeed.

Conventions

  • Exit 0 on success, non-zero on failure. Fledge captures the exit code and reports it as success: false to the caller.
  • Use output for results, log for diagnostics. Anything in log is invisible to the LLM, which is what you want for noisy internal stderr.
  • Trailing newlines are fine. The Merlin CLI strips trailing whitespace before display.
  • Keep output structured. Tool results are fed back as opaque strings to the LLM. JSON is great when you need structure; plain text is fine when you don’t.