Vimek is the assistant our Vimo Swim team talks to in Slack. It reads the CRM, prepares GitHub tasks, 4DX entries and Meta campaigns, and every change waits for a person to click Approve. Today I wanted the same Vimek in Gmail, next to the email I have open: who is this lead, what did they order, add a task to call them back.
The first plan was an HTTP endpoint on the Slack bot. I dropped it after one question: where does the agent belong? The bot held the model choice, the Claude to OpenAI fallback, the tools, the proposals and the cost footer. A second client would mean a second copy of all that. So the agent moved into the backend, and every client became a thin one.
The new shape
flowchart LR
SL["Vimek Slack"] --> BE["vimoswim-backend<br/>agent, tools, proposals, costs"]
GM["Vimek Gmail"] --> BE
CL["Vimek CLI"] --> BE
BE --> LLM["Claude or OpenAI"]
BE --> DATA["CRM, 4DX, docs, ads<br/>called in-process"]
BE --> GH["GitHub Projects"]
BE --> CZ["cezar"]
Each client has its own API key and knows three calls: start a run, read it back, and approve, edit or cancel a proposal. A run is answered in a Celery task. Gmail add-on actions stop after 30 seconds, so the POST can wait up to 25 seconds and the client polls after that. Proposals moved from memory into a table, so a restart no longer drops a pending Approve, and a double click writes once.
The part I liked: the backend calls itself, without the network
Vimek already wrote to the backend through a small /api/v1/vimek/... API: lead changes, 4DX records, documents, ads. Those views carry the real rules: field whitelists, the audit entry on the lead's timeline, error shapes the agent knows how to explain. Rewriting them as ORM calls would have meant two copies of every rule.
Instead the integration clients got an injectable httpx transport. Inside the backend it builds a Django request and hands it straight to the view that would have answered over HTTP. Same serializers, same audit log, no socket, no key.
match = resolve(environ["PATH_INFO"])
response = match.func(WSGIRequest(environ), *match.args, **match.kwargs)
The permission class accepts a flag in request.META in place of a key. WSGI keys from the network always start with HTTP_, so an outside request cannot set it. There is a test that tries.
The read tools went the same way: the MCP server object is called in-process, and its audit log names the caller.
How it went
What the tests did not catch
Every suite was green: the backend, the ported engine, the Slack client. The first real request on production came back with Claude API 401: invalid x-api-key. The backend had its own Anthropic key, and it was dead. The old bot had used a different, working one. Nothing in CI could have seen that, because CI never talks to Anthropic with the production secret.
| Check | Caught it? |
|---|---|
| Unit and API tests | no |
| Local end-to-end run with my key | no |
| One real request on production | yes |
One more lesson: a security review flagged that the [cez] shortcut, which starts a coding agent with no Approve, was now open to any key holder. In the bot only allowed Slack users could reach it. It is limited to the Slack key again.
What it took
About a day of focused work, most of it moving tests and deleting code from the bot. What I would do differently: run one real production request before calling anything done, and check every secret the new home inherits, not only the ones I copied over.

Related notes
NFC Tag Emulation on nRF52840 with Rust — No SDK Required
How to turn a bare nRF52840 into an NFC Type 2 Tag using only PAC registers and Embassy async, running alongside BLE SoftDevice. A practical walkthrough with full code.
One MCP Server, Two Auth Stories: Claude Code vs. Claude Desktop
Why adding Claude Desktop support to an internal MCP server meant building real OAuth 2.1, even though Claude Code was happy with a static token.



