How I Connected Google Chat to Claude via a Self-Hosted AI API
The original bot was simple. Slash commands, whitelist check, run some kubectl or MySQL query, return the result. That was the whole thing.
Then someone asked: can I just ask the bot a question? Not a slash command. Just a question, in natural language, inside a Google Chat thread, and get a useful answer back.
That broke what I had.
What I wanted was specific: manage our Kubernetes cluster from anywhere, including from a phone. No terminal, no SSH, no context-switching to a laptop. Type a message in Google Chat, get a real answer, and keep the conversation going across turns in the same thread.
Here is what an actual session looks like:

From listing namespaces to rolling out a deployment to reading pod logs, all in one thread, no terminal needed.
Getting there took more than adding an API key.
Why Not Just Call the API Directly
The obvious move is to call the Anthropic API from the bot. Add a route, drop in an API key, call completions, return the response. Half an hour of work.
I tried that first and hit three problems pretty fast.
Session continuity. Google Chat threads are persistent. A conversation might span hours, with different people jumping in. If every message is a fresh API call with no memory of what came before, you basically have a stateless chatbot that forgets everything. I needed conversation state tied to a thread somehow.
Context injection. The bot already knew things about our infra: namespaces, services, common failure patterns. I wanted Claude to have that context without duplicating it across every caller. Clean injection at request time, not copy-pasted system prompts in three different codebases.
Duplication. I also maintain a Discord bot with a similar purpose. Every time I wanted to change the model or tweak the system prompt, I was doing it in two places. That gets old fast.
So I put a service in the middle.
What the Wrapper Does
The wrapper is a separate service (TypeScript, Fastify, PostgreSQL) sitting between the bots and Claude. Any client can call it without knowing what is running underneath.
The core endpoint is /projects/:name/run:
POST /projects/sre-bot/run
X-Api-Key: <project-key>
{
"prompt": "What's using the most memory in the staging namespace?",
"session_id": "spaces/XYZ/threads/ABC",
"timeout": 270
}
Response:
{
"output": "The top memory consumers in staging are...",
"session_id": "spaces/XYZ/threads/ABC",
"memory_id": 4821,
"duration_ms": 8340
}
Under the hood, a few things happen that the bot never needs to think about.
Each project has a stored system prompt plus an optional set of skills, which are reusable prompt fragments injected at runtime. The bot project’s system prompt has context about our Kubernetes setup, naming conventions, and what actions are safe to suggest versus what needs explicit confirmation before running.
For session continuity, the wrapper runs Claude Code CLI as a subprocess using --resume <session_id>. The session ID from the first run becomes the input for the next call. The bot just passes back whatever session ID it last received.
The wrapper also supports two runners: Claude and OpenCode. The project config picks which one. From the bot’s side, the API call looks identical either way, so switching backends is a config change, not a code change.
Every prompt and response gets stored in a memory table, tied to project and session. That has already come in handy more than once when something unexpected came back and I needed to trace what happened.
How the Bot Actually Uses It
The bot code is intentionally thin. When a user @mentions the bot or sends a DM without a slash command, the message hits a handler that does exactly three things:
session_id = _load_session_id(thread_name)
reply_text, new_session_id = _call_ai(user_text, session_id)
_save_session_id(thread_name, space_name, new_session_id)
That is the entire integration. Load the session, call the wrapper, save the new session ID. Conversation state lives inside the Claude Code session the wrapper manages, plus a thread-to-session mapping in the bot’s own PostgreSQL database.
Session Continuity in Practice
Sessions are keyed to the Google Chat thread name, which looks like spaces/SPACE_ID/threads/THREAD_ID. Each thread gets its own conversation, isolated from everything else. When someone comes back to a thread two hours later with a follow-up question, the wrapper resumes the same Claude Code session with full context of what was discussed.
The Discord bot does the same thing, just without delegating to the wrapper. There, the bot manages the session ID itself. Here, it hands that off to the wrapper and just stores a key.
The cost is one extra database roundtrip per message. In practice that is under two milliseconds, completely invisible when the response itself takes 5 to 10 seconds. Worth it to keep the bot code clean.
Thread Summarization
One feature that validated the wrapper approach: thread summarization. If someone writes “summarize” or “ringkas” in a message, instead of sending that literally to Claude, the bot fetches the full thread history from the Google Chat API, builds it into a transcript, and sends that as the prompt.
if _is_summarize_request(user_text):
messages = list_thread_messages(space_name, thread_name)
prompt = _build_summarize_prompt(messages)
reply_text, _ = _call_ai(prompt, session_id=None)
The session_id=None is intentional. A summary is a one-shot thing with no prior context needed, so the wrapper starts fresh. If I had hardwired the Anthropic API into the bot, this would work the same way. But with the wrapper, the summarization prompt and model choice live at the project level. Changing them does not touch the bot.
Why Claude Code CLI Instead of the SDK
People ask this when I walk through the setup. Why run the CLI as a subprocess rather than just using the SDK?
Claude Code already handles the things I would have to build from scratch: multi-turn conversation state, tool use, context window management, and session continuity with --resume. When I hand it a prompt via subprocess, I get something that maintains a coherent conversation across calls. The --output-format json flag keeps the output structured and predictable.
For an internal ops tool, that simplicity is worth more than the control you get from direct API calls. I do not need custom tool definitions or fine-grained context management. I need something that can answer infrastructure questions and occasionally suggest a command that needs a confirmation step before it runs. Claude Code handles that without extra work on my end.
The real trade-off is that the wrapper depends on Claude Code being installed and authenticated on the host. That would be a deal-breaker for a customer-facing product. For an internal tool on a server I already own, it is fine.
The Full Picture
Here is how everything fits together:
Each Google Chat thread maps to one Claude Code session. The session ID travels from the wrapper to the bot, gets stored in PostgreSQL, and comes back on the next message. The thread is the conversation boundary, persistent and isolated per channel.
The MCP servers are where the actual work happens. The kubectl MCP server is how listing namespaces, rolling out deployments, and reading pod logs actually gets done. Claude figures out what to run, the MCP server executes it, and the result feeds back into the next turn as context.
The Wrapper as Infrastructure
What makes this setup worth maintaining is that the wrapper is real infrastructure now. It has its own deployment, its own database, its own API keys scoped per project. Adding a new bot means creating a project, writing a system prompt, and pointing at /projects/:name/run. The AI layer is shared. Project isolation keeps contexts separate.
The Google Chat bot is one project. The Discord bot is another. A Slack or Telegram integration would each get their own project with their own system prompt and session space.
That keeps each bot small. They do not carry any AI logic. Each one is just an interface: take input, call the wrapper, return the output.
There is also a cost angle worth mentioning. Claude Code runs on a subscription, not per-token billing. For the usage pattern of an internal ops bot (a few multi-turn conversations per day, each with tool calls and some reasoning in between), per-token API costs accumulate faster than a flat subscription. With the wrapper, multiple bots share one Claude Code instance. Ten bots pay once, not per token per bot.
I did not design this as a platform from the start. I built the wrapper when I got tired of copying session management code into a second bot. Whether it stays as is or grows into something bigger depends on how many more bots end up pointing at it.