I’ve already posted here about prompts for SRE/SysAdmin/DevOps, about local and open source agents, and about development with AI more broadly. Those three pieces document different phases of the same obsession. This one documents the current phase: fewer new tools per week, more real infrastructure on top of what already works.
From the “tried everything” phase to the “this one sticks” phase
I ran OpenRouter for a good while just to compare model against model without marrying any provider. Through these aggregators I went through T3 Chat, Mammouth, NanoGPT, AymoAI, Perplexity… It even spawned a post about LLMs that run anonymously and via Tor: https://esli.blog/posts/best-free-ai-chatbots-without-login-accessible-via-tor-and-anonymous-use/
I tested a handful of others at work too (self-hosted, including LocalAI, Koboldcpp, and Llama…), pointing at models that each promised to be the next big leap in reasoning. In the end, what stuck in my daily routine, across 2 Arch laptops with Hyprland (Omarchy), EndeavourOS, and a Fedora 44 KDE workstation, was a much more boring trio:
- Claude for heavy coding work and as a terminal agent
- AbacusAI (ChatLLM, with RouteLLM routing between models behind the scenes)
- OpenCode as the open source CLI/TUI that talks to both of the above and to the rest of the market
That doesn’t mean I stopped testing new things. It means I stopped rebuilding my workflow every time a new tool shows up. And that’s the real motivation for this article: it’s not just about standardizing an instruction file, it’s about having a way to port to AbacusAI and OpenCode (and to whatever LLM I test next) what already works well in Claude Code, without rebuilding from scratch for every new tool. The SRE prompt I used to paste manually is the first example of that.
The prompt is published in the previous post and in the original Gist. In Claude Code it became a skill, only loads when invoked instead of weighing down every session, placeholders swapped for $ARGUMENTS… It’s exactly the kind of pattern I want to carry to the other CLIs as each one gains equivalent support, instead of recreating a similar workflow from scratch in each one.
RTK: token savings
RTK (Rust Token Killer) is a CLI proxy that rewrites shell commands before execution to save tokens, handling things like paginating git/kubectl/terraform output before it blows up the context. Single Rust binary, no external dependency.
Global install on Linux:
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh
Alternatives: brew install rtk-ai/tap/rtk, or via cargo install --git https://github.com/rtk-ai/rtk --branch master rtk (use the Git URL, not plain cargo install rtk: there’s a name collision with another crate called “Rust Type Kit”).
Once installed, the actual setup:
rtk init -g # installs the native hook + generates RTK.md in the project
rtk init -g --opencode # variant for OpenCode instead of Claude Code
rtk init --show # confirms it installed correctly
rtk init -g does two things: it registers a native PreToolUse hook in Claude Code (no extra jq/bash needed since 0.37.2, works even on PowerShell) and creates a RTK.md in the project, documenting the commands and savings available for the agent itself to consult. That’s why my AGENTS.md ends with @RTK.md: RTK generates the technical reference, AGENTS.md/CLAUDE.md carries the behavior rules. Once running, rtk gain shows the savings dashboard and rtk gain --graph draws the ASCII graph for the last 30 days.
One AGENTS.md, symlinked everywhere
This is my current AGENTS.md/CLAUDE.md, in constant evolution and evolved from the ones I already used in previous articles:
# Global Rules
- Write in English by default; use Brazilian Portuguese only when explicitly requested.
- When writing Portuguese, always use correct diacritics.
- Do not use ASCII tables.
- Do not use horizontal rules (`---`) or ASCII lines as section separators.
- Do not use the em dash (U+2014, `—`, `—`) as punctuation; use a comma, colon, or rewrite.
- Do not use the en dash (U+2013); use a hyphen or rewrite the sentence.
- Files are UTF-8, LF line endings, with a trailing newline at EOF.
## Precedence
1. Direct user instructions in the current task.
2. Project-specific instructions (e.g. project AGENTS.md, RTK.md).
3. This global file.
## Git Commits
- Commit messages MUST describe only the *what* and *why* of the change.
- Do NOT include tooling metadata, trailers, attribution, or co-author lines (e.g. `Co-Authored-By`, `Signed-off-by` unless required by the project, `Generated-by`, `Made-with`, or any IDE/editor/assistant reference).
- Keep messages tool-agnostic: nothing about the environment used to author the code belongs in the history.
- One commit per logical change (ideally one file/concern at a time).
- Follow Conventional Commits v1.0.0: https://www.conventionalcommits.org/en/v1.0.0/
- Subject line: imperative mood, 50 chars max, no trailing period.
- Body (when needed): wrap at 72 chars, separated from subject by a blank line.
- Reference issues/tickets in the footer (e.g. `Refs: #123`), not the subject.
- Never bundle unrelated changes; split refactors from behavior changes.
- Do NOT auto-generate or template commit messages; write them from the diff.
## Approach
- Think before acting. Read existing files before writing code.
- Be concise in output but thorough in reasoning.
- Prefer editing over rewriting whole files.
- Do not re-read files you have already read unless the file may have changed.
- Test your code before declaring done.
- No sycophantic openers or closing fluff.
- Keep solutions simple and direct.
- Do not create more than a few new files without confirming scope first.
## Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them; don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it; don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: every changed line should trace directly to the user's request.
## Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" becomes "Write tests for invalid inputs, then make them pass".
- "Fix the bug" becomes "Write a test that reproduces it, then make it pass".
- "Refactor X" becomes "Ensure tests pass before and after".
For multi-step tasks, state a brief plan:
~~~
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
~~~
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
## Shell / Scripts
- zsh is the interactive shell; validate interactive customizations in `~/.zshrc`.
- For scripts, target POSIX sh unless zsh/bash features are required; always state the shebang used.
- Run shellcheck on shell scripts before declaring done.
- Quote variable expansions; prefer `set -euo pipefail` in bash scripts.
## Secrets
- Never hardcode credentials, tokens, or keys; read from env or a secrets store.
- Never print secrets in logs or commit them.
## Comments
- Comment the why, not the what. No redundant or narrating comments.
## Imports
<!-- Document below what each imported file covers. -->
@RTK.md
Claude Code only loads CLAUDE.md, it doesn’t recognize AGENTS.md natively. OpenCode and AbacusAI read AGENTS.md directly. Solution: AGENTS.md as the single source, and Claude Code points to it.
Global scope, applying to any project. Canonical inside ~/.claude/, sitting next to CLAUDE.md instead of living inside another tool’s config directory:
mkdir -p ~/.claude ~/.config/opencode
$EDITOR ~/.claude/AGENTS.md # canonical file
cd ~/.claude && ln -s AGENTS.md CLAUDE.md # relative symlink, same folder
ln -s ~/.claude/AGENTS.md ~/.config/opencode/AGENTS.md
OpenCode still has a native fallback to ~/.claude/CLAUDE.md if it can’t find AGENTS.md (disable it with OPENCODE_DISABLE_CLAUDE_CODE=1), so even without any symlink neither tool is left without instructions. But symlinking makes explicit which file is the source of truth, instead of relying on an implicit fallback.
Project scope: each /init with its own, or a symlink for both
Claude Code and OpenCode each have their own /init, and each scaffolds the file its own tool’s way: CLAUDE.md for one, AGENTS.md for the other. AbacusAI CLI has no native /init, probably because the number of models behind RouteLLM makes an automatic scaffold inconsistent depending on which model picks up the task; asking it directly to create the file works fine, it just goes through whichever model the routing picks.
OpenCode’s /init does file discovery before writing anything. Running it with GLM 5.3, the first call was a Glob:
{AGENTS.md,CLAUDE.md,.cursorrules,.cursor/rules/**,.github/copilot-instructions.md,opencode.json,opencode.jsonc,README*,package.json,Makefile,Taskfile*,*.toml,*.cfg,*.ini}
Same idea as Claude Code’s /init, which reads Cursor and Copilot rules by default and, with CLAUDE_CODE_NEW_INIT=1, also AGENTS.md, .devin/rules/, .windsurf/rules/, and .clinerules: before generating new instructions, look for a convention that already exists from another tool.
I actually tested all four: cloned an empty repository (just .git/, zero commits) and ran /init with Claude Code, with OpenCode using GLM 5.3, with OpenCode using Kimi K2, and asked AbacusAI directly (which routed to DeepSeek). Result, summarized:
- Claude Code: detected the empty repo, left placeholders in the commands and architecture sections, and the interesting part is what it did NOT repeat: the conventions section opens with “Beyond the global rules in
~/.claude/CLAUDE.md” and only adds the requirement forshellcheckon shell scripts, without duplicating the rest of my global CLAUDE.md that was already loaded in the session. - OpenCode + GLM 5.3: went beyond describing an empty repo. Asked for the language to bootstrap trivial configs.
- OpenCode + Kimi K2: the opposite stance. Refused to assume language or toolchain, explicitly said to wait for code to exist before inferring build/test/lint. Safer, shallower.
- AbacusAI + DeepSeek: structure similar to Claude’s, and documented in passing a detail I hadn’t noticed: there’s a local
.abacusai/for agent permission config, equivalent to a project’s.claude/settings.json.
The practical conclusion changed my own recommendation: since /init genuinely diverges between tools, letting each /init run separately produces a better result than forcing a single project AGENTS.md symlinked to both. I reserve the project symlink for when the file is hand-written by me, without any tool’s /init, and I want the same text everywhere:
ln -s AGENTS.md CLAUDE.md
If you want Claude Code-specific instructions on top of the shared AGENTS.md, you can swap the symlink for an import, since here the file lives inside the project itself:
@AGENTS.md
## Claude Code
- Use plan mode for changes under src/billing/.
The @path syntax expands the referenced file at session load (up to 4 levels of recursion), and still leaves room for Claude Code-specific instructions below, without duplicating the whole AGENTS.md.
What breaks in Cowork
In a Cowork session running on desktop, the restriction is more specific than it looks at first: it only applies to the user-scoped ~/.claude/CLAUDE.md, not to a project’s CLAUDE.md. The symlink and the project import from the section above work normally in Cowork, no adjustment needed, because the referenced AGENTS.md lives inside the session’s own working directory.
Global scope is where it actually breaks. Here Cowork blocks two things in parallel, and one isn’t a workaround for the other. ~/.claude/CLAUDE.md being itself a symlink gets ignored entirely. And an @import inside a user-scoped file that resolves outside the session’s working directory also gets ignored, line by line. Since ~/.claude/AGENTS.md sits outside any specific project, a real ~/.claude/CLAUDE.md containing only @AGENTS.md falls right into that second restriction: the import line is dropped, leaving an empty file. Swapping symlink for import doesn’t get around anything at this scope.
There’s no clean workaround for this today. The options are accepting the gap (personal global rules don’t reach a Cowork session, only rules versioned in the project do) or pasting the literal content, without @import, straight into ~/.claude/CLAUDE.md. The second option works because neither the symlink restriction nor the import restriction applies to literal text, but it costs the single source of truth: any change to the canonical AGENTS.md has to be manually copied to that file too.
On an import outside the project directory, outside a Cowork session (for example, @~/.claude/my-project-instructions.md referenced from a project’s CLAUDE.md), Claude Code shows an external import approval dialog the first time it encounters the reference.
AbacusAI CLI has no global, only project
Abacus’s documentation says AGENTS.md works “at the user and project level,” but that didn’t match actual behavior. I tested it by hand: I put an actionable rule (not a “repeat my instruction,” which any model tends to refuse because it smells like system prompt extraction; a real behavior instruction, like “always end the response with tag X”) in ~/AGENTS.md and ran abacusai -p "What's 2+2?" in a folder with no project AGENTS.md. The tag never showed up. I swapped the same rule to the project folder’s AGENTS.md and it appeared in the response. Ran it again with --no-agents-md and it disappeared.
Practical conclusion: AbacusAI CLI’s AGENTS.md is workspace-only (the folder where you run abacusai), and the --no-agents-md flag turns off exactly that loading. There’s no working user-level global AGENTS.md in the CLI today, neither at ~/AGENTS.md nor at ~/.abacusai/AGENTS.md; what Abacus calls “user level” in the documentation text probably refers only to Skills (~/.abacusai/builtin-skills, ~/.abacusai/desktop/skills), not to AGENTS.md. In practice, this means: for AbacusAI, keep AGENTS.md at the root of each project (the same canonical file that OpenCode and symlinked Claude Code use) and drop the idea of a single global covering all three CLIs at once. If Abacus changes this in a future version, it’s worth running this same test again before trusting any “global support” announcement.
Does the global file’s @ work in the other tools? And who wins: project, global, or hook?
Three questions that are still missing after all this digging, and all three have a direct answer.
@path is real syntax only in Claude Code. It works to import the global AGENTS.md or any other file, with automatic expansion at session load.
In OpenCode, @path inside an AGENTS.md does nothing on its own, their documentation is explicit that OpenCode doesn’t automatically expand file references. The equivalent mechanism there is the instructions field in opencode.json or opencode.jsonc, which accepts a list of paths (globs included) and even a remote URL:
{
"instructions": ["AGENTS.md", "docs/guidelines.md", ".cursor/rules/*.md"]
}
In AbacusAI I found no documented support nor any sign of import-equivalent behavior; AGENTS.md there is a single file, read whole, with no reference to others.
The order of precedence changes from tool to tool, and none of them guarantee the more specific rule wins:
- Claude Code concatenates everything: managed policy, then
~/.claude/CLAUDE.md(user), then the project’sCLAUDE.md/.claude/CLAUDE.md, thenCLAUDE.local.md. The project one enters last in the context, so it sits textually closer to the prompt, but that’s not a guarantee of override: if the global says one thing and the project says the opposite, it’s the model reading both that decides which rule applies, not a deterministic precedence mechanism. - OpenCode is mutually exclusive, it doesn’t concatenate: if a local
AGENTS.md(orCLAUDE.md) exists, it loads only that one and doesn’t even look at the global~/.config/opencode/AGENTS.md. It only falls back to global when it finds nothing local walking up the directory tree from the cwd. - AbacusAI doesn’t even have this discussion: only project loading exists, so there’s no scope conflict to resolve.
Hooks don’t enter this dispute in any of the three, because a hook isn’t content the model interprets, it’s code the client executes before or after the tool runs, with the power to block or rewrite the command. .md is instruction the model tries to follow; a hook applies regardless of what the model “decided.” Hence the practical rule: anything that needs to hold always, without exception, doesn’t go into AGENTS.md, it goes into a hook.
And Claude Code’s hooks don’t port to the other two. settings.json with PreToolUse/PostToolUse is Claude Code’s proprietary format. OpenCode has its own plugin system, conceptually similar but technically incompatible: JS/TS modules in .opencode/plugins/ (project) or ~/.config/opencode/plugins/ (global), with lifecycle hooks like tool.execute.before and tool.execute.after instead of PreToolUse/PostToolUse, plus command.executed, file.edited, session.created. Porting one of my Claude Code hooks to OpenCode means rewriting the logic in JS/TS against that plugin API, not copying the .sh. Nothing similar is documented for AbacusAI.
The gap in the first project
After setting up the global config (~/.claude/AGENTS.md symlinked in both places) I moved to a project that already had an AGENTS.md at the root and a symlinked CLAUDE.md inside the repository itself.
For Claude Code, nothing is missing. It concatenates ~/.claude/CLAUDE.md with the project’s CLAUDE.md through its own loading hierarchy, no import needed. An @ pointing at the global there would just duplicate what already gets in on its own.
The blind spot is OpenCode. With a project AGENTS.md present, it switches to using only that file and ignores the global ~/.config/opencode/AGENTS.md entirely, without concatenating the way Claude Code does. And @path inside AGENTS.md doesn’t fix this, because, as already explained, it’s not syntax OpenCode expands. The real way to recover the global in that project is the instructions field of the root opencode.json (or .jsonc), listing both:
{
"instructions": ["AGENTS.md", "~/.claude/AGENTS.md"]
}
If ~ doesn’t expand in your OpenCode, use an absolute path. And any rule that only existed in the global and wasn’t repeated in the project’s AGENTS.md (commit convention, requiring shellcheck on shell scripts, etc.) stays invisible to OpenCode in that repository until this instructions field is in place.
For AbacusAI nothing changes, with or without @, with or without instructions: it only sees the project AGENTS.md already at the root and ignores anything at global scope either way.
Hooks aren’t optional
Here’s the point that usually slips by: CLAUDE.md/AGENTS.md is context, not applied configuration. Nothing guarantees the model remembers a rule in a long session. A rule that needs to hold always, without depending on the model remembering, becomes a hook.
I published the package at ai-md-stack: six files, settings.example.json plus four hooks and a shared library, shellcheck-clean and bash -n validated across all of them.
_common.sh holds the position check the other hooks reuse, so terraform plan triggers the guard and jq --arg c 'terraform plan' doesn’t:
is_command_invoked() {
local line="$1" name="$2"
line=$(printf '%s' "$line" | tr '\n' ';')
line=$(printf '%s' "$line" \
| sed -E "s/'[^']*'/''/g" \
| sed -E 's/"[^"]*"/""/g')
printf '%s' "$line" | grep -qE "(^|[;&|(]|&&|\|\|)[[:space:]]*(sudo[[:space:]]+)?${name}([[:space:]]|$)"
}
Without this check, any guard fires a false positive every time the command name shows up as data instead of an invocation, and the first instinct after getting hit by a false positive is to ignore the warning, worse than having no guard at all.
On top of that, four small hooks:
mutation-guard.sh(PreToolUse): blocks write verbs onaws/gh/kubectl/terraform. With it on, you can open upaws:*andgh:*entirely in thesettings.jsonallowlist without giving up control: the hook is what blocks mutation, not a granular permission list that’s impossible to keep up to date. Explicit escape hatch per command:ALLOW_MUTATION=1 <command>.commit-guard.sh(PreToolUse): validatesgit commitagainst AGENTS.md’s own commit rules, valid type, 50 characters, no trailing period, no tooling trailer.session-context.sh(SessionStart): injects current branch (warning ifmain/master), dirty file count, and last commit, replacing what I’d run manually as the first task of the session.redact-secrets.sh(PostToolUse): redacts any secret that leaked into stdout/stderr (GitHub token, OpenAI or Anthropic key, AWS access key, PEM block), trims fixed noise (Terraform’s “Refreshing state…”), and truncates output above 30 KB while keeping the start and the end. UsesupdatedToolOutput, the field that actually replaces what the model sees: PostToolUse isn’t just a log after the fact, it rewrites the result before it becomes context.
Beyond those four, one event deserves to be on the radar even without a custom hook: InstructionsLoaded, which fires when CLAUDE.md/rules are loaded, and is useful for debugging exactly the problem that motivates this whole article: confirming that the imported or symlinked file actually made it into that session’s context, instead of finding out only when the model ignores a rule.
Conclusion
The central point isn’t AGENTS.md itself, it’s stopping the rebuild of the same base every time a new tool shows up. AGENTS.md covers what the model should try to follow; a hook covers what needs to hold always, without depending on the model remembering. One solves context, the other solves guarantee, and the three CLIs (Claude Code, OpenCode, AbacusAI) handle that pair differently enough to be worth documenting before assuming it “works the same everywhere.”
Full package, sanitized and tested: github.com/Esl1h/ai-md-stack.
