Skip to content

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 context is merged in. Anything the caller passed as context on 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_id and caller_prn vanish 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:

  • result omitted or empty resolves successfully with null. A bare {"jsonrpc":"2.0","id":"..."} is a successful no-op, not an error. If you meant to signal failure, you must send error.
  • id in 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_id only 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 from params.inputs rather than trusting request_id to 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.

GET /logs?since=2026-06-22T05%3A25%3A00.000Z
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 POST endpoint; dispatch on method, not on context.command.
  • Reject requests whose bearer token or API key header does not match, using a constant-time comparison.
  • Validate params.inputs yourself — the platform does not.
  • Scope every data access by params.tenant.project_uuid.
  • Branch on params.tenant.env (or X-Env) if dev and live must behave differently.
  • Return HTTP 200 for both success and JSON-RPC errors — a non-2xx status discards your error object.
  • Signal failure with a non-null error; an omitted result reads as a successful null, not an error.
  • Serve health_check_url without auth — health checks carry no secret.
  • Log context.request_id on 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.context beyond the platform-set ones as untrusted caller input.