An implementation-gap memo on the messaging layer the official tooling assumes a client owns.
Why this memo exists
This is the messaging-layer follow-on to my earlier memo on always-on Post Fiat agent deployment. That one covered the infrastructure baseline — hardening, firewall ordering, kernel patching, the wallet creation gap when no MCP client is present. This memo lives one layer deeper, inside the messaging subsystem itself.
The premise that worked for the deployment memo holds here too: the official @postfiatorg/pft-chatbot-mcp package and the Task Node Messages UI together assume an operator working through an MCP-compatible client (Cursor, Claude Desktop) for tool calls and through the Task Node UI for wallet operations. A headless operator orchestrating MCP tools directly on a VPS uses neither, and the messaging layer's tool surface starts revealing its own gaps the moment that operator tries to receive their first encrypted message.
What follows is the second pass at filling those gaps, scoped strictly to messaging.
The umbrella thesis
The MCP exposes sixteen tools. Sufficient for an operator working inside a client that orchestrates them. Insufficient for an operator setting up an agent end-to-end from outside that flow.
The shape of the gap is consistent: the messaging layer treats certain on-chain operations as side effects of higher-level workflows rather than first-class operator actions. Publishing the bot's encryption key happens as a side effect of registering the bot. Reading a message's content happens as a side effect of decryption performed inside another tool. The operator who needs the side effect without the workflow has no clean path.
This shape isn't a bug. It's a coherent design choice that assumes a particular operator pose — one that uses the official client for tool calls and the Task Node UI for wallet management. Outside that pose, the seams show.
Trap 1: The MessageKey chicken-and-egg
The Task Node Messages UI refuses to send encrypted messages to any wallet that hasn't published a MessageKey on-chain. The exact error message is: "This contact has not published a messaging key yet. They must unlock their wallet in Task Node at least once before you can send encrypted messages."
That sentence is unambiguous: the documented path to publishing a MessageKey is to unlock the wallet through the Task Node UI. The UI publishes the key as a side effect of unlock.
A headless operator who has never touched the Task Node UI — and shouldn't, because pasting a production bot's seed into a browser-based wallet flow defeats the entire point of running the bot on isolated infrastructure — cannot follow that path. The bot wallet exists only on the VPS, only in the MCP's environment, only in the operator's offline backups. It has no Task Node UI presence and never will.
The bot cannot receive a test message until it publishes a MessageKey. It cannot publish a MessageKey through the documented path. Chicken and egg.
Trap 2: No standalone MessageKey publish tool
The MCP exposes fifteen tools — check_balance, create_wallet, delete_bot, get_attachment, get_bot, get_message, get_thread, get_wallet_info, ping, register_bot, scan_messages, search_bots, send_message, send_pft, and upload_content. None is publish_message_key or anything equivalent.
The MCP's own startup banner hints at where the key gets published: "[ping] Skipped – no API key yet. Run register_bot first, then restart." Registration is the documented MCP path, just as UI-unlock is the documented front-end path.
Registration is the wrong shape for an unfinished agent. The moment register_bot succeeds, the bot appears publicly in the agent directory. If command handlers don't work yet, every user who messages the new entry gets silence — a measurable cost in operator credibility before launch. The operator who wants the key published without registering has no clean path through the public tool surface.
Trap 3: The publishMessageKey internal workaround
The MCP package contains a function publishMessageKey(config, wallet, messageKeyHex) exported from dist/chain/submitter.js. This is the function register_bot calls internally to land the key on chain — the actual atomic operation an operator needs, wrapped inside a higher-level workflow.
A headless operator can import this function directly, load the bot's wallet, and call it. I verified it on the TVO build: the transaction lands with tesSUCCESS and the bot is immediately reachable. But the workaround's existence is the trap, not the solution. The operator is paying for the absence of an exposed publish_message_key tool by reaching past the MCP's public surface into an internal API with no documentation, no stability guarantee across future versions, and no entry in any tool listing.
What's structurally missing isn't a clever workaround. It's a first-class tool that does what publishMessageKey does, independent of registration, named and documented in the MCP's public surface. Until that exists, every headless operator who hits the chicken-and-egg has to discover the workaround themselves or burn time on the bot-register-then-delete dance.
Trap 4: The X25519 key-derivation trap
Calling publishMessageKey requires the bot's X25519 encryption public key in a specific hex format. First instinct: derive it from the wallet seed using a standard crypto library like tweetnacl. This fails. The PFTL family seed (the sEd... format) isn't raw bytes — it's a base58-encoded representation that needs proper decoding before being fed to lower-level crypto primitives. Passing the raw seed string to tweetnacl.sign.keyPair.fromSeed produces a bad seed size error and a dead end.
The clean path is to skip the derivation entirely. The MCP's own get_wallet_info tool returns an x25519_encryption_key field as part of its standard response. The MCP has already done the correct derivation internally. Reading the value from get_wallet_info and formatting it as the MessageKey transaction expects is the entire derivation step.
An operator who reaches for a crypto library before checking what get_wallet_info already provides loses an hour or more to seed-encoding debugging. The information is there; the documentation doesn't point at it.
Trap 5: scan_messages returns envelopes, not content
A polling-loop architecture calls scan_messages periodically to check the bot's inbox. The response, with encryption stripped from the structure, looks like this for each message:
tx_hash, sender, recipient, direction, amount_drops, amount_pft,
memo_type, cid, thread_id, content_kind, is_encrypted, ledger_index, timestamp
Notable absence: any field containing the message's text. A first-time builder reads this response, sees is_encrypted: true and no content field, and concludes the message body must be elsewhere — but the documentation doesn't say where. The intuition that scan_messages is the read-everything tool is wrong.
The actual content lives off-chain (referenced by the cid field, an IPFS content ID) encrypted to the recipient's X25519 key. Fetching and decrypting it is the responsibility of a separate tool: get_message, called with the tx_hash as input. The MCP handles the IPFS fetch and the decryption transparently using the operator's seed. The two-step pattern is intentional — scan_messages is cheap and listing-shaped, get_message is per-message and decryption-heavy.
This split is sensible architecture. It's invisible from the MCP's documentation, and an operator who builds the polling loop without this knowledge will spend time looking for a message body that isn't there.
Trap 6: Empty-content command-parsing false trail
Following directly from Trap 5: if a command-routing scaffold reads the content field from scan_messages envelopes and treats it as the user's input, every encrypted message routes the same way — to "no command matched, here's help." The handler that should fire never fires. The bot appears to be ignoring real commands.
The failure mode is visually identical to a broken command parser. The actual cause is that the field being parsed is reliably empty because content lives one tool call away. An operator debugging this without the envelope/content split in mind will verify their parsing logic (works), verify handlers are registered (they are), add log statements (which only confirm the empty content without triggering the realization), and burn variable time chasing parser or registration bugs before looking more carefully at the scan_messages response shape. The cost is bounded but real — every operator hits it once if they don't know to expect it.
What the docs assume
The Phase 1+2 deployment memo identified a structural assumption gap at the infrastructure layer: the docs assume a laptop, the directory requires always-on. The messaging layer's gap follows the same shape one level deeper: the docs assume a client orchestrates tool calls and a UI manages wallet operations. A headless VPS operator does both directly, and the seams that don't matter inside a client-driven flow become hard walls in the operator-driven one.
The structural fix in both cases is the same: surface what's currently implicit. Add a publish_message_key tool. Document the envelope-versus-content split prominently in the scan_messages reference. Note that get_wallet_info is the source of truth for the X25519 key. None of these require architectural changes — just documentation that acknowledges operators who aren't following the client/UI happy path.
Until that happens, the chicken-and-egg, the internal workaround, the derivation trap, and the false-trail debugging are paid in operator hours by everyone who tries to deploy without a laptop in the loop.
Closing observation
This memo isn't a tutorial. It's a map of one specific subsystem — encrypted messaging bootstrap — where the official tooling's assumptions diverge from a headless operator's reality. The six traps above will save another operator somewhere between two and eight hours depending on which ones they hit and how quickly they recognize the failure modes.
If you're building with Cursor or Claude Desktop pointed at the MCP, and you go through Task Node Messages to test, none of this applies. If you're building outside both, the six traps above are unavoidable on the current tool surface.