docs/channel-adapters-guide.md

Channel adapters

Adapter model for HTTP and additional channels.

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 jobGateway endpoint
Create or reuse a conversationPOST /api/sessions
Submit user textPOST /api/sessions/:id/messages
Stream events back outGET /api/sessions/:id/events
Add context to active workPOST /api/sessions/:id/context
Stop active workPOST /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_TOKEN to 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 threadId as the Randal threadId when possible.
  • Include from so Randal jobs carry useful origin metadata.
  • Do not call POST /job for 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/sessions returns the existing session, continue normally.
  • If POST /api/sessions/:id/messages returns 400, 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 503 for 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_TOKEN server-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.