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:
- Connect — open a WebSocket to the Rawtoh RPC server
- Register — prove you hold the module's private key by signing a server-issued challenge
- Subscribe — the server tells your module which events it should emit
- Emit events — send data to Rawtoh when something happens
- Receive calls — Rawtoh calls your module when an action needs something done
Step 1: Enroll your module
Modules authenticate with an Ed25519 key pair they generate themselves — never a token they're handed. Before your module can connect, bind a public key to it:
- Go to Settings → Module Definitions and create a new definition (or use an existing one)
- Go to Settings → Modules and create a new instance
- Copy the enrollment token — it's shown only once and expires after 15 minutes
- In your module, generate an Ed25519 key pair and redeem the token:
POST /api/module-enrollwith{ token, public_key }— the response gives you theinstance_idto use below
Keep the private key — it never leaves your module, and there is no way to recover it if lost; re-enroll with a fresh token instead.
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:
- Return a unique string as the subscription ID — you'll use it later to emit events
- Return
nullif you don't recognize the event name — never return an error - The server may also call
event.unsubscribewhen an event is no longer needed
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"
}
}
}subscription— the ID you generated duringevent.subscriberesult— the event payload. Any JSON. This becomesevent.payloadin triggers and actions.
Events are rate limited to 10 per second per connection. If you exceed this, the event is 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:
| Method | What you return | Purpose |
|---|---|---|
ping | "pong" | Heartbeat check |
event.subscribe | subscription ID or null | Start emitting an event |
event.unsubscribe | true | Stop 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 Settings → 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:
- A result is a Content Descriptor —
{ "name": …, "schema": … }, not a bare schema. That's the spec. - Params are passed by name. Rawtoh always calls your module with a params object, never positionally.
- Every method and event needs a
summary. It's what users read in the module browser, and what the AI assistant reads when it writes automations against your module — the one part no generator can produce for you.
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:
nameis the exact string Rawtoh will pass to yourevent.subscribe. Declare an event and your module must recognise it there — otherwise you returnnull, the subscription never happens, and the user's trigger silently never fires. Conversely, an event you emit but never declare is one nobody can subscribe to.payloaddescribes theresultobject of yourevent.subscriptionnotification — that object only, not the JSON-RPC envelope around it.
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 code | What to do |
|---|---|
4000 | Don't reconnect. The user disconnected the module on purpose. |
4001 | Don't reconnect. The instance was re-enrolled with a new key — the current private key is invalid. |
| Anything else | Reconnect 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:
- Events:
sensor.temperature,door.opened,game.score_changed - Methods:
lights.set_color,display.show_text,motor.move
Summary
To build a module, your program needs to:
- Open a WebSocket to the Rawtoh RPC server
- Complete
session.challenge+session.registerwithin 5 seconds - Handle
ping,event.subscribe, andevent.unsubscribe - Emit events via
event.subscriptionnotifications - Optionally handle custom method calls from actions
- 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 and JSON.