The open-source ERP on the Frappe framework. Every doctype is a REST resource, and every server-side method is reachable via RPC. The manufacturing stack — Items, BOMs, Work Orders, Job Cards, Stock Entries — is the strongest part of the tree, backing real factories today.
ERPNext is the enterprise resource planning system most open-source-friendly factories, distributors, and professional services firms end up on. It runs on Frappe — a metadata-driven Python framework where every business entity is a "doctype" and every doctype gets an automatic REST endpoint for free.
You get two APIs for the price of one:
| API | Base path | Use when |
|---|---|---|
/api/resource/<DocType> |
REST | CRUD on any document — Item, Sales Order, BOM, Stock Entry |
/api/method/<dotted.path> |
RPC | Server-side logic — stock balance, BOM explosion, document submission |
The canonical pattern: read from ERPNext, run an AI-assisted workflow (production planning, reorder suggestions, quality escalations), and write approved decisions back as first-class Frappe documents. Never let the AI be the source of truth for stock or BOMs — the ERP's double-entry and submission semantics exist for reasons a model won't understand.
Frappe offers three ways to authenticate. The first is the right one for integrations. The others exist, but use them and you'll regret it.
# 1. API Key + Secret — recommended for agents and integrations Authorization: token api_key:api_secret # 2. Basic Auth — for one-off scripts Authorization: Basic base64(user:password) # 3. Session cookies — for browser-like flows only $ curl -X POST https://site.example.com/api/method/login \ -d '[email protected]&pwd=password' # use returned cookies for subsequent requests
Generate the API Key and Secret from User > API Access in the ERPNext UI. The secret is only shown once — store it immediately in a secrets manager.
// Cloudflare Worker — ERPNext list call with token auth const token = `${env.ERPNEXT_KEY}:${env.ERPNEXT_SECRET}`; const res = await fetch( `${env.ERPNEXT_URL}/api/resource/Item?filters=${filters}&limit_page_length=50`, { headers: { 'Authorization': `token ${token}` } } ); const { data } = await res.json();
Generate a read-only user for reporting agents and a separate read/write user for fulfilment and submission. Frappe enforces role-based permissions per doctype — don't give the AI the site administrator role just to make a demo work.
Three things to internalise: how to shape REST requests, how Frappe's JSON filter syntax works, and the document lifecycle that drives the manufacturing flow. Get those right and the rest is typing.
List, get, create, update. All four are a single REST verb on /api/resource/<DocType>. Pagination is off-by-default tiny — set it explicitly.
# List records with filters and specific fields GET /api/resource/Item ?filters=[["item_group","=","Finished Goods"]] &fields=["name","item_name","item_group","stock_uom"] &limit_page_length=50 &order_by=item_name asc # Get a single record by name (the ID, not the display name) GET /api/resource/Item/ITEM-CODE-001 # Create a new record POST /api/resource/Item Content-Type: application/json { "item_code": "ITEM-001", "item_name": "Item Name", "item_group": "Finished Goods", "stock_uom": "Nos" } # Partial update PUT /api/resource/Item/ITEM-001 { "description": "Updated description" }
Filters are JSON arrays. Each filter is [field, operator, value]. Multiple filters become an array of arrays. Server-side filtering, not client-side post-processing.
| Operator | Meaning | Example |
|---|---|---|
= | Equals | ["item_group","=","Raw Material"] |
!= | Not equals | ["status","!=","Cancelled"] |
>/>=/</<= | Comparison | ["qty",">",0] |
like | Wildcard | ["item_name","like","%Oak%"] |
in | In list | ["status","in",["Open","In Process"]] |
between | Range | ["posting_date","between",["2026-01-01","2026-03-31"]] |
Core doctypes. The ones that come up in every manufacturing integration — not the whole tree, just the load-bearing ones.
Item — Products, raw materials, sub-assemblies BOM — Bill of Materials — recipe for a manufactured item Work Order — A production order Job Card — Per-operation tracking on a work order Workstation — Machine or assembly station Operation — Manufacturing step Stock Entry — Inventory movement (issue, receipt, manufacture) Quality Inspection — QC checks tied to receipts or work orders Sales Order — Customer orders (drives work order creation) Purchase Order — Supplier orders Warehouse — Storage locations
The manufacturing flow. If you only know one thing about ERPNext, know this chain. Every real manufacturing integration ends up walking it top-to-bottom.
RPC methods. Some operations aren't exposed as REST resources — stock balances, BOM explosions, document transformations. These live behind Frappe's method RPC.
POST /api/method/erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry
Content-Type: application/json
{
"work_order": "WO-00045",
"purpose": "Material Transfer for Manufacture",
"qty": 30
}
| Method | Purpose |
|---|---|
frappe.client.get_count | Count documents matching filters |
frappe.client.get_list | List with server-side aggregation |
erpnext.stock.utils.get_stock_balance | Real-time stock balance for an item at a warehouse |
erpnext.manufacturing.doctype.bom.bom.get_bom_items | Explode a BOM into its raw material requirements |
...work_order.make_stock_entry | Create a stock entry from a Work Order |
...sales_order.make_work_order | Create Work Orders from a Sales Order |
Seven specific failure modes lifted from the canonical skill. Every one of these has cost someone an afternoon at least once.
0 is draft, 1 is submitted, 2 is cancelled. Most queries need ["docstatus","=",1]. Forgetting this means your "sales" list includes draft orders that don't actually exist.
BOM items, Sales Order items, Stock Entry items are child tables on the parent document. Fetch them via the parent GET or with dotted fields like fields=["items.item_code","items.qty"].
Frappe's default rate limit for API keys is 5 requests per second. Scheduled syncs need batching or throttling — or you'll spend an hour wondering why every other call fails.
Set limit_page_length explicitly. Use limit_page_length=0 to fetch everything, but only on small doctypes — on a 50k-item master this will hang the site.
name is the ID, not the display name
Every Frappe doctype has a name field that's the record's unique ID. Use name for lookups, not the display-friendly field like item_name.
An item can have many BOMs. For production queries, filter with ["is_active","=",1] and ["is_default","=",1] — otherwise you might pick up an old recipe that still exists but isn't in use.
"Material Issue", "Material Receipt", "Material Transfer for Manufacture", "Manufacture" — exact strings. Typos silently fail or create the wrong kind of movement, which is worse than failing because it breaks the audit trail.
The canonical skill declares its requires and improves in frontmatter. Here are the nodes in the tree erpnext is wired into.
improves biz/erp — its manufacturing patterns feed back into the shared ERP skill.The canonical SKILL.md is the authoritative version and gets updates as the production skill evolves. Frappe and ERPNext docs are the upstream truth.