Satellite JSON-RPC Contract
This page is the wire contract for the endpoint you expose as jsonrpc_endpoint_url. It describes exactly what Protokol sends, exactly what it accepts back, and how it treats failures.
If you are looking at satellites for the first time, read Satellites first for the template and schema model. This page assumes you already have a satellite registered.
The Endpoint
You expose one HTTP endpoint. Every command in your schema arrives at that same URL — the method field tells you which one to run. You never create a route per command.
| Property | Value |
|---|---|
| Method | POST |
| URL | Your jsonrpc_endpoint_url, exactly as registered |
Content-Type |
application/json |
| Body | A single JSON-RPC 2.0 request object |
Protokol never sends JSON-RPC batches, and never sends notifications. Every request has an id and expects a response.
Request
Full example
For a satellite crm-satellite with the schema command { "name": "uppercase", "method": "workflow.uppercase" }, invoked from a workflow:
POST /jsonrpc HTTP/1.1
Host: crm.example.com
Content-Type: application/json
Authorization: Bearer <your-configured-secret>
X-Project-Uuid: 5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11
X-Workspace-Uuid: 2b7d4e6a-1c3f-4b8e-9d0a-7f5c2e1b3a44
X-Project-Env: dev
X-Env: dev
X-Satellite-Id: crm-satellite
X-Satellite-Command: uppercase
X-Request-Uuid: 9c2f7a10-4d6b-4e2a-b1c8-3f5d9e7a2b60
X-Session: prn:user:5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11:auth0|64f0c
X-Claims-Entity-Type: user
X-Claims-Entity-Id: auth0|64f0c
X-Claims-Entity-Name: Ana Kovac
X-Claims-Entity-Prn: prn:user:5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11:auth0|64f0c
X-Workflow-Run-Id: run_01HZX9T4KQ
{
"jsonrpc": "2.0",
"id": "9c2f7a10-4d6b-4e2a-b1c8-3f5d9e7a2b60",
"method": "workflow.uppercase",
"params": {
"inputs": {
"text": "hello world"
},
"context": {
"command": "uppercase",
"method": "workflow.uppercase",
"request_id": "9c2f7a10-4d6b-4e2a-b1c8-3f5d9e7a2b60",
"workflow_run_id": "run_01HZX9T4KQ",
"caller_prn": "prn:user:5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11:auth0|64f0c",
"claims_entity": {
"type": "user",
"id": "auth0|64f0c",
"name": "Ana Kovac",
"prn": "prn:user:5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11:auth0|64f0c"
}
},
"satellite": {
"id": "crm-satellite",
"schema_version": "2026-06-22.crm.v1",
"schema_hash": "9f2c41a7be08d3c5"
},
"tenant": {
"project_uuid": "5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11",
"workspace_uuid": "2b7d4e6a-1c3f-4b8e-9d0a-7f5c2e1b3a44",
"env": "dev"
}
}
}
Envelope
| Field | Type | Notes |
|---|---|---|
jsonrpc |
string | Always the literal "2.0". |
id |
string | Always a string, never a number. Equal to X-Request-Uuid. When the platform has no correlation id of its own it generates one shaped satellite:{satellite_id}:{unix_nano}. |
method |
string | The method value from the matching jsonrpc_commands entry. If that entry omits method, the command name is sent instead. |
params |
object | Always present. Four keys, described below. |
Dispatch on method, not on the command name
method is what you switch on. params.context.command is the platform-facing command name and the two are frequently different — that indirection is the whole point of the name → method mapping in the schema. Reading command instead of method will appear to work right up until someone renames a command in the schema.
params.inputs
Whatever the caller passed as inputs, forwarded verbatim with no coercion, validation, or schema enforcement by the platform. If the caller sent nothing, this is {} — never null, never absent.
For workflow nodes, the keys are the inputs[].name values you declared on the node. For direct execute calls, they are whatever the caller put in the request body.
Validate inputs yourself. Declaring an input as required: true on a workflow node drives the canvas UI; it is not a runtime guarantee at your endpoint.
params.context
Execution context. The platform-set keys:
| Key | Always present | Meaning |
|---|---|---|
command |
Yes | Platform command name, matching X-Satellite-Command. |
method |
Yes | Same value as the top-level method. |
request_id |
Yes | Same value as the top-level id. |
claims_entity |
Yes | Object with the caller's type, id, name, prn. Individual keys are omitted when unknown, so this may be {}. |
workflow_run_id |
No | Present only when the call originated from a workflow node. |
caller_prn |
No | PRN of the authenticated caller. Omitted when the platform cannot attribute the call to an actor. |
Two things to know about this object:
- Caller-supplied
contextis merged in. Anything the caller passed ascontexton the execute call lands here too. Platform keys are written last and win on collision, so the six keys above cannot be spoofed by a caller — but any other key is caller-controlled input. Treat it as untrusted. - Empty values are dropped, not nulled. The merge skips empty strings and nulls, which is why
workflow_run_idandcaller_prnvanish rather than appearing as"". Use presence checks ("caller_prn" in ctx), not truthiness on an assumed-present field.
params.satellite
| Key | Meaning |
|---|---|
id |
Your satellite id. |
schema_version |
The schema_version string currently deployed to this environment. |
schema_hash |
Platform-computed hash of that schema version. |
schema_version is the useful one: it tells you which contract version the platform believes it is talking to, which is what lets one deployment of your service serve an old and a new schema at the same time during a rollout.
params.tenant
| Key | Meaning |
|---|---|
project_uuid |
Project that owns the satellite. |
workspace_uuid |
Workspace the call was made in. |
env |
dev or live. |
Scope all your data access by project_uuid. A satellite registered in one project is only reachable by that project, but if your service backs several projects, this field is your tenant boundary.
Headers
Every header below is set on every command request. They duplicate values already in the body so that proxies, log pipelines, and load balancers can route and filter without parsing JSON. The body is authoritative.
| Header | Notes |
|---|---|
X-Project-Uuid |
Mirrors params.tenant.project_uuid. |
X-Workspace-Uuid |
Mirrors params.tenant.workspace_uuid. |
X-Env |
dev or live. |
X-Project-Env |
Identical value to X-Env. Both are sent. |
X-Satellite-Id |
Mirrors params.satellite.id. |
X-Satellite-Command |
Mirrors params.context.command. |
X-Request-Uuid |
Mirrors the top-level id. Log this — it is your correlation key with platform-side traces. |
X-Claims-Entity-Type |
Omitted when empty. |
X-Claims-Entity-Id |
Omitted when empty. Stable authenticated actor id. |
X-Claims-Entity-Name |
Omitted when empty. |
X-Claims-Entity-Prn |
Omitted when empty. Falls back to the caller PRN. |
X-Session |
Compatibility header carrying the caller PRN, for runtimes that already read session context. Prefer X-Claims-Entity-Id. The literal value present is treated internally as a placeholder and suppressed, so it never reaches you. |
X-Workflow-Run-Id |
Sent only for workflow-originated calls. |
HTTP header names are case-insensitive; read them through your framework's normal accessor rather than matching this casing literally.
Authentication
Protokol authenticates to you using the credential stored on the template. There is no request signing, no HMAC, and no timestamp or nonce — verification is a constant-time comparison of a shared secret.
auth_type |
What arrives |
|---|---|
bearer |
Authorization: Bearer <secret> |
api_key |
Your configured auth_config.header_name, with <secret> as the raw value |
If no secret is configured, no auth header is sent at all and requests arrive unauthenticated. Reject those.
import { timingSafeEqual } from 'node:crypto';
const EXPECTED = Buffer.from(process.env.SATELLITE_SECRET);
function authorized(req) {
const header = req.get('authorization') ?? '';
if (!header.startsWith('Bearer ')) return false;
const got = Buffer.from(header.slice(7));
return got.length === EXPECTED.length && timingSafeEqual(got, EXPECTED);
}
Your endpoint is on the public internet
A shared bearer secret is the only thing standing between your satellite and anyone who finds the URL. Terminate TLS, reject unauthenticated requests rather than defaulting them to a tenant, and rotate the secret by updating the template's auth_config.secret.
Response
Return HTTP 200 with a JSON-RPC 2.0 response object.
Success
{
"jsonrpc": "2.0",
"id": "9c2f7a10-4d6b-4e2a-b1c8-3f5d9e7a2b60",
"result": {
"text": "hello world",
"uppercase": "HELLO WORLD",
"length": 11
}
}
result is returned to the caller as-is. It may be any JSON value — object, array, string, number, boolean. For workflow nodes, make its keys match the outputs[].name values you declared, since that is what downstream nodes bind to.
result is not validated against your schema. Nothing enforces that a node's declared outputs are actually present; if you omit one, the downstream node simply reads undefined.
Errors
{
"jsonrpc": "2.0",
"id": "9c2f7a10-4d6b-4e2a-b1c8-3f5d9e7a2b60",
"error": {
"code": -32602,
"message": "inputs.text is required"
}
}
Return JSON-RPC errors with HTTP 200
The platform checks the HTTP status before it parses the body. A non-2xx response is turned into an opaque satellite jsonrpc returned status <code>: <raw body> failure and your error object is never read — so the code, message, and data you carefully filled in are lost, and the caller sees a truncated blob instead.
This is the single most common satellite implementation mistake, because returning 400 alongside a JSON-RPC error object feels correct. Under JSON-RPC 2.0 it is not: the transport succeeded, so the transport status is 200, and the failure is expressed in the envelope. Reserve non-2xx for genuine transport-layer problems such as rejecting an unauthenticated request.
Standard JSON-RPC codes are the right default:
| Code | Use for |
|---|---|
-32700 |
Parse error |
-32600 |
Invalid request |
-32601 |
Unknown method |
-32602 |
Invalid or missing params.inputs |
-32603 |
Internal error |
-32000 to -32099 |
Your own application errors |
Error codes are not interpreted
The platform does not branch on code. It stringifies the whole error value into the failure message surfaced to the caller and to workflow run logs. So message is the field a human will actually read — write it for them, and put anything machine-readable in data. No code triggers a retry, and no code is treated specially.
What counts as a failure
The platform fails the command when any of these hold:
They are evaluated in this order, and the first match wins:
| # | Condition | Resulting behavior |
|---|---|---|
| 1 | HTTP status outside 200–299 |
Fails immediately; the raw body is embedded in the message and the JSON-RPC envelope is never parsed. |
| 2 | Body is not valid JSON | Fails with a decode error. |
| 3 | error is present and non-null |
Fails with your error stringified. Checked before result, so sending both discards the result. |
| 4 | Connection refused, DNS failure, TLS error | Fails. |
| 5 | Timeout exceeded | Fails; the request is cancelled. |
Two cases that are not failures and are worth knowing:
resultomitted or empty resolves successfully withnull. A bare{"jsonrpc":"2.0","id":"..."}is a successful no-op, not an error. If you meant to signal failure, you must senderror.idin your response is not checked. The platform does not correlate it against the request. Echo it anyway — it costs nothing and it is what makes your own logs joinable — but do not rely on the platform to catch a mismatch.
Response size
The response body is read up to 10 MB; anything beyond that is truncated, which will usually surface as a JSON decode error rather than a clean message. Return references — a PRN, a signed URL, an id to page against — instead of large payloads.
Timeouts and Retries
| Behavior | Value |
|---|---|
| Timeout | The template's timeout_ms. Falls back to 30 s when unset or non-positive. |
| Retries | None. Exactly one HTTP attempt per command execution. |
| Idempotency | Not provided by the platform. |
This is the part implementors most often get wrong, so to be explicit: the platform never retries a satellite command. A timeout or a 500 is a terminal failure of that execution.
The consequences are worth sitting with:
- Slow is the same as failed. If your handler takes longer than
timeout_ms, the platform cancels and reports failure — but your service usually keeps running the work to completion. That is how you get a workflow that reports failure while the side effect actually happened. Keep handlers well inside the budget and move genuinely long work to a job you kick off and poll. - Retries are the caller's problem. Workflows can retry a node, and workflow retries reuse
request_idonly if the workflow itself does; a fresh execution gets a fresh id. If a command is not naturally idempotent, dedupe on a key you control fromparams.inputsrather than trustingrequest_idto be stable.
Reserved Command: resolve_resource
If your schema declares a command named resolve_resource, the platform routes Resource Resolver lookups for prn:satellite:<satellite_id>:<path> to it. The name is fixed by convention; there is no separate resolver configuration field. The method it maps to is yours to choose.
The request is an ordinary command execution with a fixed inputs shape:
{
"jsonrpc": "2.0",
"id": "b1f0d3c2-77a9-4e51-9c22-0d8e4a6b1f30",
"method": "resource.resolve",
"params": {
"inputs": {
"ref": "prn:satellite:crm-satellite:contacts/42",
"path": "contacts/42",
"params": { "expand": "owner" }
},
"context": {
"kind": "resource_resolver",
"command": "resolve_resource",
"method": "resource.resolve",
"request_id": "b1f0d3c2-77a9-4e51-9c22-0d8e4a6b1f30",
"claims_entity": { "type": "user", "id": "auth0|64f0c" }
},
"satellite": { "id": "crm-satellite", "schema_version": "2026-06-22.crm.v1", "schema_hash": "9f2c41a7be08d3c5" },
"tenant": { "project_uuid": "5f1c1b0c-9a2e-4a1f-8c3a-6d2b7e9f0a11", "workspace_uuid": "2b7d4e6a-1c3f-4b8e-9d0a-7f5c2e1b3a44", "env": "dev" }
}
}
| Input | Meaning |
|---|---|
ref |
The full PRN as the caller wrote it. |
path |
The portion after prn:satellite:<satellite_id>:. |
params |
Query parameters from the resolver call. Always a string-to-string map. |
context.kind is set to resource_resolver, which is how you distinguish a resolver call from a direct execution of the same method.
Your result is wrapped by the platform as { "data": <result> } before it reaches the caller. Return the resource itself, not a pre-wrapped envelope.
Health Check Endpoint
Optional. Configure it as health_check_url and the platform polls it on a fixed schedule.
| Property | Value |
|---|---|
| Method | GET |
| Interval | Every 60 s, per satellite |
| Timeout | 5 s — note this is independent of timeout_ms |
| Headers | X-Project-Uuid, X-Satellite-Id |
| Healthy | Any 2xx |
| Unhealthy | Any other status, a timeout, or a transport error |
The response body is ignored, so 200 with an empty body is a perfectly good health check. The result drives the health indicator in the Developer UI and nothing else — an unhealthy satellite is still called.
The 5-second timeout is tight and independent of timeout_ms, so do not put deep dependency checks behind this URL.
The health check is sent unauthenticated
Unlike command and logs requests, health checks carry no Authorization or API key header — only X-Project-Uuid and X-Satellite-Id. Your health URL must therefore be reachable without the shared secret. Do not put it behind the same auth middleware as /jsonrpc, and do not return anything sensitive from it, since anyone who can reach the URL can read the response.
Logs Endpoint
Optional. Configure it as logs_url and the Developer UI can pull logs through the platform.
| Property | Value |
|---|---|
| Method | GET |
| Query | since, an ISO-8601 timestamp. Omitted when the UI has no cursor. |
| Timeout | The template's timeout_ms, defaulting to 30 s |
| Headers | Same tenant and env headers as command calls, plus your auth header |
| Success | Any 2xx |
Return entries newer than since. A JSON body is passed through to the UI untouched. A non-JSON body is wrapped as { "logs": "<trimmed body>" }, so plain text works — it just arrives as one opaque blob rather than structured entries. As with commands, the body is read up to 10 MB.
Reference Implementation
A complete satellite serving the schema from Satellites — commands, resource resolver, health, and logs.
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
const app = express();
app.use(express.json({ limit: '1mb' }));
const SECRET = Buffer.from(process.env.SATELLITE_SECRET ?? '');
function authorized(req) {
const header = req.get('authorization') ?? '';
if (!header.startsWith('Bearer ')) return false;
const got = Buffer.from(header.slice(7));
return got.length === SECRET.length && timingSafeEqual(got, SECRET);
}
// JSON-RPC error helper. `code` follows the standard ranges; `message` is what
// a human sees in the workflow run log, so it carries the real explanation.
function rpcError(id, code, message, data) {
return { jsonrpc: '2.0', id, error: { code, message, ...(data && { data }) } };
}
const handlers = {
'echo': ({ inputs }) => ({ message: inputs.message }),
'math.add': ({ inputs }) => {
const { a, b } = inputs;
if (typeof a !== 'number' || typeof b !== 'number') {
throw new RpcFault(-32602, 'inputs.a and inputs.b must be numbers');
}
return { sum: a + b };
},
'workflow.uppercase': ({ inputs }) => {
const text = inputs.text;
if (typeof text !== 'string') {
throw new RpcFault(-32602, 'inputs.text is required and must be a string');
}
// Keys match the workflow node's declared outputs.
return { text, uppercase: text.toUpperCase(), length: text.length };
},
'resource.resolve': async ({ inputs, tenant }) => {
const record = await lookupByPath(tenant.project_uuid, inputs.path);
if (!record) throw new RpcFault(-32004, `no resource at ${inputs.path}`);
return record; // platform wraps this as { data: record }
},
};
class RpcFault extends Error {
constructor(code, message, data) {
super(message);
this.code = code;
this.data = data;
}
}
app.post('/jsonrpc', async (req, res) => {
if (!authorized(req)) {
return res.status(401).json(rpcError(req.body?.id ?? null, -32600, 'unauthorized'));
}
const { id = null, method, params } = req.body ?? {};
if (typeof method !== 'string' || !params) {
return res.json(rpcError(id, -32600, 'invalid request'));
}
const handler = handlers[method];
if (!handler) return res.json(rpcError(id, -32601, `unknown method: ${method}`));
const { inputs = {}, context = {}, tenant = {}, satellite = {} } = params;
console.log('satellite call', {
request_id: context.request_id,
command: context.command,
method,
env: tenant.env,
project: tenant.project_uuid,
schema_version: satellite.schema_version,
// Absent entirely for unattributed calls — check presence, not truthiness.
caller: context.caller_prn ?? '<anonymous>',
});
try {
const result = await handler({ inputs, context, tenant, satellite });
res.json({ jsonrpc: '2.0', id, result });
} catch (err) {
if (err instanceof RpcFault) {
return res.json(rpcError(id, err.code, err.message, err.data));
}
console.error('satellite handler failed', { request_id: context.request_id, err });
res.json(rpcError(id, -32603, 'internal error'));
}
});
app.get('/health', (_req, res) => res.status(200).end());
app.get('/logs', (req, res) => {
const since = req.query.since ? new Date(String(req.query.since)) : new Date(0);
res.json({ entries: recentLogs().filter((e) => new Date(e.ts) > since) });
});
app.listen(8787);
Implementation Checklist
- Single
POSTendpoint; dispatch onmethod, not oncontext.command. - Reject requests whose bearer token or API key header does not match, using a constant-time comparison.
- Validate
params.inputsyourself — the platform does not. - Scope every data access by
params.tenant.project_uuid. - Branch on
params.tenant.env(orX-Env) ifdevandlivemust behave differently. - Return HTTP
200for both success and JSON-RPC errors — a non-2xx status discards yourerrorobject. - Signal failure with a non-null
error; an omittedresultreads as a successfulnull, not an error. - Serve
health_check_urlwithout auth — health checks carry no secret. - Log
context.request_idon every call so your traces join to platform traces. - Respond well inside
timeout_ms; there are no retries, so hand long work off to a job. - Keep responses under 10 MB — return references, not payloads.
- Treat any key in
params.contextbeyond the platform-set ones as untrusted caller input.