# Building Relay Adapters
Source: https://dorkos.ai/docs/integrations/building-relay-adapters

How to build custom adapters that bridge external platforms into Relay





# Building Relay Adapters [#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](https://github.com/dork-labs/dorkos/blob/main/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 [#the-relayadapter-interface]

Every adapter implements four required methods, one optional method, and three readonly properties:

```typescript
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 `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. 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 its `PlatformClient.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 [#adapter-lifecycle]

<Steps>
  <Step>
    ### Registration [#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.
  </Step>

  <Step>
    ### Running [#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`.
  </Step>

  <Step>
    ### Hot-Reload [#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.
  </Step>

  <Step>
    ### Shutdown [#shutdown]

    On server shutdown, `stop()` is called on every running adapter. Drain in-flight messages, close connections, and release resources.
  </Step>
</Steps>

<Callout type="warn">
  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.
</Callout>

## Inbound Messages [#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()`:

```typescript
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 [#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 [#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 [#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

```typescript
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 [#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 [#configuration]

Adapter configurations live in `~/.dork/relay/adapters.json`:

```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 [#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: Discord Adapter [#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.

```typescript
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`:

```json
{
  "adapters": [
    {
      "id": "discord-team",
      "type": "plugin",
      "enabled": true,
      "plugin": { "package": "your-org-discord-adapter" },
      "config": {
        "token": "your-bot-token"
      }
    }
  ]
}
```

<Callout type="info">
  Before publishing a new adapter, run it against the [compliance test
  suite](https://github.com/dork-labs/dorkos/blob/main/packages/relay/src/testing/compliance-suite.ts)
  (`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.
</Callout>

## Advanced Architecture [#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 `PlatformClient` and delegates platform API calls to it. The built-in Telegram and Slack adapters use `GrammyPlatformClient` and `SlackPlatformClient` respectively.

* **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 [relay-adapters guide](https://github.com/dork-labs/dorkos/blob/main/contributing/relay-adapters.md) in the repository.

<Callout type="info">
  The **Telegram adapter** (`packages/relay/src/adapters/telegram/`, built on
  [grammY](https://grammy.dev/)) 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.
</Callout>

## Security Considerations [#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.

<Callout type="warn">
  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.
</Callout>

## Next Steps [#next-steps]

<Cards>
  <Card title="Relay Concepts" href="/docs/concepts/relay">
    The message bus architecture that adapters plug into.
  </Card>

  <Card title="Mesh Discovery" href="/docs/concepts/mesh">
    How agents are discovered and registered in the network.
  </Card>

  <Card title="SSE Protocol" href="/docs/integrations/sse-protocol">
    How streaming events flow from the server to clients.
  </Card>
</Cards>
