ruvnet/ruflo · error

no key held for — accept a grant first…

Error message

no key held for ${i.channel} — accept a grant first (x_federation_channel_accept)

What it means

When publishing to a private channel, the tool looks up the channel's key in the local store to encrypt the message with NIP-44. If no key entry exists (i.e., this node never created the channel or never accepted a grant), encryption is impossible and the handler throws before contacting the relay.

Solutions

  1. Run x_federation_channel_accept with a valid grant code for that channel before publishing
  2. Confirm you are publishing on the same host/key file where the grant was accepted (check RUFLO_NOSTR_KEY_FILE and store paths)
  3. Verify the channel id matches the one the grant was issued for (16 hex after prv:)

Example fix

// before: publishing without a key
await publish({ channel: 'prv:0123456789abcdef', msgType: 'Task', payload: {} });
// throws: no key held ... accept a grant first
// after: accept the grant first
await acceptChannel({ code: 'v2.<grant-token>' });
await publish({ channel: 'prv:0123456789abcdef', msgType: 'Task', payload: {} });
Defensive patterns

Strategy: validation

Validate before calling

const store = JSON.parse(await fs.readFile(STORE_PATH, 'utf8'));
if (channel.startsWith('prv:') && !store[channel])
  throw new Error(`accept a grant for ${channel} before publishing`);

Type guard

const canPublishPrivate = (store: Record<string, unknown>, ch: string): boolean =>
  ch.startsWith('pub:') || Object.prototype.hasOwnProperty.call(store, ch);

Try / catch

try {
  await publish({ channel, msgType, payload });
} catch (e) {
  if (String(e.message).includes('accept a grant first')) {
    await acceptChannel({ code: grantCode });
    await publish({ channel, msgType, payload });
  } else throw e;
}

Prevention

When it happens

Trigger: Calling the publish tool with a prv:<16 hex> channel for which this node holds no key: the owner never granted access, the grant was accepted on another host/key file, or the local store was reset after accepting.

Common situations: Trying to post into a private channel you were told about but never ran accept for; switching machines or wiping ~/.ruflo so the accepted key vanished; store path overridden via RUFLO_NOSTR_KEY_FILE/env so the accept and the publish see different stores.

Understand the failure class

Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.

Related errors


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

Appendix: source

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

    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.',
    inputSchema: { type: 'object', properties: {
      channel: { type: 'string', description: 'Channel id (pub:<name> or prv:<16 hex>).' },
      sinceSeconds: { type: 'number', description: 'Look-back window (default 3600).' },
      limit: { type: 'number', description: 'Max messages (default 100).' },
      relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS.' },
    }, required: ['channel'] },
    handler: async (input) => {
      const i = input as { channel: string; sinceSeconds?: number; limit?: number; relayWs?: string };

View on GitHub (pinned to 2602b642d9)