DorkOS
Guides

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 RelayPublisher for 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 AdapterStreamManager aggregates text_delta events into an AsyncIterable<string> and calls this method instead of sending per-event deliver() 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:

  1. Extract the recipient identifier from the subject (e.g., parse the user ID from relay.human.slack.U12345)
  2. Format the message content for your platform
  3. Send the message through your platform's API
  4. Return a DeliveryResult indicating 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.package to the package name. The package must export a default function or class implementing RelayAdapter.
  • Local files — Set plugin.path to 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 PlatformClient and delegates platform API calls to it. The built-in Telegram and Slack adapters use GrammyPlatformClient and SlackPlatformClient respectively.

  • AdapterStreamManager — Sits in the delivery pipeline and intercepts StreamEvent payloads. For adapters that implement deliverStream(), it aggregates text_delta events into AsyncIterable<string> streams, handles done/error/approval_required lifecycle events, and includes TTL reaping for abandoned streams. Adapters without deliverStream() continue receiving per-event deliver() calls unchanged.

  • ThreadIdCodec — Standardizes how adapters encode and decode platform thread IDs to and from relay subjects. Each codec defines an encode(platformId, channelType) and decode(subject) method, replacing per-adapter buildSubject()/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.

Next Steps