Engineering research / system design

An Anti-Corruption Layer for Agent Tools Over a Legacy System: Translating Codes, Invariants and Errors Before the Model Sees Them

How to put a translation layer between agent tools and a legacy back office: mapping status codes and units into a clean model, enforcing invariants the old system kept implicit, turning multi-call sequences into one operation, and migrating tool by tool.

By Sandeep Belgavi · Published 2026-11-01

Agent-era architecture · Practical technical guide · 1 November 2026

How to put a translation layer between agent tools and a legacy back office: mapping status codes and units into a clean model, enforcing invariants the old system kept implicit, turning multi-call sequences into one operation, and migrating tool by tool.

The legacy model leaks into the prompt

The returns desk at Belgavi's Book Store runs on a back-office system older than most of its staff. A return is a row in RTN_HDR with a status column holding one-letter codes: O for open, H for held, X for closed, and R, which means "refunded" for orders after 2019 and "rejected" for older ones, depending on a flag in another table. Amounts are integers in pence, except store-credit refunds, which are stored in points at 200 points per pound. Issuing a refund takes three calls in a fixed order: reserve, post and release.

The first agent integration wrapped those endpoints one to one. The tool descriptions explained the codes, and the system prompt grew a paragraph about the R ambiguity. It mostly worked. Then an agent told Geetha that her return had been rejected when it had been refunded, because it read R without the flag, and another agent called post without reserve after a timeout and left a refund half-recorded. Neither was a model failure in any useful sense. The tools exposed a model of the business that only made sense with knowledge the agent did not have.

What an anti-corruption layer is

Eric Evans named the pattern in Domain-Driven Design: when a new model has to work with an existing system whose model is different, put a layer between them that translates in both directions, so the new model is not bent out of shape by the old one. Microsoft's pattern catalogue describes the same thing as a facade or adapter that keeps the legacy semantics from leaking into a new subsystem, at the price of another component to run and keep correct.

Agents make the pattern more valuable, not less. A human integrator reads the quirks once and encodes them; a model rereads them on every call, from text that competes with everything else in its context, and is probabilistic about applying them. Every quirk that lives in a tool description is a quirk the agent can misapply. The layer is where those rules become code that is tested once and holds every time.

Four things the layer translates

What leaksLegacy shapeWhat the agent sees
CodesR plus an era flag in another tablestatus: refunded or rejected
Unitspence, or points for store creditamount with currency, method named explicitly
Sequencesreserve, post, release in orderone refund operation with an idempotency key
ErrorsERR-1042, HTTP 200 with an error bodya typed error with a retry hint
Implicit invariantsno refund above the paid amount, enforced nowherea validation error before anything is posted

The last row matters most. Legacy systems often rely on the people using them for rules that were never written into code: nobody refunds more than the customer paid, nobody refunds a return that is still held for inspection. An agent does not share that tacit knowledge, and it will try things a clerk never would. The translation layer is the natural place to make those rules explicit, because it already understands both models.

A worked translation

Illustrative python, checked for syntax; adapt names and storage to your system.

from dataclasses import dataclass

POINTS_PER_POUND = 200
PRE_2019_ERA = 'A'

@dataclass(frozen=True)
class ReturnView:            # the only shape tools return to the agent
    case_id: str
    status: str              # open, held, refunded, rejected, closed
    paid_pence: int
    refunded_pence: int

def to_view(hdr, era_flag):
    code = hdr['STS']
    if code == 'R':
        status = 'rejected' if era_flag == PRE_2019_ERA else 'refunded'
    else:
        status = {'O': 'open', 'H': 'held', 'X': 'closed'}[code]
    refunded = hdr['RFD_AMT']
    if hdr['RFD_MTH'] == 'SC':                 # store credit is stored in points
        refunded = refunded * 100 // POINTS_PER_POUND
    return ReturnView(hdr['CASE'], status, hdr['PAID_AMT'], refunded)

class RefundRejected(Exception):
    pass

