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. DorkOS already ships built-in adapters for Telegram, Slack, generic webhooks, and Claude Code, so check ~/.dork/relay/adapters.json and set type to one of those before writing anything (see contributing/relay-adapters.md for how each one is configured). Write your own adapter when you need a platform that isn't on that list, or a private, internal integration nobody else needs.
To build one, 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: lightweight credential check without the full start/stop cycle
testConnection?(): Promise<{ ok: boolean; error?: string; botUsername?: string }>;
}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. Streaming to platforms that support progressive updates happens inside
deliver(): the adapter posts an initial message and edits it in place as more content arrives (see the Telegram reference adapter, which does this through itsPlatformClient.editMessage). - getStatus(): Return a snapshot of your adapter's runtime state for the UI.
- testConnection(): Optional. A lightweight credential check that validates configuration without running the full
start()/stop()lifecycle (long-polling loops, webhook servers). When present,AdapterManager.testConnection()prefers it, which avoids side effects like Telegram's 409 Conflict when a polling session lingers.
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, or you'll
double-subscribe and spend an afternoon wondering why every message arrives twice.
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-discord-bot",
"type": "plugin",
"enabled": true,
"plugin": {
"package": "your-org-discord-adapter"
},
"config": {
"token": "..."
}
}
]
}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: Discord Adapter
Discord isn't a built-in adapter type, so it's a realistic candidate for the plugin path. Here's a complete implementation bridging Discord 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 DiscordConfig {
token: string;
maxMessageLength?: number;
}
const SUBJECT_PREFIX = 'relay.human.discord';
const DEFAULT_MAX_LENGTH = 2000;
export class DiscordAdapter implements RelayAdapter {
readonly id: string;
readonly subjectPrefix = SUBJECT_PREFIX;
readonly displayName: string;
private readonly config: DiscordConfig;
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: DiscordConfig) {
this.id = id;
this.config = config;
this.displayName = `Discord (${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 the Discord SDK optional
const { Client, GatewayIntentBits } = await import('discord.js');
this.client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages],
});
// Handle inbound messages
this.client.on('messageCreate', async (message: any) => {
if (message.author.bot || !message.content) return;
await this.handleInbound(message);
});
// Subscribe to Relay signals (typing indicators, etc.)
this.signalUnsub = relay.onSignal(`${SUBJECT_PREFIX}.>`, (subject, signal) => {
if (signal.type === 'typing') {
// Wire up channel.sendTyping() here if you want the indicator.
}
});
await this.client.login(this.config.token);
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.destroy();
} 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 {
const user = await this.client.users.fetch(userId);
await user.send(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.author.id}`;
const payload = {
content: message.content,
senderName: message.author.username,
channelType: 'dm' as const,
responseContext: {
platform: 'discord',
maxLength: this.config.maxMessageLength ?? DEFAULT_MAX_LENGTH,
supportedFormats: ['text', 'markdown'],
instructions: 'Format responses as Discord markdown.',
},
platformData: {
channelId: message.channel.id,
messageId: message.id,
userId: message.author.id,
},
};
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": "discord-team",
"type": "plugin",
"enabled": true,
"plugin": { "package": "your-org-discord-adapter" },
"config": {
"token": "your-bot-token"
}
}
]
}Before publishing a new adapter, run it against the compliance test
suite
(packages/relay/src/testing/compliance-suite.ts). It checks the interface shape, the
testConnection() return shape, and start/stop idempotency against your real adapter code, so you
catch contract violations before they show up as double-sent messages in production.
Advanced Architecture
Two abstractions help reduce boilerplate across adapters:
-
PlatformClient: Separates platform-specific communication (posting messages, editing in place for 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. -
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 relay-adapters guide in the repository.
The Telegram adapter (packages/relay/src/adapters/telegram/, built on
grammY) is the reference implementation. It shows the full contract:
long-polling inbound, echo prevention, streaming via in-place message edits, approval buttons, and
message splitting for platform length limits.
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.