Appearance
Creating a plugin
A plugin is a folder with a plugin.json manifest and the program that does the work. This page describes the manifest, the protocol between the Kadmo runtime and a handler, and the service runtime for tools that need to keep state. For how plugins are installed, listed and loaded, see Plugins.
A minimal plugin
A folder named greet with two files:
text
greet/
├── plugin.json
└── greet.shplugin.json:
json
{
"name": "greet",
"version": "1.0.0",
"description": "Greeting tools",
"tools": [
{
"name": "greet_person",
"description": "Greet someone by name",
"inputSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
},
"handler": "bash greet.sh"
}
]
}greet.sh reads the arguments from stdin and prints an MCP tool result. It uses jq to read and build JSON safely:
bash
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
name=$(printf '%s' "$input" | jq -r '.name // "world"')
jq -n --arg text "Hello, $name!" '{content: [{type: "text", text: $text}]}'Try it by hand, then install it:
bash
echo '{"name": "Ada"}' | bash greet/greet.sh
kadmo plugin install ./greet
kadmo plugin listjson
{"content":[{"type":"text","text":"Hello, Ada!"}]}Restart kadmo serve or your MCP client session, and greet_person appears in tools/list as [plugin: greet] Greet someone by name.
The manifest
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Becomes the folder name under ~/.kadmo/plugins/ and the name kadmo plugin remove takes. Use lowercase letters, digits and dashes |
version | string | Yes | Shown by kadmo plugin list and /health. Not compared or enforced |
description | string | Yes | Shown by kadmo plugin list |
runtime | string | No | process (default; exec means the same) or service |
tools | array | For process | One entry per tool; ignored for service |
tools[].name | string | Yes | The public tool name, used as is. Must not equal a core tool name, or the whole plugin is skipped |
tools[].description | string | Yes | Shown to the model, after a [plugin: <name>] prefix |
tools[].inputSchema | object | Yes | JSON Schema; must have a string type, normally "object" |
tools[].handler | string | Yes | The command to run for this tool |
service | object | For service | See Service plugins |
The runtime does not check call arguments against inputSchema. It passes the schema to the client and the client's arguments to the handler. A call without a required field still reaches your handler, so check your input.
Tool names are global across the runtime. Pick names that are unlikely to clash, for example with a plugin prefix (greet_person, not greet). If two process plugins declare the same name, only the one whose folder sorts first is ever called.
The handler protocol
For each call to a process tool, the runtime starts the handler, writes the input, waits for it to exit and reads the result.
How the command is started
| Item | Value |
|---|---|
| Command line | handler split on whitespace: the first word is the program, the rest are its arguments |
| Shell | None. Quotes, pipes, $VARS, ~ and globs are not interpreted; put such logic in a script |
| Program lookup | A bare name (bash, python3) is searched on PATH. A path such as ./run.sh is relative to the plugin folder and the file must be executable |
| Working directory | The installed plugin folder, ~/.kadmo/plugins/<name>/ |
| Arguments | Only the fixed words after the program in handler. Call arguments are never put on the command line |
| Environment | A copy of the runtime's own environment (PATH, HOME, DISPLAY and so on). No plugin-specific variables are added |
Several tools can share one script, for example bash run.sh start and bash run.sh stop; the fixed word tells the script which tool was called.
Input on stdin
The handler receives the call's arguments object as one JSON document, with no trailing newline, and then end of input. With no arguments it receives {}. For the minimal plugin above:
json
{"name": "Ada"}Read all of stdin before writing your result. The runtime writes the whole input as soon as the process starts.
Output on stdout
After the handler exits, the runtime parses everything it printed on stdout as a single JSON document. Print nothing else on stdout; send logs and debug output to stderr.
| Handler prints (exit code 0) | The client receives |
|---|---|
An object with a content array | That object as it is, including isError if you set it |
Any other valid JSON, for example {"ok": true} | A text result whose text is the printed JSON |
| Text that is not JSON, or nothing | An error: Invalid JSON output from handler: ... with the first 500 characters |
The normal result is an MCP tool result:
json
{
"content": [{ "type": "text", "text": "Hello, Ada!" }]
}To report a failure the model should see as an error while still exiting 0:
json
{
"content": [{ "type": "text", "text": "Error: file not found" }],
"isError": true
}Exit codes, errors and the time limit
| Situation | The client receives (always with isError: true) |
|---|---|
| Exit code other than 0 | Everything the handler wrote to stderr; if stderr is empty, Handler exited with code <n>. Stdout is ignored |
| The program cannot be started (not found, not executable) | Failed to spawn handler: ... |
| Still running after 30 seconds | The handler process is killed with SIGKILL and the result is Plugin handler timed out after 30000ms |
The 30-second limit is fixed. Work that takes longer belongs in a service plugin, or in a background process your handler starts and a second tool checks on.
Two behaviours to plan for:
- The kill reaches only the handler process itself, not programs it started. If one of them still holds stdout open (for example a
sleepinside a bash script), the reply waits until that program exits, and only then reports the timeout. Useexecfor the last command of a wrapper script, or make sure child programs do not inherit stdout. - If a handler exits without reading stdin and the input is larger than the pipe buffer (about 64 KB on Linux), the runtime process itself crashes with
write EPIPE, ending every client's session. Always read stdin to the end, even when the tool takes no arguments.
Keeping state with a process plugin
Each call is a fresh process, so nothing in memory survives between calls. Files do: write them inside the plugin folder (the working directory) or anywhere else the runtime's user can write. Remember that kadmo plugin install deletes the installed folder before copying the new version, so files kept inside it are lost on reinstall. For state that must stay in memory, such as an open connection or a held key, use a service plugin.
Service plugins
With "runtime": "service", the runtime starts your program once and keeps it running. The program must be an MCP server over stdio: it answers initialize, tools/list and tools/call, writes only MCP messages to stdout and logs to stderr. Any MCP SDK can provide this.
json
{
"name": "counter",
"version": "1.0.0",
"description": "Stateful counter",
"runtime": "service",
"service": {
"command": "node server.mjs",
"timeoutMs": 60000,
"toolNamespace": "counter",
"tools": {
"sleep": { "timeoutMs": 1000 }
}
}
}| Field | Required | Notes |
|---|---|---|
service.command | Yes | Started the same way as a handler: split on whitespace, no shell, plugin folder as working directory, the runtime's full environment |
service.timeoutMs | No | Default time limit per call in milliseconds. Without it, 120000 |
service.toolNamespace | No | Prefix for public tool names; defaults to the plugin name |
service.tools | No | Per-tool settings keyed by the child's own tool name; currently only timeoutMs |
Numbers must be positive and the namespace a non-empty string, or the plugin is skipped. A tools array in a service manifest is ignored.
The lifecycle, as the runtime runs it:
| Stage | What happens |
|---|---|
| Start | On load, the runtime starts the child in the background and has 20 seconds to complete initialize and tools/list |
| Tool names | Each child tool <tool> is listed as <namespace>_<tool>, for example counter_increment. A name already used by a core or process tool is dropped |
| Before ready | The service's tools are missing from tools/list until the child is ready. A call that arrives meanwhile waits up to 5 seconds, then fails with service plugin "<name>" is not ready |
| Calls | One at a time per service, in arrival order. A call over its limit fails with Request timed out; the child keeps running |
| Crash | The runtime restarts the child after 0.5 s, doubling the wait up to 30 s. After 6 failed starts in a row it stops retrying by itself; a later call to one of its tools makes one more attempt. State in memory is lost on every restart |
| Tool list | Read once per start. A child that adds tools later must be restarted for them to appear |
| Stop | On plugin removal (at the next reload), on a change to the service block, when kadmo serve receives SIGTERM or SIGINT, and when a kadmo mcp session ends |
The child's stderr goes to the runtime's log, which is where start failures and crashes show up.
Checklist before you share a plugin
plugin.jsonpasseskadmo plugin installand the tools appear inkadmo plugin list, with no warning.- Each handler works from the shell:
echo '{...}' | bash handler.shprints one JSON object. - Handlers read all of stdin, write only JSON to stdout, and finish well inside 30 seconds.
- Tool names cannot be mistaken for core tools or for another plugin's tools.
- Dependencies are documented.
node_modulesat the top level is not copied byinstall.
Next steps
- Plugins: runtimes, loading rules and known issues
- CLI reference:
kadmo plugin list,installandremove - MCP: connecting a client that will see your tools