Skip to documentation
Documentation

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:

JSON
{
  "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:

JSON
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:

JSON
{
  "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
userIdYour identifier for the person. Required when the person is known.
roleA label for the prompt and the dashboard. Free text; Keiki does not compare it.
scopesThe permission strings tool policies are checked against.
ttlSecondsHow long Keiki may reuse this answer. Default 15 minutes, maximum 24 hours.
mcpHeadersOptional. 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:

JSON
{
  "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:

JSON
{ "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:

text
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.