
This is the playbook your agent receives when the skill activates โ you don't need to read it to use the skill, but it's here to audit before installing.
Skill โ secrets and integrations
How to wire credentials without ever seeing them, and how to tell the user where to enter what.
The hard rule
You never see raw secret values. Not in chat, not in tool calls, not by mistake. If the user pastes an API key into the conversation, you:
- Tell them not to ("That's an API key โ please don't paste it into chat. Use the secret form instead.").
- Don't acknowledge what the key looked like, don't try to set
it via
set-env-create(which would put it in your tool-call history). - Trigger the punch-out flow (below) so they enter it in a PostHog UI form instead.
- Recommend rotating the key they just pasted, since chat history may be retained.
Three distinct concepts
People conflate these. Be precise.
| Concept | Scope | Where it lives | How to set |
|---|---|---|---|
| Secret | Per-application | agent_application.encrypted_env (Fernet) | Punch-out form OR agent-applications-set-env-create (raw โ avoid) |
| Integration | Per-team | posthog_integration (OAuth tokens) | Team admin installs via PostHog integrations UI |
| Trigger auth | Per-trigger | spec.triggers[].auth.modes | Edit on the draft revision; controls who can invoke the agent |
A Slack-posting agent needs Slack secrets (SLACK_SIGNING_SECRET +
SLACK_BOT_TOKEN) on the agent โ not a team integration. Each agent
brings its own Slack app + bot token. A Stripe-querying agent likewise
needs a Stripe secret on the agent. Integrations are for systems
that legitimately want one workspace-level OAuth connection many agents
share (e.g. some PostHog data sources). When in doubt: it's a secret.
Secrets split further by who declares the name:
- Author-declared (
spec.secrets[]) โ the agent's tools read these (e.g.STRIPE_API_KEY,OPENAI_API_KEY). The author picks the name. Validation surfaces "secret X is declared but not set" at freeze time so you know to drive a punch-out before promote. - Trigger-required (
TRIGGER_REQUIRED_SECRETSregistry) โ the platform picks the name. The author never types it. Today this isSLACK_SIGNING_SECRETforslacktriggers (verifies inbound Slack signature). See the next section.
Trigger-required secrets
Some triggers require entries in encrypted_env that the spec
doesn't list explicitly. The contract lives in the platform-wide
TRIGGER_REQUIRED_SECRETS registry (spec_schema.py Django-side,
services/agent-shared/src/spec/trigger-secrets.ts runner-side), so
authors don't pick the names and the platform can't drift on what a
trigger consumes.
Current registry:
| Trigger type | Required keys | What each is |
|---|---|---|
slack | SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN | App signing secret (verifies inbound webhooks) + bot user OAuth token (lets @posthog/slack-post-message etc. call the Slack API as the bot). |
chat | (none) | |
webhook | (none) | |
cron | (none) | |
mcp | (none) |
SLACK_SIGNING_SECRET lives at Slack app dashboard โ Settings โ Basic Information โ Signing Secret.
SLACK_BOT_TOKEN lives at Settings โ Install App โ Bot User OAuth Token (starts with xoxb-), generated when the app is installed to a workspace.
Enforcement โ the promote endpoint walks the spec's triggers
and refuses with a clear error if any required key is missing:
Cannot promote: agent is missing required encrypted_env entries: SLACK_BOT_TOKEN (for slack trigger). Set the value(s) via the env editor then retry.
You can recover from this by setting the key and re-running
promote โ but a better user experience is to catch it during
Phase 4 of the authoring-new-agents playbook: as soon as the spec
declares a slack trigger, drive the punch-out for BOTH required
keys before reaching freeze. See the setting-up-slack-app playbook for
the step-by-step flow (create app โ set Request URL โ install โ
copy + punch-out tokens). PostHog Code's env editor also surfaces
"Required for this trigger" hints next to the relevant fields, so a
user setting things up in the UI sees the requirement without you
having to spell it out.
The punch-out call shape is the same as any other secret โ pass the key the registry names:
set_secret { agent_slug: "<slug>", secret: "SLACK_SIGNING_SECRET",
purpose: "Verifies inbound Slack event signatures." }
set_secret { agent_slug: "<slug>", secret: "SLACK_BOT_TOKEN",
purpose: "Lets the agent call Slack APIs as the bot user." }After each save, an env-keys-get precheck confirms the write
landed. Then proceed to freeze + promote.
Note: the platform does not fall back to a team-wide Slack OAuth integration. Each agent owns its own Slack app and bot token via
encrypted_env. If a user pastes a workspace-wide Slack bot token they want shared across agents, save it on each agent individually โ there is no shared store.
Setting a secret โ the punch-out flow
The punch-out flow is live in PostHog Code. You never see the value; the user enters it into a UI form scoped to that key. Three paths, picked by what the client supports โ preferred to least.
Path A (preferred) โ client.kind = posthog-code, inline tool
PostHog Code fulfills a set_secret client tool by rendering an
inline form inside the matching tool-call card, right in the
chat transcript. The user fills it in without leaving the
conversation.
set_secret is an interactive client tool โ the platform's
park + wake pattern (spec.tools[].interactive: true). It behaves
differently from a normal tool, and you need to read the rest of
this section before invoking it. TL;DR: your call returns a
queued envelope synchronously, you end the turn, the user
responds on their own time, and on a fresh turn you receive a
wake message with the real outcome.
Loop:
- Check current state with
posthog__agent-applications-env-keys-get{ id: "<slug>", key: "ANTHROPIC_KEY" }โ returns{ key, is_set }. If already set and the failure mode suggests the value is wrong, passmode: "rotate"; otherwise omit /mode: "set". - Invoke
set_secretwith{ agent_slug, secret, mode?, purpose? }:agent_slugis required โ pull it fromget_context(bare) or from the agent the user is configuring. Do NOT assume "the agent on screen" โ the user may navigate while the form is up.purposeis a one-line hint shown above the input. Keep it factual ("Used for the daily summary call"), no value hints.
- The tool result is immediate and synthetic. You will receive
a JSON envelope like
{ "queued": true, "interactive": true, "call_id": "<uuid>", "tool_id": "set_secret", "message": "Awaiting user input. The result will arrive on the next turn โ end this turn now." }. That is NOT the user's answer โ it's the platform telling you the form has been mounted and the runner has parked the session. - End the turn cleanly. Acknowledge briefly in plain text
("I've put up a form for you to enter the value.") and stop.
The model that keeps emitting tool calls after seeing a
queued: trueenvelope wastes turns; do not retry, do not poll, do not callenv-keys-getagain. - Wait for the wake. The session is parked โ your worker
slot is freed and the user has unbounded time to respond. When
they submit (or cancel), a fresh turn starts and the very first
usermessage you see carries an envelope like{ "call_id": "<the same uuid>", "ok": true, "result": { "key": "ANTHROPIC_KEY", "action": "set" } }on success or{ "call_id": "...", "ok": false, "error": "user_cancelled" }on cancel / failure. Match bycall_idto be safe. - Continue with whatever you were doing. On
ok: trueno need to re-checkenv-keys-get; the wake envelope confirms the write landed. Onok: falsewitherror: "user_cancelled", tell the user the form was cancelled and ask whether they want to retry. On any other error, surface the error text and suggest the user retry or use the deep-link fallback (Path B).
If the runtime returns unhandled_client_tool immediately (older
PostHog Code version that doesn't yet know set_secret), fall through
to path B โ the runner returns the unhandled error directly, no
park + wake.
Path B โ client.kind = posthog-code, deep link
When the inline tool isn't available, hand the user a link to the secrets editor and wait for a session callback. Loop:
-
Same
env-keys-getprecheck. -
Hand the user a link to the editor:
/agents/<slug>/connections?edit_secret=<KEY>&callback_session=<this session id><this session id>comes fromget_context. Render as markdown:[Set ANTHROPIC_KEY](https://github.com/PostHog/posthog/blob/master/services/mcp/playbooks/secrets-and-integrations/agents/...). Don't use afocus_*tool for this โ the editor wants its own modal, not a panel hand-off. -
Wait for the callback. When the user saves, PostHog Code posts a
[system]message into the same session:[system] User set secret KEY on agent SLUG. Continue.Don't poll โ the callback is push, not pull. If the user closes the dialog without saving, ask once after a turn of silence then drop it.
Path C โ non-PostHog-Code client
No inline tool, no callback wire โ same URL, but you ask the user to confirm manually. Loop:
-
Same
env-keys-getprecheck. -
Generate the absolute URL (host comes from the user's PostHog instance; if you don't know, give the path and let them prepend the host themselves):
https://<host>/project/<team>/agents/<slug>/connections?edit_secret=<KEY>Omit
callback_session=โ without PostHog Code there's nothing to receive it. -
Tell them: "Open , set your value, then say 'done' here."
-
When they say done, verify with
env-keys-getbefore continuing. The user may have closed the tab without saving.
When to use agent-applications-set-env-create directly
Almost never. The raw API exists for CI / scripts that already hold the value in a variable. Using it from chat puts the value in your tool-call history โ it'd be in the session trace indefinitely โ that's a leak even though it's encrypted at rest. The only exception is when the user has explicitly told you to ("I have it in 1Password and the punch-out form is broken, here's the value โ set it once and we'll rotate it after"), and even then warn them about the trace before complying.
Connecting a third-party service
There is no spec.integrations[] field. For a third-party service
the agent should call:
- MCP server โ connect it once (OAuth/DCR or api-key) and
reference it with
mcps[].connection; every asker of the agent shares that one owner-connected credential. - Per-asker OAuth โ wire an
identity_providers[]entry plus anauth.provideron the MCP/tool so each asker authenticates as themselves. Seeskills/authenticating-as-the-user.
Slack is not one of these. Use
SLACK_BOT_TOKEN+SLACK_SIGNING_SECRETon the agent'sencrypted_envvia the punch-out flow. See thesetting-up-slack-appplaybook.
Rotating a secret
Standard flow:
- User updates the underlying provider (rotates the Stripe key, etc.).
- You drive the same punch-out flow as Path A above, but invoke
set_secretwithmode: "rotate"(theenv-keys-getprecheck will show the key is already set). The user enters the new value in the inline form. - The next session opened uses the new value (the runner reads it at session start, not at agent-define time).
In-flight sessions keep the old value until they end โ the secret is resolved once per session.
When a tool call fails because of auth
Common patterns:
provider_error: invalid_api_keyโ the secret is wrong / expired- A raw Slack error like
invalid_authfrom@posthog/slack-post-messageโ the agent'sSLACK_BOT_TOKENis wrong or revoked 403 Forbiddenfrom the PostHog MCP โ the user's principal doesn't have the scope (agent_application:writeetc.)
Don't try to "retry with different auth". Surface the failure:
The
@posthog/slack-post-messagecall failed withslack.chat.postMessage error: invalid_auth. The agent'sSLACK_BOT_TOKENis wrong or revoked โ rotate it via the punch-out and the next session will pick up the new value.
Things not to do
- Don't suggest hardcoding a secret in
agent.mdor a custom tool. Plaintext secrets leak into model context AND don't benefit from rotation. Alwaysspec.secrets[]+ nonce-substitution at session start. - Don't suggest disabling auth. "Add
publicto a trigger'sauth.modesto fix the 401" is almost always wrong. Find the auth bug; don't remove the lock. - Don't infer integration state. If a Slack call fails, you can't tell from your side whether the integration is broken or the call was malformed. Ask the user to check the integrations page.
- Don't paste env state to the user. If you ever do see the
encrypted_envfield by mistake (you shouldn't, the MCP shouldn't return it), don't relay it.
Quick reference โ what each error means
| Symptom | Cause | Action |
|---|---|---|
validate_error: missing_secret | spec.secrets[] has a name with no value set | Trigger punch-out for that key |
provider_error: invalid_api_key | The secret value is wrong | Trigger punch-out + tell user the previous value was rejected |
Slack invalid_auth from @posthog/slack-post-message | SLACK_BOT_TOKEN wrong / revoked | Rotate SLACK_BOT_TOKEN via the punch-out; next session picks it up |
403 from the PostHog MCP | User's principal scope insufficient | Surface the missing scope; user gets it via OAuth re-auth or asking an admin |
set-env-create succeeds but agent still fails | Old session in flight using old value | Wait for in-flight sessions to drain; new sessions get the new value |
npx skills add https://github.com/posthog/posthog --skill secrets-and-integrationsRun this in your project โ your agent picks the skill up automatically.
No common issues documented yet. If you hit a problem, the repository's GitHub Issues page is the best place to look.