Cross-process plugins
Ship a bingo-native tool, command, hook, contributor, compactor or provider in any language, over JSON-RPC on stdio.
The bridge
bingo-plugin-rpc hosts external plugin processes and turns what they answer
into ordinary contributions (ADR-0015). The kernel never learns that a tool is
remote: the bridge owns one proxy struct per capability, implementing the
sdk’s own trait, whose method bodies are wire calls. There is no parallel
remote trait hierarchy, because that would be a second representation of one
contract.
The exit criterion for the design is in the repository:
examples/plugins/wordcount/ — Python 3, standard library only, one tool and
one command, driven end to end by a black-box test through the real binary.
Installing one
A plugin is a directory holding a plugin.json. Two layers:
~/.bingo/plugins/<name>/ yours, in every project
<project>/.bingo/plugins/<name>/ this repository's — and it wins the name
The directory’s name is the plugin’s name and must match the manifest’s
name: the path is what one layer overrides another by, so two spellings would
be two answers to “which plugin is this”.
The manifest
{
"name": "wordcount",
"version": "0.1.0",
"entry": {
"command": "python3",
"args": ["${PLUGIN_ROOT}/main.py"]
}
}
entry.env adds to the host’s environment rather than replacing it.
${PLUGIN_ROOT} — usable in command, in any argument and in any environment
value — is the directory the manifest was read from, which is how a manifest
names the interpreter and the script beside itself without knowing where the
directory was installed.
An optional config field holds a JSON Schema for the plugin’s own settings:
the slice a person writes under plugins.<name>, which reaches the process as
initialize.config. It is documentation for the person writing the settings;
nothing in the workspace validates a document against it.
The wire
JSON-RPC 2.0, one message per line, UTF-8, on the process’s stdin and stdout.
Stdout carries messages and nothing else; anything else the plugin would
print goes to stderr, which the host writes to
~/.bingo/data/logs/plugin-<name>.log — never to the terminal.
The types on that wire are the kernel’s own. ToolSpec, CommandSpec,
ToolOutput, CommandOutcome, Completion and View are already
serialisable and already have schemas; the bridge adds envelopes and never
shapes. schema/plugin.json at the root of the repository is generated from
those types and committed, and it is the document a non-Rust author writes
against.
Handshake
The host sends initialize {protocol, pluginRoot, config, env}. The plugin
answers with what it is and everything it contributes:
{
"protocol": 5,
"name": "wordcount",
"version": "0.1.0",
"tools": [ … ],
"commands": [ … ]
}
tools, commands, contributors, compactors, providers, hooks and
services are each optional — declare the kinds you have and leave the rest
out. A protocol the host does not speak is refused with a notice rather than
guessed at.
Methods and notifications
| method | direction | for |
|---|---|---|
initialize | host → plugin | the handshake |
tool/call | host → plugin | run a tool: {callId, name, input, cwd, session, turn} → {output} |
command/run | host → plugin | {name, args, cwd, session} → {outcome} |
command/complete | host → plugin | {name, partial, cwd} → {completions} |
context/contribute | host → plugin | {id, query} → {pieces} |
compactor/compact | host → plugin | {id, context, reason} → {compaction} |
provider/stream | host → plugin | one model response, streamed back as notifications |
hook/decide | host → plugin | {id, site, point, payload} → {outcome, value?} |
service/call | both ways | {key, method, params} → {result} |
| notification | direction | for |
|---|---|---|
tool/progress | plugin → host | {callId, tail} — the call’s live output line |
tool/cancel | host → plugin | the turn was interrupted |
provider/delta | plugin → host | one chunk of a streamed response |
provider/cancel | host → plugin | stop streaming |
hook/observe | host → plugin | an observation point; nothing waits on it |
The host still waits for a cancelled call’s answer, so a plugin that ignores
tool/cancel is slow, never broken.
Nothing a process says about itself is believed
Bridge tools wear ToolTraits::default() — untrusted, not read-only, not
concurrency-safe, interrupt Block — whatever the plugin claims. The gate asks
about every call. Tool names are rewritten to plugin__<name>__<tool> so the
permission grammar can address them:
{ "permissions": { "allow": ["plugin__wordcount__count"] } }
Capabilities come from the handshake declaration, and the unknown answers
false. A query crosses as its serialisable projection: an external contributor
reads the query, not the host.
Every crossing has a deadline. A contributor past it is dropped from that round with a notice — the turn is never blocked. A compactor, provider or hook past one fails that call with the error its trait already speaks, and a hook that misses its deadline decides nothing at all.
A process is allowed to die
There are no health checks and no supervisor. A dead process makes its sources answer nothing and raises one notice; the next source read respawns it with backoff. Answering with nothing is never wrong, so a crashed plugin costs the turn its contributions and nothing else.
What crosses, and what does not
Tools, commands, context contributors, compaction strategies, providers, hooks and services cross (ADR-0015, ADR-0030, ADR-0031, ADR-0032).
Policies do not. The verdict plane stays in-process. Hooks are allowed
across precisely because HookOutcome has no Allow: an external hook can
Continue, Deny, Ask, Block or Redirect, so it can only tighten what
happens, never widen it.
Stores do not. Every frame’s append is the hottest write path, and lock semantics would have to survive process death.
Surfaces do not, because their door already exists: an external client speaks JSON-RPC or the host protocol to a surface, and that lane is not the plugin wire’s to duplicate.
The wordcount walkthrough
Three files, and one of them is a README.
examples/plugins/wordcount/
plugin.json the manifest above
main.py 186 lines of Python 3, standard library only
README.md the contract, in a page
main.py is a loop over stdin. Its handshake declares one tool and one
command:
def handshake():
return {
"protocol": PROTOCOL,
"name": "wordcount",
"version": "0.1.0",
"tools": [{
"name": "count",
"description": "Counts the words, lines and characters in a file.",
"inputSchema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "…"}},
"required": ["path"],
},
}],
"commands": [{
"name": "wordcount",
"hint": "count the words in a file",
"args": {"kind": "free", "hint": "<path>"},
"instant": True,
"family": "plugin",
}],
}
The tool answers with two things at once — text for the model, and a View for
the person:
answer(request_id, {
"output": {
"parts": [{"type": "text", "text": said}],
"display": table(path, counts),
}
})
display is a View, so the terminal surface draws a real table, --print
folds it to text, and an IM channel sends the fold. The same value is what
/wordcount <path> returns as a CommandOutcome:
answer(request_id, {"outcome": {"kind": "view", "view": table(path, counts)}})
Completion is one more method — the files in the working directory that start with what has been typed — and progress is a notification the surface shows as the call’s live output line:
notify("tool/progress", {"callId": params["callId"], "tail": "reading %s" % path})
Install it, and both work:
cp -r examples/plugins/wordcount ~/.bingo/plugins/wordcount
bingo "how many words are in notes.txt?"
bingo "/wordcount notes.txt"
Asking the host for something
A plugin process can reach exactly two host doors, and no more (ADR-0033):
ask {call, question}puts a question to the person, on acallIdthe plugin is currently running. It rides the same asking machinery an in-process tool’s question does. A call that has ended, and a call that is not yours, are refused in words.notice {level, message}says one line under the plugin’s own name, at any time. It takes no grant and spends nothing but a line.
Both arrive as service/call against the reserved key bingo.host, which the
bridge registers. A question is a question and nothing else — a permission
prompt is the host’s own to open.
Everything a plugin might ask of another plugin goes through wire services instead.