ERP
Desain Tool AI Agent ERP: Draft, Bukan Write Langsung
Oktober 202612 menit baca

Di sebagian besar desain ERP, tidak. Beri agent tool read dan tool yang membuat dokumen berstatus draft, lalu serahkan submit, approve, posting dan pengiriman ke orang di ERP sesuai approval matrix yang ada. Draft yang salah tinggal dihapus, sedangkan jurnal posted yang salah butuh jurnal pembalik dan bisa jadi mustahil di periode yang sudah ditutup.
Dokumen ERP sudah punya state machine di mana draft adalah satu-satunya status yang bebas diubah. Menulis ke draft berarti hasil agent masuk ke antrean review yang sudah berjalan, tanpa mutasi stok, entri ledger atau pesan keluar sampai ada orang yang bertindak. Agent menjadi staf yang cepat, bukan approver tanpa pengawasan.
Beri setiap tool write idempotency key yang dibentuk dari agent run id milik executor ditambah client reference yang dipakai ulang model saat retry. Klaim key itu di transaksi database yang sama dengan pembuatan draft, kembalikan draft yang sudah ada saat key di-replay, dan tolak pemakaian ulang key dengan argumen yang berbeda.
Tidak. Ambil cabang, user dan scope dari request yang terautentikasi, lalu filter setiap query dengannya. Jika branch_id menjadi argumen, catatan vendor yang berisi prompt injection bisa mengubahnya. Spesifikasi tools MCP juga mengizinkan daftar tool berbeda sesuai authorization pemanggil, sehingga user hanya melihat tool yang diizinkan scope-nya.
Kembalikan sebagai tool execution error dengan isError bernilai true, lalu sebutkan apa yang gagal, apa yang tidak dilakukan, dan panggilan apa yang harus dibuat berikutnya, memakai nomor dokumen alih-alih id internal. Spesifikasi MCP menggambarkan error ini sebagai feedback actionable agar model bisa mengoreksi diri. Kode buram seperti 422 biasanya membuat model mengirim ulang panggilan yang sama.

