Auth: identity & profiles
Today, a room can be identified by data the person types, and that data can be proved with a one-time code the tenant sends. There are no profiles yet.
Identifying a room: identifies: true
A form field marked identifies: true identifies the room, under the field's name, as soon as
the guard accepts the form. This holds for a skill's form (its form guard, in select.guards) and for the submit
guard's (submit_guard.form).
select:
guards:
- form:
address: {type: text, required: true, label: "Delivery address"}
phone: {type: phone, required: true, label: "Phone", identifies: true}
Then:
- The room does not ask again. The room's next forms — the same card shown again, another form
with a
phonefield, the submit guard — are published without that field, and Core answers it itself. A form left with nothing to ask is no longer shown. - The submit sends it. An item's field is read with
$item.fields.phone; a submit guard field with$submit.phone(see The submit). - A new answer replaces the old one. If the person types another number, that is the one the room keeps.
- The model knows the room has a phone, never which one. The room summary gives it the name of the data, so it does not ask again.
This is declared data: typed, not proven. It stays with this room and never finds another one; anyone can type someone else's number.
Only a field of type text, email or phone can identify a room
(the types). At load time, Core refuses
identifies with any value other than true, on a field of another type, and one name declared by
two forms with different types: the room would fill one with the other's answer.
The deployment must give CONVOY_IDENTITY_KEY to the tenant's agent and MCP server, so both doors
know the same data. The provisioning scripts create it once per tenant, and the provisioner derives
the key of each tenant it creates from the deployment's (one per tenant); with a rooms database and
without it, a tenant that identifies its rooms refuses to start. Without a rooms database (in
development), each door keeps its rooms in memory, and what they know too.
Two different keys fail no boot: each door is right on its own, but the same address becomes two
people. So each door publishes its key's fingerprint, never the key: the agent in /status, MCP in
/health (identity_key). The probe scripts/readiness/identity_key.py compares the two and fails
when they differ.
Proving data: a one-time code
The station declares its identity providers in station.identity.providers. One mechanism
exists today: otp, a one-time code the tenant sends and checks with its own operations
(via: tenant).
apis:
- name: identite
openapi_url: https://api.example.com/openapi.json
operations:
- {id: sendVerificationCode, access: write}
- {id: checkVerificationCode, access: write}
station:
identity:
providers:
email:
mechanism: otp
verifies: customer_email # the name of the data proved
via: tenant
send: sendVerificationCode # sends a code to the value
check: checkVerificationCode # says whether the typed code is right
- What the operations receive.
sendreceives the value under the data's name (customer_email);checkreceives the value andcode. Core reads in each operation's schema where it takes them, as a parameter or in its JSON body; the body is better, since an address and a code have no place in a URL that logs keep. An operation that does not take these names is refused at load time. - Only an explicit yes counts.
checkanswers{"verified": true}(ortrue). Any other answer —verified: false, no verdict, an error — means no. - The model never leads the proof. It holds
sign_inalone. In the widget and the PWA, a frame asks for the datum and the code; in Claude and ChatGPT, the host's sign-in window does. With no value typed, the code goes to the one the room already declared. A value typed to receive a code is the one the check uses. verifiesnames a field that identifies the room (identifies: true): the room then holds the value, and the code goes out without the model repeating it.- One spelling per type. Before it is declared, sent to the tenant in the submit, or proved, a
value is written one way, by the field's type:
emaillower-cased,phonein international form (+33…). A national number (06…) is not guessed: the tenant receives it as typed, and no code proves it. - A right code makes the data verified, and the room becomes the person's. Its owner is an opaque id derived from the value, never the value. The same person can prove another datum in their room (the phone after the e-mail); another value for a datum already proved, or a room claimed with nothing proved in it, belongs to someone else. A proof always signs in the person who makes it. A call with no browser key (a script, a stale bundle) is refused before any code is sent: it would sign no one in.
- The model learns which data is proved, never its value. The room brief says "Proved in this room: customer_email".
- Convoy never sees the code sent. The tenant keeps it and limits its lifetime and its tries; Core passes what the person typed and records the answer.
- Core counts wrong codes too, whatever the tenant does. After five wrong codes for one value, it stops asking the tenant for a quarter of an hour; a new code sent starts a new count. It sends no more than five codes an hour to one value, and that count does not start again. The counts live in the rooms database: the agent and MCP share it, and a restart does not clear it. It is kept under the value's fingerprint, never the value.
At load time, Core refuses a mechanism other than otp, a via other than tenant, a send or
check missing, declared by no API or not taking these names, a verifies no identifies: true
field carries, and data a form declares as a number. A station
that proves data needs CONVOY_IDENTITY_KEY, like one that declares it.
Who is signed in: the holder
A proof signs in the person who made it, on this surface, not the room. In the widget and the
PWA, the browser keeps a key of its own (convoy-holder), which Core stores only hashed. A right
code binds that browser to the person:
- a room's code gives no rights. Someone who opens with its code a room that belongs to no one sees the selection, but is not signed in for that. A room that has an owner opens only to them, signed in (Context);
- the person stays signed in from one visit to the next, on that browser. A room they open that belongs to no one becomes theirs; someone else's stays someone else's;
- their rooms follow them: signed in, a person who arrives without a room code (a new device,
Claude, ChatGPT) finds the room they used last, wherever it was. The ⋮ menu and the
room_listtool list their other rooms, by what they are about, never by what was said in them. Reading a room, or opening it with its code without owning it, does not change the room they come back to; - a sign-in nobody uses ends: after
station.identity.idle_minutesunused (a week by default, counted from the last use), the person signs in again. Se déconnecter (POST /api/identity/sign-out) ends it at once, on the server. This is what protects a shared computer; - the model knows whether the person is signed in, by names only: "Signed in on this surface" or "Not signed in";
- the conversation opens with an invitation to sign in, in the chat (the widget, the PWA) or in the frame of the room's opening (Claude, ChatGPT);
- in the widget, the steps show in a frame: the datum, the code, then "You are signed in", in
the conversation: from the invitation, or when the person asks and the model calls
sign_in. Once signed in, "✓ Signed in" shows under the input; - the ⋮ menu in the header, in the widget and the PWA, offers Sign in (the steps open in the conversation) or Sign out, then Start a new conversation, the one way to make a room. A sign-out that did not go through is said, never shown as done;
submit_guard.submitted_by: [{who: owner}]reserves the submit to the room's owner, signed in: the room's code is no longer enough. The selection panel signs the person in before the button; on Claude and ChatGPT, the submit asks for the host's sign-in. The address and the code go from the frame to Core: the model never sees them. A datum the room already holds is offered without being shown ("Use the one you already gave"). A tenant's refusal shows in its own words. Recommended for every station that places orders: without a sign-in, nothing tells the customer on another device from a stranger holding the code. Where the station can sign people in without requiring it, the selection invites a sign-in before finalising, without making it;
On Claude and ChatGPT, signing in goes through the host. As soon as the station declares a
provider, the tenant's MCP server is also an authorization server (OAuth 2.1, the MCP
authorization method both hosts follow). The connector installs without signing in. When the person
asks to sign in, the model calls sign_in: Claude or ChatGPT opens its sign-in window, which leads
to the tenant's page on this server, with the same steps (the datum, the code). The host then keeps
the token: the person stays signed in from one conversation to the next. On Claude, opening
someone's room unsigned, with any tool, opens that window too: an owned room opens to its owner
only.
On Grok, signing in happens when the connector is added. Grok runs the sign-in only then: a tool that asks for it later looks broken to it. On a station that signs people in, Grok therefore asks for the sign-in as soon as the connector is added, and cannot be used there without it. A connector added before this change must be removed and added again.
Only known hosts register. A host registers with the authorization server before signing anyone
in, giving the address to send the person back to. It must be https on a host the station serves,
exactly (claude.ai.evil.com is not claude.ai), or the person's own machine for an installed app.
By default: claude.ai, claude.com, chatgpt.com, grok.com. A station may give its own list in
station.identity.hosts, which replaces this one; a name with a scheme, a path or a port is refused
at load.
- The token is valid for this server's tools only; it carries the person's opaque id, never the e-mail; Core keeps only its hash, and it never goes to the tenant's API.
- The model holds
sign_inalone; it sees neither the datum nor the code. - The server describes itself at the address it is called on;
MCP_PUBLIC_URLfixes it when it sits behind a tunnel or a proxy that does not pass it.
See the target's authentication flow.
Reserved skills, and the person's own token
A skill can be reserved to a signed-in person: authenticated: true. Core does not run it for
someone who is not signed in, and does not even call the tenant. Each surface then asks for its
sign-in: the steps in the conversation (widget, PWA), the host's window (Claude, ChatGPT). Once the
person is signed in, the skill runs again. The model learns it from the skill's description; it
never asks for a code itself.
Once signed in, the person is served on their behalf. If the tenant's check answers with a token for that person, Core keeps it and sends it on the calls made for them: the tenant's API then answers for them, with their scope and their refusals. The tenant decides what each person sees, not Convoy.
apis:
- name: lecture
openapi_url: https://api.example.com/openapi.json
operations:
- {id: listTeams, access: read}
- {id: getTimesheets, access: read}
- {id: sendVerificationCode, access: write}
- {id: checkVerificationCode, access: write}
auth:
type: api_key # the station's key: what anyone may ask
key_name: X-Api-Key
key_location: header
key_env: EXAMPLE_API_KEY
person: {provider: email} # + the signed-in person's token (Authorization: Bearer …)
skills:
- name: feuilles
invoke: {method: getTimesheets}
authenticated: true # only for a signed-in person
station:
identity:
providers:
email:
mechanism: otp
verifies: email
via: tenant
send: sendVerificationCode
check: checkVerificationCode
credential: access_token # the field where `check` returns the person's token
- Where it lives. With the person's sign-in (the holder), encrypted in the database. Never in a room, an event, a log, what the model reads, or an answer to the surface.
- When it is sent. Only on the calls Core makes for that person: their skills, their submit.
person.headerandperson.schemechange the header (Authorization,Bearer). - When the tenant no longer accepts it. A 401 to a call that carried it signs the person out, and the surface asks them to sign in again. A 401 to a call that did not carry it comes from the station's key, and signs no one out.
- Data no form asks for (a tenant that sells nothing) is for signing in only: the person types it
into the sign-in steps. The provider's
type(text,emailorphone) then says how to ask for it and how to write it; a form that declares it must have the same one. - At load, Core refuses an
auth.personnaming a provider withoutcredential, and a reserved skill on a station without a provider.
The Shiftly demo shows it: camille@example.com sees her two teams, anta@example.com, the manager,
all four; the codes show on /demo/inbox.
What does not exist yet
- No SSO, no tenant login, no code sent by Convoy itself (
via: convoy). - No silent sign-in from a company's SSO (Claude's "Enterprise Managed Auth"), and no Client ID Metadata Documents: hosts register dynamically.
- Every door offers all of the station's providers: a portal does not pick its own yet.
- An owner's other rooms are not yet given back to them. Their sign-in does not expire on its own, and there is no "Sign out" yet.
- Without
credential, an API's key is the station's key: for the tenant's tool, everyone is the same person (Sources). - No rule tells one customer from another: no profiles.
The target adds identity providers, a room claimed by its verified owner, and profiles: Identity, Profiles.