A worker is the background logic a plugin runs in a process of its own, a good fit for parsing, conversion and computation that belongs to the plugin. The interface and the Agent call it by operation, and it reaches files, the network and services through the host broker instead of holding those capabilities itself.
Two kinds of worker
| Plugin worker | Global Worker service | |
|---|---|---|
| How it ships | Declared and installed with the .seplugin | A separate .seworker package, signed and installed on its own, with its own lifecycle |
| Who it serves | The plugin it belongs to | Several plugins can depend on it by service id and API major version |
| Permissions | Gets the permissions granted to its plugin, through the host broker | Does not inherit the calling plugin's permissions |
| Where it fits | Parsing, conversion and background computation that belongs to the plugin | Heavy capabilities shared by several plugins, each on its own version, such as OCR or media processing |
A plugin can only declare a dependency on a service in required_services; it can never silently download, install, upgrade or replace one.
Writing with the official Node SDK
The official Node SDK handles the handshake, health checks, cancellation and host calls, so you only define your business operations:
import { runNodeWorker } from "./sdk/worker/index.mjs"; // ship it under payload/worker/
runNodeWorker({
operations: {
async "document.extract"(input, context) {
const response = await context.callHost("host.net.fetch", {
url: "https://api.example.com/v1/extract",
method: "POST",
body: JSON.stringify(input),
response_type: "json"
});
return { status: response.status, body: response.body };
}
},
health: () => ({ status: "ready" })
});
The context an operation handler receives contains identity (plugin_id, plugin_version, worker_id), signal (an AbortSignal) and callHost(method, params). At the handshake the SDK reports the keys of operations, sorted, as methods, and always declares features: ["cancel", "host-broker"].
payload/worker/ and be listed in files. Without the SDK you can write the protocol by hand as described below.Protocol 2.0
The host and the worker exchange JSON-RPC 2.0 messages as UTF-8 NDJSON on standard input and output: one complete message frame per line.
{"jsonrpc":"2.0","id":"req-1","method":"worker.invoke","params":{"operation":"document.extract","input":{"file_token":"token"}}}
{"jsonrpc":"2.0","id":"req-1","result":{"output":{"text":"example"}}}
Lifecycle
- The host starts an isolated process and sends
worker.initialize. - The worker echoes its identity, protocol version and the list of business methods.
- The host checks the five fields
protocol_version,plugin_id,worker_id,methodsandfeatures, then moves to the ready state. - Calls run within the deadline of each method.
- When the interface sends
worker.cancel, the host aborts that call and ends the whole worker process; the next call starts the process again. - The host does not send
worker.shutdownat the moment, so stopping means ending the process. Make temporary resources survive being killed without leaving a dirty state.
Handshake response
In a hand-written worker the result of worker.initialize must carry all five fields below; a missing one is refused with PLUGIN_WORKER_HANDSHAKE_INVALID:
{
"protocol_version": "2.0",
"plugin_id": "<echo params.plugin_id unchanged>",
"worker_id": "<echo params.worker_id unchanged>",
"methods": ["document.extract"],
"features": []
}
featuresis a required array, the host does not read its values, and[]is enough when you write by hand.methodslists only real business operations; do not put lifecycle methods such asworker.healthorworker.invokethere. An empty array means the worker accepts no business calls.result.statusofworker.healthcan only bereadyordegraded; anything else is refused withPLUGIN_WORKER_HEALTH_INVALID.
Methods
| Method | Direction | Permission required | Deadline | Cancellable |
|---|---|---|---|---|
worker.initialize | Host → worker | — | 10 s | No |
worker.health | Host → worker | — | 5 s | No |
worker.invoke | Host → worker | — | 30 s | Yes |
worker.shutdown | Host → worker | — | 5 s | No |
host.fs.stat | Worker → host | fs.source.read | 5 s | No |
host.fs.read | Worker → host | fs.source.read | 30 s | Yes |
host.net.fetch | Worker → host | net.fetch | 120 s | Yes |
host.blob.read | Worker → host | storage.blob | 10 s | No |
host.blob.delete | Worker → host | storage.blob | 5 s | No |
host.schedule.upsert | Worker → host | — | 5 s | No |
host.schedule.remove | Worker → host | — | 5 s | No |
host.schedule.pause | Worker → host | — | 5 s | No |
host.service.invoke | Worker → host | service.invoke | 30 s | Yes |
Notifications: $/cancelRequest (both directions) and worker.progress (worker → host). Note that the host cancels a call by ending the process, so a worker never receives $/cancelRequest and does not need cooperative cancellation.
host.schedule.* can only manage triggers declared in the manifest's triggers, and cannot go below the declared minimum interval, otherwise it reports PLUGIN_SCHEDULE_TRIGGER_NOT_DECLARED or PLUGIN_SCHEDULE_INTERVAL_INVALID.
JSON-RPC error codes
| Name | Value | Retryable |
|---|---|---|
WORKER_PARSE_ERROR | -32700 | No |
WORKER_INVALID_REQUEST | -32600 | No |
WORKER_METHOD_NOT_FOUND | -32601 | No |
WORKER_INVALID_PARAMS | -32602 | No |
WORKER_INTERNAL_ERROR | -32603 | Yes |
WORKER_PERMISSION_DENIED | -32001 | No |
WORKER_DEADLINE_EXCEEDED | -32002 | Yes |
WORKER_CANCELLED | -32003 | Yes |
WORKER_PAYLOAD_TOO_LARGE | -32004 | No |
WORKER_SERVICE_UNAVAILABLE | -32005 | Yes |
A JSON-RPC error the worker returns shows up in the interface and on the Agent side as PLUGIN_WORKER_REMOTE_ERROR:<code>:<message>:<data>. Branch on the number or on your own data, not on the message text.
Implementation rules
- Write protocol frames and nothing else to stdout, one per line; send all business logging to stderr. No banners, progress bars, debug prints or multi-line JSON.
- Echo the request id back unchanged, and return a stable error for an unknown method.
- Write startup errors to stderr: if the worker exits before answering, the host returns
PLUGIN_WORKER_EXITEDwith the last lines of stderr. - Do not block the main thread with long synchronous calls; put heavy work in
worker_threads. After a call times out the host sends oneworker.health: with an answer it reportsPLUGIN_WORKER_TIMEOUTand keeps the process, without one it reportsPLUGIN_WORKER_UNRESPONSIVEand ends it. - Pass file bodies and large binary data as tokens or blobs, never inside an RPC frame.
- An idempotent method must not produce its side effects twice because of a retry.
- Do not keep tokens in environment variables, logs, KV or error messages.
Crashes and isolation
Crashes and protocol violations (non-JSON output, oversized frames, invalid responses and so on) count towards the crash total: a worker that exits three times within five minutes is quarantined, the host stops starting it and calls return PLUGIN_WORKER_QUARANTINED at once. Timeouts do not count. Disabling and re-enabling the plugin, uninstalling it or installing over it clears the count immediately; while you develop, install again after changing the code, or toggle Disable → Enable once.
Execution policies
| Policy | Manifest declaration | Behaviour |
|---|---|---|
Sandboxed (sandbox) | the default | Runs in the system sandbox, and a failed isolation refuses to start. By default it has no access to files, the network, the system keychain or other plugins' data, with one exception: the plugin's own data folder. child_process is disabled, so it cannot start child processes. |
Trusted (trusted) | "execution_policy": "trusted" | Needs your confirmation item by item at install. It still runs in the same sandbox and still cannot start child processes; only the approved host broker capabilities are relaxed. |
This-computer mode (unrestricted) | "isolation": "dev_unrestricted" | Runs outside the sandbox as an ordinary process with your system account's permissions, so it can read and write any file, reach the network and start child processes. You must grant This-computer mode to this worker on the plugin detail page or in Settings → Plugins; installing or upgrading clears the grant, so it has to be granted again. |
- A worker declared as
sandboxed_pluginthat asks for This-computer mode is refused (PLUGIN_WORKER_ISOLATION_POLICY_MISMATCH). - When the Agent calls a worker in This-computer mode it has to pass
policy: "unrestricted", so say so in the Skill file; otherwise the call goes through the sandbox. - Declare
dev_unrestrictedonly when you really depend on system capabilities, and say in the plugin description why and what it reaches. If the host broker can do the job, do not ask for This-computer mode.
Private data folder
The host gives a sandboxed worker a folder of its own to read and write through the STABLEDGE_PLUGIN_DATA_DIR environment variable. Embedded databases such as SQLite work there, and it is where background task results mostly go. Do not assemble or probe its parent folder or other plugins' folders; the package folder itself is read-only.
Network
A worker has no raw socket and can only reach the network through host.net.fetch, and then:
- only https on port 443 is allowed;
- the domain and method must match that worker permission's
scope.domains/scope.methods; - the target must resolve to a public address, and this machine, link-local and private addresses are all refused. Reach local or network services through
net.fetchon the interface side; - redirects are not followed, and the Authorization and Cookie headers the host injects are not returned to the worker.
Request parameters support url, method, headers, body, body_base64, body_encoding, timeout_ms, max_bytes, response_type, file_name and credential_refs. credential_refs refer to credentials declared in the manifest, which the host injects.
Background calls
A worker is passive: it runs only when the host sends worker.invoke. A host call a worker makes from its own setInterval has no one to receive it and hangs. For scheduled work and event listening use triggers and event_channels in the manifest; see the Manifest reference. On a trigger call the input carries the host-injected _trigger (id, kind, reason, scheduled_at, cursor), and the return value may carry next_cursor and next_run_in_seconds / next_run_at.
Depending on a global Worker service
Declare the dependency in the manifest, and have the worker that calls the service request service.invoke:
"required_services": [
{ "service_id": "com.stabledge.ocr", "version": "^1.0.0", "worker": "main", "optional": true }
],
"requested_permissions": [
{ "permission": "service.invoke", "worker": "main", "optional": false }
]
- When a required dependency is missing or its version does not fit, the plugin sits in a blocked state; when an optional dependency is missing, the plugin has to take an explicit local fallback.
- Services that are compatible by major version can be shared, and services with incompatible major versions can coexist; a singleton conflict is diagnosed clearly and never resolved silently.
- A worker calls a service through
host.service.invoke, with a 30-second deadline per call.
Service descriptor
The host keeps a descriptor for every installed service, with these fields:
| Field | Constraints | Notes |
|---|---|---|
service_id | ^[a-z][a-z0-9.-]{2,127}$ | Service identifier. |
version | semantic version | Service version. |
api_major | integer ≥1 | API major version. |
singleton | boolean | Whether only one instance is allowed. |
methods[].name | — | Method name. |
methods[].deadline_ms | 1–300000 | Method deadline. |
methods[].cancellable | boolean | Whether the method can be cancelled. |
methods[].idempotent | boolean | Whether the method is idempotent. |
methods[].max_payload_bytes | 1–2097152 | Largest payload per call. |
Service states are stopped, starting, ready, degraded and quarantined. Service-level error codes are SERVICE_INVALID_REQUEST, SERVICE_NOT_FOUND, SERVICE_VERSION_UNSATISFIED, SERVICE_SINGLETON_CONFLICT, SERVICE_PERMISSION_DENIED, SERVICE_UNAVAILABLE and SERVICE_DEADLINE_EXCEEDED.
A global Worker service ships as a separate .seworker package.