def refund(legacy, view, amount_pence, key):
    earlier = legacy.find_posting(key)         # replay of a call that already worked
    if earlier:
        return earlier
    if view.status != 'open':
        raise RefundRejected('return is %s, not open' % view.status)
    if view.refunded_pence + amount_pence > view.paid_pence:
        raise RefundRejected('refund would exceed the amount paid')
    hold = legacy.reserve(view.case_id, amount_pence, ref=key)
    try:
        posting = legacy.post(hold, ref=key)
    finally:
        legacy.release(hold)
    return posting

The agent calls one refund tool with a case, an amount and an idempotency key. It never learns that R exists. The layer first looks for an existing posting carrying the same key, so a retried call returns the earlier result instead of posting twice or tripping the amount check, then checks the two invariants before touching the legacy system, and always releases the hold. The legacy system must accept a caller reference on its postings for that replay check to work; if it cannot, the layer keeps its own table of keys and postings and writes it in the same transaction as the call log. The companion example in agent-decision-lab runs these translations against a fake back office, including the timeout that used to leave a refund half-recorded.

What stays deterministic

The agent decides whether a refund is appropriate and how to explain it to the customer. The layer decides what the legacy data means and whether a request is allowed. Code-to-status mapping, unit conversion, call ordering, idempotency, invariant checks and error classification are all deterministic, all testable without a model, and all owned by the team that owns the legacy integration. A useful test: if a rule would be correct for a human operator using a new screen, it belongs in the layer, not in a prompt.

Keep the layer's output vocabulary small and stable. It becomes the contract that tool descriptions, evaluation fixtures and agent memory refer to, so a rename here has the same blast radius as a public API change.

Migration steps, tool by tool

  1. List every tool that touches the legacy system and every sentence in prompts and tool descriptions that explains a legacy quirk. Each sentence is a candidate translation rule.
  2. Write the read side first: a view type and a mapping function, tested against a sample of real rows, including the odd ones. Switch read tools to return the view and delete the quirk sentences from the prompt.
  3. Add the invariant checks the legacy system lacks, in log-only mode, and count how often agents would have tripped them. That count is your evidence that the layer is worth having.
  4. Replace multi-call sequences with single operations that carry an idempotency key, one write tool at a time, keeping the old tools available behind a flag until evaluation results match.
  5. Route the legacy system's error codes through one classifier and return typed errors with a retry hint.
  6. Once no agent calls a raw legacy tool for two weeks, remove it. Strangler-fig migration then becomes possible behind the same contract, because the agent no longer depends on legacy shapes.

Failure modes

  • The layer becomes the new legacy. If every team adds its own exceptions, the translation turns into a second undocumented model. Give it an owner, a schema and tests on real data.
  • Translation hides information the agent needed. A view that collapses held and open into "in progress" stops the agent from telling Tanishka why nothing has happened. Translate meaning, do not delete it.
  • Stale mapping tables. Codes get added to legacy systems quietly. Make unknown codes an explicit error, never a default, and alert on them.
  • Invariants enforced twice, differently. If the legacy system later gains its own check, decide which one wins and remove the other, or the two drift.
  • Latency. One refund is still three legacy calls. Measure it, and give the agent a status it can poll rather than a call that blocks past its tool timeout.

When the layer is unnecessary

If the existing API already speaks the domain language, with readable statuses, consistent units and atomic operations, a thin tool wrapper is enough; adding a layer only adds a hop. The same holds for a short-lived integration that will be replaced within a quarter, or a read-only reporting agent whose answers a person checks anyway. The test is whether the tool descriptions need paragraphs about quirks. If they do, the knowledge in those paragraphs should be code.

Sources and scope

Checked against these sources on 1 November 2026. The back-office system, its codes and the refund sequence are synthetic. The companion example runs the translations against a fake legacy client; no real system or model was called.

If tool descriptions need paragraphs about legacy quirks, that knowledge belongs in code. Put a translation layer between agent tools and the legacy system that maps codes and units, enforces unwritten invariants, turns call sequences into one idempotent operation and returns typed errors.

Views are my own and do not represent any employer, past or present.