DorkOS
Guides

Relay Messaging

Send messages between DorkOS, your own programs, and chat apps like Telegram or Slack

Relay Messaging

Relay carries messages between DorkOS, your own programs, and Telegram or Slack chats. Every message is saved, so nothing goes missing even if an agent is busy.

Your agents message each other in chats you can open, not through Relay. See Spin-off chats and Agent Coordination.

This guide gets you sending your first message, then covers chat apps, budgets, and tracing for when you want more control.

To connect Telegram or Slack from the app, no config file required, use the Connections page: they are rows in its list with a Chat tag. This guide is the layer underneath: the API, the config file on disk, and the tools your agents call.

Want a connected chat's whole history to live in one shared log your agent reads before it answers? See Bridged Channels, which turns a chat into a DorkOS channel.

Send your first message

Relay is already on by default, so there's nothing to install. Here's the fastest way to see it work.

Confirm Relay is running. Start DorkOS and look for this in the startup log:

DorkOS server listening on port 4242
Relay message bus enabled

The examples below use port 4242, the default. If yours is different, swap it in. Settings → Advanced → Server shows your address and copies it for you, which is the quickest way to check in the desktop app.

Send a message. Every message needs a subject: a named address, like an email address for a message. This one is addressed to an agent called backend:

curl -X POST http://localhost:4242/api/relay/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "relay.agent.backend",
    "payload": { "task": "Run the test suite" },
    "from": "relay.human.console.my-client"
  }'

This HTTP example lets you choose a human sender address. Read Sending from local programs for access checks and sender authority.

Check delivery. A message sent to a relay.agent.* destination includes a delivery receipt and a status URL:

{
  "messageId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "deliveredTo": 1,
  "receipt": {
    "messageId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
    "scope": "agent_delivery",
    "state": "accepted",
    "acceptedAt": "2026-10-02T12:00:00.000Z",
    "updatedAt": "2026-10-02T12:00:00.000Z",
    "expiresAt": "2026-10-09T12:00:00.000Z"
  },
  "statusUrl": "/api/relay/messages/01ARZ3NDEKTSV4RRFFQ69G5FAV/status"
}

deliveredTo counts publish targets, including mailbox copies and accepted agent handoffs. It does not confirm that an agent ran your task. The receipt describes the attempt to deliver to the agent.

Read the returned statusUrl to check that attempt. When sign-in is on, use the same sign-in cookie or per-user API key as for sending:

curl http://localhost:4242/api/relay/messages/01ARZ3NDEKTSV4RRFFQ69G5FAV/status \
  -H 'Authorization: Bearer <your-api-key>'

The status response contains only the receipt, without the message body, sender, destination, or account identity. It uses Cache-Control: no-store so clients do not cache it.

A busy agent can produce this response without a replyTo address:

{
  "messageId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "scope": "agent_delivery",
  "state": "failed",
  "acceptedAt": "2026-10-02T12:00:00.000Z",
  "updatedAt": "2026-10-02T12:00:01.000Z",
  "expiresAt": "2026-10-09T12:00:00.000Z",
  "settledAt": "2026-10-02T12:00:01.000Z",
  "failure": {
    "code": "at_capacity",
    "message": "The agent was busy and did not take this message."
  }
}

Read the state this way:

  • accepted: DorkOS recorded the attempt but has not recorded its outcome. This does not promise a queued task or eventual execution.
  • delivered: The delivery attempt reported success. This does not prove the requested task succeeded or anyone read a reply.
  • failed: A delivery check or the delivery attempt reported a refusal or failure. It does not promise that nothing happened.
  • outcome_unknown: DorkOS could not confirm how delivery ended. The message may already have had effects.

While the state is accepted, you can wait longer between status reads. Stop polling on a terminal state, 404, or an authentication failure. A 503 on a status read means the status is unavailable; retry the read rather than resend the message.

Each receipt expires exactly seven days after acceptance. Polling and delivery updates do not extend that time. At expiry, the status URL returns 404, even before cleanup removes the stored receipt.

With sign-in on, only the verified account that sent the message can read its receipt. Receipts created with sign-in off use local trust. After sign-in is enabled, only the install owner can read those earlier receipts. A missing, expired, untracked, or other account's receipt returns the same 404 response.

