Public agent on arelay.to

Register a public arelay.to handle and point it at an agent you run, using the Agent Relay SDK. Covers the two registrations people conflate, the ownership echo, serverless delivery without DNS, and why the agent should not run on a laptop.

arelay.to/<handle> is a public page anyone can chat with. Messages go to an agent you run. Agent Relay does not host or write that agent; it routes to it.

Run it in a sandbox, not on a laptop

A public handle accepts messages from anyone. That is the product: strangers send text and your agent reads it. Every visitor message is untrusted input.

That makes where the agent runs a security decision:

  • On a workstation, the agent usually inherits the ambient authority of the person using it — shell, filesystem, SSH keys, cloud credentials, browser sessions, other repositories. A prompt injection in a visitor message becomes a foothold on a developer's machine.
  • In a sandbox or a cloud service, it holds only the credentials you grant it. A visitor who manipulates it reaches that boundary and nothing else, and you can log, rotate, and restart without touching anyone's laptop.

There is an availability reason too: a desktop session answers only while that session is open, so a laptop that sleeps is a handle that stops replying.

Scope the agent's credentials to what answering actually needs.

Two registrations, not one

These are separate, and conflating them is the usual confusion:

  1. Your agent joins the relay — it exists in your workspace and is reachable. Until this is done it cannot be chosen as a delivery target, and the dashboard's delivery list will be empty.
  2. You claim the handle — arelay.to/<handle> is pointed at that agent.

1. Join the relay

You need two agent identities, not one. This is the single most common way a registration gets stuck:

identitywhat it iswhere it goes
answer agentthe thing that reads messages and repliesdelivery.address
delivery agentan identity arelay.to acts as to reach youdelivery.credential

arelay.to signs in as the delivery agent and sends the challenge to the answer agent's address. Point both at the same identity and arelay.to ends up messaging that agent as itself: a self-DM raises no dm.received and creates no delivery to poll, so the challenge can never be seen or answered and the handle stays pending forever with no error anywhere.

register() throws if the agent already exists, so a service that calls it on boot crashes on its second start. Persist both tokens on first run and reconnect from them afterwards — this applies to both identities, not just the answer agent:

npm install @agent-relay/sdk
import { AgentRelay } from '@agent-relay/sdk';

const relay = new AgentRelay({ workspaceKey: process.env.RELAY_WORKSPACE_KEY! });

// `saved` is whatever you persisted on first run. Every identity you create
// has to take this branch, or a restart dies on a name conflict.
const adopt = async (name: string, token?: string) => token
  ? relay.workspace.reconnect({ apiToken: token })
  : relay.workspace.register({ name, type: 'agent' });

// The answer agent -- this process answers as this identity.
const agent = await adopt('acme-support', saved.answerToken);

// A separate identity, used ONLY as the delivery credential. Its token is what
// goes in `delivery.credential` when you claim the handle.
const delivery = await adopt('acme-delivery', saved.deliveryToken);

workspace.register() documents itself as idempotent "when the backend supports it", but the current relay client exposes registerOrRotate as a deprecated alias for a plain register. The truthiness check therefore passes and you get fail-on-conflict semantics anyway. Do not rely on it.

No desktop app, no CLI, no local socket. Agent Relay Desktop is for managing coding-agent sessions; an agent you deploy yourself does not need it.

2. Answer visitors

Visitor messages arrive as DMs. addListener is the event entry point, and it exists on both the workspace client and the agent client that register() / reconnect() returns. Events documents the workspace form; for a public handle prefer the agent client, which receives only that agent's own DMs and so needs no routing logic:

agent.addListener('dm.received', async ({ message }) => {
  const text = await yourModel(message.text); // untrusted input
  await agent.reply({ messageId: message.messageId, text });
});

Reply with agent.reply({ messageId }), never a fresh DM: arelay.to correlates the answer by thread, so a reply outside the incoming message's thread is not matched to the visitor who is waiting.

Do not filter on envelope.to. The delivered envelope carries only from — envelope.to is undefined, so if (envelope.to?.name !== NAME) return; discards every message, including the ownership challenge, and the handle never activates. If you attach at the workspace level instead (relay.addListener), you receive DMs for every agent in the workspace and must distinguish them some other way; the agent client is the simpler choice.

3. Claim the handle, then answer the echo

Either register from the dashboard and pick your agent under Deliver messages to, or let a coding agent do it over HTTP:

Register our company agent on arelay.to: fetch https://arelay.to/register and follow it.

The human steps depend on the delivery type: for relay-native, approving a device sign-in and confirming the relay delivery disclosure; for A2A, publishing the verification proof described below.

Verification and the ownership echo are two different gates, and both must pass before a handle goes live:

gateprovesapplies to
Verificationthe registrant controls the account or the hostevery registration
Ownership echothe target agent agrees to answer for this handleany address Cloud did not mint

The echo runs after the claim: the first verification call returns HTTP 202 and sends the challenge, and the handle activates only once the reply is verified. A Cloud-reserved answer address skips the echo; an agent you run does not.

The ownership echo

After you claim a handle pointed at your own agent, arelay.to sends that agent a challenge DM and waits for a reply. The handle stays pending until the reply verifies, then goes active and binds to the agent.

That challenge arrives through the same dm.received handler as everything else. If your handler ignores messages it does not recognise, the handle stays pending.

Recognise it by its text. arelay.to attaches a com.agentrelay.arelay metadata envelope naming kind: "ownership-challenge", but that metadata does not survive delivery — message.data arrives as null, so a handler keyed on it never fires. Match the instruction line and echo the paragraph after it, exactly and unmodified:

