RawtohRawtoh/Docs
Documentation

Create Your Own Module

Build a custom module to connect any tool or service to Rawtoh.

A module is any program that connects to Rawtoh via WebSocket. It can emit events (things that happen) and receive action calls (things to do). If you can write a script that opens a WebSocket, you can build a module.

How a module works

Your module connects to Rawtoh's WebSocket server and communicates using JSON-RPC 2.0. The flow is simple:

  1. Connect — open a WebSocket to the Rawtoh RPC server
  2. Register — prove you hold the module's private key by signing a server-issued challenge
  3. Subscribe — the server tells your module which events it should emit
  4. Emit events — send data to Rawtoh when something happens
  5. Receive calls — Rawtoh calls your module when an action needs something done

Use an SDK

Everything below is already implemented in the official SDKs — enrollment, the challenge/response handshake, reconnection with backoff, and the subscription table behind emit():

use rawtoh_module_sdk::{enroll, Handler, HubConnection, HubOptions, Identity, MethodFuture};
use serde_json::{json, Value};

struct Lights;
impl Handler for Lights {
    fn call(&self, method: &str, params: Value) -> MethodFuture {
        let method = method.to_owned();
        Box::pin(async move {
            match method.as_str() {
                "lights.set_color" => Ok(json!({ "status": "ok" })),
                _ => Err(rawtoh_module_sdk::RpcError::method_not_found(&method)),
            }
        })
    }
    fn subscribe(&self, event: &str) -> MethodFuture<bool> {
        let known = event == "sensor.temperature";
        Box::pin(async move { known })
    }
}

// First run: redeem the one-shot token, persist `identity` (mode 600).
let enrolled = enroll("https://app.rawtoh.io", "rth_e_...").await?;
let identity: Identity = enrolled.identity;

let hub = HubConnection::new(
    HubOptions { url: "wss://rpc.rawtoh.io".into(), identity, label: None },
    Lights,
);
hub.start().await?;  // challenge/response, then reconnects on its own
hub.emit("sensor.temperature", json!({ "celsius": 21.5 }), None);

The rest of this page is the wire protocol the SDKs implement — read it to understand what happens, or to write a module in another language.

Step 1: Enroll your module

Modules authenticate with an Ed25519 key pair they generate themselves — never a secret they're handed. Before your module can connect, bind its public key to an instance:

  1. Go to Module → Definitions and create a new definition (or use an existing one)
  2. Go to Module → Instances and add an instance for it
  3. Copy the enrollment token (rth_e_…) — it's shown only once, works once, and expires after 15 minutes. Headless modules conventionally read it from RAWTOH_ENROLL_TOKEN on first run
  4. In your module, generate an Ed25519 key pair and redeem the token
// → POST <hub>/api/module-enroll  (no authentication — the token is the credential)
{
  "token": "rth_e_kR3vQ8mT...",
  "public_key": "<raw Ed25519 public key, 32 bytes, base64url>"
}

// ← 200
{
  "instance_id": "0f3c...",
  "instance_name": "production",
  "organization_id": "org_...",
  "module_id": "mod_...",
  "module_slug": "my-module"
}

// ← 401 unknown token · 410 already used or expired · 400 bad public key

Persist instance_id and the private key, then forget the token — it is spent. The token carries no hub address: your module is configured with the hub's API and WebSocket URLs separately (RAWTOH_API_URL, RAWTOH_WS_URL in the SDKs).

The private key never leaves your module, and there is no way to recover it if lost. Redeeming a new token (Re-enroll on the instance) replaces the stored public key — the old one is disconnected with close code 4001 and can't reconnect.

Step 2: Connect and register

Open a WebSocket connection to the Rawtoh RPC server. Within 5 seconds, complete a two-step challenge/response: ask for a nonce, then sign it with your private key.

// → Send to server
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "session.challenge",
  "params": { "instance_id": "0f3c..." }
}

// ← Server responds
{ "jsonrpc": "2.0", "id": 1, "result": { "nonce": "kR3v...", "expires_in": 60 } }

// → Sign "rawtoh-module-register:v1\n<instance_id>\n<nonce>" with your Ed25519
//   private key, base64url-encode it, and send:
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "session.register",
  "params": { "instance_id": "0f3c...", "signature": "<base64url signature>" }
}

// ← Server responds
{ "jsonrpc": "2.0", "id": 2, "result": true }

If the signature is invalid, the server returns an error and closes the connection. If you don't complete registration within 5 seconds, the connection is also closed. A nonce is good for one attempt only — a failed register needs a fresh session.challenge.

Step 3: Handle subscribe calls

Right after registration, the server tells your module which events it needs. For each event, it sends a request like this:

// ← Server asks your module to subscribe to an event
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "event.subscribe",
  "params": ["chat.message"]
}

// → Your module responds with a subscription ID (any unique string)
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": "sub_abc123"
}

Rules:

Step 4: Emit events

When something happens (a chat message, a button click, a sensor reading — anything), send a notification to the server. Notifications have no id field — they're fire-and-forget.