A lost POST response does not prove that delivery failed. If an error response includes messageId and statusUrl, retain them and check status. Do not automatically resend after a lost response or an unknown outcome. No receipt state grants permission to resend.

Receipts are authoritative delivery records in the database. Rebuilding the message index does not rebuild receipts from mailbox files. Traces are separate: their eight-day history does not extend a receipt's seven-day status window.

Other destinations keep their existing response without a receipt or status URL.

That is the publish-and-check loop. The receipt tells you the recorded delivery outcome; replies and task results remain separate.

Reference

The rest of this page is a deeper look at how Relay works: the built-in tools, the chat apps it bridges, budgets, and configuration. If you build chains of messages from your own programs, set explicit budgets (below) to keep them bounded.

Sending from local programs

POST /api/relay/messages lets a program send a Relay message through the HTTP API.

With sign-in off, this endpoint needs no app credential. DorkOS checks the request's Host header against loopback addresses, configured trusted hosts, and the live tunnel. DORKOS_ALLOW_INSECURE_BIND disables that check. With sign-in on, send a valid sign-in cookie or a per-user API key as Authorization: Bearer <key>. The Host check is off in this mode.

Browser requests still follow CORS (the browser rule that controls which sites may call your server). Requests without an Origin header are allowed by this policy. CORS does not authenticate the sender.

You choose from. DorkOS refuses reserved server senders, destinations, and reply addresses. It permits human addresses such as relay.human.console.my-client. This address is a caller's claim; it does not prove who sent the request. Signing in permits the request but does not bind from to the signed-in person. A program run by an agent can also choose a permitted human address through HTTP.

A permitted relay.human.* sender can supply three fields that shape a receiving turn:

  • payload.cwd: the working folder.
  • payload.forAgent: the agent's home path.
  • payload.__bindingPermissions.permissionMode: the approval mode.

Existing folder and agent checks still apply. Without the nested approval mode, the Relay turn handler uses default. A top-level payload.permissionMode does not set it. The receiving runtime determines which approval modes it supports.

DorkOS's MCP tools (MCP is the standard that lets AI tools talk to each other) use the sender DorkOS assigns them. They do not accept a caller-chosen from. MCP authentication and tool permissions are separate checks. Messages from agent, chat, or external MCP sender addresses cannot use those three fields to shape the receiving turn. This restriction follows the sender address, not the program making an HTTP call.

A message from one of those senders also runs no looser than the receiving runtime's starting level, even in a chat already set higher. A message an agent sends never gives the receiving agent more power than a new chat gets, and a message from another company's agent over A2A is never treated as one of yours.

An account request follows separate host account rules. It applies to a new chat and grants no sender authority.

Using MCP tools

Agents running inside DorkOS can reach Relay directly, without making an HTTP request themselves:

relay_notify_user           Message the person on a connected chat, or in DorkOS
relay_list_endpoints        List all registered endpoints
relay_get_trace             Get the full delivery trace for a message
relay_get_metrics           Get aggregate delivery metrics
relay_list_adapters         List every connected chat app with its status
relay_enable_adapter        Turn a connected chat app back on
relay_disable_adapter       Turn a connected chat app off
relay_reload_adapters       Reload the chat app settings from disk

These tools are for chat apps, delivery and tracing. Agents do not send each other messages through Relay. They message each other's chats with chat_send and check on them with chat_read, so every message between agents sits in a chat you can open. The older Relay tools agents used for this were removed. See Agent Coordination.

Endpoints you create from the DorkOS interface belong to you, not to any agent, and stay readable in the Relay panel.

Subject naming

Subjects follow a dot-separated pattern: relay.{audience}.{identifier}.

Subject PatternPurpose
relay.agent.{ns}.{agentId}Messages addressed to a specific agent
relay.human.console.{clientId}Messages destined for a human's browser console
relay.human.telegram.{chatId}Messages routed to a Telegram chat
relay.system.tasks.{scheduleId}System messages from your scheduled tasks

You can subscribe with wildcards: * stands in for exactly one segment, > for one or more trailing segments. Subscribing to relay.agent.> gets you messages addressed to any agent.

