Asking for authentication
A skill can be reserved to an authenticated person. The model does not guess it, and does not ask for a code out loud: Core knows it from the config, announces it, and asks itself, at the right moment, with the right flow for the surface.
Nothing new is written for this. An authentication request follows from what the station already declares: the grants and permissions, the profiles, the providers and the provider each portal offers.
When Core asks
Core asks for authentication when two conditions hold:
- the current identity does not pass a rule: a missing grant, a
permission, arequire; - a proof this portal can run could open it. For example, the rule reads
'email' in identity.verified, and the portal offers a provider thatverifies: email.
If no proof possible on this portal can open the rule, Core asks nothing: it is a refusal, or a hidden item. Nobody is made to prove an e-mail for nothing.
If the proof is made but is not enough (the person is verified, but not premium), the rule
refuses as usual, and the request is not repeated.
The moment depends on the phase
| the rule bears on | Core asks | what the person sees before |
|---|---|---|
invoke (or the grant of the operation read) | before the call: the skill does not run | nothing of the skill |
present (a reserved kind) | before the call, as for invoke | nothing of the skill; they may continue without, and then see what is not reserved |
select (choosing a kind) | when they choose the item | the items, including the one they want |
submit (the grant of the write, submitted_by: owner) | when they submit | their whole selection |
For present, the request is decided on the config, not on the result: it says the skill has
a reserved part, never that this search found something hidden. Reserved items are still removed
before the model, and counts are made after the filter
(Permissions).
The call resumes by itself
Once the proof is made, Core runs the pending call again, with the same arguments: the search, the choice, or the submit. The person has nothing to ask again.
- A room has one pending request; a new one replaces the old.
- It expires with the request: an abandoned call is not run an hour later.
- The re-run goes through every rule again: the proof opens access, it does not force it.
The label, written by Core
A reserved skill is announced as such, to the model and on screen, on both doors. Core writes the label from the rules; the tenant does not write it, so it cannot lie.
| phase | label |
|---|---|
invoke | "Reserved: prove your e-mail to use it." |
present | "Part of it is reserved to customers who proved their e-mail." |
select | "Prove your e-mail to choose this option." |
submit | "Prove your e-mail to submit." |
The name of the data comes from the portal's provider (verifies:); the text comes from Core's text
catalogue, in the tenant's language.
The flow: an authentication playbook
A request is shown like a playbook: steps on screen, one after the other, then what follows. It
is not a playbook of the config — the tenant does not write it, and it does not count among its
playbooks — but it reads and displays the same way. Its steps depend on the provider's mechanism.
otp — three steps.
- The data. "Your e-mail". Pre-filled when the room already holds it (declared by a form); the person confirms or corrects it. It is written in its one spelling (e-mail lower-cased, phone in international form) before sending.
- The code. "Enter the code sent to a***@mail.com." With "Send again", and the tenant's own sentence when it refuses (too many codes asked, code expired). Convoy sets its own limits, per datum and across both doors at once: 5 wrong codes block the tries for 15 minutes, and at most 5 codes are sent per hour. A new code resets the tries, not the sends.
- Done. "Your e-mail is verified." Then the pending call resumes.
oidc — one step. A "Sign in with …" button opens the provider's page. On return, it is done,
and the call resumes.
assertion — one step, or none. If the person is signed in on the tenant's site, they are
already verified: nothing shows. Otherwise, a link to the sign-in page the tenant declares;
Convoy never invents an address on its domain.
By surface
The steps are the same everywhere; where they show depends on the surface. In the widget and the PWA, in the frame. On Claude and ChatGPT, in the host's sign-in window, which leads to the tenant's Convoy page; the host runs the call again itself. On WhatsApp, in the conversation, led by Core. See The authentication flow.
The model never starts a proof where the surface can show the steps, and it sees neither the datum nor the code. It only receives "the person is signing in; wait", then the result of the call run again. Where the surface has neither a frame nor a sign-in window (WhatsApp), Core leads the steps, not the model.
Authentication belongs to whoever speaks on this surface (their Claude account, their browser, their number), not to the room: a room's code gives no rights.
What loading checks
The permission graph checks requests too:
- a rule that no provider of the station can satisfy (
'phone' in identity.verifiedwith no provider thatverifies: phone) is refused: the request would be impossible everywhere; - a skill exposed on a portal whose provider cannot open its rule gives a warning: on that portal, the skill is simply unavailable, with no request;
- an
assertionprovider used by a request must declare its sign-in page.
Example
Anonymous visitors see the grocery. Proving one's e-mail gives the order history, and the products reserved to customers.
station:
identity:
providers:
email: {mechanism: otp, verifies: email, via: tenant,
send: sendVerificationCode, check: checkVerificationCode}
profiles:
anonyme:
when: "identity.authenticated == false"
grants: [boutique.searchProducts, boutique.createOrder]
client:
when: "'email' in identity.verified"
grants: [boutique.searchProducts, boutique.createOrder, boutique.listOrders]
skills:
- name: commandes # listOrders: only `client` received it → request at invoke
invoke: {method: listOrders}
- name: produits
invoke: {method: searchProducts}
kinds:
reserve: # request before the call; "continue without" shows the rest
when: "$fields.tier == 'client'"
present: {guards: [{permission: {profiles: [client]}}]}
present:
fields:
name: {from: name, type: text, role: title}
tier: {from: tier, type: text}
agent:
portals:
web:
widget: {identity: {auth: email}}
An anonymous visitor asks for their orders. The model calls commandes; Core does not run it and
shows the flow: the e-mail, the code, "verified". Then commandes resumes and shows the history.
The model saw neither the address nor the code.
If reserve required a subscription ('abonne' in identity.plans), an e-mail code could not open
it: Core would ask nothing, and the reserved products would stay hidden.