Skip to content

feat(integrations): propagate Slack sender identity into the agent prompt#224

Open
oss-agent-shin wants to merge 4 commits into
BerriAI:mainfrom
oss-agent-shin:shin/slack-sender-identity
Open

feat(integrations): propagate Slack sender identity into the agent prompt#224
oss-agent-shin wants to merge 4 commits into
BerriAI:mainfrom
oss-agent-shin:shin/slack-sender-identity

Conversation

@oss-agent-shin
Copy link
Copy Markdown
Contributor

Summary

When a user @-mentions the bot or DMs it, the agent's harness receives only the stripped message text — it has no idea who wrote it. Agents that want to address the user back (hi <@U…>) or @-mention them in a thread reply have to guess. Shin's system prompt's promise that "the current user's Slack handle and user ID will be passed in via context" never actually held — there was no wiring for it.

This PR threads sender identity from the Slack webhook all the way to the harness prompt.

What's new

  • IntegrationSender type on every inbound event variant (new_task, followup, message). Always carries provider + id; handle and display_name are best-effort.
  • withSenderHeader() helper (src/server/integrations/core/sender.ts) renders a one-line prefix the harness can read:
    [from: <@U123ABC> handle=ishaan_jaff name="Ishaan Jaffer" via slack]
    
    <original message text>
    
  • Slack webhook adapter captures event.user and best-effort resolves it via users.info (src/server/integrations/providers/slack/users.ts, with a 5-minute LRU keyed by (team, user)).
  • users:read scope added to the manifest AND oauth.ts SCOPES so the OAuth flow requests it too. Existing installs without the scope continue to work — the lookup degrades silently to id-only.
  • Dispatcher threads sender through both the spawn and followup paths.
  • CreateSessionBody + SendMessageBody accept an optional sender; the routes apply the header before handing the prompt to the harness. Direct API / UI callers that omit sender see zero behavior change.

