WebSocket Protocol¶
The WebSocket tunnel connects Gremia Shell to Gremia Cloud for bidirectional, real-time communication. All messages are encrypted with AES-256-GCM after the initial key exchange.
Endpoint¶
Connection Lifecycle¶
sequenceDiagram
participant S as Shell
participant C as Cloud
Note over S,C: 1. WebSocket Handshake
S->>C: Upgrade request + JWT in Sec-WebSocket-Protocol
C->>C: Validate JWT
C-->>S: 101 Switching Protocols
Note over S,C: 2. Authentication
S->>C: {"type":"auth","shell_id":"..."}
C->>C: Bind shell_id to user
Note over S,C: 3. Key Exchange
C->>S: {"type":"key_exchange","session_key":"base64..."}
S->>S: Store session key
Note over S,C: 4. Encrypted Communication
S->>C: EncryptedEnvelope (task, tool_result, heartbeat)
C->>S: EncryptedEnvelope (tool_call, task_response, heartbeat)
Authentication¶
JWT is sent via the Sec-WebSocket-Protocol header (not a query parameter, to avoid logging):
The Cloud validates the JWT and echoes the subprotocol back during the 101 response.
After the WebSocket is accepted, the Shell must send an auth message with its shell_id within 10 seconds:
If the auth message is not received within the timeout, the connection is closed.
Client Certificate Validation¶
If mTLS is configured (via Nginx), the X-Client-Cert header is checked:
- Extract the certificate serial number
- Check against the revocation list in the database
- Reject if revoked; allow if valid or no cert present
mTLS is optional — connections without client certificates are allowed but may have reduced access.
Key Exchange¶
After successful authentication, the Cloud initiates a session key exchange:
The Shell stores this key and uses it for all subsequent message encryption/decryption. The key is ephemeral and discarded when the connection closes.
Encrypted Envelope¶
After key exchange, all messages are wrapped in an encrypted envelope:
Encryption process:
- Serialize the message to JSON
- Generate a random 12-byte nonce
- Encrypt with AES-256-GCM using the session key
- Base64-encode both nonce and ciphertext
Decryption process:
- Parse the envelope JSON
- Base64-decode the
ivandciphertext - Decrypt with AES-256-GCM using the session key
- Parse the resulting JSON as a tunnel message
Message Types¶
Shell to Cloud¶
auth¶
Initial authentication message sent after WebSocket handshake.
task¶
Submit a new task for execution.
{
"type": "task",
"shell_id": "shell-uuid",
"payload": {
"id": "task-uuid",
"content": "Generate the weekly sales report",
"manifest_id": "manifest-uuid"
}
}
tool_result¶
Return the result of a tool execution.
{
"type": "tool_result",
"shell_id": "shell-uuid",
"correlation_id": "corr-uuid",
"payload": {
"success": true,
"output": "{\"rows\": 42}",
"duration_ms": 150
}
}
| Field | Description |
|---|---|
correlation_id |
Matches the correlation_id from the tool_call message |
success |
Whether the tool executed successfully |
output |
Tool output (JSON string) |
error |
Error message if success is false |
duration_ms |
Execution time in milliseconds |
heartbeat¶
Keep-alive ping from the Shell.
Cloud to Shell¶
heartbeat¶
Keep-alive ping from the Cloud. Sent every 30 seconds.
tool_call¶
Request the Shell to execute a tool on a local MCP server.
{
"type": "tool_call",
"id": "call-uuid",
"execution_id": "exec-uuid",
"server_id": "google-sheets",
"tool_name": "read_range",
"arguments": {
"spreadsheet_id": "abc123",
"range": "Sales!A1:D100"
},
"requires_approval": false,
"correlation_id": "corr-uuid",
"timestamp": "2026-02-15T10:00:00Z"
}
| Field | Description |
|---|---|
server_id |
MCP server ID from the manifest |
tool_name |
Tool to invoke via JSON-RPC tools/call |
arguments |
Tool arguments (JSON object) |
requires_approval |
If true, Shell must get user consent before executing |
correlation_id |
Used to match with the corresponding tool_result |
task_response¶
Streamed response from agent execution.
{
"type": "task_response",
"id": "task-uuid",
"execution_id": "exec-uuid",
"content": "{\"output\": \"Report generated successfully\"}",
"is_final": true,
"agent_id": "report-gen",
"event_type": "complete",
"timestamp": "2026-02-15T10:00:10Z"
}
| Field | Description |
|---|---|
is_final |
If true, this is the last message for this task |
event_type |
step, complete, or error |
agent_id |
Which agent produced this response |
key_exchange¶
Session key delivery (sent once after auth).
error¶
Error notification from the Cloud.
Heartbeat Protocol¶
The Cloud sends heartbeat messages every 30 seconds. If the Shell does not receive a heartbeat for 90 seconds, it should consider the connection lost and attempt reconnection.
The Shell may also send heartbeats, which the Cloud acknowledges silently.
Reconnection¶
When the connection drops, the Shell reconnects with exponential backoff:
Attempt 1: wait 2s → reconnect
Attempt 2: wait 4s → reconnect
Attempt 3: wait 8s → reconnect
...
Attempt N: wait min(2^N, 64)s → reconnect
Attempt 10: give up
On reconnection:
- The full handshake repeats (JWT auth + shell_id + key exchange)
- A new session key is generated
- In-flight tool calls from the previous session are lost
Rate Limiting¶
| Limit | Value |
|---|---|
| Max connections per IP | 5 per minute |
| Exceeded action | WebSocket close code 4029 |
Close Codes¶
| Code | Meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (server shutdown) |
| 4001 | Authentication failed |
| 4029 | Rate limit exceeded |
Example: Full Session¶
import asyncio
import json
import websockets
async def connect():
uri = "wss://api.gremia.io/api/v1/tunnel/ws"
token = "eyJhbG..."
async with websockets.connect(
uri,
subprotocols=[f"access_token.{token}"]
) as ws:
# 1. Send auth
await ws.send(json.dumps({
"type": "auth",
"shell_id": "my-shell-001"
}))
# 2. Receive key exchange
msg = json.loads(await ws.recv())
assert msg["type"] == "key_exchange"
session_key = base64.b64decode(msg["session_key"])
# 3. Send a task (would be encrypted in production)
await ws.send(json.dumps({
"type": "task",
"shell_id": "my-shell-001",
"payload": {
"id": "task-001",
"content": "Hello, agents!",
"manifest_id": "manifest-001"
}
}))
# 4. Receive responses
async for message in ws:
data = json.loads(message)
print(data)
if data.get("is_final"):
break