Ringkasan Utama
Desain tool AI agent ERP yang baik memberi model tiga tier: tool read, tool yang membuat dokumen draft, dan tool posting yang tidak pernah dipegang agent. Setiap write memakai idempotency key, cabang dan user diambil dari token bukan dari argumen, error memberi tahu model langkah berikutnya, dan setiap dokumen mencatat agent run yang membuatnya.
Bayangkan sebuah distributor dengan tiga cabang yang ingin agent mengubah alert reorder menjadi purchase order. Prototipe pertama biasanya satu tool yang membungkus REST API ERP: method, path, body. Saat demo semuanya lancar. Lalu sebuah timeout membuat agent melakukan retry, dua order yang sama sampai ke vendor, dan tidak ada yang bisa memastikan apakah orang atau agent yang menekan tombolnya. Masalahnya bukan di model, melainkan di tool surface yang diberikan kepadanya.
Desain tool AI agent ERP adalah disiplin menentukan apa yang boleh dilihat model, apa yang boleh diubah, dan di status apa dokumen ditinggalkan. Artikel ini memaparkan desain yang opinionated untuk surface tersebut, berangkat dari cara modul ERP sudah bekerja: dokumen punya status draft, posting adalah tindakan terpisah, dan setiap perubahan bisa ditelusuri pelakunya. Rujukannya adalah dokumentasi function calling dan tool search OpenAI, spesifikasi tools MCP 2026-07-28, serta catatan engineering Anthropic tentang tool untuk agent.
Kelompokkan setiap operasi yang mungkin dibutuhkan agent ke salah satu dari tiga tier sebelum menulis satu schema pun. Tier inilah yang menentukan kebijakan approval, credential yang dipakai, dan apakah tool itu ada untuk agent atau tidak.
| Tier | Apa yang boleh diubah | Contoh | Kebijakan approval |
|---|---|---|---|
| Read | Tidak ada. Mengembalikan data yang memang sudah bisa dilihat pemanggil di layar ERP. | Cari vendor, ambil purchase order, stok on hand, invoice terbuka milik customer | Tanpa approval per panggilan; dibatasi scope dan cabang |
| Draft | Membuat atau mengubah dokumen hanya dalam status draft. Tidak ada mutasi stok, tidak ada jurnal, tidak ada yang dikirim ke luar. | Draft PO, draft quotation penjualan, draft jurnal voucher, permintaan penyesuaian stok | Approval pada tool call jika tindakannya tidak biasa; review dokumen selalu |
| Post | Submit, approve, posting, kirim atau batalkan. Membuat entri ledger atau efek samping eksternal. | Posting jurnal, approve PO, kirim invoice ke customer, void pembayaran | Tidak diberikan ke agent. Dilakukan orang di ERP, mengikuti approval matrix yang sudah ada |
Baris ketiga adalah keputusan desainnya. Tool posting dikeluarkan sepenuhnya dari daftar tool agent, bukan sekadar dijaga prompt konfirmasi, karena prompt konfirmasi hanyalah satu klik yang bisa ditekan reviewer yang lelah tanpa membaca. Panduan remote MCP OpenAI meminta developer memakai allowed_tools dan require_approval agar tindakan sensitif melewati alur approval; setelah posting dihapus, alur itu cukup menjaga tier draft, di mana kesalahan hanya berarti menghapus draft, bukan membuat jurnal pembalik.
Dokumen ERP sudah punya state machine: draft, submitted, approved, posted, kadang cancelled. Draft bisa diubah atau dihapus dengan bebas; dokumen yang sudah posted hanya bisa dibalik dengan dokumen lain, dan di periode yang sudah ditutup bahkan itu pun tidak bisa. Menulis ke status draft berarti agent menghasilkan persis apa yang dihasilkan staf junior, dan hasilnya masuk ke antrean review yang sudah berjalan di bisnis. Schema di bawah ini adalah seluruh kontrak untuk satu tool semacam itu.
// One business action, one tool. The model can describe a draft;
// it cannot choose a status, a branch, a price or a posting date.
export const createPurchaseOrderDraft = {
type: "function",
name: "purchasing_create_po_draft",
description:
"Create a DRAFT purchase order for the caller's branch. A draft is not " +
"sent to the vendor and does not touch stock or the ledger until a person " +
"submits it in the ERP. Prices come from the vendor price list. Returns " +
"the draft number and server-computed totals.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
// Strict mode: every property is listed as required;
// optional ones accept null instead of being omitted.
required: ["vendor_code", "needed_by", "client_ref", "note", "lines"],
properties: {
vendor_code: {
type: "string",
description: "Vendor code from purchasing_search_vendors, e.g. V-00231",
},
needed_by: { type: "string", description: "Date needed, YYYY-MM-DD" },
client_ref: {
type: "string",
description: "Your own id for this order within the run. Reuse it when retrying.",
},
note: {
type: ["string", "null"],
description: "Why this order is needed. Shown to the reviewer.",
},
lines: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["item_code", "qty", "uom"],
properties: {
item_code: { type: "string" },
qty: { type: "number" },
// An enum, not free text: "kg", "Kg" and "kilo" are three bugs.
uom: { type: "string", enum: ["PCS", "BOX", "KG", "LTR"] },
},
},
},
// Deliberately absent: branch_id, status, unit_price, posting_date.
},
},
} as const;Perhatikan apa yang tidak ada di schema. Tidak ada branch_id, jadi model tidak bisa mencatat order di cabang lain. Tidak ada status, jadi model hanya bisa membuat draft. Tidak ada unit_price, karena harga diambil dari price list vendor di server; model yang bisa mengetik harga juga bisa mengetik harga yang salah. Panduan function calling OpenAI menyarankan strict mode, yang mewajibkan additionalProperties bernilai false dan semua property dicantumkan sebagai required, dengan null sebagai cara menyatakan field opsional. Panduan yang sama menyarankan enum dan struktur object untuk mencegah state yang tidak valid, itulah sebabnya uom berupa enum, bukan string bebas.
Description bekerja sama kerasnya dengan schema. Ia menjelaskan apa itu draft, apa yang tidak dilakukannya, dan apa yang dikembalikan. Panduan yang sama mengusulkan intern test: apakah seseorang bisa memakai function ini dengan benar hanya berbekal informasi yang diberikan ke model? Description yang hanya berbunyi Create PO gagal dalam tes ini, karena tidak menjelaskan apakah vendor diberi tahu atau apakah stok dicadangkan.
Tool endpoint generik menyerahkan seluruh API kepada model dan memindahkan setiap keputusan permission ke parsing string. Tool update_document generik adalah kesalahan yang sama dalam bentuk yang lebih rapi: satu tool untuk semua jenis dokumen dan semua field. Ganti keduanya dengan satu tool per tindakan bisnis, diberi prefix modul, dan dikelompokkan dalam satu namespace per modul ERP.
// Wrong: one tool that can do anything the REST API can do.
// The model now chooses between GET /items and POST /journal-entries/123/post,
// and every permission check has to be re-derived from a free-form path.
{ type: "function", name: "erp_api",
parameters: { method: "string", path: "string", body: "object" } }
// Wrong, more subtly: a generic writer keyed by document type.
{ type: "function", name: "update_document",
parameters: { doctype: "string", id: "string", fields: "object" } }
// Right: one namespace per ERP module, each under 10 functions,
// loaded only when the task needs that module.
const tools = [
{
type: "namespace",
name: "purchasing",
description:
"Purchasing: search vendors and items, read purchase orders, create PO drafts. " +
"Cannot approve, send or cancel orders.",
tools: [
{ type: "function", name: "purchasing_search_vendors", defer_loading: true, /* ... */ },
{ type: "function", name: "purchasing_get_po", defer_loading: true, /* ... */ },
{ type: "function", name: "purchasing_create_po_draft", defer_loading: true, /* ... */ },
],
},
{
type: "namespace",
name: "inventory",
description: "Inventory: stock on hand and reorder points per warehouse. Read only.",
tools: [/* inventory_get_stock, inventory_list_below_reorder */],
},
{ type: "tool_search" },
];Panduan function calling OpenAI menyarankan agar jumlah function yang tersedia di awal tetap kecil demi akurasi, dengan target kurang dari 20 di awal satu turn, serta menggabungkan function yang selalu dipanggil berurutan. Panduan tool search-nya melangkah lebih jauh untuk surface besar: masukkan function ke dalam namespace, tandai dengan defer_loading agar model hanya melihat nama dan description namespace sampai ia melakukan pencarian, dan jaga setiap namespace berisi kurang dari 10 function. ERP cocok dengan pola ini, karena purchasing, inventory, sales dan finance memang sudah menjadi modul terpisah dengan pemilik masing-masing.
Panduan Anthropic tentang menulis tool untuk agent menyampaikan dua poin yang sama dari sisi lain: gabungkan beberapa panggilan API menjadi satu tool yang sesuai dengan tugasnya, dan gunakan prefix seperti purchasing_ agar batasnya jelas ketika banyak tool dimuat. Description namespace juga sebaiknya menyebutkan apa yang tidak bisa dilakukan modul itu. Kalimat Cannot approve, send or cancel orders hanya menghabiskan belasan token dan mencegah model mencari tool yang memang tidak ada.
Agent pasti melakukan retry. Panggilan HTTP ke tool server timeout setelah draft sudah di-commit; runtime mengirim ulang. Sebuah run crash lalu dilanjutkan dari checkpoint terakhir; model memanggil tool yang sama lagi. Tanpa key, setiap retry menjadi purchase order baru. Solusinya sama dengan yang dipakai API pembayaran: klaim key di transaksi yang sama dengan pembuatan dokumen, dan kembalikan hasil aslinya ketika key yang sama muncul lagi.
-- Every write tool claims its key before it does anything else.
CREATE TABLE agent_write_keys (
idempotency_key text PRIMARY KEY, -- agent_run_id || ':' || client_ref
tool_name text NOT NULL,
request_hash text NOT NULL, -- sha256 of the canonical arguments
document_type text,
document_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);export async function createPoDraft(ctx: ToolContext, args: PoDraftArgs) {
// The run id comes from the executor, so two runs can never collide,
// and a retry inside one run always lands on the same key.
const key = `${ctx.agentRunId}:${args.client_ref}`;
const hash = sha256(canonicalJson(args));
return db.transaction(async (tx) => {
const claimed = await tx.query(
`INSERT INTO agent_write_keys (idempotency_key, tool_name, request_hash)
VALUES ($1, 'purchasing_create_po_draft', $2)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key`,
[key, hash],
);
if (claimed.rowCount === 0) {
// A concurrent duplicate blocks on the primary key until the first
// transaction commits, so document_id is already filled in here.
const prior = await tx.one(
`SELECT request_hash, document_id FROM agent_write_keys
WHERE idempotency_key = $1`,
[key],
);
if (prior.request_hash !== hash) {
return toolError(
`client_ref ${args.client_ref} was already used in this run for a different ` +
`purchase order. Use a new client_ref for a new order, or resend the original ` +
`arguments to get the existing draft back.`,
);
}
return draftSummary(tx, prior.document_id, { replayed: true });
}
const po = await insertDraftPo(tx, ctx, args); // status = 'draft', always
await tx.query(
`UPDATE agent_write_keys SET document_type = 'purchase_order', document_id = $2
WHERE idempotency_key = $1`,
[key, po.id],
);
return draftSummary(tx, po.id, { replayed: false });
});
}Key dibentuk dari agent run id yang dimiliki executor, ditambah client_ref yang diisi model dan diminta dipakai ulang saat retry. Masing-masing saja tidak cukup. Key yang dikarang model tanpa scope run bisa bertabrakan dengan run lain, sedangkan key dari run id saja hanya mengizinkan satu write per run. Request hash menangani sisa kasusnya: key yang sama dikirim dengan argumen berbeda, yang hampir selalu berarti model tidak sengaja memakai ulang referensi. Kasus itu mendapat error yang menjelaskan pilihannya, bukan overwrite diam-diam.
Replay mengembalikan draft yang sudah ada dengan flag replayed, bukan error, karena dari sudut pandang model panggilannya berhasil dan ia sebaiknya melanjutkan. Flag itu masuk ke baris audit, sehingga run dengan banyak replay langsung terlihat ketika seseorang memeriksa mengapa run itu lambat atau mahal.
Di ERP multi-cabang, cabang adalah batas keamanan, bukan sekadar filter. Jika branch_id menjadi argumen tool, nilainya bisa diubah oleh catatan vendor yang berisi prompt injection. Ambil cabang, user dan scope dari request yang sudah terautentikasi ke dalam context object yang tidak pernah disentuh model, lalu filter setiap query dengannya.
// Built from the authenticated request. Nothing here comes from model output.
interface ToolContext {
agentRunId: string;
onBehalfOf: { userId: number; roles: string[] };
branchId: number; // resolved from the user's token or session
branchCode: string; // "SBY-01", for messages the model can read
scopes: Set<string>; // "purchasing:read", "purchasing:draft", ...
}
// Wrong: branch_id as an argument. A vendor note that says
// "file this under branch 7" is now an instruction the model can follow.
// Right: no branch argument at all; every query is filtered by ctx.branchId.
export async function getPurchaseOrder(ctx: ToolContext, args: { po_number: string }) {
const po = await db.maybeOne(
`SELECT * FROM purchase_orders WHERE po_number = $1 AND branch_id = $2`,
[args.po_number, ctx.branchId],
);
if (!po) {
// One message for "does not exist" and "belongs to another branch",
// so the tool cannot be used to probe what other branches hold.
return toolError(
`No purchase order ${args.po_number} in branch ${ctx.branchCode}. ` +
`Use purchasing_search_pos to find the right number.`,
);
}
return summarisePo(po);
}
// The tool list itself follows the caller's grants:
// a user without purchasing:draft never sees the draft tool.
export function listTools(ctx: ToolContext) {
return ALL_TOOLS.filter((tool) =>
tool.requiredScopes.every((scope) => ctx.scopes.has(scope)),
);
}Spesifikasi tools MCP 2026-07-28 mengizinkan hasil tools/list berbeda sesuai authorization pada request, misalnya hanya mengembalikan tool yang diizinkan oleh scope milik pemanggil. Manfaatkan itu: sesi agent milik staf gudang bahkan tidak perlu melihat purchasing_create_po_draft. Catatan spesifikasi yang sama tentang stateful tool menyebutkan bahwa handle seperti nomor dokumen hanyalah nama, bukan capability, dan server harus memvalidasi authorization pemanggil terhadap handle itu di setiap panggilan. Nomor draft yang dikembalikan sejam lalu tetap dicek terhadap cabangnya hari ini.
Jangan menegakkan semua ini dengan tool annotation atau description. Spesifikasi MCP menyatakan client wajib menganggap tool annotation tidak tepercaya kecuali berasal dari server tepercaya, dan description yang berbunyi read only hanyalah petunjuk untuk model, bukan kontrol. Tier read bersifat read only karena database role-nya tidak punya grant write, dan tier draft tidak bisa posting karena tidak ada code path di dalamnya yang mengubah status.
Error dari tool dibaca oleh model yang akan menentukan panggilan berikutnya hanya dari teks itu. Spesifikasi MCP memisahkan protocol error, seperti tool yang tidak dikenal, dari tool execution error yang dikembalikan di result dengan isError bernilai true, dan menggambarkan yang terakhir sebagai feedback actionable yang bisa dipakai model untuk mengoreksi diri dan mencoba lagi. Client sebaiknya meneruskan execution error ke model. Jadi tulislah untuk pembaca itu.
// Wrong: accurate, and useless to a model. It will resend the same call.
{ "content": [{ "type": "text", "text": "ERR_VALIDATION 422" }], "isError": true }
// Right: what failed, the allowed values, what to do next, and what did NOT happen.
{
"content": [{
"type": "text",
"text": "Item BRG-0042 is not stocked in KG. Allowed units for BRG-0042: PCS, BOX (1 BOX = 24 PCS). Resend the line with uom PCS or BOX. No draft was created."
}],
"isError": true
}| Situasi | Error yang buram | Ditulis untuk model |
|---|---|---|
| Vendor diblokir | 403 Forbidden | Vendor V-00231 diblokir finance untuk order baru sejak 12 September. Tidak ada draft yang dibuat. Pilih vendor lain untuk item BRG-0042 atau beri tahu user bahwa vendor perlu dibuka blokirnya. |
| Periode akuntansi ditutup | PERIOD_LOCKED | Agustus 2026 sudah ditutup di cabang SBY-01. Draft jurnal hanya boleh bertanggal 1 September 2026 atau sesudahnya. Kirim ulang dengan tanggal di periode yang masih terbuka. |
| Draft sudah disubmit | 409 Conflict | PO-SBY-2026-0918 sudah disubmit oleh user dan tidak bisa lagi diubah agent. Buat draft baru atau minta user mengembalikannya ke draft. |
| Kode item tidak ditemukan | Item not found | Tidak ada item BRG-042 di cabang ini. Yang mirip: BRG-0042 Kardus 40x40, BRG-0420 Lakban Coklat. Panggil inventory_search_items untuk memastikan. |
Setiap pesan yang baik di tabel itu menyebutkan apa yang tidak dilakukan, karena model yang ragu apakah sebuah write sudah terjadi sering mencoba lagi. Pesan-pesan itu juga memakai nomor dan nama dokumen, bukan id internal. Panduan Anthropic melaporkan bahwa mengubah UUID yang buram menjadi identifier yang bermakna meningkatkan presisi, dan hal yang sama berlaku bagi reviewer yang membaca transkrip belakangan. Simpan id internal di structured result untuk merangkai panggilan, dan taruh versi yang mudah dibaca manusia di teks.
Audit log ERP biasanya mencatat user dan timestamp. Dengan agent di dalam alur, jawaban itu tidak lengkap: user tidak mengetik order tersebut, agent yang melakukannya, atas nama user, di run tertentu, dengan versi prompt tertentu. Catat semuanya, dari sisi executor, untuk setiap panggilan termasuk yang gagal dan yang ditolak.
-- One row per tool call, written by the executor, never by the model.
CREATE TABLE agent_tool_calls (
id bigserial PRIMARY KEY,
agent_run_id text NOT NULL,
tool_call_id text NOT NULL, -- id of the model's function call item
tool_name text NOT NULL,
on_behalf_of integer NOT NULL REFERENCES users(id),
branch_id integer NOT NULL,
arguments jsonb NOT NULL,
outcome text NOT NULL
CHECK (outcome IN ('ok', 'tool_error', 'denied', 'replayed')),
document_type text,
document_id bigint,
model text NOT NULL,
prompt_version text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Provenance on the document itself, so the reviewer sees it without a join
-- and it survives when tool-call logs are rotated.
ALTER TABLE purchase_orders
ADD COLUMN drafted_by_agent_run text,
ADD COLUMN drafted_on_behalf_of integer REFERENCES users(id);tool_call_id menghubungkan baris audit dengan function call item yang persis di transkrip model, sehingga reviewer bisa membuka run itu dan melihat alasan yang menghasilkan dokumen tersebut. Dua kolom di purchase_orders menjaga provenance tetap menempel di dokumen, dan ini penting karena log tool call sering hanya disimpan beberapa minggu sementara dokumen disimpan bertahun-tahun. Tampilkan juga di UI: badge Drafted by agent dengan link ke run memberi tahu approver untuk membaca setiap baris, bukan sekadar percaya pada totalnya.
Kembalikan hasil yang dihitung server dari setiap tool draft: nomor dokumen, total baris setelah pricing, pajak, dan peringatan seperti kuantitas yang jauh di atas order biasa. Lalu minta agent menyampaikan ulang angka-angka itu ke user. Reviewer yang membandingkan ucapan agent dengan hitungan ERP akan menangkap kesalahan yang tidak tertangkap oleh salah satunya saja.
Jalankan setiap tool baru melalui pertanyaan berikut sebelum masuk ke daftar tool agent. Jawaban tidak pada salah satunya berarti perubahan desain, bukan sekadar perbaikan dokumentasi.
Model adalah bagian yang paling sulit dikendalikan dari sebuah agent ERP, jadi tool surface-lah yang harus membawa kontrolnya. Beri agent tool read dan tool draft, biarkan posting tetap di tangan manusia, dan buat setiap write idempotent, ter-scope ke cabang, dan bisa ditelusuri. Dengan begitu retry menghasilkan replay, instruksi hasil injection tidak menemukan argumen untuk diubah, dan auditor bisa menelusuri setiap dokumen buatan agent sampai ke run-nya.
Artikel terkait di situs ini