agent-relay-channels-host
agent-relay-channels-host
The channels host is a single agent-relay.connector.v1 connector (kind:"channel")
that bundles N lightweight egress adapters for the long tail of trivial third-party
integrations (ntfy, generic webhook, slack/discord-egress, …). It keeps Agent Relay
core free of per-service transport code — core owns only the registry, routing,
and the channel.v1 contract; the actual HTTP/push for each service lives in an
adapter here.
Heavyweight, stateful, two-way integrations (Telegram-class) keep their own repo. This host is for one-way / egress-only and other ~30-line adapters.
Status: the host (#778) installs, registers as a channel connector, and is health-polled by the existing connector subsystem. ntfy (#774) is the first bundled adapter; it registers
ntfy:default(outbound) only whenNTFY_TOPICis set, so an unconfigured install still registers zero channels cleanly.Productionized (#794): published as part of the agent-relay lockstep release set and installed into
~/.agent-relay/runtimeas a dependency ofagent-relay-server. The relay auto-registers and auto-starts this connector on boot (src/connectors.ts→ensureChannelsHostConnector), so it survives deploys/restarts with no manualregister/start. ntfy adapter config is persisted in the connector'sconfig.jsonand migrated once from the legacy corechannel-type/ntfysetting.
How it plugs into Agent Relay
The host reuses the existing connector lifecycle machinery (src/connectors.ts) — it
is a client of that subsystem, not a reimplementation:
- Register the connector:
POST /api/connectorswith the manifest (src/index.ts register→routes/connectors.ts). The manifest declares thestart/stop/restart/status/doctorcommands the relay drives, and an aggregatedconfigSchema. - Lifecycle: the relay spawns
start(which detaches the long-runningdaemon),stop,restart,status,doctorwith a 30s timeout and persists their JSON output as connector state. The 60s status poller keeps the dashboard honest.
The adapter interface
Adapters implement ChannelAdapter (src/adapter.ts). The host owns all relay
plumbing, so an adapter never touches SSE, agent registration, or tokens:
| verb | owner | what it does |
|---|---|---|
registerChannel() |
host | POST /api/agents with the integration token for each ChannelSpec: id provider:account, kind:"channel", meta.direction. Core derives the channel row + directionality from this. |
onOutboundMessage() |
host | subscribe GET /api/events?for=<channelAgentId>; the relay fans out only message.new frames addressed to that agent. |
push() |
adapter | the only transport an adapter writes — deliver one outbound message to the service. |
import type { ChannelAdapter } from "agent-relay-channels-host/adapter";
export const myAdapter: ChannelAdapter = {
provider: "ntfy",
displayName: "ntfy",
configSchema: { properties: { NTFY_TOPIC: { type: "string" } }, required: ["NTFY_TOPIC"] },
channels(ctx) {
const topic = ctx.get("NTFY_TOPIC");
return topic ? [{ account: "default", direction: "outbound", config: { topic } }] : [];
},
async push(message, channel, ctx) {
const topic = channel.config?.topic as string;
await fetch(`https://ntfy.sh/${topic}`, { method: "POST", body: message.body });
},
};
Register it by appending to src/adapters/index.ts — no host or core changes:
export const adapters: ChannelAdapter[] = [ntfyAdapter, myAdapter];
See src/adapters/ntfy.ts for the reference implementation.
Each adapter's configSchema fragment is merged into the connector manifest's
aggregated configSchema (src/manifest.ts); dashboard-managed settings flow back to
the adapter through ctx.get(name) (precedence: process.env > connector
config.json).
CLI
agent-relay-channels-host <start|stop|restart|status|doctor|register|manifest|daemon>
register—POST /api/connectorswith the aggregated manifest.manifest— print the aggregated manifest as{ manifest }JSON (no relay call). The relay's boot auto-register spawns this from the deployed binary, so the manifest'sbinary/commands resolve to the installed runtime path, never a repo-tree path.start— detach the daemon, report{status, running, endpoint}.status/doctor— JSON the relay persists as connector state.daemon— internal long-running process (SSE subscriptions + push routing + status server).
Config
| key | default | purpose |
|---|---|---|
AGENT_RELAY_URL |
http://127.0.0.1:4850 |
relay base the host registers channels against |
AGENT_RELAY_TOKEN |
— | integration/component token (agent:write); empty on a tokenless localhost relay |
CHANNELS_HOST_PORT |
4863 |
daemon status HTTP server port |
Plus every bundled adapter's namespaced keys. ntfy:
| key | default | purpose |
|---|---|---|
NTFY_TOPIC |
— | topic to publish to; empty disables the ntfy channel |
NTFY_SERVER_URL |
https://ntfy.sh |
ntfy server base URL |
NTFY_TOKEN |
— | optional access token (sent as Bearer) |
NTFY_DEFAULT_PRIORITY |
default |
priority when the event omits one (min…urgent) |
NTFY_DEFAULT_TAGS |
— | comma-separated tags merged into every notification |
NTFY_DEFAULT_CLICK |
— | default click-through URL |
NTFY_TIMEOUT_MS |
15000 |
per-request timeout |