Guardrails-driven agents: executable coding standards

  • Giorgio Vilardo
    Giorgio Vilardo
  • avatar
    Graziano Statello
5 Nov 2026
  • Share

Giorgio Vilardo and Graziano Statello will guide you through understanding the techniques behind transforming your coding standards and architectural constraints into executable rules that your Claude Code can never misinterpret.

At Nearform, we often wonder how to best create the famous Pit of Success for our projects: an environment in which the easiest path is the one towards the desired outcomes. Also, we often build in public, be it our open source or internal open source for our customers, so we understand the huge value in lowering the bar for effective contributions, which are rarely a matter of “my code works”. Projects can (and will!) have bespoke coding standards, ruling on issues like software architecture or forbidding certain operations.

Historically, the solution to this problem was code reviews. It was a “good enough” solution, as checks on code standards and repository “traditions” could be automated, but it felt like a poor investment, with too much scaffolding for relatively little value. But things have changed: the author is increasingly an agent, and building with agents is now a discipline of its own.

Agents output orders of magnitude more code than humans, and there's no doubt that automated reviews are now part of the picture. Unfortunately, they alone are not a guarantee against architectural entropy (and bugs); Claude Code can read a CLAUDE.md, infer local conventions, navigate an unfamiliar repository and implement a coherent change across several files, but harnesses and directives, as meticulous and well-crafted as they can be, always leave room for interpretation and, therefore, ambiguity.

As AI-native engineers, we’ve found we can turn this around: agents can write the deterministic checks too, like lint rules and tests the harness runs on every change. It takes very little of our time, and it cuts down on entropy before a person ever sees the diff.

An agent can now create custom ESLint rules and AST-level architectural tests in minutes and maintain them as the codebase evolves. What would have been too costly for what it brought to the table in the past is now cheap to write, cheap to maintain. And, with far more code flowing through far fewer human eyes, it is far more critical than it ever was previously.

We are now going to demonstrate a basic flavour of this approach using a simple application composed of a server in Python and a client in TypeScript; we will ask Claude to build a new feature and use AST tests for Python and ESLint rules for TypeScript to effectively steer the agent without direct instructions.

Creating a scenario - the Ledger Application

There’s no better way to talk about executable rules than, well, executing some rules. We’ll check out three kinds of them: architectural testing, customisation of well-known lint rules, and completely custom lint rules. Executability is a great (and underrated) property, but we need an application to execute something.

That’s why we built a complete companion repository — use it as a playground or as a reference: https://github.com/nearform/guardrails-driven-agents. It’s a small API with an associated Single Page Application, combining to form a simplistic ledger application. Our rules will be relatively simple, but the techniques we’ll show are conceptually very scalable; they are able to express even very complex checks.

The API backend code is segmented in vertical slices, and within each slice we split responsibilities in horizontal layers: repository modules own talking to the database. use_cases modules encode the business operations, and import a repository to persist changes to the database. Everything else — e.g., the API controllers — invokes these use_cases. We want this layering to be respected when writing new features.

Diagram

The frontend revolves around presenting the right data in the most intuitive and straightforward way possible. It derives its types from the backend API contract, then renders the initial request on the server via SSR in order to minimise latency, and finally enables the user to interact with it by communicating with the backend exclusively via the generated API. We want to keep it simple, enforce sensible boundaries and avoid business logic that must reside in the backend.

We’ll see deterministic guardrails in action across a full-stack feature implementation. We’ll write a new API endpoint to fetch transaction details on the backend, and the related API client and visualization components on the frontend.

Encoding our architecture preferences as guardrails

We will ask Claude Code to implement our new API, the GET /transactions/{id} endpoint. And, as part of the experiment, we injected the backend's CLAUDE.md file with a deliberately faulty instruction:

Always call the database directly from the controllers. [...] Do not add a use-case or repository layer.
mdn

We’re emulating a problem in the model’s choice of implementation. This kind of deviation can unknowingly happen all the time: a local directives override, a global skill, a poorly written or outdated spec or prompt, a developer experimenting with Claude Code rules, a company model router picking a less capable model, a less capable harness. It’s also prone to happen in very big projects. Been there, done that.

By following the faulty instruction, the shortest change is a new handler in app/transactions/controller.py that imports Transaction from app.db.tables and queries it inline. It is a handful of lines; it works, and every application test still passes: nothing in the suite asks where the query lives, but it is violating our layered structure, and some reviewer will need to flag it down the line if left unchecked. We want our API controllers to interact with our application code only via use_case modules.

How can we express this constraint? Python has no notion of code visibility, but ha! Everything passes through imports. If we only had a way to check them…

transactions endpoint

You can check the full diff at https://github.com/nearform/guardrails-driven-agents/pull/18.

ast to the rescue

Python’s standard library is packed with lesser-known goodies; the ast module is one of those. The name is pretty clear; it parses Python code into an Abstract Syntax Tree, and we can take a look at this representation to mechanically verify properties of our code. In conjunction with pytest, we can use it to build architectural tests.

