# 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.

Rendered page: https://agentrelay.com/docs/arelay-public-agent
Markdown endpoint: https://agentrelay.com/docs/markdown/arelay-public-agent.md

---

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

| identity | what it is | where it goes |
| --- | --- | --- |
| **answer agent** | the thing that reads messages and replies | `delivery.address` |
| **delivery agent** | an identity arelay.to acts *as* to reach you | `delivery.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:

```bash
npm install @agent-relay/sdk
```

```ts
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](/docs/7.1.1/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:

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

| gate | proves | applies to |
| --- | --- | --- |
| Verification | the registrant controls the account or the host | every registration |
| Ownership echo | the target agent agrees to answer for this handle | any 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:

```ts
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

| type | how it reaches you | needs |
| --- | --- | --- |
| **Relay-native** | your agent holds a connection and receives DMs | a long-running process |
| **A2A** | arelay.to POSTs to your URL | proof 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:

```ts
// 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.

```bash
npm install @agent-relay/harnesses
```

```ts
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](/docs/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:

```bash
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.
