Building Relay Adapters
How to build custom adapters that bridge external platforms into Relay
Building Relay Adapters
Relay adapters bridge external communication platforms into DorkOS messaging. Whether you want to connect Slack, Discord, a custom webhook service, or any other channel, implement the RelayAdapter interface and register it with the adapter system.
The RelayAdapter Interface
Every adapter implements four required methods, one optional method, and three readonly properties:
interface RelayAdapter {
readonly id: string;
readonly subjectPrefix: string | readonly string[];
readonly displayName: string;
start(relay: RelayPublisher): Promise<void>;
stop(): Promise<void>;
deliver(
subject: string,
envelope: RelayEnvelope,
context?: AdapterContext
): Promise<DeliveryResult>;
getStatus(): AdapterStatus;
// Optional — streaming delivery
deliverStream?(
subject: string,
threadId: string,
stream: AsyncIterable<string>,
context?: AdapterContext
): Promise<DeliveryResult>;
}The three properties identify the adapter:
- id — A unique string that disambiguates this adapter instance. If you run two Telegram bots, each gets a different
id(e.g.,telegram-support,telegram-alerts). - subjectPrefix — Which Relay subjects this adapter handles. When a message is published to a subject starting with this prefix, Relay routes outbound delivery to your adapter. Can be a single string or an array for adapters that handle multiple prefixes.
- displayName — What appears in the DorkOS UI when showing adapter status.
The four methods handle the adapter lifecycle:
- start() — Connect to the external service, register endpoints, and subscribe to signals. Receives a
RelayPublisherfor publishing inbound messages into the bus. - stop() — Disconnect gracefully and clean up resources.
- deliver() — Handle outbound messages. When any agent or user publishes to a subject matching your prefix, Relay calls this method with the envelope.
- getStatus() — Return a snapshot of your adapter's runtime state for the UI.
- deliverStream() — Optional. When present, the
AdapterStreamManageraggregatestext_deltaevents into anAsyncIterable<string>and calls this method instead of sending per-eventdeliver()calls. This enables real-time streaming to platforms that support progressive message updates.
Adapter Lifecycle
Registration
When the server starts, AdapterManager reads ~/.dork/relay/adapters.json, creates adapter instances for each enabled entry, and calls registry.register(adapter). This triggers start() on your adapter.
Running
Your adapter is active. Inbound messages from the external platform should be published to Relay via RelayPublisher. Outbound messages arrive through deliver(). The adapter status should report connected.
Hot-Reload
When the config file changes on disk, the adapter manager reconciles state. If your adapter's config changed, a new instance is created and registered. The new instance's start() runs before the old instance's stop(), ensuring zero-downtime transitions.
Shutdown
On server shutdown, stop() is called on every running adapter. Drain in-flight messages, close connections, and release resources.
Both start() and stop() must be idempotent. The registry may call start() on an
already-running adapter during hot-reload, or stop() on an already-stopped adapter during
cleanup. Guard against double-initialization by checking connection state before acting.
Inbound Messages
When your adapter receives a message from the external service, normalize the content and publish it to a Relay subject using the RelayPublisher passed to start():
await this.relay.publish(
'relay.human.slack.U12345', // subject
{
// payload
content: 'Deploy the backend',
senderName: 'alice',
channelType: 'dm',
responseContext: {
platform: 'slack',
maxLength: 4000,
supportedFormats: ['text', 'markdown'],
instructions: 'Format responses as Slack markdown.',
},
},
{ from: 'relay.human.slack.bot' } // options
);Subject Conventions
- Human DMs:
relay.human.{platform}.{userId}(e.g.,relay.human.slack.U12345) - Human groups:
relay.human.{platform}.group.{groupId}(e.g.,relay.human.slack.channel.C67890) - Webhooks:
relay.webhook.{adapterId}(e.g.,relay.webhook.github)
The subject determines how Relay routes responses back to your adapter. When an agent replies, the reply goes to the replyTo subject in the original envelope, which typically matches your adapter's prefix.
Payload Structure
Any JSON-serializable payload works, but the StandardPayload structure is recommended for human-facing adapters. It includes content, senderName, channelType, and a responseContext block that tells the receiving agent about platform constraints (message length limits, supported formats, formatting instructions).
Outbound Delivery
When an agent publishes a message to a subject matching your subjectPrefix, Relay calls your deliver() method.
Your deliver() method should:
- Extract the recipient identifier from the subject (e.g., parse the user ID from
relay.human.slack.U12345) - Format the message content for your platform
- Send the message through your platform's API
- Return a
DeliveryResultindicating success or failure
async deliver(
subject: string,
envelope: RelayEnvelope,
context?: AdapterContext,
): Promise<DeliveryResult> {
const start = Date.now();
const userId = subject.slice(this.subjectPrefix.length + 1);
if (!userId) {
return { success: false, error: 'Cannot extract user ID from subject' };
}
const content = typeof envelope.payload === 'string'
? envelope.payload
: (envelope.payload as any)?.content ?? JSON.stringify(envelope.payload);
try {
await this.client.sendMessage(userId, content);
this.status.messageCount.outbound++;
return { success: true, durationMs: Date.now() - start };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.recordError(err);
return { success: false, error: message, durationMs: Date.now() - start };
}
}Adapter Context
The AdapterContext parameter provides optional context about the delivery target. When Mesh is enabled, this may include the target agent's working directory, runtime type, and manifest.
Configuration
Adapter configurations live in ~/.dork/relay/adapters.json:
{
"adapters": [
{
"id": "my-slack-bot",
"type": "plugin",
"enabled": true,
"plugin": {
"package": "dorkos-slack-adapter"
},
"config": {
"token": "xoxb-...",
"signingSecret": "abc123..."
}
}
]
}Set type to "plugin" and provide a plugin block with either a package name (npm) or a path (local file). The adapter manager loads your module via dynamic import and calls it with the config.
Plugin Loading
- npm packages — Set
plugin.packageto the package name. The package must export a default function or class implementingRelayAdapter. - Local files — Set
plugin.pathto an absolute or relative path (relative to~/.dork/relay/). The file must export a default function or class.
The adapter manager watches the config file and hot-reloads adapters when modified. Enable, disable, or reconfigure adapters without restarting the server.
Complete Example: Slack Adapter
A complete adapter implementation bridging Slack into Relay — all four interface methods, error handling, status tracking, and idempotent lifecycle management.
import type {
RelayAdapter,
RelayPublisher,
AdapterStatus,
AdapterContext,
DeliveryResult,
} from '@dorkos/relay';
import type { RelayEnvelope } from '@dorkos/shared/relay-schemas';
interface SlackConfig {
token: string;
signingSecret: string;
maxMessageLength?: number;
}
const SUBJECT_PREFIX = 'relay.human.slack';
const DEFAULT_MAX_LENGTH = 4000;
export class SlackAdapter implements RelayAdapter {
readonly id: string;
readonly subjectPrefix = SUBJECT_PREFIX;
readonly displayName: string;
private readonly config: SlackConfig;
private relay: RelayPublisher | null = null;
private client: any = null;
private signalUnsub: (() => void) | null = null;
private status: AdapterStatus = {
state: 'disconnected',
messageCount: { inbound: 0, outbound: 0 },
errorCount: 0,
};
constructor(id: string, config: SlackConfig) {
this.id = id;
this.config = config;
this.displayName = `Slack (${id})`;
}
// ---- Lifecycle ----
async start(relay: RelayPublisher): Promise<void> {
if (this.client !== null) return; // Already running — idempotent
this.relay = relay;
this.status = { ...this.status, state: 'starting' };
// Dynamic import keeps Slack SDK optional
const { App } = await import('@slack/bolt');
this.client = new App({
token: this.config.token,
signingSecret: this.config.signingSecret,
});
// Handle inbound messages
this.client.message(async ({ message }: any) => {
if (!message.text || message.subtype) return;
await this.handleInbound(message);
});
// Subscribe to Relay signals (typing indicators, etc.)
this.signalUnsub = relay.onSignal(`${SUBJECT_PREFIX}.>`, (subject, signal) => {
if (signal.type === 'typing') {
// Slack does not support typing indicators via API — no-op
}
});
await this.client.start();
this.status = {
...this.status,
state: 'connected',
startedAt: new Date().toISOString(),
};
}
async stop(): Promise<void> {
if (this.client === null) return; // Already stopped — idempotent
this.status = { ...this.status, state: 'stopping' };
if (this.signalUnsub) {
this.signalUnsub();
this.signalUnsub = null;
}
try {
await this.client.stop();
} catch (err) {
this.recordError(err);
} finally {
this.client = null;
this.relay = null;
this.status = { ...this.status, state: 'disconnected' };
}
}
// ---- Outbound delivery ----
async deliver(
subject: string,
envelope: RelayEnvelope,
_context?: AdapterContext
): Promise<DeliveryResult> {
if (!this.client) {
return { success: false, error: 'Adapter not started' };
}
const start = Date.now();
const userId = this.extractUserId(subject);
if (!userId) {
return {
success: false,
error: `Cannot extract user ID from subject: ${subject}`,
};
}
const content = this.extractContent(envelope.payload);
const maxLen = this.config.maxMessageLength ?? DEFAULT_MAX_LENGTH;
const truncated = content.length > maxLen ? content.slice(0, maxLen - 3) + '...' : content;
try {
await this.client.client.chat.postMessage({
channel: userId,
text: truncated,
});
this.status.messageCount.outbound++;
return { success: true, durationMs: Date.now() - start };
} catch (err) {
this.recordError(err);
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message, durationMs: Date.now() - start };
}
}
// ---- Status ----
getStatus(): AdapterStatus {
return { ...this.status };
}
// ---- Inbound handling ----
private async handleInbound(message: any): Promise<void> {
if (!this.relay) return;
const subject = `${SUBJECT_PREFIX}.${message.user}`;
const payload = {
content: message.text,
senderName: message.user,
channelType: 'dm' as const,
responseContext: {
platform: 'slack',
maxLength: this.config.maxMessageLength ?? DEFAULT_MAX_LENGTH,
supportedFormats: ['text', 'markdown'],
instructions: 'Format responses as Slack mrkdwn syntax.',
},
platformData: {
channelId: message.channel,
messageTs: message.ts,
userId: message.user,
},
};
try {
await this.relay.publish(subject, payload, {
from: `${SUBJECT_PREFIX}.bot`,
});
this.status.messageCount.inbound++;
} catch (err) {
this.recordError(err);
}
}
// ---- Helpers ----
private extractUserId(subject: string): string | null {
if (!subject.startsWith(SUBJECT_PREFIX)) return null;
const rest = subject.slice(SUBJECT_PREFIX.length + 1);
return rest || null;
}
private extractContent(payload: unknown): string {
if (typeof payload === 'string') return payload;
if (payload && typeof payload === 'object' && 'content' in payload) {
const content = (payload as Record<string, unknown>).content;
if (typeof content === 'string') return content;
}
return JSON.stringify(payload);
}
private recordError(err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
this.status = {
...this.status,
state: 'error',
errorCount: this.status.errorCount + 1,
lastError: message,
lastErrorAt: new Date().toISOString(),
};
}
}To use this adapter, add it to ~/.dork/relay/adapters.json:
{
"adapters": [
{
"id": "slack-team",
"type": "plugin",
"enabled": true,
"plugin": { "package": "dorkos-slack-adapter" },
"config": {
"token": "xoxb-your-bot-token",
"signingSecret": "your-signing-secret"
}
}
]
}Advanced Architecture
As of Relay API v0.2.0, three additional abstractions help reduce boilerplate across adapters:
-
PlatformClient — Separates platform-specific communication (posting messages, editing, streaming, typing indicators) from relay orchestration (subject routing, envelope handling, status tracking). The adapter owns a
PlatformClientand delegates platform API calls to it. The built-in Telegram and Slack adapters useGrammyPlatformClientandSlackPlatformClientrespectively. -
AdapterStreamManager — Sits in the delivery pipeline and intercepts
StreamEventpayloads. For adapters that implementdeliverStream(), it aggregatestext_deltaevents intoAsyncIterable<string>streams, handlesdone/error/approval_requiredlifecycle events, and includes TTL reaping for abandoned streams. Adapters withoutdeliverStream()continue receiving per-eventdeliver()calls unchanged. -
ThreadIdCodec — Standardizes how adapters encode and decode platform thread IDs to and from relay subjects. Each codec defines an
encode(platformId, channelType)anddecode(subject)method, replacing per-adapterbuildSubject()/extractId()functions.
For implementation details, see the internal relay-adapters guide.
The Chat SDK Telegram adapter (telegram-chatsdk) demonstrates how to build an adapter backed by
the Chat SDK instead of a platform-native library. See its
source at packages/relay/src/adapters/telegram-chatsdk/ for a reference implementation using all
three abstractions.
Security Considerations
For adapters that handle external input:
- HMAC-SHA256 verification for webhook-based adapters. Use
crypto.timingSafeEqual()for signature comparison. - Timestamp windows on signed requests (typically 5 minutes) to prevent replay attacks.
- Nonce tracking to reject duplicate deliveries within the timestamp window.
- Secret rotation support with a previous-secret fallback so secrets can be updated without downtime.
- Never log secrets in error messages or diagnostic output.
The built-in WebhookAdapter implements all of these patterns and serves as a reference for any adapter that accepts external HTTP requests.
Always use crypto.timingSafeEqual() instead of string equality (===) when comparing
signatures. String comparison is vulnerable to timing-based oracle attacks that can recover the
secret byte by byte.