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.
| Type | Fields | Use |
|---|---|---|
output | text | Tool output that bubbles up to the LLM |
log | level ("info" / "warn" / "error"), message | Diagnostics — captured by fledge but not the LLM |
progress | message?, current?, total?, done? | Long-running progress |
prompt | id, message, default?, validate? | Ask user for free-form input |
confirm | id, message, default? | Yes/no |
select | id, message, options, default? | Pick from a list |
store | key, value | Persist a key/value (no response) |
load | id, key | Retrieve a stored value (response on stdin) |
exec | id, command, cwd?, timeout? | Have fledge run a sub-command (response on stdin) |
metadata | id, keys | Query 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: falseto the caller. - Use
outputfor results,logfor diagnostics. Anything inlogis 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.