Skip to content

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.sh

plugin.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 list
json
{"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 ​

FieldTypeRequiredNotes
namestringYesBecomes the folder name under ~/.kadmo/plugins/ and the name kadmo plugin remove takes. Use lowercase letters, digits and dashes
versionstringYesShown by kadmo plugin list and /health. Not compared or enforced
descriptionstringYesShown by kadmo plugin list
runtimestringNoprocess (default; exec means the same) or service
toolsarrayFor processOne entry per tool; ignored for service
tools[].namestringYesThe public tool name, used as is. Must not equal a core tool name, or the whole plugin is skipped
tools[].descriptionstringYesShown to the model, after a [plugin: <name>] prefix
tools[].inputSchemaobjectYesJSON Schema; must have a string type, normally "object"
tools[].handlerstringYesThe command to run for this tool
serviceobjectFor serviceSee 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 ​

ItemValue
Command linehandler split on whitespace: the first word is the program, the rest are its arguments
ShellNone. Quotes, pipes, $VARS, ~ and globs are not interpreted; put such logic in a script
Program lookupA 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 directoryThe installed plugin folder, ~/.kadmo/plugins/<name>/
ArgumentsOnly the fixed words after the program in handler. Call arguments are never put on the command line
EnvironmentA 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 arrayThat 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 nothingAn 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 ​

SituationThe client receives (always with isError: true)
Exit code other than 0Everything 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 secondsThe 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 sleep inside a bash script), the reply waits until that program exits, and only then reports the timeout. Use exec for 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 }
    }
  }
}
FieldRequiredNotes
service.commandYesStarted the same way as a handler: split on whitespace, no shell, plugin folder as working directory, the runtime's full environment
service.timeoutMsNoDefault time limit per call in milliseconds. Without it, 120000
service.toolNamespaceNoPrefix for public tool names; defaults to the plugin name
service.toolsNoPer-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:

StageWhat happens
StartOn load, the runtime starts the child in the background and has 20 seconds to complete initialize and tools/list
Tool namesEach 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 readyThe 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
CallsOne at a time per service, in arrival order. A call over its limit fails with Request timed out; the child keeps running
CrashThe 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 listRead once per start. A child that adds tools later must be restarted for them to appear
StopOn 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.json passes kadmo plugin install and the tools appear in kadmo plugin list, with no warning.
  • Each handler works from the shell: echo '{...}' | bash handler.sh prints 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_modules at the top level is not copied by install.

Next steps ​

  • Plugins: runtimes, loading rules and known issues
  • CLI reference: kadmo plugin list, install and remove
  • MCP: connecting a client that will see your tools