This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Write everything in English: commit messages, pull request titles/descriptions, and code comments must all be in English, regardless of the language used in conversation.
- Commit messages use the imperative mood ("Add feature", not "Added feature") with the first line kept under 72 characters.
A Slack bot backend built with Slack Bolt (async) + FastAPI, powered by Google's Agent Development Kit (ADK) running Vertex AI Gemini models. It handles @mention and DM messages with multimodal input (text, image, PDF, text file, video, audio), maintains conversation context per Slack thread, can search the web, fetch URLs, and generate images. Designed to run on Cloud Run.
# Setup
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in Slack + GCP credentials
# Run locally (expose /slack/events via ngrok for Slack to reach it)
uvicorn app.main:fastapi_app --host 0.0.0.0 --port 8080 --reload
# ADK Development UI for testing/debugging the agent in isolation
gcloud auth application-default login
adk web # opens http://127.0.0.1:8000
# Deploy to Cloud Run (reads .env, builds via Cloud Build, sets env vars on service)
./scripts/deploy.shThere is no test suite, linter, or formatter configured in this repo.
The entire request lifecycle lives in app/main.py. The flow:
- Slack → FastAPI —
POST /slack/events(app/main.py). This endpoint does request filtering before handing off to Bolt:- Drops Slack retries (
x-slack-retry-numheader) with a 404 — important because agent runs can exceed Slack's 3s timeout, and without this every slow reply would be re-processed. - Echoes the URL-verification
challenge. - Enforces a workspace allowlist via
ALLOWED_SLACK_WORKSPACE(403 ifteam_idmismatches). - Only then delegates to the Bolt
AsyncSlackRequestHandler.
- Drops Slack retries (
- Bolt event handlers —
handle_mention(app_mention) andhandle_direct_message(DMs only,channel_type == "im") bothack()immediately, gate the sender via_is_sender_allowed, then call the shared_handle_message. The DM handler drops human message subtypes (edits/joins) but keepsbot_messageso allowlisted bots (e.g. Slackbot reminders) get through.- Access control —
_is_sender_allowedchecks two allowlists:ALLOWED_SLACK_USERS(empty = allow all humans) andALLOWED_SLACK_BOTS(empty = no bots; bots are never allowed implicitly). The bot's own messages carry abot_idand aren't allowlisted, so reply loops are prevented. ListingUSLACKBOTinALLOWED_SLACK_BOTSis what lets Slack reminders trigger scheduled runs._deny_if_not_allowedwraps the check at the handler boundary: a disallowed human gets theACCESS_DENIED_MESSAGEreply in-thread, while anything with abot_idis dropped silently (no loops/noise). - Reaction trigger —
handle_reaction_added(reaction_added) lets a user run the bot against any message by reacting withREACTION_TRIGGER(defaultrobot_face). It ignores non-trigger emoji (so the bot's own 👀/✅ reactions never re-fire it), gates on the reacting user via_is_sender_allowed(disallowed users dropped silently), fetches the reacted message with_fetch_message(history then thread-replies fallback), skips messages already carrying the processing/completed reaction, and feeds a synthetic event into_handle_message. Requires thereactions:readscope andreaction_addedevent subscription.
- Access control —
_handle_message— the core orchestration:- Adds the 👀 reaction (
REACTION_PROCESSING), runs the agent, posts the reply, uploads any generated images, adds the ✅ reaction (REACTION_COMPLETED). - Uses
thread_tsas the ADK session id, so each Slack thread is one persistent conversation. - On a fresh session,
_populate_session_from_threadbackfills the ADK session with prior thread messages viaconversations_replies, so context survives even though sessions are in-memory only (InMemoryRunner).
- Adds the 👀 reaction (
root_agent (slack_bot_agent) is wired with:
SkillToolset— file-based ADK skills loaded fromapp/skills/viaload_skill_from_dir(greeting-skill,datetime-skill). Theget_current_datetimetool is passed asadditional_tools; a skill can also declare tools viametadata.adk_additional_toolsin itsSKILL.mdfrontmatter (seedatetime-skill).generate_imagetool (app/tools/generate_image.py).AgentTool-wrapped sub-agentsweb_search_agent(Google Search) andurl_fetch_agent(url_context) fromapp/agents/web_search_agent.py.sub_agents:comedian_agent(app/agents/comedian.py) andmock_service_agent(app/agents/mock_service_agent.py) — true delegated sub-agents, not AgentTools.- MCP integration —
mock_service_agentis built viacreate_mock_service_agent(tools=[_mock_service_mcp])._mock_service_mcpis an ADKMcpToolsetthat launchesmcp_servers/mock_service_server.pyas a stdio subprocess (python -m mcp_servers.mock_service_server). The FastMCP server wraps the JSONPlaceholder mock API (hardcodedMOCK_API_BASE_URLliteral inmock_service_server.py) and exposeslist_users/get_user. This is the template for swapping in a real backend service over MCP. Because the server runs as a subprocess,mcp_servers/must be COPYed into the Docker image.
- Speaker identification — Every user message (current and history) is prefixed with a
[Speaker: <name>]text part so the agent can attribute statements by name in multi-person threads. The root agent's instruction explicitly tells it never to echo this tag. User names are resolved viausers_infoand cached in_user_name_cache. - Image hand-off —
generate_imagecannot upload to Slack directly (it's a tool, not a handler). It stashes image bytes in a module-level dict keyed by session id, set via thecurrent_session_idContextVar before each run._handle_messagethen callsget_and_clear_images(thread_ts)to upload them. When editing image generation, preserve this ContextVar → dict → drain flow. - Multimodal input —
_build_content_from_eventdownloads Slack files viaurl_private_downloadwith the bot token, sending images/video/audio/pdf as bytes andtext/*as decoded text. Unsupported mimetypes are silently skipped. - Slack output chunking — replies are split into 3000-char (
MAX_SLACK_BLOCK_CHARS)mrkdwnblocks. The root agent instruction enforces Slack-flavored markdown (*bold*,_italic_,<url|text>), which differs from standard markdown.
All config is environment variables (see .env.example). Notable ones:
MODEL_NAME(defaultgemini-3.5-flash) — used by root and all sub-agents.IMAGE_MODEL_NAME(defaultgemini-3.1-flash-image, "Nanobanana 2") — the agent can override per-call togemini-3-pro-image("Nanobanana Pro") for higher quality.GOOGLE_GENAI_USE_VERTEXAI=TRUE,GOOGLE_CLOUD_PROJECT,GOOGLE_CLOUD_LOCATION(global) — Vertex AI auth.ALLOWED_SLACK_WORKSPACE— Slack team ID allowlist (omit to allow all).ALLOWED_SLACK_USERS— comma-separated user IDs allowed to invoke (empty = allow all humans).ALLOWED_SLACK_BOTS— comma-separated bot user IDs allowed to invoke, e.g.USLACKBOTfor Slack reminders (empty = no bots).ACCESS_DENIED_MESSAGE— reply sent to a disallowed human user (empty = built-in default).REACTION_PROCESSING/REACTION_COMPLETED— reaction emoji names.REACTION_TRIGGER(defaultrobot_face) — emoji that, when added to a message, runs the bot against that message (handle_reaction_addedinapp/main.py). Requires thereactions:readscope andreaction_addedevent subscription.
The sample MCP server's backend URL is a hardcoded literal (MOCK_API_BASE_URL) in mcp_servers/mock_service_server.py, not an env var.
deploy.sh passes env vars with a ^@^ delimiter (not commas) so allowlist values like ALLOWED_SLACK_USERS=U1,U2 survive gcloud run deploy --set-env-vars.
llms.txt/llms-full.txtare bundled ADK documentation for LLM reference — they are not source code. They are snapshots pulled from the adk-pythonrelease/v2.1.0branch:llms.txtis a concise index/overview of the ADK andllms-full.txtis the full documentation corpus. Consult them as the authoritative ADK reference (matching the pinned ADK version) before reaching for external web docs, and refresh both files from the same branch when the ADK dependency is bumped.- Sessions are in-memory (
InMemoryRunner); restarting the service loses live sessions, but thread history is rehydrated from Slack on the next message. - Adding a new ADK skill: create
app/skills/<name>/SKILL.md(+ optionalreferences/), then register it in theSkillToolsetinapp/main.py.