Skip to article
Browse chapters

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

methoddirectionfor
initializehost → pluginthe handshake
tool/callhost → pluginrun a tool: {callId, name, input, cwd, session, turn} → {output}
command/runhost → plugin{name, args, cwd, session} → {outcome}
command/completehost → plugin{name, partial, cwd} → {completions}
context/contributehost → plugin{id, query} → {pieces}
compactor/compacthost → plugin{id, context, reason} → {compaction}
provider/streamhost → pluginone model response, streamed back as notifications
hook/decidehost → plugin{id, site, point, payload} → {outcome, value?}
service/callboth ways{key, method, params} → {result}
notificationdirectionfor
tool/progressplugin → host{callId, tail} — the call’s live output line
tool/cancelhost → pluginthe turn was interrupted
provider/deltaplugin → hostone chunk of a streamed response
provider/cancelhost → pluginstop streaming
hook/observehost → pluginan 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 a callId the 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.