The boolean that had to be three values
Our agent can do things that require a third-party account — read a mailbox, check a calendar, look something up in a drive. For each of those, a capability is only real if the user has connected their account.
The obvious model is a flag. And the flag is wrong, immediately, for a reason that only shows up in the UI.
A connector can be in one of three situations, and the three demand opposite treatment:
- We do not serve it for this app, or this app does not have it built in. The user cannot do anything about this, ever.
- We serve it and the user has not connected their account. The user can fix this in about forty seconds.
- It is connected and ready.
Collapsed into a boolean you get one of two failures. Either you show things you cannot offer, which is the worst possible outcome, or you hide things the user could use, which is merely disappointing.
The distinction that drives the whole feature is between the first two — and the correct treatment of the first is to delete it rather than disable it.
Unavailable means invisible
A capability our deployment does not serve should not appear in the list, the search index, or the agent's catalogue of what it can do. Not greyed out. Not there with a "coming soon".
Greyed out is a promise. A disabled row says this exists and you cannot have it yet, which is a claim about our roadmap that we have not checked and cannot keep. It also occupies the space where a working capability would go, and it makes the list longer for no benefit.
So the rule is: an unavailable capability is removed from the snapshot, not flagged inside it. Once it is not in the surface, every consumer is correct by construction — the register, the search index and the agent's own catalogue of tools cannot offer it, because none of them has ever heard of it.
This is the whole argument for deleting rather than disabling, and it generalizes to any capability list: a disabled entry has to be suppressed at every consumer, forever, and each one is a chance to forget. An absent entry cannot be offered by anyone.
Not linked has to be decided before you ask
The middle state is where the actual design work went.
notLinked means the capability is real and the user has not connected their account. The correct experience is to show it, with the action that connects it.
The wrong experience is easy to build accidentally, and it looks like this:
- User taps a capability that needs their calendar.
- We decide it is not linked, and open the system permission sheet.
- They grant calendar access.
- We then ask them to connect their Google account.
Every individual step is correct. The sequence is wrong. The system prompt said it would let us read their calendar, and what it actually did was begin a flow to hand us an account. A user who stops at step 3 has been asked for something we never needed, and the natural conclusion is that the app is confused — or collecting access it has no use for.
So the rule is: decide the link state before you ask for anything. Check what is connected, and if the answer is "not connected," the first thing you do is offer the connection, not the permission.
There is a second reason, which is about ordering in general. Permissions are one of the few things where a user has exactly one chance to make a decision calmly. Spending it on a request that turns out not to be the real one is spending it badly.
Across a process boundary, absence is an answer
The app owns the session and the browser. The keyboard extension has neither.
That is a real constraint and it is easy to solve badly. The keyboard needs to know what is connected in order to know which capabilities it may use, and it cannot ask. So the app writes a snapshot into shared storage, and the keyboard reads it.
The dangerous default here is to treat a missing or unreadable snapshot as "I don't know, so let the user try." Consider what that does: a user who has never connected anything, or whose app has never run this week, gets a keyboard that offers account-backed capabilities and then fails when they are used. The first version of this feature had exactly that failure, and it presented as a bug in the feature rather than as a permissions error.
The rule we settled on is the opposite, and it is the rule we now apply anywhere authority crosses a process or a cache boundary:
Absence of evidence about authority is evidence of no authority.
An empty snapshot does not mean "unknown." It means nothing has been connected, because the only writer is the app, and the app writes on every change. Anything requiring a connector is unavailable. Fail closed.
The mirror of this is worth stating too, because it is where the same instinct misfires in the other direction. If you apply the fail-closed rule to something that is genuinely unknown rather than genuinely absent — a deployment that has not been queried yet, a server that has not answered — you will suppress capabilities that work. The distinction is whether something is known to be empty or not yet known. An empty file written by a component that owns the truth is a fact. A file that has never been written is a question.
Two surfaces, one shape, different lifetimes
We build two related things and it is worth being explicit that they are not the same object.
A capability surface is what this release of the app can do. It is compiled in, changes when the app changes, and is identical on every device running this build.
A connector surface is what is actually usable right now, for this account, on this device. It is rebuilt from the server's answer at every session and changes without the app changing at all.
Same shape, same field names, same consumers — and completely different lifetimes. A capability surface is a property of the binary; a connector surface is a property of the moment.
The failure mode of conflating them is precise and bad: the agent offers a capability that the binary can express but the account cannot back. The user gets a tool it will not be able to finish. That is the single thing this whole design exists to prevent, and it is much easier to prevent when the two surfaces are separate values with separate lifetimes instead of one value with an ambiguous one.
Reading an old snapshot instead of refusing it
One more decision, smaller, from the same area.
The shared snapshot carries a format version, and the previous version is still on devices — it is what every older build wrote. When a new version arrives, there are two options: refuse to read the old one, or read it and refuse to draw conclusions the old one cannot support.
We read it.
Refusing would have been permanently correct-looking and permanently broken. An old snapshot can never become a new one on its own; it changes only when the app next writes, and the app only writes if it can read. Refuse the old format and a user who updates the keyboard before opening the app has a feature that is switched off until they happen to launch the main app, with no error anywhere.
So the rule is read it, mark what it cannot say, and serve nothing rather than guess. The old format can say which connectors are linked; it cannot say what an individual operation costs, because that field did not exist. Those operations are simply not served until the next sync.
That is fail-closed applied to a field rather than to a file — narrow exactly as far as the missing information, and no further.
Takeaways
Model capability availability as a set of states, not a flag, and give each one its own treatment. The state that separates "the user can fix this" from "nobody can fix this" is the one worth finding, and it usually wants opposite UI: an invitation for one and deletion for the other.
Decide what you are asking for before you ask. Anything that reveals your hand to the operating system — a permission prompt, a consent sheet — should be preceded by every check that could have told you the request was unnecessary.
Fail closed when authority crosses a boundary, and be precise about what "empty" means. A store that is known-empty because its owner writes it is a fact and means no. A store that has simply never been written is a question, and answering it optimistically grants authority nobody confirmed.
And keep the static and the dynamic apart. What a build can express and what an account can back have the same shape and different lifetimes; merging them produces an agent that offers tools it cannot finish.