JSON-RPC Protocol
Plugins communicate with the Scriptling host over line-delimited JSON-RPC 2.0 on stdio. Each frame is a JSON object or JSON-RPC batch array terminated by a newline. The host writes requests to the plugin’s stdin and reads responses from the plugin’s stdout.
Wire Envelope
All messages are one of three shapes:
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "function.call",
"params": { }
}Response (success):
{
"jsonrpc": "2.0",
"id": 1,
"result": { }
}Response (error):
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32000, "message": "unknown function greet" }
}The id field is a unique integer set by the sender. Every response echoes back the id of the request it corresponds to, so both sides can correlate requests and responses on the shared stdio transport. Responses may arrive out of order when a plugin handles overlapping requests. The host assigns incrementing IDs for its requests.
Error codes:
| Code | Meaning |
|---|---|
-32700 |
Parse error: malformed JSON |
-32600 |
Invalid JSON-RPC request |
-32601 |
Method not found |
-32602 |
Invalid method parameters |
-32603 |
Internal JSON-RPC error |
-32000 |
Generic application/server error |
-32001 |
Fetch source or path not found; not retried |
-32002 |
Fetch access denied; not retried |
-32003 |
Fetch backend temporarily unavailable; retried with a bounded backoff |
Transport Values
Arguments and return values are encoded as Value objects. Exactly one payload
field is set depending on type:
{"type": "null"}{"type": "bool", "value": true}{"type": "int", "value": 42}{"type": "float", "value": 3.14}{"type": "string", "value": "hello"}{
"type": "list",
"items": [
{"type": "int", "value": 1},
{"type": "int", "value": 2}
]
}{
"type": "dict",
"entries": {
"name": {"type": "string", "value": "Ada"},
"age": {"type": "int", "value": 30}
}
}Callbacks are passed by reference. A callback id is valid only while the outer plugin call that received it is still running:
{
"type": "callback",
"callback": {
"id": "cb-1"
}
}Remote objects are passed by reference:
{
"type": "remote",
"remote": {
"library": "hello",
"class": "Config",
"id": "1"
}
}Methods
scriptling.handshake
Direction: Host → Plugin When: First message after the plugin process starts.
Request params:
| Field | Type | Description |
|---|---|---|
protocol |
string | Protocol version, always "1.0" |
host |
string | Always "scriptling" |
host_version |
string | Host version string |
transports |
[string] | Always ["json"] |
capabilities |
[string] | Host capabilities |
policy |
object | Optional host security context, see below |
Response result:
| Field | Type | Description |
|---|---|---|
protocol |
string | Must exactly match the host protocol version, currently "1.0" |
transport |
string | Must be "json" |
library |
object | name, version, description, and optional custom (see Custom manifest data) |
capabilities |
[string] | Plugin capabilities |
scheme |
string | The source scheme this plugin’s fetcher serves (with the fetch capability); one scheme per plugin |
schema |
object | Functions, classes, and constants |
Known capabilities: remote_objects (remote object references in results) and policy (the plugin reads and enforces the handshake policy).
The optional policy object is the host’s security context for this plugin
session, so trusted plugins can enforce it themselves. allowed_paths
restricts filesystem locations (database files, storage directories) and
network carries the outbound network policy (host allow/deny lists, CIDR
rules, category flags). Omitted or nil means the host imposes no
restrictions. Plugins that predate the field ignore it; plugins that enforce
it advertise the policy capability. The first-party database plugins are
the reference implementation.
The policy is advisory, and that is a property of the protocol, not an oversight: it is delivered to the plugin, and the plugin enforces it. The host does not sandbox the plugin process, so the policy bounds what a cooperative plugin does, not what a malicious one could. Treat plugins like any other executable you choose to run: the policy protects scripts from mistakes and misconfiguration inside a plugin you trust, and it is no substitute for trusting the plugin binary itself. Over the HTTP transport the distance is one step larger: the host hands the policy across the network to a server it does not control, and only that server’s own enforcement stands behind it.
A plugin with a fetcher is identified by its scheme, whose presence is the
whole advertisement (see Plugin Fetchers). Unknown
capabilities are ignored, so newer plugins still load on older hosts.
The schema object:
{
"functions": [
{
"name": "greet",
"source": ""
}
],
"classes": [
{
"name": "Config",
"constructor": { "name": "Config" },
"methods": [
{ "name": "get" }
],
"properties": [
{ "name": "name", "settable": true },
{ "name": "label" }
]
}
],
"constants": [
{ "name": "max_retries", "value": { "type": "int", "value": 3 } }
]
}If the plugin returns any other protocol version, the host refuses to load it and records a manager warning. Breaking protocol changes require a new protocol version and older hosts will reject the plugin during handshake.
When source is empty or absent the host auto-generates an RPC proxy: every call is a JSON-RPC round trip. When source is provided the host compiles and runs that Scriptling code itself, so it executes entirely host-side; it becomes a wrapper around RPC calls (using scriptling.plugin.call_method / call_function to reach the plugin) or pure host-side logic. If any entry carries a source the whole module registers as script: entries without sources get their auto-generated shims emitted alongside. See Host-Side Scripting for the authoring side.
Class properties are auto-generated as Scriptling @property descriptors. settable: true means the host also generates a setter. Getter-only properties are read-only from Scriptling.
Custom manifest data
The library object may carry an optional custom field — an arbitrary,
host-defined data map that scriptling transports verbatim and never interprets.
It is the channel for a host to learn plugin-specific declarations from the
manifest without running any plugin code: the data rides in the handshake,
so the host reads it the moment the plugin connects.
{
"library": {
"name": "metrics",
"version": "1.0.0",
"description": "Metrics plugin",
"custom": { "manifest": "…host-specific declaration text…" }
}
}The data must be static — the same value on every run — so a host loading the plugin on different machines sees an identical manifest. Older plugins that declare none simply omit the field, and older hosts ignore it, so it is fully backward compatible.
Declaring it — Go peer:
srv := plugin.NewServer("metrics", "1.0.0", "Metrics plugin")
srv.SetMetadata(map[string]any{
"manifest": "…host-specific declaration text…",
})
// … register functions/classes …
srv.Run()Declaring it — Scriptling-authored peer:
import scriptling.runtime as rt
rt.plugin.serve("metrics", "1.0.0", "Metrics plugin", metadata={
"manifest": "…host-specific declaration text…",
})
# … register functions …
rt.start_server()Reading it — host side (Go):
client, _ := manager.LoadPath(ctx, "metrics", path, true, nil)
custom := client.Metadata().Custom // map[string]any, nil if the plugin declared none
manifest, _ := custom["manifest"].(string)environment.open
Direction: Host → Plugin When: Reserved for future use. Currently a no-op.
Request params: None
Response result: null
environment.close
Direction: Host → Plugin When: Reserved for future use. Currently a no-op.
Request params: None
Response result: null
plugin.shutdown
Direction: Host → Plugin When: Host is shutting down the plugin process. Sent with a 10-second timeout.
Request params: None
Response result: null
The plugin should release resources and exit after responding.
function.call
Direction: Host → Plugin When: Calling a registered function.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
name |
string | No | Registered function name |
args |
[Value] | Yes | Positional arguments |
kwargs |
{string: Value} | Yes | Keyword arguments |
Response result: A single Value: the function’s return value.
Example:
→ {"jsonrpc":"2.0","id":2,"method":"function.call","params":{"name":"greet","args":[{"type":"string","value":"Ada"}]}}
← {"jsonrpc":"2.0","id":2,"result":{"type":"string","value":"Hello, Ada"}}callback.call
Direction: Plugin -> Host When: A plugin invokes a callback argument before the outer function, constructor, or method call has returned.
Callback calls are ordinary JSON-RPC requests sent over the same stdio stream while another host -> plugin request is still pending. The host executes the Scriptling callback synchronously on the same environment call stack and responds before the plugin continues.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
id |
string | No | Callback id from a callback transport value |
args |
[Value] | Yes | Positional callback arguments |
kwargs |
{string: Value} | Yes | Keyword callback arguments |
Response result: A single Value: the callback return value.
Example:
-> {"jsonrpc":"2.0","id":7,"method":"callback.call","params":{"id":"cb-1","args":[{"type":"dict","entries":{"token":{"type":"string","value":"Hello"}}}]}}
<- {"jsonrpc":"2.0","id":7,"result":{"type":"string","value":"ack"}}If the callback raises an error, the host returns a JSON-RPC error and the plugin function should fail the outer call. Once the outer call returns, all callback ids created for that call expire; later use returns unknown callback.
host.log
Direction: Plugin -> Host
When: A Go plugin writes through plugin.Logger(ctx) during an active function, constructor, or method call.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
level |
string | No | trace, debug, info, warn, error, or fatal |
message |
string | No | Log message |
args |
[Value] | Yes | Logger key/value arguments encoded as transport values |
Response result: null
Example:
-> {"jsonrpc":"2.0","id":8,"method":"host.log","params":{"level":"info","message":"plugin work started","args":[{"type":"string","value":"name"},{"type":"string","value":"Ada"}]}}
<- {"jsonrpc":"2.0","id":8,"result":{"type":"null"}}The host forwards the record to the plugin manager logger if one is configured. If no host logger is configured, the host acknowledges the request and drops the record.
object.new
Direction: Host → Plugin When: Constructing a class instance.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
class |
string | No | Registered class name |
args |
[Value] | Yes | Constructor arguments |
kwargs |
{string: Value} | Yes | Constructor keyword arguments |
Response result: A RemoteRef identifying the new instance:
{
"library": "hello",
"class": "Config",
"id": "1"
}object.call_method
Direction: Host → Plugin When: Calling a method on a remote instance.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
object_id |
string | No | Instance ID from object.new |
method |
string | No | Method name |
args |
[Value] | Yes | Positional arguments |
kwargs |
{string: Value} | Yes | Keyword arguments |
Response result: A single Value: the method return value.
Class properties also use object.call_method. A getter call sends the property name with no args; a setter call sends the property name with one positional argument containing the new value. Read-only properties return a JSON-RPC error when called as a setter.
object.destroy
Direction: Host → Plugin When: Releasing a remote instance (explicit release or GC finalizer).
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
object_id |
string | No | Instance ID to destroy |
Response result: null
The plugin removes the instance and calls __del__ if defined. Destroy is idempotent: destroying an already-destroyed ID succeeds silently.
fetch.read
Direction: Host → Plugin
When: The host needs one file from a plugin-served scheme:// source. This includes a directly executed scheme script and the library bundle attached automatically for the plugin; only files an import or package read actually touches are fetched.
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
source |
string | No | Full source string, e.g. knot://libs |
path |
string | Yes | Slash path within the source; empty means the source itself is a single script file |
Response result:
| Field | Type | Optional | Description |
|---|---|---|---|
data |
string | Yes | File content, base64-encoded |
Fetch errors are typed, so the host can tell a miss from a refusal from a flaky backend without parsing messages:
| Code | Sentinel | Meaning | Host behaviour |
|---|---|---|---|
-32001 |
ErrFetchNotFound |
source or path missing | plain not-found (a failed module probe) |
-32002 |
ErrFetchDenied |
access refused (credentials, permissions) | surfaced as a permission error; never retried |
-32003 |
ErrFetchUnavailable |
backend could not answer right now | retried a bounded number of times |
Fetch operations are idempotent reads, so the host retries -32003 (and
transport-level failures) up to three attempts with a short backoff; coded
answers are final. Any other error aborts package loading with the source
named, because hosts deliberately distinguish “module is not there” from
“plugin could not be asked”. The host caches nothing it fetches, so every
read reaches the plugin and returns content; there is no conditional-read
form. data is base64-encoded, which is what lets binary assets travel
intact.
→ {"jsonrpc":"2.0","id":7,"method":"fetch.read","params":{"source":"knot://libs","path":"lib/greet.py"}}
← {"jsonrpc":"2.0","id":7,"result":{"data":"ZGVmIGdyZWV0aW5nKG5hbWUpOg=="}}fetch.glob
Direction: Host → Plugin When: The host resolves a path, lists a directory, or matches a pattern over a source (existence checks, listings, globbing, subtree walks).
Request params:
| Field | Type | Optional | Description |
|---|---|---|---|
source |
string | No | Full source string |
pattern |
string | Yes | Glob pattern; empty means the root |
Response result:
| Field | Type | Optional | Description |
|---|---|---|---|
entries |
[{name, is_dir}] | No | Every match, in one answer |
The pattern language: slash-separated paths relative to the source root;
* matches within one segment (never /), ? one character, [class] a
character class, and a ** segment matches any number of segments including
none. A wildcard-free pattern is legal and answers at most one entry, which
is how existence and directory-ness are probed: a matched directory carries
is_dir: true, so an empty directory is distinguishable from a missing one.
Entry names are full paths relative to the source root.
No match is an empty entries list, never an error: errors mean the fetcher
could not answer (the codes above). The whole point is one round trip: a
listing is <dir>/*, a subtree is <dir>/**, and the plugin (which knows
its backend) does the matching instead of the host walking level by level.
Errors are retried per the fetch retry policy above. The host memoizes
directory listings for its listing TTL; content is never held.
Lifecycle
- Host starts the plugin executable.
- Host sends
scriptling.handshake. Plugin responds with schema. - Host sends
function.call,object.new,object.call_method,object.destroyas needed. - Host sends
plugin.shutdown. Plugin responds and exits.
environment.open and environment.close are reserved for future use. The
host does not currently send them, but plugins must accept them as no-ops if
they arrive.
Peer Environment
Executables spawned as stdio peers receive SCRIPTLING_PLUGIN_PEER=<version>
environment, on top of the host’s environment. Multi-role executables check it
to divert a bare invocation into plugin mode: an executable that is also a
general CLI can serve the protocol when scriptling spawns it with no
arguments, without dedicating a subcommand to it. The value is the scriptling
version (e.g. 0.23.0), so a peer can check compatibility and refuse to
serve a version it does not support. Only scriptling sets it, and a peer
that spawns children should unset it so the trigger does not propagate.
Skipping the handshake
Executables loaded via scriptling.plugin.load(name, path) (without
scriptling=True) skip the plugin handshake: no scriptling.handshake
exchange and no schema/version metadata collected. The host still reports
transport as "json" because raw peers use the same JSON-RPC-over-stdio
codec. In this raw JSON-RPC mode,
scriptling.plugin.call_function(library, name, ...) sends name directly as
the JSON-RPC method instead of wrapping it in function.call. This is useful
for peers such as scriptling --json-rpc setup.py, where methods are
registered with scriptling.runtime.jsonrpc.method().
plugin.shutdown is sent on unload() as a best-effort hint and any
method-not-found response is ignored, so implementing it is optional. The
host closes stdin immediately afterwards; the executable should exit promptly
when stdin reaches EOF.