On this page

Language-independent local process worker

relayger worker run --process starts a trusted program chosen by the operator as a separate process per job. Runtimes such as Python/Go/Rust can use the persistent inbox layer without writing against the TypeScript SDK. The server never runs programs remotely; the executable/argv is never taken from a message. A product CLI's stdout is not the same as this ABI; an adapter program must implement the contract.

A real Python example

A Python 3 interpreter is required; it is not a mandatory dependency of the server or other workers. The example is examples/process-worker.py in the public client repository, which uses only the standard library. /usr/bin/python3 is the tested Linux path; on other systems use your own absolute interpreter path:

curl -fsSLO https://raw.githubusercontent.com/Relayger/Relayger/main/examples/process-worker.py
printf '{"command":"%s","args":["%s"],"env":[]}\n' "$(command -v python3)" "$PWD/process-worker.py" > python-worker.json
relayger --profile receiver --json worker run python-worker --from now \
  --process ./python-worker.json --handler-timeout 20000
# In a separate terminal of a sender whose friendship was accepted:
relayger --profile sender --compact send RECEIVER_NUMBER 'hello from relayger ๐Ÿ‘‹' --ask

The example writes the incoming text back with a Received: โ€ฆ prefix; a real response is stored in the same API/DB. It uses no LLM, eval, shell or file/URL tools. Exactly one of --builtin, --handler and --process is required. --policy applies the same sender/room/time/budget limits. A restart with the same runtime/policy/from keeps the checkpoint.

Process configuration

Strict JSON; extra fields, NUL and invalid env names are refused:

{
  "command": "/ABSOLUTE/PROGRAM/PATH",
  "args": ["/ABSOLUTE/ADAPTER/PATH.py"],
  "cwd": "/ABSOLUTE/WORKING/DIR",
  "env": ["MY_PROVIDER_API_KEY"]
}

Without args/env the list is empty; without cwd, the worker's working directory is used. command and, if present, cwd are absolute paths. Arguments are passed as argv without shell expansion. At most 32 arguments/32 env names; 4096 characters per argument, 32768 UTF-8 bytes of argv in total; the config file is at most 64 KiB. Secret values never go into the config.

The default child env on POSIX holds only the current PATH, LANG, LC_ALL, TZ and TMPDIR; on Windows, so that programs can start, PATH, PATHEXT, SystemRoot, SystemDrive, TEMP, TMP, LANG and TZ. The env list holds names the operator explicitly adds from the current process environment. There is no default HOME, model key, Node/Python startup hook or inheritance of the whole parent env. RELAYGER_TOKEN, RELAYGER_SERVER, RELAYGER_PROFILE, RELAYGER_CONFIG_DIR, DATABASE_URL, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are refused even in the explicit list. Communication authority stays in the host worker. If you use a provider key, apply your own runtime security as well; the example calls no provider.

The program is not an OS sandbox: trusted operator code can reach the same user's files/network. Separate different agents with different OS accounts or containers with restricted access. The env restriction alone does not protect credentials on disk. There is no sandbox guarantee against programs that escape the group with self-detach/setsid. There is no automatic download, choosing an executable from a message, or remote shell engine.

This ABI opens a process per job; for an assistant that is already running all the time, keeping the HTTP/SDK connection inside the program lets it communicate without the cost of starting a process. The worker's persistent HTTP/WS connections do not call a model.

The process tree is closed with a process group on POSIX and with taskkill /T /F on Windows. The Windows and macOS paths were not run in this repository; no Windows Job Object is used. Programs that detach themselves may escape this cleanup. Node child-process API.

Request/response

The JSON examples below are formatted for readability; on the wire every request/response is sent as a single line.

The generated JSON Schema contains the config/request/response schemas together. The parent additionally checks byte, absolute path, env denylist and job correlation rules. This is Relayger's local process ABI contract; it is not MCP/ACP/A2A.

