CADS-Tunnel docs
How-to

Serve a callable service over a channel

This is the actual mechanism a workflow pipeline’s role-serving agents use to answer another agent’s request — the same one the flappy-demo and cookbook-demo crew bridges call. See Workflow pipelines & the auction model for how that fits into an auction, and Set up an Agent-Fabric channel for admission if you need a grant instead of the direct-address path used here. Every command and every line of output below was actually run — two fully independent, separately-started processes, real Noise transport, no shortcuts.

1. Two identities, same as any channel

./ct-agent channel init   # peer A — will call the service
./ct-agent channel init   # peer B — will serve it

Each prints its own CT_CHANNEL_HOLDER_KEY/CT_CHANNEL_NOISE_KEY — see Set up an Agent-Fabric channel if this is new.

2. Write a handler

The handler is just a script: it reads the request on stdin, writes its reply to stdout. CT_SERVICE_TYPE tells it which registered service was actually invoked, useful if one script backs several.

cat > handler.sh <<'EOF'
#!/bin/sh
read -r INPUT
echo "hello from the physics service, you said: ${INPUT}"
EOF
chmod +x handler.sh

3. Peer B: serve it

CT_CHANNEL_ROLE=accept CT_CHANNEL_ADDR=127.0.0.1:19601 \
CT_CHANNEL_NOISE_KEY=<peer B's noise private key> \
CT_CHANNEL_PEER_NOISE_KEY=<peer A's noise public key> \
CT_CHANNEL_SERVE=1 CT_AGENT_SERVICE_HANDLER_CMD=./handler.sh CT_AGENT_SERVICES=text_generation \
./ct-agent channel

Real output — note the second line confirming the handler actually registered, and the printed cert the initiator needs (same direct-address pattern as a plain channel connection):

ct-agent channel: --serve mode (MCP-over-channel; tool: ping — set CT_AGENT_CARD_* to also expose agent/card)
ct-agent channel: --serve also exposing 1 service tool(s) via CT_AGENT_SERVICE_HANDLER_CMD
ct-agent channel: listening on 127.0.0.1:19601 (responder); peer must set CT_CHANNEL_PEER_CERT=308201...

CT_AGENT_SERVICES isn’t limited to code_generation/security_review/safety_check/ text_generation — those four map to a built-in type, but any other slug (e.g. audio_generation) still registers as a real, callable service/<slug> tool via ServiceType::Custom. See Environment variables (channels, cards, offers) for the full explanation, including how to expose more than one.

4. Peer A: call it, once, from a separate process

echo "what is g on Mars?" | \
CT_CHANNEL_ROLE=initiate CT_CHANNEL_ADDR=127.0.0.1:19601 \
CT_CHANNEL_NOISE_KEY=<peer A's noise private key> \
CT_CHANNEL_PEER_NOISE_KEY=<peer B's noise public key> \
CT_CHANNEL_PEER_CERT=<the hex cert peer B printed> \
CT_CHANNEL_CALL_SERVICE=text_generation \
./ct-agent channel

Real output, actually run against a fresh accept-side process in a second terminal:

ct-agent channel: --call-service text_generation (one service call over the channel, then exit)
ct-agent channel: connected to 127.0.0.1:19601 (initiator)
hello from the physics service, you said: what is g on Mars?

Peer A’s stdin became handler.sh’s stdin on Peer B, over the real Noise-encrypted channel; the handler’s stdout came straight back as Peer A’s stdout, and it exited 0.

Correction to an earlier version of this page. It previously claimed Peer B's process "keeps running afterward... not a one-shot." Checked again, properly this time: the direct-address accept path shown above serves exactly one session and then exits — confirmed by re-running the whole sequence and finding the process gone immediately after, and by reading run_channel_command's source directly: its accept branch calls endpoint.accept() once, runs one session, and returns — no loop at all. A second call against the same still-running process fails with a real, reproduced TimedOut, not a guess.

Serving many calls without restarting: the broker-mediated path

The “stays parked, serves whoever dials next” behavior is real — it’s just gated on broker-mediated mode (CT_CHANNEL_BROKER/CT_CHANNEL_RELAY, see Set up a broker-mediated channel, or Serve your own service, solo if you don’t have a known second party to derive a channel id against yet), not direct-address. An accept-side member with CT_CHANNEL_SERVE=1 there parks, serves a peer, and loops back to admit the next automatically — no external restart loop needed, and (per a later hardening pass) admits a new peer even while a slow session is still in flight, rather than waiting for it to finish first. This is exactly what a crew bridge dials repeatedly in production.

Click-tested against the live production edge, not just source-confirmed: two sequential real calls from two separate initiator processes against one long-lived accept-side process (CT_CHANNEL_SERVE=1) both succeeded without restarting it — real output included each call’s own PID and timestamp, proving the same process really did answer both. Also source-confirmed (should_serve_loop, gated on accept-side + serve mode) and covered by ct-agent’s own test suite, including a dedicated regression test that admits five concurrent peers while one session is deliberately kept slow.

Serving from behind NAT (no dialable address of your own)

Everything above assumes you can either accept a direct dial or fall back to the plain edge relay. If you’re behind NAT with no address to advertise at all, add two more variables to your CT_CHANNEL_SERVE=1 config: CT_CHANNEL_RELAY_ONLY=1 and CT_CHANNEL_RELAY_GATE (+ CT_CHANNEL_RELAY_GATE_CERT) — see the environment variable reference for exact values and what they do. This is a real, deployed path (not a lab-only feature): grant + possession pre-auth against the edge, then relayed through an internal, network-isolated relay-node — live-confirmed working between two genuinely separate, both-NAT’d real members.

Both members of the pair must set this, not just the one behind NAT — a member that doesn’t will stay on the plain edge relay, which is a real protocol mismatch with a peer that does set it, not a graceful fallback. If you set up a service and calls into it fail immediately after a connection attempt with no clear reason, check whether your caller and your --serve process actually agree on this.

Building a real pipeline role from this

A crew bridge’s CREW_<ROLE>_CMD (or COOKBOOK_<ROLE>_CMD) is exactly the command from step 4 — CT_CHANNEL_CALL_SERVICE=<service> with the grant/broker variables from a real admitted channel instead of the direct-address ones shown here. The role-serving side is the broker-mediated pattern above, kept running long-term — not step 3’s direct-address version, which is genuinely single-shot.

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