Arthur Gonzaga
← Back to blog

Teaching an AI to Extend Synapse: CLAUDE.md and TDD as Guardrails

In my first Synapse post, I talked about turning a "notify me when the print finishes" script into a proper hub-and-spoke event router. That version of Synapse only spoke in one direction: things happened in my homelab, and Synapse pushed a message to Telegram about it.

Since then, Synapse crossed a line I hadn't planned for: it started listening back. And I built that feature almost entirely paired with Claude Code, using two things to keep the AI honest — a CLAUDE.md file and a Test-Driven Development loop I refused to skip.

The feature: Synapse talks back

Synapse Core got a new endpoint, POST /telegram/webhook, that receives updates from the Telegram Bot API and dispatches them to command handlers. The first command is deliberately tiny: /online, which replies "Estou online às {system time}". Small on the surface, but it flips Synapse's whole model — from a one-way broadcaster to something that can receive instructions and act on them. It shipped as v2.0.0, with a chat-ID allowlist (fail-closed by default) and an optional secret token validated against Telegram's X-Telegram-Bot-Api-Secret-Token header, since a public webhook that can trigger actions on my homelab is not something I wanted to leave open.

None of that is complicated code. But handing "add a new route, wire it into routing, protect it, test it" to an AI agent without supervision is how you end up with a webhook that trusts whatever hits it. I wanted delegation without losing control, and that meant investing in the two guardrails before writing a single line of the feature.

Guardrail #1: CLAUDE.md as the project's memory

Claude Code reads a CLAUDE.md file at the start of every session in a repo, and I use it as the thing I don't want to re-explain every time. Synapse's Core/CLAUDE.md documents the module layout, the exact request flow for each source, every environment variable and its default, and — critically — the design decisions that aren't obvious from the code:

Docker route and Uptime Kuma route do not use the RoutingEngine; they format messages inline and send directly via telegram_api. This is intentional for simplicity.

That single line saves me from an AI "helpfully" refactoring those two routes to use the RoutingEngine for consistency — a change that looks like cleanup but isn't what I asked for. The file also lists all test files and what each one covers, and states the hard rule: coverage threshold is 80%, enforced via pytest-cov.

When I asked Claude Code to add the inbound webhook, it didn't start from a blank slate — it started already knowing the routing engine's shape, the naming conventions, the settings singleton pattern, and where tests belong. The prompt to build /online was short specifically because the context didn't need to be repeated. CLAUDE.md turns "explain the codebase" from a recurring cost into a one-time investment.

Guardrail #2: TDD as the negotiation protocol

Documentation tells the AI what the codebase is. TDD is what keeps it honest about what the new code does. I write Synapse features red-green-refactor, AI-assisted or not, and the /online command was no exception.

For this feature that meant writing test_telegram_webhook_route.py first: an authorized chat sending /online should get a 200 and a message starting with "Estou online às "; an unauthorized chat should be silently ignored; a bad X-Telegram-Bot-Api-Secret-Token should get a 403; unsupported updates shouldn't crash the endpoint. Every one of those was a failing test before it was a line of route code. A small services/clock.py helper (now()) came out of the same discipline — the response text depends on wall-clock time, so the only sane way to test it deterministically was to isolate the clock behind a function I could mock, rather than reaching for datetime.now() inline.

Working this way with an AI pair changes the shape of the collaboration. Instead of reviewing a diff and hoping the described behavior matches the actual behavior, I get to check a stated intent (the test) against a proof (it passes) before the implementation is even "done." When Claude Code proposes a handler, the test suite either agrees with it or doesn't — there's no ambiguity to argue about. The feature only counts as finished once make ci (lint + full test run) is green, same bar as anything I write myself.

The result: 177 tests passing, 95% coverage, comfortably above the 80% floor — for a feature that touches authentication, external HTTP calls, and time-dependent output, three things that are notoriously easy to get subtly wrong.

Why this matters more with AI in the loop

None of this — CLAUDE.md, TDD, an 80% coverage gate — is new practice invented for AI collaboration. What changed is how much they're worth. A human contributor can ask a clarifying question before touching the Docker route. An AI agent, left to infer intent from code alone, will happily "fix" things you didn't ask it to fix, and it will do it fast and confidently. CLAUDE.md is the mechanism that front-loads intent; the test suite is the mechanism that verifies behavior instead of trusting a description of it.

Practically, this is what let me trust an AI-authored commit enough to ship it: the /online feature commit is co-authored by Claude, and I merged it with the same confidence I'd have in code I wrote line-by-line — because the guardrails, not the author, are what I was actually trusting.

What's next

Synapse is bi-directional now, and /online is intentionally the smallest possible command — proof that the plumbing (webhook, allowlist, secret validation, dispatch table) works end to end. The command→handler dictionary in routes/telegram.py is built to grow, and the same pattern — write the CLAUDE.md context once, write the failing test first, let the AI fill in the middle — is how I plan to add the next ones.