Also cleaned up an inconsistency where oauth.ts SCOPES was missing files:read and reactions:write (they were in the manifest but not in the OAuth flow's request).

Why a header (and not e.g. a separate harness field)?

A header on the user-visible text part is the minimum-blast-radius change — no harness wire-format changes, works with the existing claude-agent-sdk and opencode harnesses, and the agent's system prompt can simply describe how to parse it.

What this fixes for Shin (and any agent with similar prompt)

Shin's system prompt says:

The current user's Slack handle and user ID will be passed in via context. Always @mention the user you are responding to using Slack's @USER_ID format in your greeting and when directly addressing them.

With this change, on every Slack-originated turn the agent now sees [from: <@U…> handle=… name="…" via slack] at the top of the prompt — enough to actually fulfill that instruction.

Test plan

  • Reinstall the Slack app (so users:read is granted) and verify users.info lookups succeed in logs.
  • @-mention the bot in a channel; verify the harness prompt receives the [from: <@U…> …] header (check Session.history after the first turn).
  • DM the bot a follow-up; verify the follow-up turn also carries the header on /sessions/{id}/message.
  • Test with an install that hasn't been reinstalled (missing users:read): verify lookup fails gracefully and the header still shows the raw id only.
  • Direct call to POST /api/v1/managed_agents/agents/{id}/session without sender — verify the prompt is unmodified.
  • An image-only Slack message (no caption) with sender attached — verify the header still lands as a text part alongside the image.

🤖 Generated with Claude Code

…ompt

When a user @-mentioned the bot or DM'd it, the harness saw only the
stripped message text — no idea who wrote it. Agents that want to address
the user back ("hi <@U…>") or @-mention them in a thread reply had to
guess, and Shin's system prompt's promise that "the current user's Slack
handle and user ID will be passed in via context" never held.

This wires the sender all the way through:

  * `IntegrationSender` lives on `IntegrationEvent` (new_task / followup /
    message). Always carries provider + id; handle and display_name are
    best-effort. New helper `withSenderHeader()` (core/sender.ts) renders
    a one-line `[from: <@U…> handle=… name="…" via slack]` prefix.
  * Slack webhook adapter now captures `event.user` and best-effort
    resolves it via `users.info` (`core/slack/users.ts` with a 5-minute
    LRU). Missing scope / API errors degrade silently to id-only.
  * `users:read` added to both the manifest and the OAuth scopes list.
    Existing installs without the scope continue to work — they just lose
    the friendly handle until reinstall.
  * Dispatcher threads `sender` through both spawn and followup paths.
  * `CreateSessionBody` and `SendMessageBody` accept an optional
    `sender`; the session-create and message routes apply the header
    before handing the prompt to the harness. Direct API / UI callers
    that omit `sender` see zero behavior change.

The `parts[]` path on `/sessions/{id}/message` intentionally skips
header injection — UI callers building harness parts explicitly opt out.

Also brings `files:read` and `reactions:write` into oauth.ts's SCOPES
list so OAuth-flow installs match what manifest-flow installs already
declare.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 20, 2026

Greptile Summary

This PR wires Slack sender identity — user id, handle, and display name — from the webhook adapter all the way to the agent's harness prompt via a sanitized [from: <@U…> handle=… name="…" via slack] header prepended by the new withSenderHeader helper. It also corrects missing OAuth scopes (files:read, reactions:write, users:read) and adds an LRU-cached users.info resolver so friendly names are available without hammering the Slack API.

  • sender.ts introduces robust render-time sanitization (strips [, ], \", and all control characters including newlines) applied uniformly to both the Slack webhook path and direct API callers, addressing the prompt-injection concern the previous review raised.
  • users.ts now correctly implements LRU eviction by delete-and-reinsert on every cache hit, but decrypt(install.access_token) is called before the try block — if decryption ever fails, the exception propagates through buildSender and out of parse, returning a 5xx to Slack and triggering retries instead of gracefully falling back to id-only resolution.

Confidence Score: 4/5

Safe to merge after moving decrypt inside the try block in users.ts; all other paths degrade gracefully and existing callers are unaffected.

The single actionable defect is in resolveSlackUser: decrypt(install.access_token) runs before the try-catch, so a crypto failure (key rotation, corrupted record) turns what should be a transparent user-lookup failure into a hard exception that aborts webhook parsing for every message from that install. All other pieces of the change — sanitization, LRU promotion, dispatch threading, schema additions — look correct.

src/server/integrations/providers/slack/users.ts — the decrypt placement is the only outstanding issue.

Important Files Changed

Filename Overview
src/server/integrations/providers/slack/users.ts New LRU-cached users.info resolver; decrypt is called outside the try-catch, so a crypto failure breaks all webhook parsing for the install rather than degrading gracefully.
src/server/integrations/core/sender.ts New file; sanitization correctly strips structural delimiters and control characters before embedding user-controlled values into the header.
src/server/integrations/providers/slack/webhook.ts Adds buildSender and threads sender through the single parse path covering both app_mention and DM events; correct.
src/server/integrations/core/dispatcher.ts Sender is correctly forwarded through all three dispatch paths: new_task, followup, and message.
src/server/types.ts Adds optional sender: SenderBody to both CreateSessionBody and SendMessageBody Zod schemas with length-bounded fields.

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread src/server/integrations/core/sender.ts Outdated
Comment thread src/server/integrations/providers/slack/users.ts
… in header

display_name/handle/id/provider are user-controlled (Slack profile fields
or direct-API sender values). Strip header delimiters ([, ], ") and
collapse control chars/newlines before embedding in the [from: ...] header
so a hostile value can't break out and inject free-standing prompt text.
Also bound each field's length in the SenderBody zod schema.
The cache was documented as LRU but evicted FIFO — readCache never touched
insertion order, so a hot early entry was dropped before a cold recent one.
Promote hits by delete+re-insert so writeCache evicts the genuinely
least-recently-used key.
…ntity

# Conflicts:
#	src/server/integrations/core/types.ts
#	src/server/integrations/providers/slack/oauth.ts
#	src/server/integrations/providers/slack/webhook.ts
@ishaan-berri
Copy link
Copy Markdown
Contributor

@greptileai review

Comment on lines +101 to +106
const cached = readCache(team_id, user_id);
if (cached) return cached;

const token = decrypt(install.access_token);

try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 decrypt is called at line 104, before the try block begins at line 106. If decrypt throws for any reason (key rotation, corrupted ciphertext in the DB, unexpected token format), the exception propagates up through buildSender and straight out of parse, causing the webhook handler to return a 5xx. Slack treats 5xx as retriable, so every subsequent message from that install would also fail hard and retry infinitely until the underlying issue is fixed — rather than simply falling back to id-only sender resolution as intended. Moving the decryption inside the try block restores the graceful-degradation contract the function promises.

Suggested change
const cached = readCache(team_id, user_id);
if (cached) return cached;
const token = decrypt(install.access_token);
try {
const cached = readCache(team_id, user_id);
if (cached) return cached;
try {
const token = decrypt(install.access_token);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants