672 capabilities. One Go module. Zero Python.
I replaced my 85,000-line Python admin toolkit with a single Go module. One capability registry, three transports, guardrails stamped across 40 connectors. The real numbers, the mechanism, and what the rewrite cost.

672 registered capabilities across 40 connectors. 225 of them mutate production systems. One Go module. Zero Python.
I counted those numbers this week by building the registry at HEAD and dumping it. The counts come from code, not from prose about the code.
Three weeks ago the honest count was 466. A design spec in the repo used that number on June 30. The checked-in catalog that day held 386 entries; today it holds 534. Call it 148 new capabilities in under three weeks, on a nights-and-weekends repo, with no sprint.
None of that happens in the old codebase. Here is the old codebase.
The Python this replaced
In March 2026 I wrote workspace-admin-toolkit: a Typer CLI for Google Workspace admin that metastasized into everything else. Atlassian, GitHub, Slack, Cursor, Anthropic, OpenAI, CircleCI, ClickUp, Zoom, Microsoft 365. By its last commit: 85,532 lines of Python, 228 files, 252 commands, 47 vendor service modules. Born March 11. Last commit June 1. A working life of two and a half months.
It was fine. It was even disciplined for a Python CLI: the README promised that mutating commands require --confirm, and they did. That discipline survived into the successor.
But it had a ceiling, and the ceiling was architectural, not linguistic. WAT was a CLI product. Its only consumer was me at a terminal. The guarantees that mattered (how auth is loaded, what a client is allowed to talk to, what happens before a delete) lived in convention and habit, not in types. And the moment I wanted the same vendor operations available to an MCP server, an HTTP backend, and other services as an importable library, a pile of Typer commands had nothing to offer. You cannot import a CLI.
The successor's CLAUDE.md states the doctrine in two lines (trimmed):
Capability library, not a CLI product. The goal is the underlying vendor-integration packages, with their guarantees: keyfile auth, allowlisting transport, rate limiting, kill switch, typed errors.
Avoid Python at all costs. Do not introduce new Python; when touching existing Python, prefer rewriting in Go/TS over extending.
The successor is tyr (github.com/21StarkCom/tyr, Go 1.26). One module, 16 binaries in cmd/, three of which matter here: tyr (the CLI), tyr-mcp (the MCP server), tyr-serve (the HTTP backend). Everything else is projection.
| workspace-admin-toolkit | tyr | |
|---|---|---|
| Language | 100% Python | Go, one module |
| Surface | 252 Typer commands | 672 capabilities, 40 connectors |
| Non-test lines | 46,934 | 265,102 |
| Born | 2026-03-11 | first spec 2026-04-29 |
| Status | last commit 2026-06-01 | v0.69.0, 106 tagged versions |
| Consumers | me, at a terminal | 3 transports + 2 services importing it |
One struct, one registry, three projections
The entire system reduces to one struct:
// Capability is one registered backend operation.
type Capability struct {
Name string
Connector string
Description string
InputSchema json.RawMessage
Mutating bool
Sensitive bool // output carries PII/principals; stdout refused without -o or --allow-stdout-sensitive
AuthzScope string `json:"-"`
Handler func(ctx context.Context, args json.RawMessage) (any, error)
}
serve/capability.go (trimmed)
Every operation in the system is a value of this struct, registered once. The fields carry the whole design:
InputSchemais machine-readable. A real JSON schema per operation. This is what makes the surface consumable by agents and tools alike, instead of by a human reading--help.Mutatingis a policy bit, not documentation. Transports enforce it. A handler never needs to know who called it.Sensitiveis a data-handling bit. Output carrying PII or principals gets refused on stdout unless you route it to a file or pass an explicit override. The default is the safe path.Handleris the only vendor-specific part. Everything else is uniform.
Adding a capability means filling this struct and registering it. The whole marginal cost, and the explanation for the growth curve in the opening.
Transports are projections with policy