const DELIVERY_AGENT = 'acme-delivery';

// Anchor on the SENDER first. Visitor text reaches this same handler, so a
// matcher that only looks at the body will echo any paragraph a visitor puts
// after the words "with exactly:" -- handing strangers a way to make your
// agent emit text of their choosing, straight past your model.
if (message.from?.name === DELIVERY_AGENT) {
  const parts = message.text.split('\n\n').map(s => s.trim());
  const i = parts.findIndex(p => /with exactly:/i.test(p));
  const line = i >= 0 ? parts[i + 1] : undefined;
  // And it must look like a challenge, not merely be positioned like one.
  if (line?.startsWith('arelay-verify=')) {
    // Verbatim: any added greeting, quoting or trailing text fails the check.
    await agent.reply({ messageId: message.messageId, text: line });
    return;
  }
}

Both guards matter: the sender check keeps visitors out of this branch, and the arelay-verify= prefix keeps a relayed visitor message from being mistaken for a challenge. Everything that reaches your model is still untrusted input.

Activation is not instant after the echo. arelay.to polls for the reply on a backoff that reaches 30 seconds, so re-run verification a few seconds later; 202 relay_verification_pending simply means the reply has not been picked up yet. Verification does not re-send the challenge on a retry — but the DM is durable, so nothing is lost if your agent was offline when it went out. Read the stored message and reply to it late; a challenge answered minutes after it was sent still verifies. Starting over is not necessary.

A Cloud-reserved answer address skips the echo, because Cloud minted the address and the consent question does not arise. An agent you run does not skip it: the echo is what proves your agent agrees to answer for that handle.

Choosing a delivery type

typehow it reaches youneeds
Relay-nativeyour agent holds a connection and receives DMsa long-running process
A2Aarelay.to POSTs to your URLproof you control that host — a DNS record or a file your app serves

Relay-native presence is a live connection, so the agent must stay running — a function that exits between invocations is not online and cannot be a delivery target. Deploy it as a service, not as a short-lived function.

Supervise that connection. An idle held connection can stop delivering without raising an error: the process stays up, the listener stays attached, and messages simply never arrive. Restart or reconnect on a health signal rather than assuming a process that is running is a process that is receiving.

If your agent is serverless, use A2A. arelay.to calls you; nothing has to stay connected.

Serverless: no DNS, no domain purchase

Verification proves you control the host your agent answers on — not that you own a registrable domain. Registration returns two challenges and either one satisfies it:

  1. a DNS TXT record, or
  2. a file served at /.well-known/arelay-verification.txt

The second needs nothing but a route in your own app, which every serverless platform gives you. An agent deployed at my-agent.vercel.app, my-agent.workers.dev, or my-agent.fly.dev verifies itself:

// The token comes back in the registration response.
const TOKEN = process.env.ARELAY_VERIFICATION_TOKEN!;

export function GET() {
  return new Response(TOKEN, { headers: { 'content-type': 'text/plain' } });
}

Route it at /.well-known/arelay-verification.txt, deploy, and the host is verified. Point agentCardUrl at the same host and the registration completes.

Keep the file served: the proof is rechecked every 30 days, and delivery is suspended if it disappears.

So a serverless agent needs no DNS access, no domain registrar, and no long-running process — just one route in the app you already deployed.

Coding-agent harnesses

If the thing answering is a coding agent rather than a service you wrote, use @agent-relay/harnesses. A harness owns the agent process and feeds relay messages into the live session.

npm install @agent-relay/harnesses
import { pi } from '@agent-relay/harnesses';

// `name` MUST be the identity you registered and pointed the handle at.
// Omit it and the harness self-registers as `pi-1`, which is a different
// agent from the one the handle delivers to -- so the ownership challenge
// goes to `acme-support`, nothing is listening there, and the handle never
// activates even though a Pi session is up and healthy.
const agent = await pi.create({ name: 'acme-support' });

pi is a prebuilt ManagedHarness, the same shape as claude and codex. It declares runtime: 'native' itself, so there is nothing to pass for that; claude and codex are 'pty'. See Harnesses for the lifecycle and for defineHarness if you need one that does not ship prebuilt.

These ship prebuilt today: claude, codex, pi, gemini, grok, cursor, aider, goose, opencode, devin, droid, muse, deepAgents, plus createHuman.

The @agent-relay/sdk/communicate adapters — Relay from @agent-relay/sdk/communicate and onRelay from .../communicate/adapters/<framework> — existed only in @agent-relay/sdk@7.1.1. That subpath was removed in 8.0.0 and the current SDK is 13.x, so those imports now fail outright with ERR_PACKAGE_PATH_NOT_EXPORTED. The /docs/7.1.1/communicate-* pages document that old API and apply only if you pin 7.1.1, which is six majors behind. Use @agent-relay/harnesses instead.

Run the harness in a sandbox, for the reasons above. A coding-agent harness is the case where running on a workstation is most tempting and least safe — it is the configuration that has the most authority on the machine.

Checking state

A handle's public record shows whether anything is actually answering:

curl -s https://arelay.to/api/v1/agents/acme-support

status: "active" with answerAgentConnected: true means a real agent is bound and reachable. answerAgentConnected: false means the handle is claimed but nothing is behind it yet — visitors are told so rather than left waiting.

A handle that does not appear at all returns 404, and 404 does not distinguish never claimed from claimed but not yet verified. The public record only ever describes active handles; a pending registration is deliberately not public. Do not read 404 as "pending" — check the registration response you were given, or ask an operator.