At MiCode several products — AI Budżet and eKsięgowy AI among them — are built with Claude Code agents and run on one shared VPS. The rules for that server, for git and for project documentation were written down: in CLAUDE.md files, in the agent's memory, in runbooks. Written rules have one flaw: they work only when someone reads them at the right moment. Claude Code recently gained mods (in early access): function hooks that change the agent's own behaviour in code rather than in prose. In a single session we wrote four of them, shared by all our projects. This article covers why, how they were built, and what we expect from them.
Where the idea came from: one evening, four problems
It started with an ordinary task: review the telemetry of the AI Budżet web app and say what it tells us. Within a few hours we ran into four things that were not bugs in the code but holes in the process.
First, the agent could not read production data. The auto-mode classifier rightly blocked an SSH query to the database, so the analysis turned into ping-pong: the agent prepares a command, a person runs it and pastes the result back. The very first command failed on quoting inside a nested SSH call. The whole analysis took four such rounds.
Second, the numbers said something other than they seemed to. One internal team account generated the clear majority of sessions, so the funnels mostly showed how we ourselves use the app. The fix went into the product, but the lesson was general: production state has to be visible as it happens, not when someone happens to look.
Third, while collecting health-check URLs for one of the mods, we found that the health endpoint of one of our projects had been answering 404 for some time. The project had its own uptime check, but nobody was looking at its result.
Fourth, the rules. On the shared server one Docker daemon serves several projects, so a docker volume prune run to free disk space would delete other projects' databases. Commits must be in English, formatted as "ABA-123 Description" with no colon. You pull before you push, because automation commits to the branch too. And after every task the project wiki must be updated — a rule that exists because a wiki nobody updated once claimed the app had 11 AI functions when it had 18.
What a Claude Code mod is
A mod is a folder with three files: the .claude-plugin/plugin.json manifest, hooks/hooks.json pointing at the module, and the TypeScript module itself, which exports a register function. Inside it you call on(event, matcher, hook). Every hook has the shape ($, e, next): $ is the engine interface, e is the event's data, and next(e) passes the event on — to other plugins and to Claude Code's default behaviour. A hook that does not call next answers for itself: it can, for instance, refuse a tool call.
The module runs in an isolated environment with no Node and no DOM. Everything that leaves it goes through $: HTTP through $.http.fetch, processes through $.process.run, the interface through $.ui.status, $.ui.toast and the band above the A prompt is the instruction you give an AI model, and how precisely it is written decides whether the answer comes back useful or vague. Full definition →, tools for the model through $.tool.register. The whole An API is the interface two systems use to exchange data automatically, with no Excel exports and no copying by hand. Full definition → is described in a TypeScript declaration file the engine writes for its own version — over 22 thousand lines in ours, but you only need to search it for an event's name. Mods hot-reload when a file is saved, and claude plugin validate and claude plugin test check them before they load.
| Mod | Problem | What it does | Expected effect |
|---|---|---|---|
| prod-radar | Production state is visible only when someone checks | Health of every project in the status line every 90 s, a band and a notification on failure, VPS disk, a deploy watcher after git push | An outage noticed within 90 s of working in a session; deploys without manual watching |
| vps-guard | Rules for the shared server and for git live only in memory | Refuses destructive Docker commands, force pushes to protected branches, pushes without a pull, and commits that break the conventions | The "deleted another project's database" class becomes impossible by default |
| prod-query | The agent cannot read production, so a person copies commands | A tool for the model: one SELECT in a READ ONLY transaction, each call confirmed by a person, personal data masked | Data analysis in one confirmed step instead of copy-paste ping-pong |
| finish-task | Project knowledge rots when the ritual is skipped | After a commit with code but no wiki page, log line or issue key, shows a reminder | Docs updated at commit time, not weeks later |
prod-radar: production in the status line
The first mod polls the health endpoints of every project every 90 seconds and shows the result in the status line: a mark next to each project and the server's disk usage. When something goes down, a red band appears above the prompt and a system notification fires. We notify only on state changes so the session is not flooded, with one exception: a project that is already down at the first check is reported too. Had this mod existed earlier, we would have seen that 404 in the first minute of the session.
Disk is checked every 15 minutes over SSH with df, in BatchMode, so the mod can never hang on a password prompt. One configuration detail matters: the radar watches the health endpoint, not the home page. A home page served from a CDN or a static nginx container stays green while the API behind it is down.
The third part is a deploy watcher. The mod watches every Bash call, and when git push actually pushed something (the output has an arrow, not "Everything up-to-date"), it asks gh run list for the branch's GitHub Actions runs every 30 seconds. It keeps only runs created no earlier than a minute before the push, and the newest per workflow. Every finished run gets a short message; a failure also gets a system notification. Once all of them finish, the mod checks health once more, because right after a deploy is when that check matters most.
vps-guard: rules that do not depend on memory
The second mod is a hook on every Bash and PowerShell call that can refuse to run a command. The refusal carries a reason, and the model reads it and tries again, which is why every reason says not only "no" but also "do it this way instead".
- Command the agent will not run
- Reason the model reads
docker volume prune -f- The Docker daemon is shared by several projects and its volumes are their databases. Remove one volume by name, and only after the person confirms.
docker image prune -a -f- Unused-but-tagged images are other projects' rollback targets. Use image prune -f (dangling only).
docker compose down -v- Deletes the stack's volumes, database included. Use down without -v.
git push --force origin development- Rewrites shared history. Pull --rebase and push normally.
git push (the remote has new commits)- The upstream has N commits this branch lacks. Run git pull --rebase first.
git commit -m "ABA-12: ..."- No colon after the issue key: "ABA-12 Do the thing".
The pre-push check runs git fetch and git rev-list --count HEAD..@{u}. If the upstream has commits the local branch lacks, the push is refused with a request to pull --rebase. With no network or no upstream, the check lets the command through — fail open, because blocking work over a failure of the guard itself would be worse. But if the hook throws an unexpected exception, its .catch handler refuses: a guard that crashed must not silently pass what it was meant to check. A force push with no explicit branch is refused too, since the command alone does not show which branch it would overwrite.
These are regular expressions, not a security boundary. They protect against mistakes, not against someone set on getting around them. They also have false positives, and the first one caught us while writing this very article: the script that added the table above to the site contained the text docker volume prune as a quotation, so the guard refused it. The fix was simple — the script went into a file of its own — but it shows well that a rule sees the text of a command, not its intent.
prod-query: production access that a person decides on
The third mod registers a tool for the model, prod_query: one SQL is the language database questions are written in — it is what "show last quarter's sales" looks like once a database can answer it. Full definition → query against a chosen production database. The safeguards are layered. The query always runs inside BEGIN TRANSACTION READ ONLY with a 20-second statement timeout and a 2-second lock timeout, and always ends in ROLLBACK. SELECT-shaped queries are wrapped in LIMIT 500, so the database itself enforces the row cap.
A read-only transaction is not enough, because the role in the Postgres container is a superuser. Such a role can start a shell from psql with a backslash command, run COPY TO PROGRAM, or read a server file with pg_read_file. So the mod refuses backslashes, COPY, file functions, remote connections, server-control functions, row locks and SELECT INTO, as well as more than one statement per call.
The most important layer is a person. Every call shows the query's purpose and its text in Claude Code's standard question dialog and runs only after "Run" is clicked. There is no "always allow". In the output, e-mail addresses and international-format phone numbers are masked, but plain digit runs are not, because they are just as likely to be a date, an amount or an id.
A detail from the build: the auto-mode classifier blocked writing the file with this tool, for the same reason it had earlier blocked the SSH queries. We finished it only after the person explicitly approved. That is how it should be: whether an agent gets access to production data is the person's decision, not the agent's.
finish-task: a reminder at commit time
The fourth mod watches successful commits. After each one it reads the list of changed files and checks whether the commit changed code (source files outside docs, .claude and .github). If it did and the repository has a wiki, it checks whether the commit touched a wiki page and added a line to the log. It also checks that the commit subject carries an issue key — but only in repositories that use keys. It detects the habit by itself: at least half of the last 20 commits must have one.
When something is missing, a yellow band above the prompt lists it, and the model receives context asking it to run the finish ritual before pushing. The mod never blocks a commit: it is an observer, so its own failure simply lets the operation through. Where the ritual comes from, and how we keep a project wiki with an agent, we described separately.
How we built it
All four mods were made in one Claude Code session. The agent read the generated API declaration file and the examples, then for each mod moved the pure logic into its own file: the guard's rules, SQL validation, commit classification, parsing of df and gh output. That way the tests run with no network and no processes. In all it comes to about 740 lines of code and 23 tests. Every mod passed claude plugin validate, claude plugin test and a strict tsc type check, and each mod's configuration — the list of projects, the URLs, the databases — lives in a single config.ts. Claude Code agents build more than tooling for us, by the way: they also make our promo videos rendered in code.
Expected effect
The mods have been running for a few hours, so what follows are expectations, not measured results. We will come back with numbers after a month.
The "deleted another project's database" and "overwrote someone's commit" class of mistakes stops depending on memory and becomes impossible by default. An outage is noticed within 90 seconds of working in a session rather than by chance. This does not replace the scheduled uptime checks in GitHub Actions, because the radar runs only while a session is open. After a push nobody has to watch the deploy by hand. Production data analysis takes one confirmed call instead of four rounds of copy-paste. Documentation is updated at commit time instead of being discovered stale weeks later.
What we will measure: guard refusals and the share of false positives, finish-task reminders and how many of them end in a wiki update, prod_query calls and refusals, and the time from an outage to noticing it. The mod API is in early access and may change between Claude Code releases, so the mods will need occasional fixes.
Frequently asked questions
- What is a Claude Code mod?
- A folder with a manifest and a TypeScript module that registers hooks on Claude Code events: tool calls, prompt submission, interface rendering, session start. A hook can pass the event on, change it, or answer it itself, for example by refusing to run a command. A mod can also add its own tool for the model, a status-line entry, a band above the prompt or a slash command. The API is in early access.
- How do you safely give an AI agent read access to a production database?
- In layers. The query runs in a READ ONLY transaction with a timeout and always ends in ROLLBACK. Since the role in the container is often a superuser, psql backslash commands, COPY, file functions and locks are refused separately. The output has a row cap and personal data is masked. Most importantly, a person confirms every query, and there is no "always allow".
- Is a command-blocking hook a defence against an attacker?
- No. They are regular expressions over the command's text: they protect against a mistake by the agent or a person, not against someone set on getting around them. They also have false positives — our guard refused a script that contained a dangerous command only as a quotation in an article's text. The real boundary is still permissions, backups and human confirmation.
- Does a mod replace uptime monitoring?
- No. A mod runs only while a Claude Code session is open. The scheduled uptime checks in GitHub Actions, with Telegram alerts, keep running around the clock. The in-session radar shortens the time to notice an outage while you work and shows every project's state in one place.