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
- Run x_federation_channel_accept with a valid grant code for that channel before publishing
- Confirm you are publishing on the same host/key file where the grant was accepted (check RUFLO_NOSTR_KEY_FILE and store paths)
- 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
- Accept the grant on the same host and key file you publish from
- Check for the channel key in the local store before sending on prv: channels
- Keep ~/.ruflo backed up so accepted keys survive machine migrations
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
- no key held for — create it or accept a grant first
- claim rejected ( )
- channel must be pub: or prv:<16 hex>
- Concurrent write detected on aggregate
- Consensus is disabled
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)