ruvnet/ruflo · error

channel must be pub: or prv:<16 hex>

Error message

channel must be pub:<name> or prv:<16 hex>

What it means

The federation publish tool validates the channel identifier against CHANNEL_ID_RE, which only accepts public channels of the form pub:<name> or private channels of the form prv:<16 hex chars>. Any other string (bare name, wrong prefix, wrong length/charset for private) is rejected before any network work.

Solutions

  1. Format public channels as pub:<name>, e.g. pub:general
  2. Format private channels as exactly prv: plus 16 lowercase/uppercase hex characters, copying the id returned when the channel was created
  3. Copy channel ids directly from the create/grant tool output instead of typing them

Example fix

// before
await publish({ channel: 'general', msgType: 'Status', payload: {} });
// after
await publish({ channel: 'pub:general', msgType: 'Status', payload: {} });
Defensive patterns

Strategy: validation

Validate before calling

const CHANNEL_ID_RE = /^(pub:[^\s]+|prv:[0-9a-fA-F]{16})$/;
if (!CHANNEL_ID_RE.test(channel)) throw new Error(`bad channel id: ${channel}`);

Type guard

const isValidChannelId = (ch: unknown): ch is string =>
  typeof ch === 'string' && /^(pub:[^\s]+|prv:[0-9a-fA-F]{16})$/.test(ch);

Try / catch

try {
  await publish({ channel, msgType, payload });
} catch (e) {
  if (String(e.message).startsWith('channel must be pub:')) {
    console.error(`Channel '${channel}' is malformed; expected pub:<name> or prv:<16 hex>`);
  } else throw e;
}

Prevention

When it happens

Trigger: Passing a channel like 'general' (no prefix), 'private:abc' (wrong prefix), 'prv:xyz' (fewer than 16 hex chars), or 'prv:zzzz...' (non-hex characters) to the publish tool.

Common situations: Users echoing a channel name from logs without the pub:/prv: prefix; hand-crafting a private channel id that is not exactly 16 hex characters; confusing this CLI's channel format with another product's channel naming.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15). Data as JSON: /api/errors/0aa1d03967cbf4bb. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:195

          accepted.push(body.channel);
        } catch { failed.push(body.channel); }
      }
      if (accepted.length) writeStore(store);
      return { pubkey, accepted: [...new Set(accepted)], unopenable: [...new Set(failed)], keyStoredAt: STORE_FILE() };
    },
  },
  {
    name: 'x_federation_channel_publish',
    description: 'Publish a message to a channel with YOUR OWN key. A private channel is encrypted locally under its channel key before it leaves this machine, and the message type is hidden behind k=enc so the relay sees only an opaque id and ciphertext. Use when the message should be attributable to you. The admin-gated gateway channel_publish is wrong for that, because it signs as the gateway and cannot reach private channels at all.',
    inputSchema: { type: 'object', properties: {
      channel: { type: 'string', description: 'Channel id (pub:<name> or prv:<16 hex>).' },
      msgType: { type: 'string', description: 'Message type (Status, Task, Result, …). Hidden on private channels.' },
      payload: { type: 'object', description: 'JSON body. Never put secrets or credentials in it, even on a private channel.' },
      relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS.' },
    }, required: ['channel', 'msgType', 'payload'] },
    handler: async (input) => {
      const i = input as { channel: string; msgType: string; payload: Record<string, unknown>; relayWs?: string };
      if (!CHANNEL_ID_RE.test(i.channel)) throw new Error('channel must be pub:<name> or prv:<16 hex>');
      const t = await loadTools(); if (!t) return degraded();
      const { sk, pubkey } = loadOrCreateKey(t.nt as never, KEY_FILE());
      const priv = isPrivateChannel(i.channel);
      const body = { type: i.msgType, from: pubkey, ts: new Date().toISOString(), ...i.payload };
      let content: string;
      if (priv) {
        const entry = readStore()[i.channel];
        if (!entry) throw new Error(`no key held for ${i.channel} — accept a grant first (x_federation_channel_accept)`);
        content = t.nip44.v2.encrypt(JSON.stringify(body), Uint8Array.from(Buffer.from(entry.key, 'hex')));
      } else { content = JSON.stringify(body); }
      const tags = [['t', 'ruflo-swarm'], ['c', i.channel], ['k', priv ? 'enc' : i.msgType]];
      const eventId = await relayCall(RELAY_WS(i.relayWs), sk, t.nt, (ws) => publishEvent(ws, t.nt, sk, tags, content));
      return { ok: true, channel: i.channel, visibility: priv ? 'private' : 'public', encrypted: priv, eventId, pubkey };
    },
  },
  {
    name: 'x_federation_channel_read',
    description: 'Read a channel and decrypt what your keys can open. Public messages come back as JSON; private ones are decrypted locally with the cached channel key, and anything you have no key for is returned as encrypted:true rather than silently dropped. Use when the channel is private: reading it through the gateway channel_sync tool is wrong there, because the gateway holds no key and can only hand you ciphertext.',

View on GitHub (pinned to 2602b642d9)