zeroclaw-labs/zeroclaw · error · anyhow::Error

approval route channel '{channel_key}' does not support outb

Error message

approval route channel '{channel_key}' does not support outbound delivery (it is inbound-only); its approval notice cannot be sent (route '{route}')

What it means

The route's channel is configured, but it is inbound-only (channel.supports_outbound_send() is false). Its send() is a no-op returning Ok, so spawning it would report success while delivering nothing; deliver() refuses and surfaces the error so the notice is not silently dropped. The broker only logs this — the gate is unaffected.

Source

Thrown at crates/zeroclaw-runtime/src/sop/approval/channel_route.rs:386

        kind: ApprovalNoticeKind,
        route: &str,
        notice: &GateNotice<'_>,
    ) -> anyhow::Result<()> {
        let (channel_key, msg) = build_delivery(kind, route, notice)?;
        let Some(channel) = self.channels.get(&channel_key).cloned() else {
            // A misconfigured route (names a channel that isn't configured) is a real
            // operator error worth surfacing: return Err so the broker logs it. It
            // still never affects the gate (the broker's deliver_* wrappers only log).
            anyhow::bail!(
                "approval route channel '{channel_key}' is not a configured channel \
                 (route '{route}')"
            );
        };
        // An inbound-only channel's `send` is a no-op that returns `Ok`, so spawning it
        // would report success without delivering anything. Refuse and surface it (the
        // broker logs the Err) rather than silently dropping the notice.
        if !channel.supports_outbound_send() {
            anyhow::bail!(
                "approval route channel '{channel_key}' does not support outbound \
                 delivery (it is inbound-only); its approval notice cannot be sent \
                 (route '{route}')"
            );
        }
        // Fire-and-forget: hand the async send to the runtime and return. The gate is
        // never blocked on channel I/O; a send failure is logged in the task.
        // Native gate prompt first (buttons / keyboards, answered through the
        // channel's inbound path); channels without one fall back to the text
        // notice, whose `approve <run_id>` reply the orchestrator also resolves.
        let prompt = build_gate_prompt(kind, notice);
        let recipient = msg.recipient.clone();
        let run_id = notice.run_id.to_string();
        let route = route.to_string();
        self.handle.spawn(async move {
            let prompted = match channel.send_gate_prompt(&recipient, &prompt).await {
                Ok(prompted) => prompted,
                Err(e) => {

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Point the approval route at a channel with outbound delivery (e.g. a discord/slack/telegram channel key).
  2. If the integration supports sending, enable/configure its outbound side so supports_outbound_send() is true.
  3. Keep a documented list of outbound-capable channel keys and validate routes against it in config review.

Example fix

# before
[channels.webhook-ingest]   # inbound-only
url = "https://..."
[approval]
route = "webhook-ingest:ops"

# after
[channels.discord.ops]
token = "..."
[approval]
route = "discord.ops:123456789"
Defensive patterns

Strategy: validation

Validate before calling

let (channel_key, _) = route.split_once(':').expect("validated route");
if let Some(ch) = channels.get(channel_key) {
    assert!(ch.supports_outbound_send(), "channel '{channel_key}' is inbound-only; pick an outbound channel for approvals");
}

Type guard

fn route_channel_can_send(route: &str, channels: &ChannelMap) -> bool {
    route.split_once(':')
        .and_then(|(c, _)| channels.get(c))
        .map(|ch| ch.supports_outbound_send())
        .unwrap_or(false)
}

Try / catch

match router.deliver(kind, route, &notice).await {
    Err(e) if e.to_string().contains("inbound-only") => {
        log::warn!("route {route} targets an inbound-only channel; repoint at an outbound-capable one");
    }
    rest => rest?,
}

Prevention

When it happens

Trigger: deliver() with a route whose channel half names an inbound-only channel (e.g. a webhook listener or feed-style channel) — any channel registered without outbound send support.

Common situations: Routing approvals to a webhook/ingest channel by mistake; channel type changed to inbound-only in an upgrade; reusing an existing inbound channel key for approvals instead of adding a two-way one; misreading which integrations can send messages.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/35e1274da343a7c3. Report an issue: GitHub.