---
title: "How to Build an AI Agent to ERP Integration: A Code-First Architecture Tutorial"
slug: how-to-build-an-ai-agent-to-erp-integration-a-code-first-tutorial
date: 2026-05-18
author: Yuvraj Muley
categories: ["AI & Agents", Guides, Engineering]
excerpt: "Learn how to architect a secure, code-first integration between AI agents and legacy ERPs like NetSuite and SAP using Tool Calling, JSONata, and MCP."
tldr: "Connecting AI agents to ERPs requires normalized data and strict governance. Use the Model Context Protocol (MCP) and declarative API mappings to expose ERP actions without writing integration-specific code, while keeping rate limit handling in the agent's orchestration layer."
canonical: https://truto.one/blog/how-to-build-an-ai-agent-to-erp-integration-a-code-first-tutorial/
---

# How to Build an AI Agent to ERP Integration: A Code-First Architecture Tutorial


Connecting a Large Language Model (LLM) to QuickBooks for a weekend side project takes an afternoon. Connecting an autonomous AI agent to your enterprise customer's heavily customized NetSuite instance—so it can read open invoices, verify line items, create journal entries, and reconcile bank transactions without hallucinating a $250,000 wire transfer—is a different category of problem entirely.

You are building an AI agent designed to automate financial operations. The LLM understands the intent perfectly. The orchestration framework is ready. Then you hit the integration layer.

Legacy Enterprise Resource Planning (ERP) systems like Oracle NetSuite and SAP are notoriously hostile to modern software patterns. LLMs expect clean, predictable JSON schemas to execute tool calls accurately. ERPs return heavily nested, highly customized XML, SOAP envelopes, or complex OData structures with cryptic field names. 

This friction is where most agentic workflows die. According to industry research and Gartner analysis, 64% of organizations report integration issues, and 59% cite integrating with existing systems as a major bottleneck when modernizing enterprise architecture. By the end of 2026, 40% of enterprise applications will be integrated with task-specific AI agents, up from less than 5% in 2025. Whoever ships first with reliable ERP write-back capabilities wins the enterprise account.

This tutorial provides a code-first architectural blueprint for connecting AI agents to enterprise ERPs. We will cover how to normalize legacy payloads into AI-ready schemas, handle the strict infrastructure plumbing of enterprise APIs, and expose these actions using Tool Calling and the Model Context Protocol (MCP). The target reader is a senior PM or engineering lead at a B2B SaaS company shipping AI features into enterprise accounts.

## The Challenge: Why Agent-to-ERP Integration Breaks Standard Frameworks

ERPs hold the operational heartbeat of a business. They were designed in an era where integration meant a nightly batch job from a finance team's workstation. They were not designed to be queried by a probabilistic reasoning loop running in a vector database adjacent to a chat UI.

If you attempt to wire an orchestration framework like LangChain or LlamaIndex directly to an ERP's native API, you will encounter three immediate architectural failures:

1. **Schema Hallucinations and Impedance:** NetSuite uses SuiteQL, REST, and SOAP while SAP uses OData, BAPIs, and RFCs. A standard agent tool definition assumes clean JSON in, clean JSON out. If you feed a raw NetSuite SuiteTalk response containing 400 custom fields with internal IDs into a prompt window, the LLM will hallucinate relationships, misinterpret field types, or simply blow past its context window.
2. **Customization Chaos:** No two ERP instances are identical. A "Customer" object in one NetSuite environment might have 20 custom fields that differ entirely from another environment. Hardcoding integration logic means your agent will break the moment you deploy it to a new tenant.
3. **Maintenance Black Holes:** Oracle requires all new integrations to use OAuth 2.0 starting with NetSuite 2027.1, and SOAP endpoints will be fully removed by 2028.2. Every custom connector you write today has a known expiration date. Maintaining a single custom point-to-point integration costs between $50,000 and $150,000 annually. This includes the ongoing engineering costs of handling API deprecations, OAuth token refreshes, QA, and monitoring.

To build scalable AI agents, you must abstract the ERP behind a unified integration layer. The agent should only interact with a normalized, predictable schema, while the integration layer handles the chaotic translation to the underlying provider.

## Architectural Patterns: RAG vs. Tool Calling vs. MCP

Before writing code, you must select the correct architectural pattern for exposing ERP data to your agent. Three patterns dominate agent-to-ERP integration in 2026:

### Retrieval-Augmented Generation (RAG)
RAG is excellent for unstructured data. If your agent needs to read HR policies, company wikis, or historical email threads, vectorizing documents and performing semantic search is the correct approach. 