Three transports sit on the one registry, and each applies a different filter:
- Local CLI.
tyr list,tyr describe,tyr invoke --confirm. The full write surface lives here: all 225 mutating capabilities, each behind two keys turned together, the--confirmflag and a per-surfaceTYR_*_ENABLEenvironment gate. - MCP.
tyr-mcpexposes three generic tools (list_capabilities,describe_capability,invoke_capability) plus 20 curated named tools for the hot paths. Zero writes, by construction. The invoke input has no field that could carry a mutating confirmation, and the adapter drops the option that would. Not discouraged. Unreachable. An agent gets a curated slice of the read surface and no way to even spell a write. - HTTP.
tyr-serveruns on Cloud Run as a mostly-read edge, gated by a checked-inexpected-caps.txtof 261 lines. Thirteen of those lines mutate (ClickUp tasks and comments, Jira admin objects), and the server refuses each one unless the request carries an explicitallow_mutatingconfirmation. The deployed surface, reads and the write slice alike, is pinned in git next to the code that defines it.
Counted from source, not from docs: 44 distinct TYR_*_ENABLE write gates and 35 TYR_*_DISABLED kill switches. Every write surface is opt-in per environment. Every vendor can go dark without a deploy.
Guardrails you stamp, not craft
The first ADR in the repo (docs/adr/001-hibob-read-only-vendor-client.md) defines what the codebase calls a full vendor citizen, and the connectors stamp it mechanically:
- a keyfile loader (stamped across the roughly 20 keyfile-auth connectors; GCP rides ADC instead) that requires 0600 permissions, rejects symlinks via
O_NOFOLLOW, requires owner == euid, and re-checks by fstat-ing the already-open fd, so a TOCTOU swap buys nothing - an HTTPS-only transport with a host allowlist and no injectable inner transport
- a per-service rate limiter
- a kill switch consulted before any I/O
- a typed, sealed error envelope
- verify-before-destroy on every delete
None of this is clever. All of it is stamped. The decision was made once, in an ADR, then repeated connector after connector without debate. That is what "library with guarantees" means in practice: the guarantee is the boilerplate. Python made every one of these invariants a convention someone could skip under deadline. Go plus a stamped pattern made them the path of least resistance.

Drift guards instead of documentation
A surface this size will lie to you the moment you trust prose to describe it. So the repo trusts counts:
- The CLI catalog is a checked-in
catalog.json(534 entries) guarded by a test. Behind it sits aRegisterAllNilbuild over typed-nil service interfaces, described in its own comment as "the single source of 'all capabilities', shared by the generator and the drift guard" (internal/cli/registry/catalogbuild.go). Regenerate or fail. There is no third option. - 17 wire-diff probes, most of them on write surfaces: request-body diff harnesses that pin the exact bytes going to a vendor. A refactor cannot silently change what goes over the wire.
- The GA4 connector alone (100 capabilities, the largest) carries 45 read and 34 write wire-diff probes, plus an aggregate test literally named
TestCountedCatalogIs37ShippedIs34PendingIssue244. The counting culture is in the test names. - 47 live-smoke example dirs covering the 40 connectors. Local green means nothing against a vendor API; the smoke suites hit the real thing.
And the "library, not CLI" claim is verified by consumers, not slides. My voice-to-text service pins tyr v0.62.0 in its go.mod and imports the hibob, openai, and slack packages directly. My cost-observability backend pins v0.65.0. Two services consume the vendor clients with every guarantee intact, no CLI in sight.
What it cost
Now the part vendor blogs skip.
I paid 5.6x the lines. tyr is 472,169 lines of Go at HEAD, counted from git ls-files: 265,102 non-test, 207,067 test. (A naive find-based count doubles that by swallowing a stale embedded worktree. Even LOC needs counting discipline.) 44% of the repo is tests. WAT's non-test Python was 46,934 lines. So roughly 5.6x the non-test code for 2.7x the surface. Typed clients cost lines. JSON schemas cost lines. Stamped guardrails cost lines, connector after connector. If LOC efficiency is your metric, keep the Python. My metric is what I can safely let an agent touch, and on that metric the two codebases are not in the same sport.
The rewrite started embarrassingly early. WAT's initial commit: March 11. The first Go design spec: April 29. Seven weeks. The replacement overlapped the original almost immediately, and WAT was dead by June 1. Call it churn if you like. Building the wrong tool fast is how I found out what the right tool was. WAT's two and a half months were the requirements phase wearing a trench coat.
The last gap is 93 lines, and it is still open. One vendor keeps WAT undeleted: Microsoft 365. A capability diff on June 29 (tracked as an open issue) found tyr covering every vendor WAT does, plus six WAT never had, with that one exception: microsoft365.py, all of 93 lines. Migration tails are always small and they gate everything. Respect the tail or it owns you.
A mid-life rename cost real engineering. In July the repo went from its old name to tyr while two services imported it. The module path moved, and 329 environment variables were renamed from the old prefix to TYR_* behind a startup shim that every binary calls once. Both prefixes resolve either way, unset-only, snapshot-based so it cannot loop. My favorite two lines in the repo:
const (
legacyPrefix = "STARK_" + "ADMIN_" // split to survive a naive STARK_ADMIN_→TYR_ sweep
newPrefix = "TYR_"
)
internal/envcompat/envcompat.go
The prefix is split so the mechanical find-and-replace that renames everything else cannot rename the shim's own idea of "legacy". The migration shim defends itself from the migration. That constant is doing more threat modeling than most design docs.
One comedy beat from the same week: release-please saw the breaking feat! commit and proudly computed version 1.0.0. I forced it back down to 0.62.0 with an empty Release-As commit. It is a playground, not a 1.0 product. Version numbers should tell the truth too.
CI is nightly-only. On purpose. No per-push tests, no PR gate. Merges land unchecked and a nightly run aggregates everything since the last green tag. On a team product this would be malpractice. On a single-operator platform with a 44% test share, wire-diff probes, and live-smoke suites, per-push CI buys latency and nothing else. Spend rigor where it pays, not where habit puts it.
Registry, catalog, edge
Here is the concept I want you to walk away with, because the title of this post is a trap without it.
Which number is true: 672, 534, or 261? All three. They name different tiers of the same system, and I call the split registry, catalog, edge:
- Registry: 672. Everything the code registers when every gate is open, including env-gated write surfaces and gated connectors. This is your blast radius. What the system can do.
- Catalog: 534. What the tool admits to: checked in, drift-guarded, what
tyr listprints. This is your contract. (On my machine it lists 533; one capability needs local state that is not there. Even the off-by-one tells you something real.) - Edge: 261. What production actually exposes: the 261-line expected-caps file gating the Cloud Run deploy. This is your attack surface.

The invariant is registry ≥ catalog ≥ edge. The discipline is being able to produce all three numbers on demand, from code, and explain every gap between them. The 466 in that June spec was a registry count. Today's 672 is too. Now you know which tier the title names.
One number is marketing. Three numbers is a security model.
If you run any capability surface (an MCP server, an internal gateway, a plugin system) and you cannot name your registry, catalog, and edge counts, you do not know what you shipped. Most teams have one number, and it is stale.
If you're sitting on your own WAT
Five moves, in order. This is the playbook, not the theory.
- Bound the port by the call graph. Before writing any Go, I audited which WAT operations were actually being called and checked the audit into the repo (
docs/python-call-graph.md). Each Go package's v1 scope was exactly those operations. Everything else became a backlog issue, not a blocker. Call it call-graph-bounded porting. It kills the "port everything" paralysis that makes rewrites die at 60%. - Registry before transports. Define the capability struct and the registration path first. CLI, MCP, and HTTP are afternoon projects once the registry exists. They are death marches when each transport grows its own surface.
- Stamp the guardrails. Decide the vendor-client invariants once, write the ADR, repeat mechanically. Boring is the feature. Creativity in your auth path is a vulnerability.
- Ship deployable slices. Prior migrations of mine cost four to five weeks each, and the only reason this one never stalled is that every slice landed usable. Big-bang rewrites are how you end up maintaining two systems forever.
- Guard with counts, not prose. Checked-in catalogs, expected-caps files, wire-diff probes, tests with numbers in their names. Documentation describes. Guards enforce. Only one of those survives contact with week two.
The corpse on the table
Exactly two Python files remain in the tyr repo: docs/specs/wat-source/github_billing.py and github_cost.py. Reference copies of the code being ported, kept where the port can see them. So the title holds on its own terms: zero Python in the build, two reference corpses in the docs. The corpse stays on the dissection table until the transplant is verified. Then it goes out with the rest.
The name, since you are wondering: Týr, the Norse god who bound the wolf Fenrir and paid for it with his right hand. The correct patron for an admin layer. 225 of these capabilities can mutate live vendors; binding dangerous things is the entire job, and the myth is honest about what vendor decks never are: the binding has a price. Mine was eleven weeks from first spec to 672 capabilities, and 472,169 lines of Go.
Here is what the price bought. Capability 673 costs one struct literal and one handler. That is the only definition of a platform I trust: the place where the next thing is cheap. WAT answered "can I do this today". tyr answers "how cheap is tomorrow".
Kill your Python accordingly.