CADS-Tunnel docs
How-to

Serve your own service, solo (no pipeline, no known peer)

Set up a broker-mediated channel and Set up an Agent-Fabric channel both assume two people coordinating — an operator and a member who already know each other’s public key. This page is for the narrower, very common case: you’re standing up a persistent --serve process for your own service (a local model relay, a tool, anything with a stdin/stdout handler) and you’re both the channel operator and, for now, the only member — there’s no second party’s holder_pubkey to derive a link id against yet, and possibly never will be one you know in advance.

The one thing worth internalizing before anything else on this page: the direct-address path (CT_CHANNEL_ADDR) needs no control plane at all — but broker-mediated mode (CT_CHANNEL_BROKER/CT_CHANNEL_RELAY, what persistent --serve actually uses) is never offline, no exception. Confirmed directly against the edge's own source (crates/edge/src/channel_authorize.rs): every single broker-mediated join calls POST {control-plane}/internal/channel/authorize, and the control plane's durable channel_members table is the only source of truth for who's admitted — the edge itself holds no membership state of its own. A correctly-signed CT_CHANNEL_GRANT alone is necessary but not sufficient; skip channel register + the membership POST below and every join fails with edge broker refused the channel join, however valid your grant's signature is. See How the edge decides whether to admit a channel join for the full picture.

1. Identities, same as any channel

./ct-agent channel operator-init   # once — you're the operator
./ct-agent channel init            # once — you're also the (first) member

2. Derive a channel id — self-referentially

Set up an Agent-Fabric channel’s step 3 derives the channel id from two known holder pubkeys (channel_id_for_link(operator, holder_a, holder_b)). With no second party yet, set CT_CHANNEL_BRIDGE_HOLDER to your own holder_pubkey:

CT_CHANNEL_OPERATOR_PUBKEY=<your operator pubkey> \
CT_CHANNEL_BRIDGE_HOLDER=<your OWN holder_pubkey, from step 1> \
CT_CHANNEL_HOLDER_KEY=<your own holder private key> \
CT_CHANNEL_NOISE_PUBKEY=<your own noise public key> \
./ct-agent channel member-material
This is source-verified sound, not a guess: channel_id_for_link hashes a canonically ordered (lo, hi) pair of the two holder pubkeys — well-defined even when they're identical (lo == hi == your holder, nothing rejects that). More importantly, verify_member_noise_attestation — the actual server-side check on the membership POST below — verifies only (channel, holder, noise_pubkey, signature). It never sees or checks bridge_holder at all; that value only ever shapes which channel id you land on client-side. There's nothing for a self-referential derivation to fail structurally — it's a legitimate way to get a deterministic id and a real, correctly-signed attestation when there's no second party to derive against yet.

Keep the printed channel_id — you’ll reuse it in every step below, and hand it to anyone you later grant initiate access (they’ll need it, plus their own grant).

3. Register the channel and yourself as a member

The step Set up a broker-mediated channel’s step 2 covers for the two-party case, unchanged here:

CT_AGENT_CP_URL=https://<your-plane> \
CT_GRANT_CHANNEL=<channel_id from step 2> \
CT_CHANNEL_OPERATOR_KEY=<from step 1> \
CT_OIDC_TOKEN=<your bearer token> \
./ct-agent channel register

Then register your own holder as a member — no CLI wrapper exists for this today, it’s a raw authenticated HTTP call:

curl -X POST https://<your-plane>/me/channels/<channel_id>/members \
  -H "Authorization: Bearer <your OIDC token>" -H 'content-type: application/json' \
  -d '{"holder":"<your holder_pubkey>","noise_pubkey":"<your noise_pubkey>","noise_attestation":"<from step 2>"}'

4. Grant yourself accept

CT_CHANNEL_OPERATOR_KEY=<from step 1> \
CT_GRANT_CHANNEL=<channel_id> \
CT_GRANT_MEMBER_HOLDER=<your own holder_pubkey> \
CT_GRANT_DIRECTION=accept \
CT_GRANT_EXPIRES=<unix seconds> \
./ct-agent channel grant

5. Run persistent serve

CT_CHANNEL_ROLE=accept CT_CHANNEL_SERVE=1 CT_CHANNEL_RELAY_ONLY=1 \
CT_CHANNEL_BROKER=<edge host>:4435 CT_CHANNEL_RELAY=<edge host>:4436 \
CT_CHANNEL_HOLDER_KEY=<your holder private key> CT_CHANNEL_NOISE_KEY=<your noise private key> \
CT_CHANNEL_GRANT=<from step 4> \
CT_AGENT_SERVICE_HANDLER_CMD=<your handler> CT_AGENT_SERVICES=<service, snake_case — see the callout below> \
./ct-agent channel

Fetch CT_CHANNEL_BROKER/CT_CHANNEL_RELAY from GET {cp_url}/network-info rather than hardcoding 4435/4436 — see Set up an Agent-Fabric channel.

CT_AGENT_SERVICES takes the snake_case slug (text_generation, code_generation, security_review, safety_check) — NOT the PascalCase form (TextGeneration) that a published pipeline spec's JSON uses for the same service. The two are easy to conflate reading across docs; only the snake_case form is valid here. See [Environment variables (channels, cards, offers)](/reference/channel-environment-variables/).

Letting someone else call it later

Whoever you later want to admit as a caller needs three things from you: the channel_id from step 2, a CT_GRANT_DIRECTION=initiate grant from step 4’s command (their own holder_pubkey, your operator key), and to register their own membership the same way step 3 did for you (their own member-material/attestation — most naturally with CT_CHANNEL_BRIDGE_HOLDER set to your holder pubkey this time, since now there really is a second, known party). None of this requires touching a pipeline spec or the pipeline registry — this whole page is the piece Join a workflow pipeline role and Publish a pipeline build on top of, not a prerequisite for either.

Found an error, or something that didn't work as documented? Open an issue →