A credential’s level is served for SDK integrations:
'team': stored on the team account. A team admin (someone withproducts.activate) connects it once, and it serves the whole team. This is the default.'user': stored on the person’s own personal account. Each member connects for themselves, with no admin permission needed. An integration whose every credential is'user'connects only per person.
ctx.identity is never set there.
The API
Declare a user-level OAuth credential:onTokens, always write with ctx.secrets.set. The host builds that context on the account that owns the credential: ctx.connection?.owner === 'user' for a user-level one. The same code stores a team token on the team and a person’s token on that person. ctx.secrets there also reads the app’s admin-issued client credentials, which the code exchange needs.
In a tool, action or widget handler, read the caller’s own token and the team’s:
ctx.principalsays who the call runs for:{ kind: 'user', userId, displayName, avatarUrl }or{ kind: 'machine' }. The host sets it from the session, never from a request body. It carries no email.ctx.identityis{ userId, secrets }: the caller’s own credential tier. It is set only when the principal is a person who has connected a user-level credential and the Source serves the level.identity.secretsholds only that person’s values, never the team’s, the Source’s or the app’s client id and secret.setwrites back to it, so a refreshed token lands there.ctx.secretsstays the team tier, so existing code is unchanged.
ctx.identity is absent. If it acts as the team on behalf of a person, attribute the write to ctx.principal (“created by Ada”) where the vendor allows it. See Capability context.
Who acts as whom
A machine principal never has
ctx.identity.
Workflows that act for a person
A workflow can use a person’s own credential while they are not signed in, in two ways:- A personal workflow acts for its owner on every trigger: run by hand, on a schedule, or from its webhook (which needs the workflow’s own secret). The owner creating and enabling it is the consent.
- A team workflow acts for a person who granted it specific operations of your integration, from the workflow’s page (“Use my account”). The grant can expire and the person can turn it off any time in their preferences.
ctx.identity — never a
tool an AI step or a code step picks — and only while the workflow is
enabled. A team workflow’s grant covers the whole workflow exactly as the
person approved it, and is checked against the version each run executes:
any change to its steps, gates, conditions, order, trigger or settings stops
the steps that act for them with needs_reapproval until the person reviews
what changed and re-approves. Renaming the workflow does not.
While a grant is waiting for re-approval, every step that reaches one of its
operations is refused, even a step set to ['team']: a workflow changed
after someone’s approval does not keep running those operations by another
route until they have looked at it.
What a grant does not pin: your integration’s published code (a step
names a tool, not a version, so a new admitted release of your integration
runs under existing grants — keep a tool’s behaviour stable under its name),
which integration an experience slot is bound to, and the team’s own
connection settings and secrets.
Each step says whose credential it runs on, with the same modes words as
experience slots. The default, ['invoker', 'team'], uses the person’s own
credential and otherwise the team’s; on the team’s, the call runs as the team
(ctx.principal is a machine on a team workflow, ctx.identity is absent)
and is never attributed to the person. ['invoker'] runs only on the
person’s own and fails with not_connected otherwise; ['team'] always runs
on the team’s. Your code sees ctx.principal and ctx.identity exactly as
on the person’s own call; nothing in your integration changes.
Declare a level per credential
A credential is the mainoauth, each oauthConnections entry, or a group of secret activation.fields. Leave level out and the credential is 'team', as it always was.
A Linear-shaped integration has three: the team’s own app for its agent, each person’s sign-in, and an optional personal API key.
Activation credentials
activation.credentials groups secret activation fields into credentials, each with an id, a label, a level and the fields it is made of (a key, or a key and its secret). The rules defineIntegration() checks:
- Every name in
fieldsis a declaredactivation.fieldsentry withsecret: true. - Each
idis kebab-case and unique, and each field belongs to one credential at most. - An activation credential is
'team'or'user', never'source'.
Authorization: <key> or Bearer <key>) stays in your own ctx.fetch call; the host only stores it.
Where source and user are allowed
'source'is allowed only on the mainoauth, and only withsourceConnection: a Source connects your integration through its main connection and nothing else.- A named connection with a
teamAppis the team’s own app, so its level is'team'.
Connection levels in your contract
Your contract’sconnection.levels is derived from the credentials:
- An explicit
connection.levelswins. - Otherwise, once any credential states a level, the levels are every credential’s level, sorted and without repeats (a credential that states none counts as
'team'). The example above derives['team', 'user']. - Otherwise, the old inference from
proposesapplies:['user']forcredentialModel: 'user',['source']forcredentialModel: 'source', else['user']forscope: 'user', else['team'].
declaredCredentialLevels(definition) from @ekanos/sdk/integration returns the answer for steps 2 and 3.
Dropping a level is a breaking change in the contract diff; adding one is compatible.
What the toolchain does with them
ekanos validateand the integration admission checks report each credential declared at'user'(definition.credential-levels, info) so you can see which credentials are per person.- They warn when an explicit
connection.levelsdisagrees with the credential levels;ekanos contractreports the same ascontract/levels-mismatch. - They warn when
proposes.scopeis not one of the levels (definition.scope-levels), whether the levels are explicit or derived. ekanos checkand the experience admission checks hold an experience’smodesfor your integration to the levels in its pinned contract. An experience withmodes: ['invoker', 'team']passes against['team', 'user']. See Connection levels for the rules.
Testing
createMockContext() takes the person’s tier as identity and derives principal from the actor:
principal to set a display name. A machine actor with identity throws, as a machine never has one on the host.
In the harness
The Connect tab labels each credential with its level: For the team, For you or For the Source. Secret activation fields appear beside the OAuth connections, so the personal API key above can be entered locally; it is saved to.ekanos/local-secrets.json and read with ctx.secrets.get('linear_api_key'). Each credential has a Connect as: me / the team switch that defaults to its declared level, and each integration has Run as: me / machine, so you can exercise ctx.identity and the machine path locally.