---
title: "Tunnels"
description: "A public HTTPS hostname for an agent running somewhere it cannot be reached."
canonical_url: "https://www.agent-identity.dev/docs/tunnels"
markdown_url: "https://www.agent-identity.dev/docs/tunnels.md"
last_updated: "2018-10-20"
x_farming_labs_generated_preamble: true
agent:
  tokenBudget: 800
  task: "Give a locally running agent a public hostname by creating a tunnel and connecting the CLI client to the edge."
  outcome: "GET /v1/tunnels/:id reports connected true, and a request to https://<hostname>.<PUBLIC_TUNNEL_DOMAIN> reaches the local process."
  appliesTo:
    package:
      - "@aid/cli"
      - "@aid/tunnel-edge"
      - "@aid/tunnel-protocol"
  prerequisites:
    - "The tunnel-edge service is running and reachable at TUNNEL_EDGE_URL."
    - "PUBLIC_TUNNEL_DOMAIN resolves to the edge, with a wildcard DNS record and a wildcard TLS certificate."
    - "A logged-in CLI, since connect reuses the stored API key rather than minting a session token."
  files:
    - "packages/cli/src/tunnel.ts"
    - "apps/tunnel-edge/src/server.ts"
    - ".env"
  commands:
    - run: "aid tunnel create --agent <identity-id> --hostname support"
      description: "Reserve a hostname for the agent."
    - run: "aid tunnel connect <tunnel-id> --local http://localhost:3000"
      description: "Open the reverse WebSocket and forward requests to the local process."
  sideEffects:
    - "connect holds a long-lived WebSocket open until interrupted; closing it takes the hostname offline immediately."
  verification:
    - description: "With connect running, read the tunnel."
      expect: "GET /v1/tunnels/:id returns connected true; a request to the public hostname is served by the local process."
  rollback:
    - "Interrupt the connect process. The tunnel row remains and can be reconnected; there is no delete-tunnel route."
  failureModes:
    - symptom: "The WebSocket upgrade is rejected."
      resolution: "Auth is the x-api-key and x-tunnel-id headers on the upgrade itself. A revoked key, or a tunnel id belonging to another agent, fails at upgrade time with no frames exchanged."
    - symptom: "connected stays false although the client reports success."
      resolution: "connected reflects an active tunnel_connections row written by the edge. If the client connected to a different edge than the API reads, TUNNEL_EDGE_URL and the edge's own database point at different places."
    - symptom: "The public hostname does not resolve."
      resolution: "PUBLIC_TUNNEL_DOMAIN needs a wildcard DNS record and a matching wildcard certificate. The default tunnel.localhost is for local development only."
---

# Tunnels
URL: /docs/tunnels
LLM index: /llms.txt
Description: A public HTTPS hostname for an agent running somewhere it cannot be reached.
Related: /docs/cli, /docs/getting-started/quickstart, /docs/concepts/identities

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Give a locally running agent a public hostname by creating a tunnel and connecting the CLI client to the edge.
Outcome: GET /v1/tunnels/:id reports connected true, and a request to https://<hostname>.<PUBLIC_TUNNEL_DOMAIN> reaches the local process.

### Applies To

- Package: `@aid/cli`, `@aid/tunnel-edge`, `@aid/tunnel-protocol`

### Prerequisites

- The tunnel-edge service is running and reachable at TUNNEL_EDGE_URL.
- PUBLIC_TUNNEL_DOMAIN resolves to the edge, with a wildcard DNS record and a wildcard TLS certificate.
- A logged-in CLI, since connect reuses the stored API key rather than minting a session token.

### Files

- `packages/cli/src/tunnel.ts`
- `apps/tunnel-edge/src/server.ts`
- `.env`

### Commands

- `aid tunnel create --agent <identity-id> --hostname support` — Reserve a hostname for the agent.
- `aid tunnel connect <tunnel-id> --local http://localhost:3000` — Open the reverse WebSocket and forward requests to the local process.