// → Send to server (notification — no "id" field)
{
  "jsonrpc": "2.0",
  "method": "event.subscription",
  "params": {
    "subscription": "sub_abc123",
    "result": {
      "user": "alice",
      "message": "hello world"
    }
  }
}

Events are paced per organization (1,000 per second by default, shared by all its modules). Over the cap, the hub simply holds the event until the next second — nothing is refused or dropped.

Step 5: Expose methods (optional)

If you want actions to be able to call your module (e.g. module("my-tool").request("do.something", params)), your module needs to handle incoming JSON-RPC requests:

// ← Server sends a request to your module
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "lights.set_color",
  "params": { "color": "#ff0000" }
}

// → Your module responds
{
  "jsonrpc": "2.0",
  "id": 42,
  "result": { "status": "ok" }
}

You define the method names and params — it's your API. Users will be able to call them from action scripts.

Required methods

Your module must handle these 3 methods from the server:

MethodWhat you returnPurpose
ping"pong"Heartbeat check
event.subscribesubscription ID or nullStart emitting an event
event.unsubscribetrueStop emitting an event

The manifest

To make your module's events and methods appear in Rawtoh's script editor (autocomplete, documentation), you can provide a manifest — an OpenRPC 1.3.2 document describing what your module can do. Set it on the module definition in Module → Definitions.

OpenRPC is to JSON-RPC what OpenAPI is to REST, so the methods half is standard and works with off-the-shelf OpenRPC tooling. The events your module emits go under x-events — explained just below.

{
  "openrpc": "1.3.2",
  "info": { "title": "My Module", "version": "1.0.0" },
  "methods": [
    {
      "name": "lights.set_color",
      "summary": "Set the light color",
      "params": [
        {
          "name": "color",
          "summary": "CSS color value",
          "required": true,
          "schema": { "type": "string" }
        }
      ],
      "result": {
        "name": "result",
        "schema": {
          "type": "object",
          "properties": { "status": { "type": "string" } }
        }
      },
      "paramStructure": "by-name"
    }
  ],
  "x-events": [
    {
      "name": "sensor.temperature",
      "summary": "Temperature reading from sensor",
      "payload": {
        "type": "object",
        "properties": { "celsius": { "type": "number" } }
      }
    }
  ]
}

Three things to get right:

How you produce the document is entirely up to you: generate it from your types, from your schemas, or write it by hand. Only the document is the contract.

One caveat on schemas: Rawtoh turns them into TypeScript declarations for the script editor, and understands type, properties, required, items and enum. Anything else — $ref, oneOf, allOf, format — is accepted, but shows up as unknown in the editor. Inline your types instead of referencing components/schemas, and your users get real autocomplete.

The manifest is optional — your module will work without it. But it makes the experience much better for anyone writing automations.

Why events live under x-events

OpenRPC only describes request/response: someone calls a method, the method returns a result. That covers everything Rawtoh calls on your module, but not the other direction — your module pushing something to Rawtoh on its own initiative, which is what an event is. The spec has no object for that, so there is nothing standard to fill in.

Rather than bend methods into a shape they don't fit, events go in a specification extension. OpenRPC explicitly reserves any field starting with x- for exactly this: a compliant tool ignores what it doesn't recognise. So your document stays a valid OpenRPC document — you can still run it through an OpenRPC validator, generator or playground — and Rawtoh reads the extra part it knows about.

Because it's our extension and not the spec, we keep it plain: payload is a bare JSON Schema, with none of the Content Descriptor wrapping that result requires.

What you declare is a promise you have to keep. An x-events entry is the declaration of the protocol you already implemented in Steps 3 and 4:

That payload is what users receive as event.payload in their scripts, and your schema is the only reason they get autocomplete on it:

// In an action script, typed from your x-events payload schema
log(`It is ${event.payload.celsius}°C`);

Reconnection

Your module should reconnect automatically when the connection drops. But check the close code first:

Close codeWhat to do
4000Don't reconnect. The user disconnected the module on purpose.
4001Don't reconnect. The instance was re-enrolled with a new key — the current private key is invalid.
Anything elseReconnect with exponential backoff: 1s → 2s → 4s → 8s → … up to 64s. Reset on successful registration.

After reconnecting, the server starts a fresh subscribe phase. Discard any old subscription IDs.

Naming convention

All event and method names use dot notation:

Summary

To build a module, your program needs to:

  1. Redeem an enrollment token once at POST /api/module-enroll and keep its private key
  2. Open a WebSocket to the Rawtoh RPC server
  3. Complete session.challenge + session.register within 5 seconds
  4. Handle ping, event.subscribe, and event.unsubscribe
  5. Emit events via event.subscription notifications
  6. Optionally handle custom method calls from actions
  7. Reconnect on disconnect (respecting close codes)

That's it. Your module can be written in any language — JavaScript, Python, Rust, Go — anything that supports WebSocket, JSON and Ed25519. In TypeScript or Rust, the SDKs above do all of it for you.