Identity and permissions
Tell the agent who is talking, in your terms — your users, roles and scopes — and have every tool call checked against them.
An agent in a shared channel talks to many people, and what each of them may do is decided by your product, not by Keiki. Identity lets you answer one question for every message — "who is this, and what are they allowed to do?" — and Keiki carries the answer through the turn: into the prompt, so the agent knows who it is talking to; into tool execution, so a call the person is not allowed to make never runs; and into approvals, so only someone with the right permission can press Approve.
Keiki never invents a permission model for your data. Scopes are opaque strings you choose
(billing:write, customers:read, admin); Keiki only compares them.
Identity is rolling out in phases; the contract on this page is the one to build against.
The principal
Every inbound message resolves to a principal before the turn starts:
{
"channel": "slack",
"externalUserId": "U_CMO",
"displayName": "Maya Chen",
"email": "maya@acme.com",
"userId": "usr_9f2",
"role": "admin",
"scopes": ["billing:read", "billing:write", "customers:read"],
"addressing": { "mentionsAgent": true, "mentionsOthers": false, "isDm": false }
}channel, externalUserId, displayName and addressing come from the channel. email
comes from Slack when the app has users:read.email. userId, role and scopes come
from your identity webhook — they are what your system says about this person, and they
are the only fields a permission check reads.
The principal is passed to the model as a structured speaker line on the message, not as free text it could be talked out of, and the model is told when a field is unknown.
The identity webhook
Point the agent at an HTTPS endpoint you run. Keiki calls it once per new sender per conversation (and again when the cached answer expires), with the channel identity it has:
POST https://api.acme.com/keiki/identity
X-Keiki-Signature: sha256=…
X-Keiki-Timestamp: 1789600000
{
"agent": { "id": "agt_…", "externalRef": "org_acme" },
"sender": {
"channel": "slack",
"teamId": "T_ACME",
"userId": "U_CMO",
"email": "maya@acme.com",
"displayName": "Maya Chen"
},
"conversation": { "id": "C_BILLING:1789599000.000100" }
}You answer with what you know:
{
"userId": "usr_9f2",
"role": "admin",
"scopes": ["billing:read", "billing:write", "customers:read"],
"ttlSeconds": 900,
"mcpHeaders": {
"Acme API": { "Authorization": "Bearer <token issued to usr_9f2>" }
}
}| Field | |
|---|---|
userId | Your identifier for the person. Required when the person is known. |
role | A label for the prompt and the dashboard. Free text; Keiki does not compare it. |
scopes | The permission strings tool policies are checked against. |
ttlSeconds | How long Keiki may reuse this answer. Default 15 minutes, maximum 24 hours. |
mcpHeaders | Optional. Per-sender headers for the agent's MCP connections, keyed by connection name — the same thing PUT /mcp-senders sets, delivered in one round trip so a user is authorized and credentialed together. |
A person your system does not know is { "userId": null } (or a 404). They still get a
principal — Slack name and id — with no scopes, so a public agent keeps answering questions
while every gated tool stays out of reach.
The request is signed with the secret you set alongside the URL: X-Keiki-Signature is an
HMAC-SHA256 of <timestamp>.<body>; reject anything older than five minutes. The endpoint
must be a public https:// URL, like every webhook Keiki calls. A webhook that fails, times
out or answers anything other than 2xx/404 is a visible denial: the turn still runs
and the agent still answers, but every tool with requiredScopes is refused and the model is
told the agent could not verify who is speaking — never a silent drop, never a fallback to
"allow". Tools without requiredScopes are unaffected.
Configuring it
On a partner deployment, send it with the agent:
{
"external_ref": "org_acme",
"agent": { "template": "billing-assistant" },
"identity": {
"webhook_url": "https://api.acme.com/keiki/identity",
"secret": "whsec_…"
},
"channels": [{ "type": "slack", "bot_token": "xoxb-…", "signing_secret": "…" }]
}Omit identity to leave it unchanged; send null to remove it. The partner API is the only
place it is set today. Like channel credentials, it is deployment
configuration rather than agent configuration: agent versions do not snapshot or restore it.
Revoking access
Your answer is cached for ttlSeconds. To cut someone off sooner, call
DELETE /api/v1/developer/deployments/:external_ref/identity/cache?channel=slack&team_id=…&user_id=…
(or drop the whole agent's cache with no query); the next message from them asks your
webhook again. Removing someone's per-sender MCP headers is the same call as before:
DELETE …/mcp-senders.
How permissions are enforced
A tool declares the scopes it needs in its policy:
{ "name": "update_customer_plan", "policy": { "requiredScopes": ["billing:write"] } }Before the tool runs — on any channel — Keiki checks the principal's scopes against the tool's
requiredScopes. All must be present. A call that fails the check never reaches the tool; the
model receives a result saying who lacked which scope, and its instructions tell it to say so
plainly rather than work around it. Today SDK and custom tools carry a policy; see
which sources accept one. The principal is resolved once per turn
on the Flue harness; a turn with no principal refuses every scoped tool.
Persisted action approvals check the approver, not the requester. An Approve press from someone without the scopes the action needs is refused with an ephemeral reply that names the missing scope, and the card stays pending for someone who has it. Someone with the scopes can approve an action another person asked for, and the action record keeps both names.
Tools without requiredScopes are open to anyone the agent talks to — the same as today.
Identity only ever narrows.
Slack Connect and public channels
Channel membership decides what the agent reads: it sees the channels the app was invited to and nothing else. Identity decides what each person may do. The two are deliberately separate, so a customer in a Slack Connect channel can ask the agent a question and be turned down, by name, for a write — instead of finding the agent unresponsive.
If you want people outside your workspace kept out entirely, do it in the webhook: return
no scopes for a teamId that is not yours, and gate every tool. The denial is then a
policy you wrote, visible to the person it applies to.
The API gateway
On the API gateway, the sender is the x-conversation-id your client
sends. The webhook receives { "channel": "api", "conversationId": "…" }; since your backend
minted that id, it already knows the user behind it.
What the agent knows
The agent sees the speaker's name, role and scopes on every message, so it can answer
"can I do this?" without trying, address people by name, and notice when a new person joins
the thread. It never sees an email, a token or an mcpHeaders value: those go to the
tool call, not the model.
Signing requests
For identity and action lifecycle webhooks, compute the HMAC-SHA256 digest over
<unix_timestamp>.<raw_request_body> and send it as:
X-Keiki-Timestamp: 1789600000
X-Keiki-Signature: sha256=<hex digest>Reject requests outside your replay window before parsing the body, then compare the signature with a constant-time operation.