### Side Effects

- connect holds a long-lived WebSocket open until interrupted; closing it takes the hostname offline immediately.

### Verification

- With connect running, read the tunnel.
  - Expected: GET /v1/tunnels/:id returns connected true; a request to the public hostname is served by the local process.

### Rollback

- Interrupt the connect process. The tunnel row remains and can be reconnected; there is no delete-tunnel route.

### Failure Modes

- The WebSocket upgrade is rejected. — Recovery: Auth is the x-api-key and x-tunnel-id headers on the upgrade itself. A revoked key, or a tunnel id belonging to another agent, fails at upgrade time with no frames exchanged.
- connected stays false although the client reports success. — Recovery: connected reflects an active tunnel_connections row written by the edge. If the client connected to a different edge than the API reads, TUNNEL_EDGE_URL and the edge's own database point at different places.
- The public hostname does not resolve. — Recovery: PUBLIC_TUNNEL_DOMAIN needs a wildcard DNS record and a matching wildcard certificate. The default tunnel.localhost is for local development only.
<!-- farming-labs:agent-contract:end -->

# Tunnels

An agent on a laptop, behind NAT, in a container with no ingress — none of these can
receive an inbound HTTP request. A tunnel gives it a public hostname anyway: the agent
dials out to the edge over a WebSocket, and the edge forwards inbound requests back down
that connection.

<Callout type="info">
  This is Phase 2 scope. The edge server, the frame protocol, and the CLI client are
  implemented; it sits outside the MVP wedge of agent, email, and durable events.
</Callout>

## Create one

```bash title="terminal"
aid tunnel create --agent "$AID_AGENT" --hostname support
```

```
Created tunnel 3c9f… — https://support.tunnel.example.com
```

`--hostname` is optional and defaults to the identity's handle. The public name is that
hostname under `PUBLIC_TUNNEL_DOMAIN`.

Same thing over HTTP:

```bash title="terminal"
curl -sS -X POST "https://api.agent-identity.dev/v1/identities/$AID_AGENT/tunnels" \
  -H "authorization: Bearer $AID_KEY" \
  -H 'content-type: application/json' \
  -d '{"hostname":"support"}'
```

```json
{
  "id": "3c9f…",
  "identity_id": "6f21…",
  "hostname": "support.tunnel.example.com",
  "status": "active",
  "connected": false,
  "edge_url": "wss://tunnel.example.com",
  "created_at": "2026-09-27T10:00:00.000Z"
}
```

`connected` is `false` until a client actually dials in. Creating a tunnel reserves the
name; it does not open anything.

## Connect

```bash title="terminal"
aid tunnel connect 3c9f… --local http://localhost:3000
```

```
Connecting tunnel 3c9f… -> http://localhost:3000... (Ctrl-C to stop)
Connected. Forwarding requests to http://localhost:3000
```

The process stays in the foreground and holds the WebSocket open. Interrupt it and the
hostname goes offline immediately. `--local` defaults to `http://localhost:3000`.

Authentication is the API key the CLI is already logged in with, sent as `x-api-key`
alongside `x-tunnel-id` on the WebSocket upgrade itself. There is no separate session
token to mint and no extra round trip before the connection opens.

## Inspect

```bash title="terminal"
aid tunnel list --agent "$AID_AGENT"
```

```
3c9f…  https://support.tunnel.example.com  active
```

`GET /v1/tunnels/:id` returns one, including live `connected` status derived from whether
the edge currently holds an active connection row.

## Deployment

Two variables, both defaulted for local development and both needing real values in
production:

- `PUBLIC_TUNNEL_DOMAIN` — the parent domain hostnames hang off. Needs a wildcard DNS
  record and a wildcard TLS certificate. Defaults to `tunnel.localhost`.
- `TUNNEL_EDGE_URL` — the WebSocket URL clients dial. Defaults to `ws://localhost:8081`.

The edge is a separate service (`apps/tunnel-edge`), so it scales and deploys
independently of the API.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