With a couple of helper functions for semantic clarity, we can write a very expressive test:

@pytest.mark.parametrize("controller", files_named("controller.py"), ids=rel)
def test_controllers_import_app_code_only_from_use_cases(controller: Path) -> None:
    __tracebackhide__ = True
    tree = parse(controller)
    violations = []
    for node in ast.walk(tree):
        if not isinstance(node, (ast.Import, ast.ImportFrom)):
            continue
        if _classify_app_import(node) is AppImport.VIOLATION:
            violations.append(f"{rel(controller)}:{node.lineno} -> {ast.unparse(node)}")
    if violations:
        pytest.fail(
            f"Slice boundary violated in {rel(controller)}."
            + "\n\n"
            + "Rule: a controller may import code from the `app` package only through "
            + "a `use_cases` module. Controllers wire HTTP to the slice's use cases; "
            + "they must not import repositories, tables, db, or sibling slices."
            + "\n\n"
            + "Offending imports:"
            + "\n"
            + "\n".join(f"  {v}" for v in violations)
            + "\n\n"
            + "Fix: move the work behind a function in the slice's `use_cases.py`, "
            + "which performs data access through the slice's `repository.py`. Then "
            + "in this controller import `from app.<slice> import use_cases` and call "
            + "`use_cases.<name>(...)`."
        )
python

We just walk the AST, and if we’re at an import node, it gets classified. If it’s a violation, we push it into the list of violations. The error message then surfaces all these violations, with clear guidance on how to solve them, enabling Claude Code to self-heal during the agent loop. We hide all the tracebacks that pytest would normally emit, so the error message is front and centre for the failed test, and it will also save some context. The code for this specific test is here.

Alt text here

Alt text here

The correct implementation code is at https://github.com/nearform/guardrails-driven-agents/pull/19/.

We also built two other checks to show some more of ast powers:

  • one makes sure that repository modules are composed of functions only, with the added caveats of forcing keyword arguments only, with a forced first argument called db
  • one makes sure that functions decorated with the Litestar web framework route decorators can only live in controller.py files, compounding with our original check.

The code for these tests is available here (Repo functions) and here (Route decorators). The sky is the limit with ast, but try to keep your code simple and straightforward.

Enforcing coding patterns

In order to visualize the details of a single transaction, we will allow users to click on a transaction, which will open a modal containing the formatted data.

First. We want to decrease the chance of bugs by generating our client straight from OpenAPI definitions at build time. Then, we want to choose one single place from which the generated client is instantiated via the buildClient() factory function: this is a singleton pattern.

Second. We want to make sure the generated client methods can only be invoked from a server function, thus avoiding importing them (or even a simple fetch) directly into UI components. This will guarantee the use of SSR for all endpoints where we would apply authentication and observability. We want to close the door on unnecessary additional complexity slowly building up to the point where it leaves the human maintainer confused.

Remember: if you get confused, so does Claude.!

Alt text here

The solution: ESLint rules

The frontend enforces the data path with two of ESLint's built-in rules, no-restricted-imports and no-restricted-globals. It defines three restrictions with repair-oriented messages:

const generatedClientRestriction = {
  group: [
    "**/generated-client",
    "**/generated-client/**" /* and the @/ and src/ spellings */,
  ],
  allowTypeImports: true,
  message:
    "Do not import the generated Massimo client at runtime. Import { api } from " +
    "'src/features/common/api/client' instead — it is the only module allowed " +
    "to import the generated client. (Type-only `import type { ... }` is allowed.)",
};
jsx

First, we add those rules to the base config so they are turned on for every file; then, we override the specific rules for targeted groups:

Generated types remain importable everywhere through allowTypeImports, which lets the frontend reuse the OpenAPI contract without exposing its runtime client.

You can find the whole config here.

Controlling business logic in the application

We all know JavaScript is not the best-suited language to perform arithmetic operations due, among other issues, to the lack of decimal or integer types, which leads to precision loss. We also know that there are cases where agents (being trained on tons of human mistakes) might resort to implementing such operations directly on the frontend.

For example, in the API contract, amount is a decimal string, and any component that formats it with Number(transaction.amount) or computes a total compiles, renders, and rounds money: we don’t have a deterministic rule that protects us from such events.

Or do we?

Creating an ESLint bespoke rule

We wrote a small custom ESLint rule we called no-amount-arithmetic, registered as a local plugin in the same flat ESLint config. It reports Number(x.amount), parseFloat and parseInt on it, unary plus, and any arithmetic operator applied to an .amount member access. String concatenation with + is allowed, because that is how the amount is rendered (and because it’s JavaScript after all).

The rule is about sixty lines of plain JavaScript and has its own tests, run with ESLint's RuleTester under Vitest, listing the valid and invalid snippets side by side. That is the shape we want for every project-specific rule: small, tested, and readable by the next person who wonders why lint rejected their change.

