Relay Messaging
Send messages between your agents, your screen, and chat apps like Telegram or Slack
Relay Messaging
Relay lets your agents send and receive messages: to each other, to you in DorkOS, or to a Telegram or Slack chat. Every message is saved, so nothing goes missing even if an agent is busy.
This guide gets you sending your first message, then covers adapters, budgets, and tracing for when you want more control.
To connect Telegram or Slack from the app, no config file required, use the Messaging half of the Connections page. This guide is the layer underneath: the API, the adapter config 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 enabledThe examples below use port 4242, the default. If yours is different, swap it in. Settings → 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"
}'See it arrive. The response confirms delivery:
{
"id": "01HX...",
"delivered": 1,
"deadLettered": 0
}delivered: 1 means an agent picked it up. If it had failed, it would show up in deadLettered instead (Relay's term for "delivery failed, here's why").
That's the whole loop: publish a message to a subject, Relay routes it, the recipient gets it. Everything below is what you can build once you know that.
Reference
The rest of this page is a deeper look at how Relay works: the built-in tools, chat adapters, budgets, and configuration. Agent-to-agent chains are still a newer part of DorkOS, so if you're chaining more than a couple of hops, set explicit budgets (below) to keep things bounded.
Using MCP tools
Agents running inside DorkOS can call Relay directly, without making an HTTP request themselves:
relay_send Send a message to a Relay subject
relay_inbox Read inbox messages for an endpoint
relay_list_endpoints List all registered endpoints
relay_register_endpoint Register a new endpoint
relay_unregister_endpoint Unregister an endpoint (use to clean up dispatch inboxes)
relay_send_and_wait Blocking request/reply: sends a message and waits for the response
relay_send_async Fire-and-poll: dispatches a message, returns immediately with an inbox to poll
relay_get_trace Get the full delivery trace for a message
relay_get_metrics Get aggregate delivery metrics
relay_list_adapters List all adapters with status
relay_enable_adapter Enable a disabled adapter
relay_disable_adapter Disable a running adapter
relay_reload_adapters Hot-reload adapter configuration from diskrelay_send_and_wait (added in v0.8.0) handles request and reply in one call. It registers a temporary inbox, sends the message with that inbox as the reply address, and waits until the agent replies. Use it when you need the answer before your next step.
relay_send_async returns immediately with a dispatch inbox instead of waiting. The agent keeps working in the background. Poll relay_inbox with ack: true to check progress: by default it only returns messages waiting for you, and ack: true clears each one so the next poll only returns new ones. When you get a payload with done: true, call relay_unregister_endpoint to clean up.
Acknowledging is permanent. ack: true deletes the message content from disk as soon as it is handed to you: later reads still show the record (who sent it and when) but the content comes back empty, and there is no undo. Take what you need from the response. Leave ack off to look without clearing anything.
Inboxes are private. An agent can read or unregister an endpoint only when it is the agent's own address, an inbox subject relay_send_async handed it, or one it registered itself with relay_register_endpoint. Asking for another agent's endpoint fails with ENDPOINT_ACCESS_DENIED, so one agent cannot read or clear another's mail.
An inbox keeps belonging to the agent that registered it, even after DorkOS restarts: the owner is recorded next to the mailbox on disk, so a second agent that asks for the same address afterwards is turned down rather than handed the mailbox. Agents register their own inboxes under relay.inbox.*; the relay.agent.*, relay.system.* and relay.human.* names are managed by DorkOS and an agent asking for one gets RESERVED_SUBJECT. Two names that differ only in capital letters cannot both exist, because on macOS and Windows they would end up sharing a single mailbox.
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 Pattern | Purpose |
|---|---|
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
Agents publish messages to named subjects, and Relay routes each one to the right recipient. Agents don't need to know about each other directly; they just need to agree on a subject name.
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 a recipient adapter is failing repeatedly, 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 session messages are delivered. Instead of POST /api/sessions/:id/messages calling the agent SDK directly, the message is published to relay.agent.{sessionId} and the Claude Code adapter picks it up from there. That means every session 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/configThe response includes relay.enabled: true when Relay is active.
To turn Relay off, set DORKOS_RELAY_ENABLED=false in your .env file. Relay's routes will
return 404 and session 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 agent-to-agent 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 agent-to-agent workflows, set explicit budgets so a chain of agents can't consume more time or calls than you intended.
You don't have to set one for an agent that's answering a message, though. When a message starts an agent's turn, anything that agent sends during that turn continues the same budget, one step further along — so a reply can't hand itself a fresh allowance. An agent can ask for a tighter budget than the one it inherited; it can't ask for a looser one.
The hourly ceiling
Budgets bound one chain of messages. The ceiling bounds your bill.
Every route into an agent — another agent's message, an outside system speaking A2A, a webhook posting back — 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 route can go around it and starting a fresh chain doesn't buy a fresh allowance.
Two numbers, both per hour, both editable:
| Setting | Default | Bounds |
|---|---|---|
relay.maxAgentTurnsPerAgentPerHour | 1000 | What any one agent — or one scheduled task — can cost |
relay.maxAgentTurnsTotalPerHour | 5000 | Everything above, altogether — agent messages and scheduled runs |
dorkos config set relay.maxAgentTurnsTotalPerHour 2000Set 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 Agent SDK sessions. 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
}
}
]
}maxConcurrent limits how many agent sessions run at once. defaultTimeoutMs sets the longest a message may wait for a busy agent — not how long a session may run. What bounds a running session 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 session is busy is not turned away. It waits, and it runs as soon as one of those sessions 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 session slot, so 30 with the default
maxConcurrentof 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 session 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}. The adapter 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: adapters 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/reloadMessage tracing
Every message gets a trace that follows it from publish to delivery. 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 the Claude Code adapter starts the agent session 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
dorkosSubscribe to relay.system.tasks.* on the SSE stream to watch every Tasks dispatch as it happens.
Configuration reference
Prop
Type
Data directory
| Path | Purpose |
|---|---|
~/.dork/relay/index.db | SQLite index database (messages, traces; WAL mode) |
~/.dork/relay/endpoints/ | Maildir message store, one directory per endpoint |
~/.dork/relay/adapters.json | Adapter configuration (hot-reloaded) |
~/.dork/relay/config.json | Reliability settings (rate limits, circuit breakers, backpressure) |
~/.dork/relay/access-rules.json | Access 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
| Method | Path | Description |
|---|---|---|
POST | /api/relay/messages | Publish a message to a subject |
GET | /api/relay/messages | List messages with filters and cursor pagination |
GET | /api/relay/messages/:id | Get a single message by ID |
GET | /api/relay/messages/:id/trace | Get the full delivery trace for a message |
GET | /api/relay/conversations | List grouped request/response exchanges |
GET | /api/relay/endpoints | List registered endpoints |
POST | /api/relay/endpoints | Register a new endpoint |
DELETE | /api/relay/endpoints/:subject | Unregister an endpoint |
GET | /api/relay/endpoints/:subject/inbox | Read inbox for an endpoint |
GET | /api/relay/dead-letters | List dead letter messages |
GET | /api/relay/dead-letters/aggregated | Dead letters grouped by source and reason |
GET | /api/relay/metrics | Relay system metrics |
GET | /api/relay/trace/metrics | Aggregate delivery metrics |
GET | /api/relay/stream | SSE event stream (supports ?subject= filter) |
GET | /api/relay/adapters | List adapters with status |
GET | /api/relay/adapters/catalog | List available adapter types |
GET | /api/relay/adapters/:id | Get a single adapter's status |
POST | /api/relay/adapters/reload | Hot-reload adapter configuration |
POST | /api/relay/adapters/test | Test an adapter configuration before saving it |
POST | /api/relay/adapters/:id/enable | Enable an adapter |
POST | /api/relay/adapters/:id/disable | Disable an adapter |
GET | /api/relay/bindings | List adapter-to-agent bindings |
POST | /api/relay/bindings | Create an adapter-to-agent binding |
DELETE | /api/relay/bindings/:id | Remove a binding |
POST | /api/relay/webhooks/:adapterId | Inbound webhook receiver |