The ask
One person, several identities. A personal mailbox, a work mailbox, a job-hunting mailbox — and
different agents that should each use a different one. Today an account holds exactly one Gmail,
one Drive, one WorkDrive, one Sheets, one MCP credential.
Reported from a live case: the operator's serge.pro.job@gmail.com is connected for job
applications, and a household task needs sivochkin@gmail.com. Both are wanted at once, on the
same PAGS account, on different agents.
The platform is already inconsistent with itself
GitHub is multi-account and always has been.
-- migrations/0020_coding_workspaces.sql:59-71
CREATE TABLE github_installations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
installation_id INTEGER NOT NULL,
account_login TEXT NOT NULL DEFAULT '', -- org/user the app is installed on
...
UNIQUE(user_id, installation_id)
);
One user, many installations, each labelled, each selectable per repo. That is exactly the shape
being asked for.
Every OAuth-vault connector is single-account by primary key.
-- migrations/0003_user_keys.sql:6-15
CREATE TABLE user_api_keys (
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
...
PRIMARY KEY (user_id, provider)
);
A second Gmail has nowhere to be stored. This is not a policy that can be relaxed in code — it is
the table definition. compileConnector (lib/connectors/manifest.ts) hardcodes
grantModel: "user" for every manifest connector, which is the same statement one layer up.
So the App-auth connector got multi-account and the OAuth ones did not, and nothing records that
as a decision. It reads as an accident, and the live case says it is one.
What is NOT the ask, and must not be built
Preferences.tsx says of connections:
Connections get no such control and must never grow one: it would promise a per-agent
scoping that cannot exist.
That comment is correct and stays correct. It forbids an agent silently OVERRIDING the
account-wide connection — the misread that caused #355, where disconnecting Gmail from the
Coder's settings silently disconnected it for the Job Application Assistant.
Holding two genuine credentials and choosing between them is a different thing: the account page
still owns connect/disconnect, and an instance picks from what the account holds rather than
establishing its own. The comment needs updating to say which of the two it means, or the next
reader will take it as blocking this.
Design
1. Re-key the vault, additively. PRIMARY KEY (user_id, provider, account_id), with
account_id defaulting to '' so every existing row keeps working and keeps meaning "the
account's connection". account_label already exists (migration 0035) and becomes the display
name. SQLite cannot alter a primary key in place, so this is a table rebuild — which is why it
wants doing before the table grows further, not after.
2. Per-instance selection as config, not a new grant table.
agent_instances.config.connectorAccounts = { "gmail": "<account_id>" }. It is a preference
about which of the owner's own credentials to use, not a grant of new reach — the owner already
holds all of them, and modelling it as a grant would imply a permission that is not being made.
3. Resolution, and what happens when it is ambiguous. connectorClient(provider) already
carries caller.instanceId, so readConnectorRefreshToken gains the instance's chosen account.
The decision that matters: with two or more accounts connected and no per-instance choice
recorded, REFUSE rather than pick one. Sending mail from the wrong identity is not a
degraded outcome, it is a wrong one — it reaches a real person under a name the owner did not
choose. One connected account keeps working with no choice recorded, so nothing regresses for
anyone who never adds a second.
4. Console. Preferences lists each connector's accounts with "Add another"; disconnect names
WHICH account it is removing and which agents were pointed at it. The instance Settings tab gets
a picker over accounts the owner already holds — never a connect button.
Scope
Gmail, Google Drive, Zoho WorkDrive, Google Sheets, outbound MCP — every auth: "oauth" and
auth: "token" connector. Doing it per-provider is how five copies of this start; the whole
point of the registry is that a connector declares itself and the framework does the rest.
GitHub is out of scope: it already works, and its mechanism (App installations) is genuinely
different from a stored refresh token. The goal is parity of BEHAVIOUR, not a shared table.
Not verified
Whether any existing code assumes the (user_id, provider) pair is unique beyond the vault
reads — keys.ts, the proxy path, and connected-accounts.ts all touch that table and each needs
reading before the rebuild, not after.
The ask
One person, several identities. A personal mailbox, a work mailbox, a job-hunting mailbox — and
different agents that should each use a different one. Today an account holds exactly one Gmail,
one Drive, one WorkDrive, one Sheets, one MCP credential.
Reported from a live case: the operator's
serge.pro.job@gmail.comis connected for jobapplications, and a household task needs
sivochkin@gmail.com. Both are wanted at once, on thesame PAGS account, on different agents.
The platform is already inconsistent with itself
GitHub is multi-account and always has been.
One user, many installations, each labelled, each selectable per repo. That is exactly the shape
being asked for.
Every OAuth-vault connector is single-account by primary key.
A second Gmail has nowhere to be stored. This is not a policy that can be relaxed in code — it is
the table definition.
compileConnector(lib/connectors/manifest.ts) hardcodesgrantModel: "user"for every manifest connector, which is the same statement one layer up.So the App-auth connector got multi-account and the OAuth ones did not, and nothing records that
as a decision. It reads as an accident, and the live case says it is one.
What is NOT the ask, and must not be built
Preferences.tsxsays of connections:That comment is correct and stays correct. It forbids an agent silently OVERRIDING the
account-wide connection — the misread that caused #355, where disconnecting Gmail from the
Coder's settings silently disconnected it for the Job Application Assistant.
Holding two genuine credentials and choosing between them is a different thing: the account page
still owns connect/disconnect, and an instance picks from what the account holds rather than
establishing its own. The comment needs updating to say which of the two it means, or the next
reader will take it as blocking this.
Design
1. Re-key the vault, additively.
PRIMARY KEY (user_id, provider, account_id), withaccount_iddefaulting to''so every existing row keeps working and keeps meaning "theaccount's connection".
account_labelalready exists (migration 0035) and becomes the displayname. SQLite cannot alter a primary key in place, so this is a table rebuild — which is why it
wants doing before the table grows further, not after.
2. Per-instance selection as config, not a new grant table.
agent_instances.config.connectorAccounts = { "gmail": "<account_id>" }. It is a preferenceabout which of the owner's own credentials to use, not a grant of new reach — the owner already
holds all of them, and modelling it as a grant would imply a permission that is not being made.
3. Resolution, and what happens when it is ambiguous.
connectorClient(provider)alreadycarries
caller.instanceId, soreadConnectorRefreshTokengains the instance's chosen account.The decision that matters: with two or more accounts connected and no per-instance choice
recorded, REFUSE rather than pick one. Sending mail from the wrong identity is not a
degraded outcome, it is a wrong one — it reaches a real person under a name the owner did not
choose. One connected account keeps working with no choice recorded, so nothing regresses for
anyone who never adds a second.
4. Console. Preferences lists each connector's accounts with "Add another"; disconnect names
WHICH account it is removing and which agents were pointed at it. The instance Settings tab gets
a picker over accounts the owner already holds — never a connect button.
Scope
Gmail, Google Drive, Zoho WorkDrive, Google Sheets, outbound MCP — every
auth: "oauth"andauth: "token"connector. Doing it per-provider is how five copies of this start; the wholepoint of the registry is that a connector declares itself and the framework does the rest.
GitHub is out of scope: it already works, and its mechanism (App installations) is genuinely
different from a stored refresh token. The goal is parity of BEHAVIOUR, not a shared table.
Not verified
Whether any existing code assumes the
(user_id, provider)pair is unique beyond the vaultreads —
keys.ts, the proxy path, andconnected-accounts.tsall touch that table and each needsreading before the rebuild, not after.