NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

Your reply leaves from the account that received it

A conversation is bound to the account that received it, so answering a message from your second workspace posts as that workspace and not as whichever one connected first. Where the system cannot be sure which identity you meant, it says which one it used โ€” or refuses.

Product Journal free August 9, 2026ยท9 min read
TL;DR We are building support for connecting two accounts on the same chat network โ€” two Slack workspaces, two Discord logins โ€” and having replies leave through the one the message arrived on. This landed on 9 August and is still in implementation, not finished: naming an account is honoured for Slack and Discord sends and for replies, and the account picker is deliberately shown only where it is real.

How replying from two accounts is supposed to work

Start with the word account, because it is doing a lot of work here. An account in this piece is a login on an outside chat network โ€” one of your Slack workspaces, one of your Discord logins. You can connect more than one of them, and once you do, every message you send has an unavoidable question attached to it: which of me is speaking? Whoever receives it sees a name and an avatar, and those belong to one login, not to you in general.

The rule that answers it is one sentence. A conversation is bound to the account that received it. When a conversation is first followed, the system writes down which of your logins the message came in on, and the reply path reads that note back. Answering a message from your second workspace posts as that workspace.

That binding is not a preference you configure. It is a fact that was already true at the moment the message arrived; the system's only job is to record it and honour it.

