Custom Relay Guide
Bring Randal to any product without shipping a new official adapter. Your relay owns the external product. Randal owns sessions, jobs, controls, and events.
Use this guide when you want messages from a chat product, support queue, incident tool, internal app, or workflow system to reach Randal through the gateway.
The Contract
Run Randal:
randal serve
Configure the HTTP gateway with an auth token:
gateway:
channels:
- type: http
port: 7600
auth: "${RANDAL_API_TOKEN}"
corsOrigin: "https://your-product.example" # optional for browser relays
Your relay calls the session API:
| Relay job | Gateway endpoint |
|---|---|
| Create or reuse a conversation | POST /api/sessions |
| Submit user text | POST /api/sessions/:id/messages |
| Stream events back out | GET /api/sessions/:id/events |
| Add context to active work | POST /api/sessions/:id/context |
| Stop active work | POST /api/sessions/:id/stop |
Lower-level routes such as /job, /jobs, /events, and /messages remain available for operator tooling. For user-facing conversation flows, prefer /api/sessions/* so the relay does not have to manually join thread IDs, job IDs, message history, and event filters.
Relay Responsibilities
A good relay is small and boring:
- Authenticate requests from the external product.
- Map external users and threads to Randal session IDs.
- Normalize inbound text into
POST /api/sessions/:id/messages. - Subscribe to
GET /api/sessions/:id/events. - Render event payloads into the external product's message format.
- Reconnect SSE streams after disconnects.
- Retry safe operations with backoff.
- Store enough mapping state to resume after a relay restart.
- Never expose
RANDAL_API_TOKENto the external product or browser clients.
Session Mapping
Use a stable session ID derived from the external thread/conversation ID. Keep it deterministic and reversible only inside your relay.
function sessionIdForExternalThread(product: string, threadId: string): string {
return `${product}:${threadId}`;
}
Create the session before sending messages. Calling this twice is safe when threadId is the same.
await gateway.post("/api/sessions", {
threadId: sessionIdForExternalThread("product", inbound.threadId),
title: inbound.threadTitle ?? "Randal relay session",
from: inbound.userId,
});
Inbound Relay Loop
This is the core path: receive product input, normalize it, and hand it to Randal.
async function handleInboundMessage(inbound: ProductMessage): Promise<void> {
assertProductSignature(inbound.rawRequest);
if (!inbound.text.trim()) return;
const sessionId = sessionIdForExternalThread("product", inbound.threadId);
await gateway.post("/api/sessions", {
threadId: sessionId,
title: inbound.threadTitle ?? `Thread ${inbound.threadId}`,
from: inbound.userId,
});
const result = await gateway.post(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
content: inbound.text,
from: inbound.userId,
});
await product.send(inbound.threadId, renderAccepted(result));
ensureOutboundStream(sessionId, inbound.threadId);
}
Recommended behavior:
- Return a quick acknowledgement to the product before long work finishes.
- Preserve the external
threadIdas the RandalthreadIdwhen possible. - Include
fromso Randal jobs carry useful origin metadata. - Do not call
POST /jobfor normal conversation flows; let sessions submit jobs.
Outbound Event Loop
The session event stream emits existing runner events filtered to that session's known job IDs.
async function ensureOutboundStream(sessionId: string, productThreadId: string): Promise<void> {
if (activeStreams.has(sessionId)) return;
activeStreams.set(sessionId, true);
while (activeStreams.get(sessionId)) {
try {
for await (const event of gateway.sse(`/api/sessions/${encodeURIComponent(sessionId)}/events`)) {
if (event.type === "ping") continue;
const message = renderEventForProduct(event);
if (message) await product.send(productThreadId, message);
if (["job.complete", "job.failed", "job.stopped"].includes(event.type)) {
activeStreams.delete(sessionId);
break;
}
}
} catch (error) {
await wait(backoff.next(sessionId));
}
}
}
Render the payloads people care about first:
function renderEventForProduct(event: RandalEvent): string | null {
const data = event.data ?? {};
if (event.type === "iteration.output" && data.outputLine) return data.outputLine;
if (event.type === "iteration.tool_use") return `Using tool: ${data.toolName ?? "unknown"}`;
if (event.type === "job.plan_updated") return "Plan updated.";
if (event.type === "job.context_injected") return "Context received.";
if (event.type === "job.complete") return data.summary ?? "Done.";
if (event.type === "job.failed") return `Failed: ${data.error ?? "unknown error"}`;
if (event.type === "job.stopped") return "Stopped.";
if (event.type === "brain.progress" && data.message) return data.message;
if (event.type === "brain.alert" && data.message) return `Alert: ${data.message}`;
return null;
}
Controls
Stop active work:
await gateway.post(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, {});
Inject context into active work:
await gateway.post(`/api/sessions/${encodeURIComponent(sessionId)}/context`, {
text: "The user clarified that the outage only affects EU traffic.",
});
Handle conflicts as normal user-facing states. 409 means the session exists but has no active job for that control action.
try {
await injectContext(sessionId, text);
} catch (error) {
if (error.status === 409) {
await product.send(threadId, "There is no active Randal job to update right now.");
return;
}
throw error;
}
Failure Handling
Design for boring recovery:
- If the external product retries the same inbound event, dedupe by external message ID before calling Randal again.
- If
POST /api/sessionsreturns the existing session, continue normally. - If
POST /api/sessions/:id/messagesreturns400, show a human-readable validation error. - If it returns
401, rotate or repair the relay's gateway token. Do not retry forever. - If it returns
503for message history, Randal is missing message storage; surface that as an operator issue. - If SSE disconnects, reconnect with exponential backoff.
- If the relay restarts, reload external thread to session mappings and reopen streams for active sessions.
Security Checklist
- Store
RANDAL_API_TOKENserver-side only. - Verify external webhook signatures before creating sessions or sending messages.
- Scope CORS to your relay origin when browser code talks to the relay.
- Put production gateways behind TLS.
- Rate limit inbound product events before they reach Randal.
- Log session IDs, external thread IDs, and job IDs, but not secrets or full sensitive transcripts.
Custom Relay Builder Prompt
Use this prompt when asking an agent to build a relay for your product:
Build a custom relay that connects <PRODUCT NAME> to Randal through the HTTP gateway session API.
Randal contract:
- Base URL: <RANDAL_GATEWAY_URL>
- Auth: server-side Bearer token from RANDAL_API_TOKEN
- Create/reuse session: POST /api/sessions with { threadId, title, from }
- Send message: POST /api/sessions/:id/messages with { content, from }
- Stream events: GET /api/sessions/:id/events as SSE
- Inject context: POST /api/sessions/:id/context with { text }
- Stop active work: POST /api/sessions/:id/stop with {}
Product details to fill in:
- Inbound webhook shape:
- Webhook signature/auth model:
- User identifier field:
- Thread/conversation identifier field:
- Message text field:
- Outbound send-message API:
- Formatting constraints, length limits, markdown support:
- Deployment target:
- Persistence available for message dedupe and thread/session mapping:
- Retry and rate-limit expectations:
Implementation requirements:
- Keep product auth and Randal auth separate.
- Never expose RANDAL_API_TOKEN to clients.
- Dedupe inbound product events by external message ID.
- Map external thread IDs to stable Randal session IDs.
- Render iteration.output outputLine, tool-use events, progress, completion, failure, and stopped states.
- Reconnect SSE with exponential backoff.
- Return human-readable errors for 400, 401, 409, and 503 responses.
- Do not implement an official Randal adapter inside the Randal repo unless explicitly requested.
Out Of Scope
This guide does not ask you to add named official adapters to Randal. Keep product-specific SDKs, credentials, formatting, and deployment choices in your relay service unless the project explicitly decides to promote that integration later.