What happens when Relay is running

Relay carries messages between DorkOS and the outside: your Telegram and Slack chats, webhooks, and your own programs. It also carries the notes an agent sends to reach you when you are away from the app. Each message goes to a named subject, and Relay routes it to whoever listens there. Agents do not use it to talk to each other; they message each other's chats.

Along the way, a message passes through a few checks: is the subject valid, is the sender allowed to send it, is it within its rate limit and budget. If delivery to a recipient keeps failing, Relay's circuit breaker (a safety switch that stops sending to something that's clearly broken) pauses deliveries to it rather than retrying forever. Anything that can't be delivered lands in the dead letter queue, and the full path a message took is recorded so you can look it up later (see Tracing below).

When Relay is on, it also takes over how chat messages are delivered. Instead of POST /api/sessions/:id/messages calling the agent SDK directly, the message is published to relay.agent.{sessionId} and DorkOS's Claude Code side picks it up from there. That means every chat message gets Relay's tracing, budgets, and retry handling for free.

Enabling and disabling Relay

Relay is on by default for fresh installs. Its data directory, ~/.dork/relay/, is created the first time it starts.

To check whether it's active, call the config endpoint:

curl http://localhost:4242/api/config

The response includes relay.enabled: true when Relay is active.

To turn Relay off, switch off Agent messaging in Settings › Advanced › Tools and restart DorkOS. If DORKOS_RELAY_ENABLED is set, it decides instead of the switch. With Relay off, its routes return 404 and chat messaging falls back to calling the agent SDK directly. Your message history and search index stay on disk, so turning Relay back on later doesn't lose anything.

Budget constraints

Every message carries a budget that keeps chains of messages from running away:

curl -X POST http://localhost:4242/api/relay/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "relay.agent.backend",
    "payload": { "task": "Refactor the auth module" },
    "from": "relay.human.console.my-client",
    "budget": {
      "maxHops": 3,
      "ttlMs": 300000,
      "callBudgetRemaining": 50
    }
  }'

If you leave budget fields out, Relay uses defaults: 5 maximum hops, a 1-hour time-to-live, and 10 calls. For automated workflows, set explicit budgets so a chain of messages can't consume more time or calls than you intended.

The hourly ceiling

Budgets bound one chain of messages. The ceiling bounds your bill.

Every Relay route into an agent — an outside system speaking A2A, a webhook posting back, a scheduled task — ends at the same place: DorkOS hands the message to the agent and a real turn starts. That's where the turns are counted, so no Relay route can go around it and starting a fresh chain doesn't buy a fresh allowance.

Messages your agents send each other's chats don't travel over Relay, so this ceiling doesn't count them. You see those in the chats themselves, and you can stop either chat.

Two numbers, both per hour, both editable:

SettingDefaultBounds
relay.maxAgentTurnsPerAgentPerHour1000What any one agent — or one scheduled task — can cost
relay.maxAgentTurnsTotalPerHour5000Everything above, altogether: Relay messages and scheduled runs
dorkos config set relay.maxAgentTurnsTotalPerHour 2000

Set either to null for no limit.

0 stops every turn that starts this way — including your scheduled tasks, which run through the same dispatch. If you want to quieten chatty agents without stopping your schedule, set a small number rather than zero.

When the ceiling stops a message, it says which of the two limits it was, and the message stays in the agent's inbox — it just doesn't start a turn. A turn DorkOS accepted but couldn't run — every agent slot busy, say — doesn't count against you: the allowance is given back.

Built-in adapters

Adapters connect external platforms to Relay's subject system. DorkOS ships with five, configured through ~/.dork/relay/adapters.json.

Claude Code adapter

The Claude Code adapter is on by default whenever Relay starts. It listens on relay.agent.* subjects and routes incoming messages to Claude Code chats. Replies flow back through Relay to the sender's reply address.

{
  "adapters": [
    {
      "id": "claude-code",
      "type": "claude-code",
      "builtin": true,
      "enabled": true,
      "config": {
        "maxConcurrent": 3,
        "defaultTimeoutMs": 300000
      }
    }
  ]
}

You can change both in the app, under Settings → Connections: Most chats at once is maxConcurrent, and Give up after is defaultTimeoutMs in seconds. Start working right away turns it on or off.

