This is the complete setup flow for @temper mentions in Slack against a temper deployment:
the Slack app, the mention agent, the temper-api environment, the IdP client, and how to verify it.
Follow the sections in order.
Scope. This wires the account link (a Slack user connects their temper account, once, in a
browser) and the grant vault (temper stores an encrypted per-user grant so a future mention can
act as that human). Answering mentions with real work — search, writes — is not wired yet.
Today a linked mention gets a "you're connected, but I can't answer questions yet" reply. Standing
this up now provisions the durable link so those answers land cleanly when they ship.
| Piece | What it is | Where it lives |
|---|---|---|
| temper-api | Serves the OAuth callback, resolves the user, holds the encrypted vault. | Your temper deployment (e.g. temper-cloud.vercel.app behind temperkb.io). |
| the mention agent | An eve app that watches Slack, asks temper "what do I say to this user?", and posts the connect link. | Its own Vercel project, root packages/agent-workflows/mention — not temperkb.io, not temper-cloud. |
| the Slack app | The bot users mention. Created from the committed manifest. | api.slack.com, one app per workspace. |
They are tied together by one shared secret, SLACK_LINK_SECRET, which must be byte-identical on
temper-api and the agent. A mismatch is a 401 on every mention, not a warning.
One deployment serves exactly one Slack workspace. This is a hard ceiling in eve +
@vercel/connect, not a setup choice — see the mention agent's
CLAUDE.mdbefore planning a multi-workspace or
community rollout. Self-hosted (one app : one workspace : one temper) is the natural shape.
The link flow is an OAuth client of whatever issuer fronts your instance, using Authorization
Code + PKCE with no client secret (it mirrors the temper CLI). Configure the client
SLACK_LINK_CLIENT_ID will point at:
Auth0 mode (e.g. temperkb.io):
<PUBLIC_BASE_URL>/api/auth/slack/callback exactly — not aredirect_uri is the classic authorization-code interception vector.offline_access.AS mode (self-hosted temper AS): register the same redirect_uri in the client's AS_CLIENTS
entry; endpoints are derived on the instance itself (<issuer>/oauth/authorize, /oauth/token).
The requested scopes are fixed: openid profile email offline_access. email/profile let the link
resolve the user by the IdP's email; offline_access is what returns the refresh token the vault
stores.
Set these on your temper deployment (they sit alongside the auth vars in
enterprise-install.md). All four are required as a unit — any missing, or
a malformed vault key, disables the whole link flow (fail-closed; the callback answers "Account
linking is not configured"). There is no half-on state.
| Variable | What it is |
|---|---|
SLACK_LINK_CLIENT_ID | The public client from Step 1. |
SLACK_LINK_SECRET | Shared HMAC secret gating POST /internal/slack/link-state. openssl rand -hex 32. Must match the agent's copy (Step 6). |
PUBLIC_BASE_URL | This instance's public origin, e.g. https://temperkb.io. The callback is <PUBLIC_BASE_URL>/api/auth/slack/callback. |
SLACK_VAULT_ENC_KEY | AEAD key encrypting each stored refresh token. 32 bytes, base64 — openssl rand -base64 32. See § The vault key. |
Set
SLACK_VAULT_ENC_KEYas part of the deploy that ships the vault, not after. Because the
four are all-or-nothing, deploying vault code to an instance already running the link flow turns
the link flow off until the key is present.
A fifth secret gates the mint — and it is deliberately not part of the all-or-nothing four,
so an instance that has not set it keeps a fully working link flow and loses only minting:
| Variable | What it is |
|---|---|
SLACK_MINT_SECRET | Shared HMAC secret gating POST /internal/slack/mint, which vends an access token acting as the mentioning human. openssl rand -hex 32. Must match the agent's copy (Step 6). Unset disables minting only. |
These secrets must all hold DIFFERENT values, and temper-api refuses to boot if any two match.
The split is the security property, not tidiness:SLACK_LINK_SECRETgates an endpoint that
answers "is this principal linked?",SLACK_MINT_SECRETgates one that hands back a token
carrying that human's entire temper reach, andSLACK_VAULT_ENC_KEYdecrypts every stored
refresh token. One value across two of them means whoever holds the cheap capability already holds
the expensive one — and because the link and mint secrets are also set on the agent's Vercel
project (Step 6) while the vault key never leaves temper-api, a vault key reused as either one
hands the agent the keys to the whole vault.The check covers all five of the instance's shared secrets — these three plus
INTERNAL_RECONCILE_SECRETandEMBED_DISPATCH_SECRET— and the error names the offending pair.
Generate each one separately.openssl rand -base64 32is the vault key's documented generator,
which makes "generate once, paste everywhere" the exact mistake this guards.
The agent is its own git-connected Vercel project (the steward-agent project is the precedent):
packages/agent-workflows/mentiontasker-systems/temper, production branch main — merging main deploys it;eve deploy/vercel deploy from thesourceFilesOutsideRootDirectory is not needed (this agent has no sibling file: dep).Note the deployment host — Step 7 needs it. Every request will 401 until Step 6; that's expected
(no signing secret yet).
This is a two-phase manifest, and the order is not the obvious one. eve verifies the request
signature first and fails closed: with noSLACK_SIGNING_SECRETthe/eve/v1/slackroute
returns401and never reaches Slack'surl_verificationhandshake. But the signing secret only
exists once the app exists. Declaring a request URL up front is therefore a deadlock — Slack won't
save a URL it can't verify, and the URL can't verify without a secret that doesn't exist yet. The
way out: create the app with no event subscriptions, collect the secret, deploy, then declare the
URL.
Go to https://api.slack.com/apps → Create New App → From a manifest, choose the workspace,
and paste packages/agent-workflows/mention/slack-app-manifest.yml
unmodified. The event/interactivity block at the bottom is commented out on purpose — leave it.
With no event subscriptions declared, Slack has nothing to verify and the app is created cleanly.
The manifest already declares the bot scopes the flow needs (app_mentions:read, chat:write,
im:history, im:write, channels:history) and disables Socket Mode (eve is HTTP-only).
xoxb-…).On the mention agent's Vercel project:
| Variable | What it is |
|---|---|
SLACK_BOT_TOKEN | The xoxb-… token from Step 5. |
SLACK_SIGNING_SECRET | The signing secret from Step 5 (HMAC-verifies inbound webhooks). |
TEMPER_API_URL | The temper API origin, e.g. https://temper-cloud.vercel.app. |
SLACK_LINK_SECRET | The same value as temper-api's SLACK_LINK_SECRET (Step 2). |
SLACK_MINT_SECRET | The same value as temper-api's SLACK_MINT_SECRET (Step 2) — and a different value from SLACK_LINK_SECRET. Both sides of one HMAC, so a mismatch 401s every mint. |
vercel env add SLACK_BOT_TOKEN production
vercel env add SLACK_SIGNING_SECRET production
vercel env add TEMPER_API_URL production
vercel env add SLACK_LINK_SECRET production
vercel env add SLACK_MINT_SECRET production
Each secret is copied across two projects, and must stay distinct within each. The agent
refuses to make either signed call when its ownSLACK_LINK_SECRETandSLACK_MINT_SECREThold
the same value — it is a separate deployment with its own environment, so temper-api's boot check
cannot see it. Note which way each failure presents: a collision on only the agent cannot
authenticate both calls and shows up as a401on every mention, while both sides set to the
same colliding value works perfectly and silently has no privilege split at all. The second is
the one worth guarding, and it is why the check exists on both sides.
Then redeploy (push to main, or redeploy from the dashboard) so the functions pick them up.
TEMPER_API_URLmust point at the API origin, NOT the UI origin. The agent calls
/internal/slack/link-state, and the temper-UI (SvelteKit) proxy only forwards/api,/mcp,
/oauth,/.well-known— not/internal. Point it attemperkb.ioand the internal call hits
the UI shell and fails. (temper-api'sPUBLIC_BASE_URLdoes stay the public UI origin, because
the callback lives under/api, which the UI proxy forwards. The two are genuinely different
origins.)
Now the secret is in place, so the handshake will succeed. In the Slack app's App Manifest editor,
uncomment the phase-2 block, replace <YOUR-DEPLOYMENT-HOST> with the agent host from Step 3, and
save (equivalently: enable Event Subscriptions and Interactivity & Shortcuts and paste the
same https://<host>/eve/v1/slack URL into both):
event_subscriptions:
request_url: https://<YOUR-DEPLOYMENT-HOST>/eve/v1/slack
bot_events:
- app_mention
- message.im
interactivity:
is_enabled: true
request_url: https://<YOUR-DEPLOYMENT-HOST>/eve/v1/slack
Slack POSTs a url_verification challenge; eve verifies the signature and answers with the raw
challenge on the first try. Adding message.im may prompt a reinstall to grant im:history — if
Slack asks, reinstall and re-copy the bot token if it changed.
openssl rand -base64 32 # 44 base64 chars → 32 bytes. This is SLACK_VAULT_ENC_KEY.
SLACK_VAULT_ENC_KEY makes every stored@temper and re-link. (The schema reserves akey_version column for a future zero-downtime keyring; it is not implemented, so do not treat/invite @temper.@temper from an account that has not linked. You should get anTEMPER_API_URL and SLACK_LINK_SECRET.@temper again. Today you get "You're connected as @your-handle. IOptional DB confirmation: a row in kb_profile_auth_links (auth_provider = 'slack') and an
encrypted row in kb_slack_grant_vault.
A user unbinds their own link:
temper slack disconnect
An operator unbinds any principal — offboarding, or a user who linked the wrong profile:
temper admin slack disconnect 'slack:T0BHAHEN79C:U0BH6A3L6JF'
The principal is opaque and has two to four segments. Pass it whole, quoted — never split it.
Both are idempotent: disconnecting an already-disconnected principal succeeds quietly, and says
so. Until this existed there was no self-service recovery at all: a user who linked the wrong profile
needed an operator with direct SQL access. The "already connected to a different temper account"
refusal page has always told people to "disconnect it there first" — this is the affordance that
sentence was promising.
kb_profile_auth_links).kb_slack_grant_vault) — the row is deleted, not flagged, so theBoth surfaces return the same thing:
{
"disconnected": [
{
"slack_principal_id": "slack:T0BHAHEN79C:U0BH6A3L6JF",
"grant_deleted": true,
"intents_deleted": 1,
"idp_revocation": "revoked"
}
]
}
disconnected lists one entry per principal actually unbound. Empty is a success, not an error —
it means nothing was linked. The admin surface returns zero or one entry; the self-serve surface
returns zero or more, because a human in two Slack workspaces holds a distinct principal in each and
temper slack disconnect unbinds all of them.
idp_revocation is one of three values, and the distinction matters:
| Value | Meaning |
|---|---|
not_attempted | There was no stored grant, so nothing was revoked. Common for links made before the grant vault shipped. Not a problem. |
revoked | The IdP (or, self-hosted, the local token store) confirmed the revocation. |
failed | A revocation was attempted and did not succeed. See below. |
The response reports "idp_revocation": "failed" and the CLI warns on stderr. The disconnect still
succeeded — the local grant is destroyed either way, so temper can no longer use it. The grant may
remain live at the IdP until it expires; revoke it from the Auth0 dashboard if that matters to you.
The CLI warns on failed only. not_attempted gets no warning: there was no grant, so there is
nothing that could still be live.
Rotating SLACK_VAULT_ENC_KEY makes every pre-rotation ciphertext unopenable — that is the point of
the rotation. Disconnect handles this deliberately: it still destroys the identity row, the grant
row and the intents, and reports "idp_revocation": "not_attempted" (it could not open the token, so
it could not present it to the IdP). Revoke those grants at the IdP out-of-band. Failing the
disconnect here would be strictly worse: the situation that motivates a key rotation is a compromise,
which is exactly when the unbind lever has to work.
temper deliberately does not keep the token around to retry the revocation later: doing so would
preserve the exact secret the user just asked it to destroy. The failure is logged with the principal
and the status, never the token.
On self-hosted installs (temper-AS mode) revocation is local and happens in the same transaction as
the deletes, so it cannot fail this way at all.
Just mention @temper again. The principal is unlinked, so the normal flow offers a fresh authorize
URL — there is no special reconnect path.
Expired and consumed link intents are swept hourly by the /api/slack/intents/reap cron, gated on
the same bearer secret as the embed crons (EMBED_DISPATCH_SECRET). Consumed rows are removed
because their nonce is single-use and already spent; live unconsumed ones are spared.
| Symptom | Cause |
|---|---|
| Slack says the request URL didn't verify | SLACK_SIGNING_SECRET unset/wrong, or you declared the URL before Step 6. eve returns 401; Slack reports it as a failed handshake and names the URL, not the missing secret. |
| Bot never responds, no error | The mention was dropped (bot-authored or authorless events are silent by design), or a SLACK_LINK_SECRET mismatch is 401ing every mention. Check the agent function logs. |
| "Sign-in could not be completed" on the callback | The IdP client is confidential; make it a public/PKCE client (Step 1.1). |
| "No temper account is linked to this login" | The user has no temper account yet — the link is lookup-only; sign in at the temper UI first. |
| "Account linking is not configured" | One of the four temper-api vars is missing/malformed (Step 2). |
| Everything green but no logs on failure | The eve runtime swallows handler errors, and serverless runtime logs surface HTTP events, not app stdout — "no error in the logs" proves nothing. Diagnose from observable Slack behavior (ephemeral? thread badge?) and the DB rows. |
For the agent internals (the eve inbound identity contract, why the connect message is a channel-root
ephemeral, the one-workspace ceiling), see
packages/agent-workflows/mention/CLAUDE.md and
README.md.