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