Mid-market ERP (formerly Sage Enterprise Management) exposed via a GraphQL API through the Syracuse web server using Yoga GraphQL. Relay cursor pagination, OData-style filters, and a schema that varies per installation. Introspect first, query second.
Sage X3 is the enterprise management platform most mid-market manufacturers, distributors, and services businesses in South Africa already run. Modern instances expose a GraphQL API via the Syracuse web server โ the same server that renders the web client. That API is the right door for AI agents: one endpoint, one schema, cursor-based pagination, and server-side filtering.
The endpoint pattern is predictable:
https://<host>:<port>/<folder>/api
The schema is organised into five top-level query types โ master data, sales, purchasing, stock, accounting โ each containing the entities you'd expect: customers, orders, invoices, stock movements, journal entries.
The canonical skill pattern: read from Sage X3, enrich with AI, write results back or display in a dashboard. Never try to replace X3 as the source of truth for financial or stock data โ the audit trail, tax filings, and regulatory obligations all live there.
Sage X3's GraphQL endpoint uses plain HTTP Basic Authentication โ the same username and password the user would log into the Syracuse web client with. Trivial to implement, impossible to get wrong, provided you never put the credentials in code.
# Authorization header Authorization: Basic base64(USERNAME:PASSWORD) # Cloudflare Worker โ store secrets with wrangler $ wrangler secret put SAGE_USER $ wrangler secret put SAGE_PASS # In the Worker const auth = 'Basic ' + btoa(`${env.SAGE_USER}:${env.SAGE_PASS}`); const res = await fetch('https://sage-host/folder/api', { method: 'POST', headers: { 'Authorization': auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), });
Environment variables for local dev. wrangler secret put for Workers. AWS Secrets Manager for Lambda. If Sage X3 creds ever land in a git commit, rotate them immediately โ the operational X3 user usually has write access to master data, which means a leak is a data integrity incident, not just a security one.
Three things you need to internalise to use Sage X3's GraphQL effectively: the domain structure, the Relay-style query shape with the odd node.code wrapper, and OData-style filters. Everything else is elaboration on these.
Domain structure. The top-level schema groups by business domain. Exact fields vary per installation, but the shape is stable.
x3MasterData โ Master data โโโ customer โ Customer business partners โโโ supplier โ Supplier business partners โโโ product โ Product catalog โโโ stockSite โ Warehouses / stock sites x3Sales โ Sales domain โโโ salesOrder โ Sales orders โโโ salesInvoice โ Sales invoices โโโ salesDelivery โ Delivery notes x3Purchasing โ Purchasing domain โโโ purchaseOrder โ Purchase orders โโโ purchaseReceipt โ Goods receipt x3Stock โ Inventory domain โโโ stockChange โ Stock movements โโโ stockCount โ Stock counts x3Accounting โ Financial domain โโโ journalEntry โ Journal entries โโโ generalLedger โ GL accounts
Relay cursor pagination. Every list query uses first/after with edges โ node. There's one weird thing about Sage's implementation: fields live inside node.code, not directly on node.
query { x3MasterData { customer { query(first: 10, after: "cursor_value") { edges { node { code { โ this wrapper matters code companyName1 country { code countryName } currency { code localizedDescription } isCustomer isSupplier } } } pageInfo { hasNextPage endCursor } } } } }
OData-style filters. Filter strings are passed through the filter argument and follow OData syntax. This is where most real-world queries happen โ server-side filtering, not client-side post-processing.
query(first: 50, filter: "country.code eq 'ZA' and isCustomer eq true") query(first: 100, filter: "orderDate ge '2026-01-01'")
| Operator | Meaning | Example |
|---|---|---|
eq | Equals | status eq 'Active' |
ne | Not equals | country.code ne 'ZA' |
gt/ge/lt/le | Comparison | totalAmount gt 10000 |
contains() | Substring | contains(companyName1, 'Umbrella') |
and/or | Logical | isCustomer eq true and isSupplier eq true |
Introspection. Because the schema varies per installation, start every new integration with an introspection query. It's the only reliable way to know what's actually available in a specific X3 instance.
{
__schema {
queryType {
fields {
name
description
type { name kind ofType { name kind } }
}
}
}
}
// Discover fields on a specific type
{
__type(name: "X3MasterDataQuery") {
fields {
name
type { name kind }
}
}
}
Seven specific mistakes that waste an afternoon the first time you hit them. Each is lifted directly from the canonical skill โ these are the failure modes the skill has seen in real deployments.
node.code
Data is at node.code.fieldName, not node.fieldName. Every query needs the code wrapper or it returns nothing.
Omitting first returns zero results โ not an error, just zero. Always pass an explicit page size.
The Syracuse endpoint rejects cross-origin fetches from browsers. You need a server-side proxy โ a Cloudflare Worker is the cheapest option.
There's no GROUP BY. Pull the rows and aggregate client-side, or fall back to a reporting view on the X3 side.
Dates in filters must be '2026-01-01' format. Other formats parse as strings and filter nothing.
Dev X3 instances usually have self-signed TLS. Node needs NODE_TLS_REJECT_UNAUTHORIZED=0 to talk to them โ never set this in prod.
Custom fields, activated modules, and Sage version all change what's in the schema. Introspect first every time โ don't assume the fields from another X3 instance exist in this one.
The canonical skill declares its requires and improves in frontmatter. Here are the nodes in the tree sage-x3 is wired into.
improves biz/erp โ it contributes its patterns back up to the shared ERP skill.The canonical SKILL.md is the authoritative version and gets updates as the production skill evolves. Everything else here is context.