ruvnet/ruflo · error
no key held for — create it or accept a grant first
Error message
no key held for ${i.channel} — create it or accept a grant first What it means
The federation channel-grant tool throws this when asked to publish a grant for a private channel whose decryption key is not present in the local key store. Grants are sealed with the channel key that must have been created locally (via channel create) or received via an earlier accept. Without a stored key entry there is nothing to encrypt, so the handler fails fast instead of publishing garbage.
Solutions
- Create the channel first (the create tool generates and stores its key), then retry the grant
- If the key was created elsewhere, accept a grant for the channel on this node first (x_federation_channel_accept)
- Verify the channel name spelling exactly matches the one used at creation
Example fix
// before: granting before creating
await grant({ channel: 'prv:abc123', pubkey: otherPubkey });
// throws: no key held for prv:abc123
// after: create the channel first
await createChannel({ channel: 'prv:abc123' });
await grant({ channel: 'prv:abc123', pubkey: otherPubkey }); Defensive patterns
Strategy: validation
Validate before calling
const HEX64 = /^[0-9a-f]{64}$/i;
if (!HEX64.test(input.pubkey)) throw new Error('pubkey must be 64 hex');
const store = JSON.parse(await fs.readFile(STORE_PATH, 'utf8'));
if (!store[input.channel]) throw new Error(`create/accept key for ${input.channel} before granting`); Type guard
const hasKey = (store: Record<string, { key: string }>, ch: string): boolean =>
Object.prototype.hasOwnProperty.call(store, ch); Try / catch
try {
await grant({ channel, pubkey });
} catch (e) {
if (String(e.message).includes('no key held')) {
await createChannel({ channel });
await grant({ channel, pubkey });
} else throw e;
} Prevention
- Create every private channel on the node that will issue grants
- Keep the key/store file persistent (back up ~/.ruflo) across reinstalls
- Use the same store path configuration on create/grant/publish
When it happens
Trigger: Calling the grant tool with a channel name that exists nowhere in the local store: the channel was never created on this node, the key file/store was deleted or points elsewhere, or the channel was created on a different machine.
Common situations: Running a fresh install and trying to grant access to a channel whose key lives on another host; wiping ~/.ruflo or changing the store path via env/config and losing local channel keys; typo in the channel name so it doesn't match the stored entry.
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 — 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/50b5941d4ff765b4.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:138
return { channel, visibility, name, keyStoredAt: STORE_FILE(),
note: 'The key never leaves this machine. Grant others with x_federation_channel_grant. There is no recovery if the key file is lost, and no revocation — removing someone means rotating to a new channel.' };
},
},
{
name: 'x_federation_channel_grant',
description: "Grant a member access to a private channel by sealing its key to their pubkey with NIP-44 (ECDH), published as a ChannelGrant event only they can open. Use when adding a participant to an existing private channel. Publishing the raw key into a channel or a chat is wrong: it is a bearer secret, and anyone who sees it can read every past and future message, because there is no revocation.",
inputSchema: { type: 'object', properties: {
channel: { type: 'string', description: 'Private channel id (prv:<16 hex>) you hold the key for.' },
pubkey: { type: 'string', description: "The member's 64-hex Nostr pubkey." },
relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS (default wss://relay.ruv.io).' },
}, required: ['channel', 'pubkey'] },
handler: async (input) => {
const i = input as { channel: string; pubkey: string; relayWs?: string };
if (!isPrivateChannel(i.channel)) throw new Error('only private channels have keys to grant');
if (!/^[0-9a-f]{64}$/i.test(i.pubkey)) throw new Error('pubkey must be 64 hex');
const t = await loadTools(); if (!t) return degraded();
const entry = readStore()[i.channel];
if (!entry) throw new Error(`no key held for ${i.channel} — create it or accept a grant first`);
const { sk, pubkey } = loadOrCreateKey(t.nt as never, KEY_FILE());
const conv = t.nip44.v2.utils.getConversationKey(sk, i.pubkey);
const sealed = t.nip44.v2.encrypt(entry.key, conv);
const relay = RELAY_WS(i.relayWs);
const eventId = await relayCall(relay, sk, t.nt, (ws) => publishEvent(ws, t.nt, sk,
[['t', 'ruflo-swarm'], ['k', 'ChannelGrant'], ['c', i.channel], ['p', i.pubkey]],
JSON.stringify({ type: 'ChannelGrant', channel: i.channel, sealed, ts: new Date().toISOString() })));
return { ok: true, channel: i.channel, grantedTo: i.pubkey, grantedBy: pubkey, eventId,
note: 'Only that pubkey can open the seal. Grants are not revocable — rotate the channel to remove someone.' };
},
},
{
name: 'x_federation_channel_accept',
description: 'Accept private-channel grants addressed to your key: finds ChannelGrant events tagged to your pubkey, opens each with your own secret key, and caches the channel keys locally. Use when someone tells you they granted you a channel. Asking them to send you the key directly is wrong because it exposes a bearer secret in a channel you do not control.',
inputSchema: { type: 'object', properties: {
sinceSeconds: { type: 'number', description: 'Look-back window (default 7 days).' },
relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS.' },
}, required: [] },View on GitHub (pinned to 2602b642d9)