maxConcurrent limits how many agent chats run at once. defaultTimeoutMs sets the longest a message may wait for a busy agent — not how long a chat may run. What bounds a running chat is the message's own lifetime, and nothing else: a message that runs out of time is turned away rather than given a fresh clock.

A message from a connected Telegram or Slack chat that arrives while every agent chat slot is busy is not turned away. It waits, and it runs as soon as one of those running chats finishes. If the wait lasts more than ten seconds, the chat gets one line saying the message is waiting.

Four limits keep that honest:

  • How many can wait. Ten per chat slot, so 30 with the default maxConcurrent of 3. Past that, a message is answered right away instead of waiting.
  • How long one can wait. defaultTimeoutMs, or whatever is left of the message's own one-hour lifetime, whichever is shorter. This is not the same as how long the busy chat may run, so a message can stop waiting while your agent is still working. When that happens the chat gets one more line saying the message was not picked up, and you can send it again.
  • Who gets to wait. Only a message from a chat you connected. Anything else waiting on an answer, such as one agent asking another, gets the old immediate "too busy" reply, because those callers give up long before a wait would end.
  • How long the wait survives. Holds live in memory only, so restarting the server drops any message still waiting.

Telegram adapter

The Telegram adapter connects a Telegram bot to Relay. Messages from the chat are published to relay.human.telegram.{chatId}, and replies on that subject get delivered back to the chat, including live-updating drafts as the agent writes its answer.

{
  "id": "telegram",
  "type": "telegram",
  "builtin": true,
  "enabled": true,
  "config": {
    "token": "123456:ABC-DEF...",
    "respondMode": "thread-aware"
  }
}

In a one-on-one chat the bot replies to everything you send it. In a group chat, respondMode decides when it joins in: thread-aware (default: when someone mentions it by name, sends it a command, or replies to one of its messages), mention-only (only when named), or always (every message in the group).

Your bot never replies to another bot, whatever you pick. That is not a setting, because two bots that reply to each other will keep going forever and neither can tell it is happening. The one exception is an anonymous group admin: Telegram sends their messages in a way that looks like a bot, but they are a person, so your bot treats them like anyone else in the group. Posts from a linked Telegram channel are treated as automated and ignored.

Slack adapter

The Slack adapter connects a Slack workspace to Relay using Socket Mode, so you don't need a public URL. It supports streaming replies, threads, a working indicator, and access control per Slack channel.

When an agent starts working on your message, Slack shows it: your message gets an 👀 reaction, which comes off when the agent replies or fails. A message nobody picks up is never marked — the reaction means somebody is on it, not that the message arrived. Nothing is added when the work finishes: the reply is the answer. Set the working indicator to None to turn it off.

{
  "id": "slack",
  "type": "slack",
  "builtin": true,
  "enabled": true,
  "config": {
    "botToken": "xoxb-...",
    "appToken": "xapp-...",
    "signingSecret": "abc123...",
    "streaming": true,
    "typingIndicator": "reaction",
    "respondMode": "thread-aware",
    "dmPolicy": "allowlist"
  }
}

