---
title: Pair a client to a self-hosted server
description: Pair a viewer client to a ggui server you run yourself — no account, no managed backend.
---

:::note[This is the self-hosted path]
You run `ggui serve` on your own machine or hardware. Paired clients connect to it directly — **no account, no managed backend**.

A managed hosted experience is coming soon; the self-hosted path on this page is the available-now route.
:::

If you followed the [OSS Quick Start](/oss-quickstart/), you already have a local `ggui serve` running. This page pairs a viewer client to it, so the client can browse agents, start chats, and view generated UIs while the server stays on your infrastructure. The pairing protocol below is live today and works with any client you build (the curl flow is the primary path); the first-party Guuey companion app (web / iOS / Android) is **coming soon**.

## Big picture

```
Your MCP agent ── MCP ──→ ggui serve (your infra)
                              │
                              │  POST /admin/pair/init (admin)  → {code, codeExpiresAt, serverName}
                              │  POST /pair              (public) → {pairingId, token, serverName, deviceName}
                              │  /ws (live channel, bearer-gated)
                              ▼
                         Paired viewer client
```

Pairing exchanges a one-shot code for a bearer token (no built-in expiry; lives until you revoke it server-side). The client stores the token and sends it on every subsequent call. Your server is the only backend involved — it never talks to `mcp.ggui.ai` or any other external service on your behalf.

## Prerequisites