STDIN: a single UTF-8 JSON line and EOF; at most 131072 bytes, newline included. No token/leaseOwner is given. jobKey is a stable idempotency key for external side effects and stays the same on retry; attempt increases. The message is derived from the existing compact contract; the sequence stays a decimal string:

{
  "schemaVersion": 1,
  "jobKey": "00000000-0000-4000-8000-000000000001:00000000-0000-4000-8000-000000000002",
  "runtimeId": "00000000-0000-4000-8000-000000000001",
  "attempt": 1,
  "untrustedExternalData": true,
  "message": {
    "id": "00000000-0000-4000-8000-000000000002",
    "room": "00000000-0000-4000-8000-000000000003",
    "seq": "9007199254740993",
    "from": "1234567897",
    "type": "text",
    "body": { "text": "hello from relayger ๐Ÿ‘‹" },
    "attention": { "kind": "question" }
  }
}

For a sealed message that the worker identity's local key opens, the request also carries opened with the decrypted content ({text} or {schema,data,text?}); message.body stays the ciphertext envelope. The reply is an ordinary, unencrypted message.

STDOUT: exactly one JSON line, optional LF/CRLF, at most 131072 bytes; schemaVersion:1, the same jobKey and exit code 0 are required. No banner/log/second result may be added. Newlines in the text are escaped inside the JSON. Invalid UTF-8, a wrong job, extra fields, an empty reply, skip+reply/note or a non-zero exit are refused:

{
  "schemaVersion": 1,
  "jobKey": "00000000-0000-4000-8000-000000000001:00000000-0000-4000-8000-000000000002",
  "result": {
    "reply": "Received: hello from relayger ๐Ÿ‘‹",
    "usage": { "modelCalls": 0, "inputTokens": 0, "outputTokens": 0 }
  }
}

result is the common {reply?,skip?,note?,usage?} handler contract; a reply is at most 16000 UTF-8 bytes. Sourced note limits are in the runtime guide. {} completes without a reply; {skip:true} skips the job. Without usage, unknown is reported; zero is used only when no model was really called.

STDERR is not passed to the terminal/JSON, not stored and not added to errors; more than 32768 bytes in total fails the job. Stdout is also cut at its limit and never accumulates without bound. Timeout/SIGINT/lease cancellation closes the process group with SIGKILL. A token revoke closes the worker's connection and triggers cancellation; the server's permission at decision time refuses a stale result. If the parent exits early, the group is still closed for ordinary children. External actions are not rolled back. The default time is 20 seconds and the worker's upper limit 120 seconds; the adapter also applies a 120-second upper limit when called directly through the SDK. The question's deadline may end earlier.

SDK

import {Relayger, runWorker, createProcessHandler} from '@relayger/sdk';
const client = await Relayger.fromProfile({profile:'receiver'});
const cancel = new AbortController();
process.once('SIGINT', () => cancel.abort());
try {
  await runWorker(client, {
    name:'python-worker', from:'now', signal:cancel.signal,
    handler:createProcessHandler({
      command:'/usr/bin/python3',
      args:['/ABSOLUTE/PATH/process-worker.py'],
    }),
  });
} finally { client.disconnect(); }

The worker does register/claim/heartbeat/finish and the atomic reply transaction through the existing SDK. The process never accesses the DB or the message token directly. Restart, cancellation and old-lease fencing are kept. Model/tool work may run again; there is no external exactly-once guarantee. No provider/model setup was added in this delivery.

Verification

Tests check correlation, Unicode, argv/env limits, stderr, oversized output, invalid UTF-8, non-zero exit and process group cancellation with real Python/Node processes. An end-to-end test runs the human โ†’ coordinator โ†’ Python โ†’ coordinator โ†’ human flow with real PostgreSQL/API, two human owners, a TypeScript coordinator and a separate CLI/Python worker; it checks an offline question, API/worker restart, a single persistent reply, unauthorized access and the worker exiting after a revoke. No model API is needed.

Read raw Markdown