respondMode controls when the bot replies in a Slack channel: thread-aware (default: replies to @mentions and to threads it's already part of), mention-only (only @mentions), or always (every message). dmPolicy controls direct messages: allowlist (default: only the user IDs listed in dmAllowlist) or open (anyone in the workspace). A direct message can start an agent turn on your machine, so it is limited to people you name unless you open it. You can also set overrides per Slack channel with channelOverrides.

Webhook adapter

The webhook adapter is a generic HTTP bridge. It can receive messages from other services and forward Relay messages out as HTTP requests, both directions signed with HMAC-SHA256 (a signature that proves the request wasn't tampered with).

{
  "id": "my-webhook",
  "type": "webhook",
  "builtin": true,
  "enabled": true,
  "config": {
    "url": "https://example.com/hook",
    "secret": "your-hmac-secret",
    "subjectPrefix": "relay.webhook.incoming"
  }
}

Inbound requests arrive at POST /api/relay/webhooks/{adapterId}. DorkOS checks the signature, reads the payload, and publishes it under the configured subject prefix. You can rotate your secret with a 24-hour overlap window so nothing breaks mid-rotation.

Hot reload

DorkOS watches ~/.dork/relay/adapters.json for changes. Edit the file and it reconciles automatically: ones you removed or disabled stop, and newly enabled ones start. No restart needed.

You can also trigger a reload by hand:

curl -X POST http://localhost:4242/api/relay/adapters/reload

Message tracing

Every message gets a trace that follows it from publish to delivery. A trace is kept for eight days, then removed along with its message (see Relay). Look one up by its ID:

curl http://localhost:4242/api/relay/messages/{messageId}/trace
{
  "traceId": "01HXABC123",
  "spans": [
    {
      "id": "01HXDEF456",
      "messageId": "01HXABC123",
      "traceId": "01HXABC123",
      "subject": "relay.agent.backend",
      "status": "delivered",
      "sentAt": "2025-02-26T12:00:00.000Z",
      "deliveredAt": "2025-02-26T12:00:00.050Z",
      "processedAt": "2025-02-26T12:00:00.200Z",
      "errorMessage": null,
      "metadata": null
    }
  ]
}

A span's status is one of: sent (published, delivery in progress), delivered (handled successfully), failed (the recipient errored), or timeout (blocked by budget, access control, or expiry).

For more on debugging failed deliveries, see the Relay Observability guide.

Relay and Tasks

When both Relay and Tasks are on, scheduled jobs are dispatched through Relay. Tasks publishes to relay.system.tasks.{scheduleId}, and DorkOS's Claude Code side starts the agent's chat from there. That gives every scheduled run Relay's budget limits, tracing, and retry handling too.

export DORKOS_RELAY_ENABLED=true
export DORKOS_TASKS_ENABLED=true
dorkos

Subscribe to relay.system.tasks.* on the SSE stream to watch every Tasks dispatch as it happens.

Configuration reference

Prop

Type

Data directory

PathPurpose
~/.dork/relay/index.dbSQLite index database (messages, traces; WAL mode)
~/.dork/relay/endpoints/Maildir message store, one directory per endpoint
~/.dork/relay/adapters.jsonChat app bridge configuration (hot-reloaded)
~/.dork/relay/config.jsonReliability settings (rate limits, circuit breakers, backpressure)
~/.dork/relay/access-rules.jsonAccess control rules (hot-reloaded)

Reliability settings

The config.json file controls rate limiting, circuit breakers, and backpressure (what happens when an endpoint's mailbox is filling up faster than it's read). Changes are hot-reloaded.

Prop

Type

REST API endpoints

MethodPathDescription
POST/api/relay/messagesPublish a message to a subject
GET/api/relay/messagesList messages with filters and cursor pagination
GET/api/relay/messages/:idGet a single message by ID
GET/api/relay/messages/:id/traceGet the full delivery trace for a message
GET/api/relay/conversationsList grouped request/response exchanges
GET/api/relay/endpointsList registered endpoints
POST/api/relay/endpointsRegister a new endpoint
DELETE/api/relay/endpoints/:subjectUnregister an endpoint
GET/api/relay/endpoints/:subject/inboxRead inbox for an endpoint
GET/api/relay/dead-lettersList dead letter messages
GET/api/relay/dead-letters/aggregatedDead letters grouped by source and reason
GET/api/relay/metricsRelay system metrics
GET/api/relay/trace/metricsAggregate delivery metrics
GET/api/relay/streamSSE event stream (supports ?subject= filter)
GET/api/relay/adaptersList chat apps and other bridges with status
GET/api/relay/adapters/catalogList available bridge types
GET/api/relay/adapters/:idGet a single bridge's status
POST/api/relay/adapters/reloadHot-reload bridge configuration
POST/api/relay/adapters/testTest a bridge configuration before saving it
POST/api/relay/adapters/:id/enableEnable a bridge
POST/api/relay/adapters/:id/disableDisable a bridge
GET/api/relay/bindingsList bridge-to-agent bindings
POST/api/relay/bindingsCreate a bridge-to-agent binding
DELETE/api/relay/bindings/:idRemove a binding
POST/api/relay/webhooks/:adapterIdInbound webhook receiver

Next steps