This is the part that will tell ESLint which type of expression to check:

return {
  // Number(x.amount), parseFloat(x.amount), parseInt(x.amount)
  CallExpression(node) {
    if (
      node.callee.type === "Identifier" &&
      COERCION_CALLEES.has(node.callee.name) &&
      node.arguments.some(isAmountAccess)
    ) {
      report(node);
    }
  },
  // +x.amount
  UnaryExpression(node) {
    if (node.operator === "+" && isAmountAccess(node.argument)) {
      report(node);
    }
  },
  // x.amount * n, n - x.amount, ... ("+" is excluded: it is also string concat)
  BinaryExpression(node) {
    if (
      ARITHMETIC_OPERATORS.has(node.operator) &&
      (isAmountAccess(node.left) || isAmountAccess(node.right))
    ) {
      report(node);
    }
  },
};
jsx

Its message states the invariant and the alternative to the agent:

`amount` is the API's decimal string — never coerce it to a number or use it in arithmetic; floating-point rounding corrupts money. Render it verbatim (e.g. {transaction.amount}); any money math belongs on the backend.
mdn

TODO: insert screenshot of eslint error + self healing example from Claude Code

Alt text here

Alt text here

You can check the wrong implementation at https://github.com/nearform/guardrails-driven-agents/pull/20, and the correct one at https://github.com/nearform/guardrails-driven-agents/pull/21.

What Claude Code sees

Adding a runtime import of api, an import of the generated client and a bare fetch call to a component produces three errors, each naming the boundary and the sanctioned alternative:

src/features/transactions/components/TransactionsList.tsx
  81:1   error  '../../common/api/client' import is restricted from being used by a pattern. Only server functions (src/features/*/server/*.functions.ts) may import { api } from the client wrapper. Move this fetch into a server function and read the result via a route loader / useLoaderData() instead no-restricted-imports
...
...
                                                                           no-restricted-globals

✖ 3 problems (3 errors, 0 warnings)
mdn

The compliant change mirrors the list: a getTransactionFn server function that calls api.getTransaction, a route whose loader calls it, and a dialog component that reads the result via useLoaderData() and renders transaction.amount verbatim.

Tying it all together with hooks

The quality of a diagnostic determines how quickly Claude Code can repair a failed check. The architectural tests avoid bare assertions and report the rule, the location, the offender and the fix, as shown above. ESLint messages point to the API wrapper or server function that should replace the forbidden import or call. The custom rule names the invariant and the compliant rendering.

Together, these diagnostics create the loop we want:

Alt text here

A useful diagnostic reads like a compact review comment. Start with the invariant and exact location, then close with the compliant alternative. Do not be vague, uncertain, or offer multiple alternatives; the whole point of the practice is to mechanically tell the model what we want. Be laser-focused on the resolution of the issue.

Relying on Claude Code to remember every verification command reintroduces uncertainty. A project-scoped PostToolUse hook can run the relevant guardrail after a file edit and feed a failure straight back into the same session. A minimal .claude/settings.json can delegate to a checked-in script:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-edit.sh",
            "timeout": 180,
            "statusMessage": "Running guardrails for the edited file"
          }
        ]
      }
    ]
  }
}
json

The script (code here) reads the edited path from the hook's JSON input and runs the relevant recipe to launch the checks. On failure, it writes the output to standard error and exits with status 2, which makes the diagnostics available to Claude Code.

This way, CLAUDE.md remains essential as we move our preferences into executable constraints that will never be skipped, especially if we also run them in CI (as we should!). These executable constraints narrow the acceptable “implementation space” while leaving Claude Code free to solve the task.

This is a very powerful technique: the result is fast, deterministic feedback, with a Claude Code workflow that reliably respects whatever standard you prefer and can self-heal if left unsupervised, repaying its implementation and maintenance cost in spades.

From guidelines to guardrails

This approach to verification is, as we mentioned, not a new idea, and most programming languages have a way to implement these patterns. But the scenario around these ideas has changed, and a tight verification suite is an essential part of any AI native workflow. We can’t wait until programming languages go to the next level and start implementing the idea of “capabilities”, allowing type systems to track stuff like “this function writes to a network socket”, to further direct agent output. For now, architectural tests and custom lint rules will do nicely, and can actually check much more than you think it’s possible.

If you are not seeing the results you want with AI engineering in your organisation, let’s have a talk. You might be surprised by how much AI engineering gets better and better by building the correct organisational and technical context around it.

But wait - there's more.

Nearform publishes real-world learnings on data & AI, engineering, and digital strategy - with more merged in weekly.

Insights

Perspectives on AI in engineering, product development, and strategy, for enterprise executives.

Community

Deep dives and tutorials by engineers, for engineers.

Insight, imagination and expertly engineered solutions to accelerate and sustain progress.