Inspiration

We already ship a Gemini + MongoDB MCP shopping concierge inside our React Native app. What we saw the moment we let a small trading team share access: they wanted the agent in the room where they already talk — Slack — and they wanted it to do more than tell them what to do. They wanted it to do something. The Slack Agent Builder Challenge gave us a forcing function to move the agent from a mobile-app curio into an actual co-seller's control room, and to close the loop with real on-chain settlement.

What it does

Kajota Coach in Slack is one bot that composes three surfaces the co-seller's team already lives in:

  • Live merchant catalogue reads through the official MongoDB MCP server — every reply grounded in what's actually in the database this second, no RAG indexer.
  • Proactive agent turns on demand — /kajota status fires a multi-tool ADK turn (recent orders, wishlist deltas, catalogue drops) and posts a Slack Block Kit card carousel in-channel with a "Used 3 MCP tool calls: find, find, find" footer.
  • Team-approved on-chain escrow settlement/kajota pay yeezy-hoodie 150 doesn't broadcast. It resolves the on-chain listing id, then posts a Block Kit card with Approve + broadcast / Deny buttons. A workspace teammate clicks Approve → the buttons vanish, the card updates in place with the approver, and a threaded reply fills with live progress as each on-chain tx confirms:

🔄 USDC.approve — broadcasting… ✅ USDC.approve confirmed 0x… 🔄 CosellEscrow.deposit — broadcasting… ✅ CosellEscrow.deposit confirmed 0x… 🔏 Escrow settled — 150 USDC locked for yeezy-hoodie

That's the co-seller's approval workflow delivered through Slack primitives — Block Kit buttons, chat_update to disable a card after click, threaded receipts — not a chatbot pretending to be one.

One agent, three transports — including MCP both ways

Kajota Coach uses MCP twice: as a client composing MongoDB MCP + Fetch MCP inside the ADK Runner, AND as a server re-exposing its own capabilities. The FastAPI process mounts an MCP endpoint at /mcp (streamable-HTTP) offering five tools any MCP client — Claude Desktop, Cursor, another ADK build, or Slack's own Agent Builder runtime — can call:

resolve_listing_id(product_hint)     — on-chain CosellRegistry read
propose_escrow(hint, amount)         — dry-run, returns listing id + calldata
settle_escrow(hint, amount)          — signs + broadcasts both txs
get_status(user_id)                  — proactive agent turn (3 MongoDB reads)
add_to_watchlist(product, user_id)   — MongoDB insert-one via the agent

How I built it

Stack — every track requirement met:

  • Slack agent: slack-bolt>=1.21 (async) AsyncApp mounted inside the existing FastAPI process via AsyncSlackRequestHandler. Routes: POST /slack/events, POST /slack/commands/kajota, POST /slack/actions.
  • AI reasoning: Gemini 2.5 Pro on Google Cloud Agent Development Kit (ADK), running as Agent(tools=[mongodb_mcp, fetch_mcp]).
  • MCP integration: Two MCP servers composed under one ADK runner: MongoDB MCP (official Node server via npx) and Fetch MCP (Python module). PLUS Coach itself exposes an MCP server at /mcp — the "MCP both ways" story.
  • Real action: /kajota pay calls kajota_concierge.mesh — a web3.py client that composes USDC.approve + CosellEscrow.deposit against our own Mesh contracts on Mantle Sepolia.

The team-approval flow uses Slack's actual collaboration primitives:

  1. Block Kit actions block with two buttons (kajota_approve, kajota_deny) on the pending card.
  2. In-memory intent store keyed by an opaque id — the button value is the id, not the tx params, so an untrusted user can't rewrite the amount in-flight.
  3. chat_update on click — the approver is stamped in and the buttons vanish, so no double-click race.
  4. Threaded progress via client.chat_postMessage(thread_ts=…) as each tx is broadcast + confirmed on Mantle Sepolia.
  5. 15-min TTL sweep — unclaimed proposals get pruned so the store stays bounded.

Each Slack user gets an isolated ADK session, keyed as slack:{team_id}:{user_id} — so /kajota status twenty minutes later picks up where the last turn left off, and cross-workspace sharing of a display name never leaks state.

One ack budget, one real reply: Slack requires a slash-command ack within 3 seconds; Gemini agent turns with 2–3 MCP tool calls routinely take 8–15s. Bolt's process_before_response=False handles the split: ack() immediately with an ephemeral, then post the actual Block Kit reply via client.chat_postMessage once the agent turn finishes.

Challenges I ran into

  • Slack's 3-second ack window meets multi-tool agent turns. Bolt's respond() alone won't cut it — a 12s agent turn expires the ack context. Fixed with ack() + chat_postMessage split.
  • Cross-workspace session collision. ADK's InMemorySessionService resolves by (app_name, user_id, session_id). Raw Slack user_id would collide across workspaces. Fixed by folding the workspace into both — slack:{team}:{user}.
  • Web3.py v7 API drift. Between v6 and v7, SignedTransaction.rawTransaction became raw_transaction and HexBytes.hex() stopped emitting the 0x prefix. Handled both defensively.
  • Escrow deploy coverage. Kajota Mesh registry is live on Polygon Amoy, Ethereum Sepolia, and Mantle Sepolia. Full stack (Registry + Escrow + MockUSDC) only on Mantle Sepolia. Defaulted MESH_CHAIN=mantle-sepolia and raise a clear MeshConfigError if you point at a chain missing an escrow.

Accomplishments that I'm proud of

  • One agent, three surfaces. The exact same _run_agent_turn coroutine drives the mobile /chat, mobile /proactive, every Slack slash command + @mention, AND the MCP server tools. Zero changes to the agent itself — Slack was ~450 lines of Python (Bolt + Block Kit + mesh web3.py client).
  • /kajota pay produces a real receipt. Most agent submissions stop at "the agent talks about doing the thing." Ours composes two on-chain calls (approve + deposit) against contracts we deployed ourselves. Judges can click the explorer links in the demo.
  • MCP both ways. We consume MongoDB MCP + Fetch MCP as a client, AND expose our own capabilities as an MCP server at /mcp. Same code paths as the Slack surface — same mesh client, same ADK Runner.
  • Faults surface where the user is looking. A Mesh config error, a bad amount, a missing listing — all render as a Block Kit :warning: card inside the Slack channel, not a 500 the user has to chase in server logs.

What I learned

  • A "Slack agent" is a transport, not an agent. Once we stopped thinking "Slack integration" and started thinking "Slack is another surface on our existing agent," the code got a lot smaller.
  • The /command <subcommand> <args> idiom keeps you inside one Slack app manifest. Routing on the first token of text inside slack_app.py is one manifest entry and one code entry.
  • Chain-agnostic env var overrides + a MeshConfigError are worth the ten extra lines. The mesh client falls back to DEFAULT_CHAINS[chain_key] and lets MESH_* env vars override every address.

What's next for Kajota Coach in Slack

  • Bring the proactive push into Slack. Wire the mobile-side proactive turn into a Render cron so a wishlist item hitting its target price posts a Slack card unprompted.
  • Wallet-connect flow for the unsigned path. A Slack interactivity handler + WalletConnect deep link would close the "user signs their own tx" loop.
  • Third MCP. Adding an Elasticsearch MCP for full-text catalogue search is one McpToolset registration + one system-prompt paragraph.

Built With

Share this project:

Updates