RAG is entirely the wrong pattern for ERP data. You do not vector search a general ledger. Financial data is highly structured, relational, and requires exact precision. A vectorized snapshot of last night's GL is fundamentally unsuitable for posting a journal entry today. Injecting stale, cached ERP data from a vector database into an LLM prompt will result in catastrophic financial errors.

### LLM Tool Calling (Function Calling)
Tool Calling allows you to provide an LLM with a JSON schema defining a specific function (e.g., `create_invoice`, `get_open_bills`). The LLM generates a structured JSON object matching that schema, which your application then executes against the target API. 

This is the baseline requirement for action-oriented agents. The model receives a list of typed functions, picks one, fills in arguments, and your runtime executes it against the ERP. To learn more about the underlying mechanics, read our guide on [what is LLM function calling for integrations](https://truto.one/what-is-llm-function-calling-for-integrations-2026-guide/).

### The Model Context Protocol (MCP)
MCP is the emerging open standard for connecting AI agents to external data sources and tools. MCP is essentially tool calling with a standard protocol layer. Instead of hardcoding tool definitions into your agent's prompt, the agent connects to an MCP Server. 

The server dynamically advertises a list of tools representing actions or queries the AI can invoke. Each tool has a name, description, input parameters, and output schema. Any MCP-compliant client—Claude Desktop, ChatGPT, Cursor, or a custom LangGraph agent—can discover and call those tools without bespoke integration code on the agent side.

```mermaid
flowchart LR
  A[AI Agent<br/>Claude / GPT / Custom] -->|MCP discovery| B[MCP Server]
  B -->|tool call| C[Unified API Layer]
  C -->|SuiteQL| D[NetSuite]
  C -->|OData| E[SAP S/4HANA]
  C -->|REST| F[QuickBooks / Xero]
  C -->|Tool schemas| B
```

MCP is rapidly becoming the default for enterprise integrations. As covered in our guide to finding the [best MCP server for Oracle NetSuite](https://truto.one/best-mcp-server-for-oracle-netsuite-in-2026-connect-ai-agents-to-erp-data/), the NetSuite AI Connector Service is a protocol-driven integration service supporting MCP that gives customers a flexible and scalable way to connect their own AI to NetSuite. Similarly, SAP HANA Cloud received full MCP support in Q1 2026, giving Joule agents direct access to the database engine.

**Practical recommendation:** Use tool calling as the execution primitive, expose tools through an MCP server for portability across agent frameworks, and reserve RAG for unstructured context—not for the ERP system of record itself.

## Handling the Plumbing: Auth, Pagination, and Rate Limits

Once you have selected MCP and Tool Calling as your architectural foundation, you must address the underlying API infrastructure. Before writing a single line of agent reasoning logic, get the boring infrastructure right. Otherwise, your agent will spend 90% of its inference budget debugging 401 Unauthorized errors.

### Authentication and Token Lifecycles
ERPs typically use OAuth 2.0 with short-lived access tokens and complex refresh token rotation policies. Agentic workflows often run for minutes, sometimes hours. Tokens expire in the middle of them. 

A well-built integration layer refreshes tokens shortly before they expire, not reactively after the next 401 comes back. The agent should never know what an access token is; it should simply pass an `integrated_account_id` to the integration layer, which injects the valid credentials into the outbound request.

```typescript
// Pseudocode: refresh logic invoked before every outbound call
async function callErp(account, request) {
  if (tokenExpiresWithin(account, 60 /* seconds */)) {
    account = await refreshOAuthToken(account)
  }
  return httpFetch(request, { authorization: `Bearer ${account.access_token}` })
}
```

In practice, you want a scheduled refresh that fires roughly 60-180 seconds ahead of expiry, independent of any incoming request. This keeps long-running agent loops from hitting an avoidable cold-cache penalty mid-workflow. If the refresh fails, mark the account as `needs_reauth` and emit a webhook so the agent knows a tool is temporarily unavailable.

### Pagination Normalization
NetSuite SuiteQL uses `limit` and `offset`. SAP OData uses `$top` and `$skip`. QuickBooks uses `startPosition`. If you expose these raw pagination mechanics to an LLM, it will fail to iterate through large datasets. 

Your integration layer must normalize all of this behind a standard cursor-based model. When the agent requests a list of invoices, the integration layer should return a single `next_page_token` string, completely hiding the underlying provider's pagination logic.

### The Reality of Rate Limits
ERPs enforce strict per-account quotas. NetSuite's concurrency governor will reject your call with a 429-equivalent the moment you exceed it. SAP throttles based on plan tier. A common mistake is expecting the integration proxy to automatically absorb and retry rate limit errors on your behalf.

This is an anti-pattern for agentic workflows. A pass-through integration layer that respects zero data retention cannot silently retry. When using a unified API layer like Truto, the system does not retry, throttle, or apply backoff on rate-limit errors. When the upstream API returns HTTP 429, that error is passed directly to the caller, with upstream rate-limit info normalized into the IETF-standard headers:
- `ratelimit-limit`: The total request quota.
- `ratelimit-remaining`: The remaining requests in the current window.
- `ratelimit-reset`: The time at which the quota resets.

**The agent's orchestration framework is entirely responsible for backoff and retry.** This is a feature, not a bug. If the integration layer silently retried, you would have no visibility into the cost of your agent's actions, and a runaway agent could exhaust a customer's daily quota in minutes. Make the limit visible; let the agent's planner deal with it.

Here is how you should handle this in a Node/TypeScript agent:

```typescript
async function invokeTool(toolName, args) {
  const response = await unifiedApi.call(toolName, args)
  if (response.status === 429) {
    const resetMs = parseInt(response.headers['ratelimit-reset']) * 1000
    await sleep(Math.min(resetMs, 30_000)) // cap the wait
    return invokeTool(toolName, args) // single retry; agent decides further
  }
  return response.body
}
```

And here is the equivalent approach for a Python-based agent using the `tenacity` library:

```python
import requests
from tenacity import retry, retry_if_exception_type, wait_exponential, stop_after_attempt

class RateLimitError(Exception):
    def __init__(self, reset_time):
        self.reset_time = reset_time

def check_rate_limit(response):
    if response.status_code == 429:
        reset_time = response.headers.get('ratelimit-reset', 5)
        raise RateLimitError(reset_time)
    response.raise_for_status()

# The agent's tool execution wrapper
@retry(
    retry=retry_if_exception_type(RateLimitError),
    wait=wait_exponential(multiplier=1, min=2, max=60),
    stop=stop_after_attempt(5)
)
def fetch_erp_invoices(account_id):
    headers = {
        "Authorization": f"Bearer {TRUTO_API_KEY}"
    }
    response = requests.get(
        f"https://api.truto.one/unified/accounting/invoices?integrated_account_id={account_id}",
        headers=headers
    )
    check_rate_limit(response)
    return response.json()
```

By passing the rate limit state back to the agent, you allow the orchestration framework to pause execution, switch to another task, or alert the user. For more details on secure API access, review our guide on [zero data retention AI agent architecture](https://truto.one/zero-data-retention-ai-agent-architecture-connecting-to-netsuite-sap-and-erps-without-caching/).

## Tutorial Part 1: Normalizing ERP Data for LLM Tool Calling

The core problem of agent-to-ERP integration is **schema impedance**. To make ERP data AI-ready, we must translate it into a unified schema. We achieve this using declarative mappings rather than integration-specific code.

### The Problem: Raw ERP Payloads
Consider a raw response from a legacy ERP representing a customer contact. NetSuite returns:

```json
{
  "id": "4521",
  "entityid": "ACME-CORP",
  "companyname": "Acme Corp",
  "billingaddress": { "addr1": "100 Main St", "state": "CA" },
  "custentity_segment": "3"
}
```

Meanwhile, SAP returns a completely different OData structure with metadata wrappers:

```json
{
  "d": {
    "results": [
      {
        "__metadata": { "uri": "...", "type": "ERP.Customer" },
        "BusinessPartner": "0001000123",
        "BusinessPartnerName": "Acme Corp",
        "to_BusinessPartnerAddress": {
          "results": [{ "StreetName": "Main St", "HouseNumber": "100", "Region": "CA" }]
        }
      }
    ]
  }
}
```

If you pass these directly to an LLM as a tool response, you waste valuable tokens on `__metadata` fields, force the LLM to navigate arbitrary nesting, and guarantee that your prompt engineering will break when a customer switches from SAP to NetSuite.

### The Solution: JSONata Transformation
A declarative transformation language—JSONata is the practical choice—lets you express the shape change as data, not code. We can define a JSONata expression that flattens these payloads into a clean, unified `Contact` model.

For NetSuite, the mapping looks like this:

```jsonata
{
  "id": id,
  "name": companyname ? companyname : entityid,
  "address": {
    "street": billingaddress.addr1,
    "region": billingaddress.state
  },
  "segment": custentity_segment
}
```

For SAP, the mapping concept is applied differently to handle the nested array, but the **output shape is identical**. When the agent queries the unified API, the proxy layer fetches the raw data from the ERP, applies the JSONata transformation in memory, and returns the normalized result:

```json
[
  {
    "id": "0001000123",
    "name": "Acme Corp",
    "address": {
      "street": "Main St",
      "region": "CA"
    },
    "segment": "3"
  }
]
```

This is the architectural move that decouples agent logic from ERP idiosyncrasies. The runtime engine evaluates whatever expression the configuration provides; it does not know or care whether it's parsing NetSuite, SAP, or Xero. New ERPs are added as configuration, not as new code paths. 

This also applies to highly complex objects like Invoices. Your agent's tool schema will see one predictable `Invoice` type with `line_items` arrays regardless of which ERP responded. For a deeper look at specific ERP quirks, see our comparison on [NetSuite vs SAP for AI agents](https://truto.one/netsuite-vs-sap-for-ai-agents-the-2026-erp-integration-guide/).

## Tutorial Part 2: Exposing ERP Actions via Auto-Generated MCP Servers

Once the unified API layer exists, generating MCP tool definitions is mechanical. Writing manual tool definitions for dozens of ERP endpoints is tedious and error-prone.

Because the integration behavior in Truto is entirely data-driven, the platform automatically generates MCP tool definitions directly from the integration configuration. Every unified resource mapped via JSONata (e.g., `list`, `get`, `create`, `update`) automatically becomes an available tool on the MCP server.

When your agent connects to the MCP server, it receives a standardized tool schema that looks like this:

```json
{
  "name": "unified_accounting_create_invoice",
  "description": "Creates a new invoice in the connected ERP system.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "integrated_account_id": {
        "type": "string",
        "description": "The ID of the target customer account"
      },
      "contact_id": {
        "type": "string",
        "description": "The unified ID of the customer"
      },
      "line_items": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "description": { "type": "string" },
            "quantity": { "type": "number" },
            "unit_amount": { "type": "number" }
          },
          "required": ["description", "quantity", "unit_amount"]
        }
      }
    },
    "required": ["integrated_account_id", "contact_id", "line_items"]
  }
}
```

### Executing the Tool via MCP

When the LLM decides to list open invoices or create a new one, it outputs a JSON object matching the `inputSchema`. The agent framework forwards this to the MCP server. Here is a minimal TypeScript implementation of how an MCP server handles this routing:

```typescript
import { Server } from '@modelcontextprotocol/sdk/server'

const server = new Server({ name: 'erp-tools', version: '1.0.0' })

// Auto-discover tools from the unified API
server.setRequestHandler('tools/list', async () => ({
  tools: await unifiedApi.getMcpTools(integratedAccountId)
}))

// Route execution back through the integration layer
server.setRequestHandler('tools/call', async (req) => {
  const { name, arguments: args } = req.params
  const result = await unifiedApi.invoke({
    integratedAccountId,
    tool: name,
    args
  })
  return { content: [{ type: 'text', text: JSON.stringify(result) }] }
})
```

The `unifiedApi.getMcpTools()` call returns tool schemas derived from the integration's configuration. The exact reverse of our previous JSONata mapping occurs on execution: the unified request body is transformed into the highly specific, nested payload required by the target ERP, authenticated, and executed.

Adding a new ERP automatically extends the tool catalog the agent can see—no code deployment required. To see how this works across specific platforms, read our guide on how to [connect AI agents to NetSuite and SAP Concur via MCP servers](https://truto.one/connect-ai-agents-to-netsuite-sap-concur-via-mcp-servers/).

## Governance and Human-in-the-Loop Approvals

MCP is a protocol, not a security guarantee. Oracle's documentation explicitly calls out two inherent LLM risks that MCP cannot eliminate: prompt injection and hallucination. Financial write operations need approval gates. ERP systems contain sensitive operational and financial information, making security and controlled access a top priority.

A pragmatic pattern is to split tools into `read_*` and `propose_*` namespaces. 
- `read_*` tools execute directly against the ERP.
- `propose_*` tools return a structured intent that a human approves through your application UI before the actual mutation hits the ERP.

This is the only sane default for any tool that creates a financial transaction. Furthermore, log every tool call with the integrated account ID, tool name, arguments, response status, and the upstream rate-limit headers. When an enterprise security team asks "what did your AI agent do in our NetSuite account on March 14," you want a precise answer derived from logs, not a guess.

## Managing Custom ERP Objects Without Breaking the Agent

Enterprise ERPs are never used out of the box. A mid-market manufacturing company might have a custom NetSuite object for `Warehouse_Pallet_Location`. Your customer Acme has `custentity_acme_segment`. Their competitor has `custbody_region_code`.

If your AI agent relies on a rigid, standardized data model, it will fail to access this critical custom data. Conversely, if you hardcode custom fields into the agent's prompt, the agent becomes unscalable and unmaintainable.

The solution is a multi-level override hierarchy that allows per-customer customization of the unified API behavior without touching the underlying source code.

### The Three-Level Override Hierarchy

1. **Level 1 - Platform Base:** The default JSONata mapping that works for standard objects (e.g., standard `Contact` or `Invoice` fields).
2. **Level 2 - Environment Override:** Modifications applied to a specific deployment environment (e.g., adding a custom field required by all users in your EU cluster).
3. **Level 3 - Account Override:** Mappings applied to a single, specific connected account.

Each override is deep-merged on top of the previous. The runtime evaluates the final merged expression for every request. 

```typescript
const overrides = get(integratedAccount, [
  'unified_model_override', 'accounting', 'invoices', 'list'
]) || {}

const mappedResponse = await applyMapping({
  baseMapping,             // platform default
  environmentOverride,     // tenant-wide
  accountOverride: overrides.response_mapping,
  data: rawErpResponse
})
```

If Customer A requires their agent to read a custom field called `custbody_priority_level`, you simply add an account-level mapping override for their specific `integrated_account_id`. When the agent requests invoices for Customer A, the unified response automatically includes the `priority_level` field. When the agent queries Customer B, the field is omitted. 

The agent's core logic and tool schema remain entirely untouched. A new custom field is a configuration row in a database, not a code deploy.

> [!NOTE]
> **Zero Data Retention Security**
> When handling financial ERP data, caching is a massive compliance liability. Ensure your integration architecture uses a pass-through proxy model. Data should only exist in memory during the JSONata transformation and should never be stored at rest in the integration layer.

## Trade-offs You Should Actually Know About

This tutorial would be dishonest without acknowledging the friction points. A unified API plus MCP is not free.

- **Latency:** Each agent tool call now traverses an extra hop. A real-time agent that does ten tool calls per turn will feel this. Mitigate with caching for read-mostly tools (catalogs, accounts) and direct passthrough for write tools.
- **Schema lag:** When NetSuite ships a new endpoint in 2026.2, your unified schema needs to absorb it. A platform that ships connectors as configuration rather than code closes this gap in hours rather than sprints, but the gap is non-zero.
- **Custom-field discovery:** Enterprises love their custom fields. Your integration layer must dynamically fetch field definitions per account (NetSuite's `custom_record_type` metadata, SAP's CDS view extensions) and expose them to the agent without re-deploying.
- **MCP is young:** The protocol is evolving. Tool definitions, transport (stdio vs SSE vs HTTP), and auth scopes are still being standardized. Pin SDK versions and budget for protocol drift through 2026.

None of these are reasons to build per-ERP code paths in your agent codebase. They are reasons to choose an integration layer that treats integrations as data and lets your team focus on agent reasoning.

## Ship Agentic ERP Workflows Faster

Building AI agents that can securely interact with enterprise ERPs is a significant engineering challenge. The complexity does not lie in the LLM—it lies in the unforgiving, legacy architecture of the underlying financial systems.

The winning architecture for agent-to-ERP integration in 2026 looks like this: a stateless unified API layer that normalizes NetSuite, SAP, QuickBooks, and Xero behind a single REST contract; declarative JSONata mappings that translate per-account schemas without code; an auto-generated MCP server that exposes those normalized operations as tools to any agent framework; a multi-level override hierarchy that handles enterprise customizations without redeployment; and explicit, observable rate-limit behavior that puts the agent's planner in charge of retries rather than hiding failures.

The alternative—writing OAuth refresh logic, SuiteQL paginators, OData filter builders, and per-tenant field mappers inside your agent codebase—is the path that has buried integration teams for the last decade. AI agents make the cost worse, not better, because every flaky tool call now degrades the model's reasoning quality, not just the user's experience.

Stop writing custom SOAP parsers and stateful OAuth handlers. Focus your engineering cycles on building smarter agents, and let a unified API handle the enterprise plumbing.

> Building AI agents that need to read and write ERP data across NetSuite, SAP, QuickBooks, and Xero? Truto's unified accounting API and auto-generated MCP tools let your agents ship in days, not quarters—without storing customer data.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