- A running `ggui serve` that your client can reach over HTTP/HTTPS + WebSocket. On localhost that means serving on `127.0.0.1` and opening the client on the same machine; across the network, see [Reaching your server from a phone](#reaching-your-server-from-a-phone).
- An operator session on the server — i.e., you can mint codes. By default `ggui serve` prints a fresh `ggui_admin_*` admin token on boot (pin it with `--admin-token <t>` to survive restarts); that bearer gates `POST /admin/pair/init`. With `--dev-allow-all` any bearer — or none at all — is accepted as `builder` — fine on localhost, unsafe anywhere else (see [Hardening](#hardening-before-leaving-localhost)).

## Step 1: Mint a pairing code

The server never hands out a bearer token without first minting a code. The code is the proof-of-presence for the first exchange.

### From the operator console

`ggui serve` exposes a minimal operator UI at `http://127.0.0.1:6781/`. Click **Start pairing** — the console calls `POST /admin/pair/init` with the current session bearer and displays a 6-digit code with an expiry timer (default: 10 minutes).

### From the command line

If you prefer a headless flow (CI, scripts, tmux):

```bash
curl -sS -X POST http://127.0.0.1:6781/admin/pair/init \
  -H "Authorization: Bearer $GGUI_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"serverName":"my laptop"}'
```

Replace `$GGUI_ADMIN_TOKEN` with the `ggui_admin_*` printed on the boot banner (or whatever you passed to `--admin-token`). With `--dev-allow-all`, any bearer — or none at all — works.

Response:

```json
{
  "code": "472301",
  "codeExpiresAt": "2026-04-20T12:34:56.000Z",
  "serverName": "my laptop"
}
```

Treat the code like a one-time password — anyone who gets it in the next 10 minutes can pair. It's consumed on the first successful `POST /pair`.

If the code expires before the client finishes pairing, `POST /pair` returns `401 {error: {code: 'pairing_rejected', …}}`. There's no auto-refresh — re-run `POST /admin/pair/init` to mint a fresh code and try again. Only one outstanding code exists at a time (a second `init` overwrites the pending one).

## Step 2: Exchange the code for a bearer

Every client performs the same exchange — `POST /pair` (public, no auth — pairing IS the bootstrap for future auth) with the code and a device name:

```bash
curl -sS -X POST http://127.0.0.1:6781/pair \
  -H 'Content-Type: application/json' \
  -d '{"code":"472301","deviceName":"my client"}'
```

Response:

```json
{
  "pairingId": "…",
  "token": "…",
  "serverName": "my laptop",
  "deviceName": "my client"
}
```

Store the `token` and send it as `Authorization: Bearer <token>` on the server's MCP (`/mcp`) and live-channel WebSocket (`/ws`) endpoints.

### In the Guuey app (coming soon)

The first-party Guuey companion app (web / iOS / Android) will wrap this exchange in a **Settings → Servers → Add server** form — enter the server URL (`http://127.0.0.1:6781` or the reachable hostname), type the 6-digit code, tap **Pair**. It stores the token and shows the server with a reachability status dot. Until it ships, the curl flow above (or your own client) is the path.

## Step 3: Talk to your server

With the bearer paired, a client can:

- **List agents** declared in your server's `ggui.json#agent` entry — OSS default is the single supervised agent process `ggui serve` boots alongside MCP; no marketplace.
- **Start chats** over the live-channel WebSocket at `ws://<your-server>/ws` (or `wss://` once you front it with TLS). Messages flow through the paired MCP transport; chat history lives in your server's session store.
- **View generated UIs** — when an agent calls `ggui_render`, the result is delivered as an MCP-Apps resource (`ui://ggui/render/<sessionId>`) your server serves. The client mounts it inline (same-origin cookies on your server, your CSP).

A paired client does NOT proxy any of this — your server is the only backend involved.

## Managing pairings

- **Revoke server-side:** The operator console has a **Paired devices** list — revoking there calls `POST /admin/pair/:pairingId/revoke` (admin bearer required, idempotent). The token is removed from the active `AuthAdapter`, so the next `/mcp` or `/ws` call from that device fails with `401 No valid credentials` and the client must re-pair.
- **Rotate a lost token:** If you suspect a token leaked, revoke the server-side entry and re-run Step 1 / Step 2.

## Reaching your server from a phone

Localhost-only by default. To pair from a phone on the same LAN:

1. Bind `ggui serve` to your LAN address (or `0.0.0.0`), not `127.0.0.1`:

   ```bash
   pnpm exec ggui serve --host 0.0.0.0 --port 6781
   ```

2. Note your LAN IP (e.g., `192.168.1.42`) and use `http://192.168.1.42:6781` as the server URL in your client.

If the phone is NOT on the same network (mobile data, different NATs), you need a public endpoint. `ggui serve` doesn't ship a managed tunnel — use any of:

- **[ngrok](https://ngrok.com)** — `ngrok http 6781` and use the `https://…ngrok-free.app` URL.
- **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** — `cloudflared tunnel --url http://localhost:6781`.
- **[Tailscale](https://tailscale.com)** — put both devices on your tailnet; use the MagicDNS hostname.

Any of these makes pairing work across arbitrary networks without exposing your port to the open internet. Once you have a public `https://` URL, the live-channel WebSocket automatically upgrades to `wss://`.

## Hardening before leaving localhost

`ggui serve` defaults to strict-auth: `/mcp` only accepts pair-minted bearers, and `/admin/pair/init` requires the per-boot `ggui_admin_*` token. With `--dev-allow-all` it relaxes to accept any bearer — or none at all — as `builder` — fine on `127.0.0.1`, unsafe anywhere else.

Before you put the server on a public URL:

1. **Swap in a real `AuthAdapter`.** `createGguiServer({ auth })` accepts a custom adapter that gates both the MCP endpoint and the live-channel WebSocket. See [`@ggui-ai/mcp-server-core`](https://www.npmjs.com/package/@ggui-ai/mcp-server-core) for the `AuthAdapter` contract.
2. **Terminate TLS in front.** A reverse proxy (Caddy, nginx, Cloudflare Tunnel) is the expected shape. The bare `ggui serve` port is plaintext HTTP.
3. **Firewall the admin paths.** `POST /admin/pair/init` is the only surface that mints codes. Block the `/admin/` prefix from the public internet with one reverse-proxy rule; keep it on a local admin interface or VPN.
4. **Rotate pairing tokens.** Revoke paired devices from the operator console when they're no longer in use.

## What this does NOT include

- **Workspaces, billing, usage metering, team management.** Guuey-hosted SaaS surfaces. They do not exist on a self-hosted server.
- **Managed (operator-paid) generation.** Generation on a self-hosted server uses YOUR provider key (BYOK) — set `ANTHROPIC_API_KEY` (or another provider key) and `ggui.json#generation.model`, or paste a key at `/settings`. See [`ggui serve` → Generation](/cli/serve/#generation-bring-your-own-key).
- **Push notifications, mobile background sync, agent marketplace.** Not part of the self-hosted server today.

## What's next

- **[OSS Quick Start](/oss-quickstart/)** — fastest path from zero to a running local server.
- **[Reference deploys](/self-hosted/reference-deploys/)** — Docker / Fly.io / Render manifests for putting `ggui serve` on a public URL.
- **[MCP Protocol Reference](/api/mcp-protocol/)** — wire format, identical across OSS and hosted.
- **[WebSocket Protocol](/api/websocket-protocol/)** — live-channel envelopes (`ActionEnvelope`, `StreamEnvelope`).