# Snek RPC Protocol API Specification
retoor <retoor@molodetz.nl>
Version 1.0 — February 2026
---
## Table of Contents
1. [Protocol Overview](#1-protocol-overview)
2. [Connection](#2-connection)
3. [Request/Response Format](#3-requestresponse-format)
4. [Authentication](#4-authentication)
5. [Channel Operations](#5-channel-operations)
6. [Messaging](#6-messaging)
7. [Events](#7-events)
8. [Visual Feedback](#8-visual-feedback)
9. [Message Routing](#9-message-routing)
10. [Streaming](#10-streaming)
11. [Image Handling](#11-image-handling)
12. [Tool System](#12-tool-system)
13. [Error Handling](#13-error-handling)
14. [Constants Reference](#14-constants-reference)
---
## 1. Protocol Overview
The Snek RPC protocol is a JSON-based remote procedure call protocol transported over WebSocket. Clients send method invocations as JSON objects and receive corresponding responses matched by a unique call identifier. The server also pushes unsolicited events (messages, typing indicators, joins/leaves) over the same connection.
Key characteristics:
- **Transport**: WebSocket (RFC 6455)
- **Encoding**: JSON over WebSocket text frames
- **Concurrency model**: Multiplexed — multiple outstanding calls share one connection, matched by `callId`
- **Direction**: Bidirectional — client sends requests, server sends responses and pushes events
---
## 2. Connection
### Endpoint
```
wss://snek.molodetz.nl/rpc.ws
```
Plaintext variant (development only):
```
ws://snek.molodetz.nl/rpc.ws
```
### WebSocket Parameters
| Parameter | Value | Description |
|-----------|-------|-------------|
| Heartbeat interval | 30 seconds | WebSocket ping/pong keepalive (`WS_HEARTBEAT`) |
| Request timeout | 190 seconds | Maximum wait for an RPC response (`DEFAULT_REQUEST_TIMEOUT`) |
| Receive retry delay | 1.0 seconds | Delay before retrying after a receive error (`WS_RECEIVE_RETRY_DELAY`) |
### Connection Lifecycle
```
1. Open WebSocket to wss://snek.molodetz.nl/rpc.ws
2. Call login(username, password)
3. Call get_user(null) to retrieve authenticated user info
4. Call get_channels() to enumerate available channels
5. Enter receive loop — process events and messages
6. On disconnect — reconnect with exponential backoff
```
### Reconnection Strategy
| Parameter | Value |
|-----------|-------|
| Max retries | 3 (`RECONNECT_MAX_RETRIES`) |
| Initial delay | 1.0 seconds (`RECONNECT_INITIAL_DELAY`) |
| Backoff factor | 2.0x (`RECONNECT_BACKOFF_FACTOR`) |
Delays follow: 1s → 2s → 4s. After exhausting retries the client should re-establish the full connection from step 1.
---
## 3. Request/Response Format
### Client Request
Every RPC call from client to server uses the following JSON structure:
```json
{
"method": "<method_name>",
"args": [<positional_arg_1>, <positional_arg_2>, ...],
"kwargs": {<keyword_args>},
"callId": "<unique_identifier>"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `method` | string | yes | RPC method name |
| `args` | array | yes | Positional arguments (may be empty `[]`) |
| `kwargs` | object | yes | Keyword arguments (may be empty `{}`) |
| `callId` | string | yes | Client-generated unique ID to correlate response. Recommended: 16-character hex string (e.g., `os.urandom(8).hex()`) or UUID v4. |
### Server Response
The server responds with a JSON object containing the same `callId`:
```json
{
"callId": "<matching_call_id>",
"data": <result_value>
}
```
| Field | Type | Description |
|-------|------|-------------|
| `callId` | string | Matches the request `callId` |
| `data` | any | Return value — object, array, string, or null depending on the method |
Additional top-level fields may be present depending on context (e.g., `event`, `message`, `username`).
### Fire-and-Forget Requests
Some calls do not require a response. The client sends the request normally but does not wait for a matching `callId` response. This is a client-side optimization — the server may still send a response.
---
## 4. Authentication
### login
Authenticate with the Snek platform.
**Request:**
```json
{
"method": "login",
"args": ["<username>", "<password>"],
"kwargs": {},
"callId": "<id>"
}
```
**Response:**
```json
{
"callId": "<id>",
"data": { ... }
}
```
Must be the first call after establishing the WebSocket connection.
### get_user
Retrieve user information.
**Request:**
```json
{
"method": "get_user",
"args": [null],
"kwargs": {},
"callId": "<id>"
}
```
Pass `null` as the sole argument to retrieve the authenticated user.
**Response:**
```json
{
"callId": "<id>",
"data": {
"username": "botname",
"nick": "BotNick"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | Login username (unique identifier) |
| `nick` | string | Display name |
---
## 5. Channel Operations
### get_channels
Retrieve all channels visible to the authenticated user.
**Request:**
```json
{
"method": "get_channels",
"args": [],
"kwargs": {},
"callId": "<id>"
}
```
**Response:**
```json
{
"callId": "<id>",
"data": [
{
"uid": "channel_unique_id",
"name": "Channel Name",
"tag": "dm"
},
...
]
}
```
### Channel Object
| Field | Type | Description |
|-------|------|-------------|
| `uid` | string | Unique channel identifier. Matches pattern `^[a-zA-Z0-9_-]+$` |
| `name` | string | Human-readable channel name |
| `tag` | string | Channel type. `"dm"` indicates a direct message channel |
### Channel Types
| Tag | Behavior |
|-----|----------|
| `"dm"` | Direct message — bot always responds to messages |
| other / none | Public channel — bot responds only if explicitly joined or triggered |
---
## 6. Messaging
### send_message
Send a message to a channel.
**Request:**
```json
{
"method": "send_message",
"args": ["<channel_uid>", "<message_text>", <is_final>],
"kwargs": {},
"callId": "<id>"
}
```
| Argument | Type | Required | Description |
|----------|------|----------|-------------|
| `channel_uid` | string | yes | Target channel UID |
| `message_text` | string | yes | Message content (Markdown supported) |
| `is_final` | boolean | yes | `true` for complete messages, `false` for streaming partial updates |
**Response:**
```json
{
"callId": "<id>",
"data": { ... }
}
```
### Drive URL Rewriting
Relative drive paths are rewritten before processing:
```
(/drive.bin → (https://snek.molodetz.nl/drive.bin
```
---
## 7. Events
The server pushes unsolicited events over the WebSocket connection. Events do not have a `callId` matching any pending request.
### Event Structure
```json
{
"event": "<event_type>",
"data": { ... }
}
```
### Message Event
Messages appear as a special case — they carry top-level fields instead of being nested under `data`:
```json
{
"event": "message",
"message": "Hello world",
"username": "sender_username",
"user_nick": "SenderNick",
"channel_uid": "target_channel_uid",
"is_final": true
}
```
| Field | Type | Description |
|-------|------|-------------|
| `event` | string | `"message"` |
| `message` | string | Message content |
| `username` | string | Sender's username |
| `user_nick` | string | Sender's display name |
| `channel_uid` | string | Channel where the message was sent |
| `is_final` | boolean | `true` for complete messages, `false` for streaming updates |
Clients should ignore messages where `is_final` is `false` unless implementing a streaming display.
### set_typing Event
Server-pushed event indicating a user is typing or providing visual feedback.
```json
{
"event": "set_typing",
"data": { ... }
}
```
### join / leave Events
```json
{
"event": "join",
"data": {
"channel_uid": "<channel_uid>"
}
}
```
```json
{
"event": "leave",
"data": {
"channel_uid": "<channel_uid>"
}
}
```
### Event Handling Pattern
Events are dispatched to handler methods named `on_<event_type>`. The `data` object is unpacked as keyword arguments:
```
Event: {"event": "join", "data": {"channel_uid": "abc123"}}
Handler: on_join(channel_uid="abc123")
```
Unknown events are silently ignored.
---
## 8. Visual Feedback
### set_typing
Set a colored typing indicator for the bot in a channel. Used for visual "thinking" feedback.
**Request:**
```json
{
"method": "set_typing",
"args": ["<channel_uid>", "<html_color_code>"],
"kwargs": {},
"callId": "<id>"
}
```
| Argument | Type | Description |
|----------|------|-------------|
| `channel_uid` | string | Target channel |
| `html_color_code` | string | HTML hex color code (e.g., `"#FF0000"`) |
### Color Generation
Random bright colors are generated using HSL with these ranges:
| Component | Min | Max |
|-----------|-----|-----|
| Hue | 0.0 | 1.0 |
| Saturation | 0.7 | 1.0 |
| Lightness | 0.5 | 0.7 |
The color must match the pattern `^#[0-9A-Fa-f]{6}$`.
---
## 9. Message Routing
Incoming messages are classified and routed in the following priority order:
| Priority | Condition | Handler |
|----------|-----------|---------|
| 1 | `username == self.username` | `on_own_message(channel_uid, message)` |
| 2 | Message starts with `"ping"` | `on_ping(username, user_nick, channel_uid, message_after_ping)` |
| 3 | Message contains `@nick join` or `@username join` | `on_join(channel_uid)` |
| 4 | Message contains `@nick leave` or `@username leave` | `on_leave(channel_uid)` |
| 5 | Message contains `@nick` or `@username` | `on_mention(username, user_nick, channel_uid, message)` |
| 6 | Default | `on_message(username, user_nick, channel_uid, message)` |
### Channel Permission Rules
For `on_message` processing, the bot applies these rules:
1. **DM channels** (`tag == "dm"`): Always respond.
2. **Public channels**: Respond only if:
- The bot has been explicitly joined to the channel, OR
- The message matches a configured trigger pattern, OR
- The bot's username appears in the message text.
### Ping/Pong
Messages starting with `"ping"` trigger an automatic `"pong"` response:
```
Incoming: "ping hello"
Response: "pong hello"
```
---
## 10. Streaming
The protocol supports incremental message delivery using the `is_final` field on `send_message`.
### Sending Streaming Messages
```
1. send_message(channel_uid, "Partial cont...", false) ← partial update
2. send_message(channel_uid, "Partial content h...", false) ← partial update
3. send_message(channel_uid, "Partial content here.", true) ← final message
```
Each partial message replaces the previous one in the UI. The final message (`is_final=true`) marks the message as complete.
### Receiving Streaming Messages
When receiving events, messages with `is_final=false` represent in-progress content from another user or bot. Standard bot implementations skip non-final messages and only process the final version:
```
if not data.is_final:
continue
```
### Streaming Update Interval
The minimum interval between streaming updates is `0.0` seconds (`STREAMING_UPDATE_INTERVAL`), meaning updates are sent as fast as they are generated.
---
## 11. Image Handling
### URL Extraction
Image URLs are extracted from message text using the pattern:
```
https?://\S+\.(?:png|jpg|jpeg|gif|bmp|webp|svg)(?:\?\S*)?
```
Matched URLs are stripped of trailing `.`, `'`, and `"` characters.
### Image Formats
Two encoding formats are supported:
#### OpenAI Format (default)
Images are sent as separate content blocks in the OpenAI multi-modal message format:
```json
{
"role": "user",
"content": [
{
"type": "text",
"text": "message text with URL removed"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,<base64_encoded_data>"
}
}
]
}
```
#### DeepSeek Format
Images are inlined into the text content as tagged references:
```json
{
"role": "user",
"content": [
{
"type": "text",
"text": "message with [image: data:image/png;base64,<base64_data>] inline"
}
]
}
```
### MIME Type Detection
MIME types are resolved in order:
1. File extension via `mimetypes.guess_type()`
2. Magic bytes detection:
| Bytes | MIME Type |
|-------|-----------|
| `\x89PNG\r\n\x1a\n` | `image/png` |
| `\xff\xd8` | `image/jpeg` |
| `GIF87a` or `GIF89a` | `image/gif` |
| `RIFF....WEBP` | `image/webp` |
3. Fallback: `image/png`
---
## 12. Tool System
The tool system follows the OpenAI function calling specification. Tools are serialized as function definitions, sent alongside the chat completion request, and executed when the LLM returns `tool_calls`.
### Tool Definition Schema
Each tool is serialized as:
```json
{
"type": "function",
"function": {
"name": "<method_name>",
"description": "<docstring>",
"parameters": {
"type": "object",
"properties": {
"<param_name>": {
"type": "<json_type>",
"default": "<default_value>"
}
},
"required": ["<param_without_default>"]
}
}
}
```
### Type Mapping
| Python Type | JSON Schema Type |
|-------------|-----------------|
| `str` | `"string"` |
| `int` | `"integer"` |
| `bool` | `"boolean"` |
| `list` | `"array"` |
| `dict` | `"object"` |
| `None` | `"null"` |
| (default) | `"string"` |
Parameters with default values are optional; parameters without defaults are listed in `required`.
### Tool Call Flow
```
1. Client sends chat completion request with tools array
2. LLM returns response with tool_calls array
3. Client executes each tool call
4. Client sends tool results back as role:"tool" messages
5. Client sends another chat completion request with updated context
6. Repeat until LLM returns a text response without tool_calls
```
### Tool Call Response (from LLM)
```json
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "database_find",
"arguments": "{\"table\": \"users\", \"query\": {\"active\": true}}"
}
}
]
}
```
### Tool Result Message
```json
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "<stringified_result>"
}
```
### Tool Call Limits
| Parameter | Value | Description |
|-----------|-------|-------------|
| Max depth | 25 | Maximum consecutive tool call rounds (`MAX_TOOL_CALL_DEPTH`) |
| Max repeated calls | 5 | Maximum identical tool invocations (`MAX_REPEATED_TOOL_CALLS`) |
| Max consecutive errors | 3 | Abort tool loop after 3 consecutive errors |
### Method Exclusions
Methods are excluded from tool serialization if:
- The name starts with `_`
- The name is `serialize` or `handle`
- The attribute is not callable
---
## 13. Error Handling
### Exception Hierarchy
```
BotError
├── ConnectionError Connection/WebSocket failures
├── AuthenticationError Login failures
├── RPCError RPC call failures, timeouts
├── ToolError Tool execution failures
├── DatabaseError Database operation failures
├── ValidationError Input validation failures
└── CircuitBreakerOpenError Circuit breaker tripped
```
### RPC Error Conditions
| Condition | Behavior |
|-----------|----------|
| WebSocket closed/closing | `RPCError` raised |
| WebSocket error frame | `RPCError` raised |
| Response timeout (190s) | `RPCError` raised |
| Invalid JSON in response | Logged as warning, skipped |
| Connection closed during call | `RPCError` raised |
### Reconnection on Send Failure
When `send_message` fails, the client retries with exponential backoff:
```
Attempt 1: send → fail → wait 1s
Attempt 2: reconnect + send → fail → wait 2s
Attempt 3: reconnect + send → fail → raise ConnectionError
```
Each reconnection attempt performs a full login sequence on a new WebSocket.
### Circuit Breaker
Tool execution is protected by a circuit breaker pattern:
| Parameter | Value |
|-----------|-------|
| Failure threshold | 5 consecutive failures |
| Recovery timeout | 60 seconds |
| Half-open max calls | 3 |
States: `CLOSED` → (threshold exceeded) → `OPEN` → (timeout elapsed) → `HALF_OPEN` → (success) → `CLOSED`
When the circuit breaker is open, tool calls fail immediately with `CircuitBreakerOpenError`.
---
## 14. Constants Reference
### Connection
| Constant | Value | Description |
|----------|-------|-------------|
| `DEFAULT_WS_URL` | `wss://snek.molodetz.nl/rpc.ws` | Default WebSocket endpoint |
| `DEFAULT_OPENAI_URL` | `https://api.openai.com` | Default LLM API base URL |
| `DEFAULT_SEARCH_API_BASE` | `https://search.molodetz.nl` | Default search API endpoint |
| `WS_HEARTBEAT` | 30.0s | WebSocket heartbeat interval |
| `DEFAULT_REQUEST_TIMEOUT` | 190s | RPC call timeout |
| `HTTP_CONNECT_TIMEOUT` | 90.0s | HTTP TCP connect timeout |
### Reconnection
| Constant | Value | Description |
|----------|-------|-------------|
| `RECONNECT_MAX_RETRIES` | 3 | Maximum reconnection attempts |
| `RECONNECT_INITIAL_DELAY` | 1.0s | First retry delay |
| `RECONNECT_BACKOFF_FACTOR` | 2.0 | Delay multiplier per retry |
| `WS_RECEIVE_RETRY_DELAY` | 1.0s | Delay after receive error |
### Concurrency
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_CONCURRENT_OPERATIONS` | 100 | WebSocket semaphore limit |
| `MAX_CONCURRENT_REQUESTS` | 10 | HTTP connection pool limit |
| `THREAD_POOL_WORKERS` | 8 | Thread pool for blocking operations |
| `PROCESS_POOL_WORKERS` | 4 | Process pool for CPU-intensive tasks |
| `EXECUTOR_TIMEOUT` | 30.0s | Thread/process pool call timeout |
### Message Handling
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_MESSAGE_HISTORY` | 50 | Recent messages retained in memory |
| `DEFAULT_OPENAI_LIMIT` | 200 | Max messages sent to LLM per session |
| `CONTEXT_WINDOW_TOKEN_LIMIT` | 120000 | Token budget for LLM context |
| `STREAMING_UPDATE_INTERVAL` | 0.0s | Min interval between streaming updates |
### Tool Limits
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_TOOL_CALL_DEPTH` | 25 | Max consecutive tool call rounds |
| `MAX_REPEATED_TOOL_CALLS` | 5 | Max identical tool invocations |
| `MAX_RETRY_ATTEMPTS` | 3 | General retry limit |
### Timing
| Constant | Value | Description |
|----------|-------|-------------|
| `TASK_CHECK_INTERVAL` | 0.5s | Service loop tick interval |
| `THINKING_TASK_INTERVAL` | 1.0s | Typing indicator refresh interval |
| `SERVICE_CLEANUP_INTERVAL` | 3600s | Periodic cleanup cycle |
| `SHUTDOWN_TIMEOUT` | 5.0s | Max wait for graceful shutdown |
| `MIN_COUNTER_UPDATE_INTERVAL` | 240s | Debounce for counter updates |
### Agentic
| Constant | Value | Description |
|----------|-------|-------------|
| `AGENTIC_MAX_RESEARCH_QUERIES` | 8 | Max search queries per research task |
| `AGENTIC_MAX_PLAN_STEPS` | 10 | Max steps in a generated plan |
| `AGENTIC_SCRATCHPAD_SIZE` | 50 | Scratchpad buffer size |
### Visual
| Constant | Value | Description |
|----------|-------|-------------|
| `COLOR_HUE_MIN` | 0.0 | HSL hue range start |
| `COLOR_HUE_MAX` | 1.0 | HSL hue range end |
| `COLOR_SATURATION_MIN` | 0.7 | HSL saturation range start |
| `COLOR_SATURATION_MAX` | 1.0 | HSL saturation range end |
| `COLOR_LIGHTNESS_MIN` | 0.5 | HSL lightness range start |
| `COLOR_LIGHTNESS_MAX` | 0.7 | HSL lightness range end |
### Media
| Constant | Value | Description |
|----------|-------|-------------|
| `MEDIA_HUNT_DEDUP_MAX_SIZE` | 10000 | Dedup cache max entries |
| `MEDIA_HUNT_DEDUP_TTL` | 86400s | Dedup cache TTL (24 hours) |
| `MEDIA_HUNT_HEAD_TIMEOUT` | 5.0s | HEAD request timeout |
| `MEDIA_HUNT_FETCH_TIMEOUT` | 30.0s | Full fetch timeout |
### Bot Name Sanitization
The following bot names are sanitized in outgoing messages to prevent cross-triggering:
```
snek, grok, snik, lisa, gemma, joanne, ira, thomas
```
Sanitization inserts a hyphen after the first character (e.g., `snek``s-nek`).
---
## Appendix A: Complete Connection Example
```
CLIENT → WebSocket CONNECT wss://snek.molodetz.nl/rpc.ws
SERVER ← 101 Switching Protocols
CLIENT → {"method":"login","args":["mybot","mypassword"],"kwargs":{},"callId":"a1b2c3d4e5f6g7h8"}
SERVER ← {"callId":"a1b2c3d4e5f6g7h8","data":{...}}
CLIENT → {"method":"get_user","args":[null],"kwargs":{},"callId":"b2c3d4e5f6g7h8a1"}
SERVER ← {"callId":"b2c3d4e5f6g7h8a1","data":{"username":"mybot","nick":"MyBot"}}
CLIENT → {"method":"get_channels","args":[],"kwargs":{},"callId":"c3d4e5f6g7h8a1b2"}
SERVER ← {"callId":"c3d4e5f6g7h8a1b2","data":[{"uid":"ch1","name":"general","tag":"public"},{"uid":"ch2","name":"DM","tag":"dm"}]}
--- receive loop ---
SERVER ← {"event":"message","message":"hello @MyBot","username":"alice","user_nick":"Alice","channel_uid":"ch1","is_final":true}
CLIENT → {"method":"send_message","args":["ch1","hey, what's up?",true],"kwargs":{},"callId":"d4e5f6g7h8a1b2c3"}
SERVER ← {"callId":"d4e5f6g7h8a1b2c3","data":{...}}
```
## Appendix B: Streaming Example
```
CLIENT → {"method":"set_typing","args":["ch1","#FF6B35"],"kwargs":{},"callId":"e5f6g7h8a1b2c3d4"}
CLIENT → {"method":"send_message","args":["ch1","Working on",false],"kwargs":{},"callId":"f6g7h8a1b2c3d4e5"}
CLIENT → {"method":"send_message","args":["ch1","Working on it...",false],"kwargs":{},"callId":"g7h8a1b2c3d4e5f6"}
CLIENT → {"method":"send_message","args":["ch1","Working on it... done!",true],"kwargs":{},"callId":"h8a1b2c3d4e5f6g7"}
```
## Appendix C: Bot State Machine
```
INITIALIZING → INITIALIZED → CONNECTING → CONNECTED → RUNNING → SHUTTING_DOWN → SHUTDOWN
ERROR
```
| State | Description |
|-------|-------------|
| `INITIALIZING` | HTTP sessions, signal handlers, and background tasks being set up |
| `INITIALIZED` | Setup complete, ready to connect |
| `CONNECTING` | WebSocket connection in progress |
| `CONNECTED` | Authenticated and channels enumerated |
| `RUNNING` | Processing messages in the receive loop |
| `SHUTTING_DOWN` | Graceful shutdown initiated, cancelling background tasks |
| `SHUTDOWN` | All resources released |
| `ERROR` | Initialization or fatal runtime failure |