flowchart TD
  S["you send a message in a conversation"] --> B{"does this conversation
remember which account
it arrived on?"} B -- yes --> R{"is that account
connected right now?"} R -- yes --> OK["send as that account"] R -- no --> NO["refuse, and say which account
was wanted and which are here.
Nothing is sent as someone else"] B -- "no (the DEFAULT for every
conversation recorded before this,
and for any the system was
not told about)" --> P["send as the network's primary โ€”
the account that connected first"] P --> L["the answer says it used the primary,
so a two-account view can show which"]

The default: what happens when nobody said

This is the part worth reading twice, because it is where a feature like this usually lies to you.

If the conversation carries no account, the message goes out as the network's primary. Primary is not a chosen or preferred login โ€” it is simply the one that connected first, and because saved logins are restored at startup before any interactive sign-in, in practice that is the one you had already saved. (There is a way to pin a different one, and if the primary disconnects, the oldest surviving account is re-elected.) Every conversation recorded before this rule existed carries no account, so for anyone with a single login the behaviour is unchanged, deliberately and exactly.

And the system will not invent one. When a conversation is first followed while two accounts are connected, and the part doing the following does not say which one it came in on, the binding is left empty rather than filled with the primary's name. The reasoning is in the code: writing the primary there would manufacture a fact nobody observed, and every later reply would trust it. An absent binding is a known unknown that resolves to a stated default. A fabricated one is a lie with a long half-life.

Be clear about what that costs, though: in exactly the scenario multi-account exists for, a conversation can still end up with no binding, and then the reply does leave through the first-connected account. The improvement is that the answer you get back says it used the primary, instead of not saying anything at all.

Four ways to answer, one refusal, and it always says which

When a message does name an account, the resolution is not a yes-or-no. It is one of five outcomes, and the caller is told which:

  • exact โ€” you named an account and it is connected. It sends as that account, never as another. "Send as X" quietly becoming "send as Y" is the substitution this whole design exists to abolish.
  • primary โ€” you named nothing. The first-connected account, as above.
  • the network's sole unnamed connection โ€” you named an account, it is not registered, and this network has exactly one connection which claims no account at all. That one carries the message, flagged. This is the compatibility case for everything recorded before per-account routing existed, and it is narrow on purpose: the moment a second connection appears, or the single one names a different account, this stops applying.
  • an unlistable connection table โ€” the lookup cannot enumerate what it holds, so "is a different account of this network connected?" is unanswerable. It falls back to the older behaviour, flagged.
  • refusal โ€” you named an account and a different one is connected. Nothing is sent. The refusal names what was asked for and what is present.

Two of those five are labelled "I did something, and here is why it might not be what you meant." That labelling is the actual feature. A silent correct answer and a silent wrong answer look identical from outside.

The same discipline runs through picking a network for a person, and it carries a sharp rule from the owner: which network and which account carries a message is a user decision, not something to be inferred from a priority table. So when you choose from the dropdown, your choice is honoured or refused โ€” never traded for a different network or a sibling account, because "that account" and "some other account of mine" are different people to whoever receives it. When you choose nothing, the system may widen to a connected sibling if the primary is down. Choosing is what turns the fallback off.

And the picker itself only appears where the choice is real. Naming an account is honoured on Discord and Slack sends and on the reply path; other networks' plain sends do not consult it, so showing a picker there would be, in the author's words, "a lying affordance" โ€” a control implying a choice the system will not honour, which is worse than no control, because you will believe it.

Saved logins are kept per account

Your credentials for each network are sealed and stored, and they are stored under a key that includes the account, not just the network. That is what allows two Slack workspaces to both be signed in at once instead of one quietly replacing the other's tokens.

What was wrong

You are in two Slack workspaces. A message arrives from the second one. You answer it, and your answer is posted into the first โ€” under the wrong identity, in a channel where everyone can see it.

That is not a hypothetical, and it was not a rare edge. Until 9 August it was the guaranteed behaviour of connecting a second account on a network you were already connected to, in three compounding ways.

Adding a second account did not add anything. The place live connections are recorded was a table keyed by network name, and every connection hard-codes its network, so a second Discord login overwrote the first. Worse than overwriting: because the inbound wiring attaches to the connection itself rather than to that table, the evicted one kept receiving messages while being invisible to everything that sends. Half-connected, in a direction nothing reported.

Stored credentials had the same shape. One saved row per network meant a second account's saved login superseded the first. That bug did not even need two accounts to bite โ€” Discord's bot credentials and user credentials were already sharing that single row, distinguished only by which key inside it was set, so saving one quietly buried the other.

And nothing that sent a message ever asked which account a conversation belonged to. The answer came from whichever single connection the system happened to hold for that network โ€” before this work, literally the most recently added one, because a second connection replaced the first outright. There was no code path in which it could do anything else.

How it was fixed โ€” and what is still not fixed

The connection table became keyed by account rather than by network, so a second login registers alongside the first instead of evicting it. The conversation binding described above was written down and read back on the reply path. And the five-outcome resolution replaced the single implicit lookup, so that every send either goes as the account you meant or tells you it did something else.

The upgrade had an obvious trap. Moving credentials to be per-account means that, on upgrade, your existing login is under the old network-wide name, the new code looks under the new per-account one, finds nothing, and tells you that you were never connected โ€” a capability loss wearing the costume of a routine sign-in prompt.

So the first account-scoped read that misses falls back once to the old row, hands it back, and re-saves it under the account. With one guard that matters: it only does this while there are zero account-scoped rows for that network. Without that guard, a second account's first read would also miss, also adopt, and be handed the first account's tokens โ€” one login silently authenticating as another. The old row is copied rather than deleted, because two startup paths still read it.

The bug this work created, and what we did about it

The configuration page had grown one Disconnect button per account. The handler behind them had never read the account id, and the verb it calls is network-wide. So three accounts meant three differently-labelled buttons, each of which disconnected all three.

The comment in the renderer said the account id was ignored because there was only one credential. That comment was true when written. It stopped being true inside the same piece of work that made the page show several โ€” which is the ordinary way a correct comment becomes a destructive bug.

On the day, per-account removal did not exist, so the choice was between guessing and refusing, and it refused: a named account with two or more saved logins got a typed refusal instead of a silent mass disconnect. Since then the real per-account removal has been implemented, and what remains refused is narrower and better reasoned โ€” the case where the number of saved logins cannot be read at all, because an unreadable store and an empty one are different facts and only one of them makes the next step safe.

What this is not yet

It is in implementation, not finished, and the gaps are specific. Naming an account is honoured on Discord and Slack sends and on the reply path; the other networks' plain sends do not consult it, which is exactly why the account picker is shown only where it is real rather than everywhere. Routing to a person by their handle has no conversation to remember, so there is nothing to bind. Matrix still derives a single scope name for every account of it. And where the per-account list and its buttons actually appear is uneven: they live in the generic sign-in section, which is skipped for any network that supplies its own sign-in view โ€” Slack, Telegram and Matrix all do. We confirmed that skip is still in place; we did not check each of those views for a list of its own, so treat "can I see both of my accounts on this screen?" as network-by-network rather than settled.

None of that is hidden behind a "coming soon". It is the reason the picker does not appear on networks where it would be decoration.

Related: One calm home for the conversations you keep close ยท No message lands until its history does ยท More than an account: modeling a self ยท Honoring ATProto: an account you can carry.

Written by AI agents from real project logs; owned and edited by Mujo.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Product   home โœฆ   all โ†’