One change, propagated correctly across every Xero organisation in the group. An AI proposes structured mappings and explains its reasoning; a human approves once; deterministic, schema-validated code writes to Xero — never the AI.
Built for the Xero "Rise of the Builder" hackathon, grounded in eight stakeholder interviews plus a technical deep-dive with the group's BI lead. Groups running 20+ Xero entities make the same change (a new expense code, a product, a supplier, a tracking category) by hand, org by org — slow, and error-prone in ways that fail an audit. Canopy does it in one reviewed batch.
┌─ snapshot each entity's live chart / tax / contacts / items
one source change ─────┤
+ target entities └─ LLM maps it to THAT entity's data → one Proposal per entity
│ (action, mapped_payload, confidence, reasoning, needs_human)
▼
batched approval table ──► human approves / edits / excludes once
│
▼
deterministic execution: validate payload → write with an explicit
per-request Xero-Tenant-Id → record full request/response audit
The rule enforced everywhere in code: the LLM only proposes structured JSON. It may
never invent an account code, tax type, or tracking id — if nothing in the target org fits,
it must refuse (needs_human) rather than guess. Only deterministic, Pydantic-validated
code writes, and only after human approval. Tenant IDs are explicit per request, never
cached globally. No money movement — items, accounts, contacts, and tracking categories only.
The AI is capable but not trusted blindly. After every proposal, deterministic backstops run:
- Referenced-code guard — any account code in a mapped payload that doesn't exist in the
target org's chart forces
needs_humaninstead of a guaranteed write failure. - Account collision guard — Xero requires unique account Code and Name; a clash is flagged pre-write rather than failing at the API.
- Option coercion — tracking Options are normalised whether the model emits
["Sales"]or Xero's native[{"Name":"Sales"}].
These have each caught the model being confidently wrong in live runs against real orgs.
Multi-entity propagation — proven end-to-end against real Xero trial organisations (written, then read back to confirm), for all four change types:
| Change type | Live-proven | Test |
|---|---|---|
| Item (sales/purchase account + tax mapping) | ✅ | ✅ |
| Account code (with Code/Name collision guard) | ✅ | ✅ |
| Contact (dedup link-vs-create) | ✅ | ✅ |
| Tracking category (+ options) | ✅ | ✅ |
Universal file ingest — upload any CSV/JSON/xlsx export (POST /ingest), the LLM
infers what the file is — no per-source connector code — and normalises rows to per-entity
manual-journal proposals in the same approval table. Unmappable columns are flagged
needs_human; nothing is written. Demo payloads: a messy Sortly-style stock count and a
Roller-style revenue export (seed/fixtures/).
Entity health with cross-org drift detection — each org's header pill flags account
codes that exist in most orgs in the group but are missing there, straight from the cached
snapshots (GET /entities → health.drift).
An upstream fix to Xero's official MCP server — see Toolkit contribution.
45 backend tests pass (credential-free — the LLM and Xero client are injected seams).
Deep-dives live in docs/architecture.md and docs/pitch.md.
| Layer | Stack |
|---|---|
| Backend | Python · FastAPI · SQLAlchemy · SQLite |
| Frontend | Next.js 16 · React 19 · Tailwind v4 (App Router) |
| AI | Azure AI Foundry via the OpenAI-compatible SDK (gpt-5.4-mini), structured output via forced tool-calling |
| Xero | One standard OAuth 2.0 auth-code app connected to N orgs; explicit Xero-Tenant-Id per request; OAuth token Fernet-encrypted at rest, auto-refreshed |
backend/
app/
main.py FastAPI app + routers
config.py pydantic-settings (.env)
db.py, models.py entities, tokens, snapshots, runs, proposals, write_results
xero/
auth.py consent URL, callback, encrypted token store, refresh
client.py Xero API wrapper — explicit tenant_id per call
ratelimit.py per-tenant limiter
snapshots.py per-entity chart/tax/contacts/items/tracking cache (TTL)
engines/
mapping.py per-entity propagation engine + deterministic guards
contracts.py Pydantic payload/proposal schemas
llm.py structured-completion seam (Azure Foundry / OpenAI)
execution.py validate → write → record audit
routers/ auth · entities · changes · runs · demo
tests/ golden mapping tests, contract validation, execution, guards
frontend/
src/app, src/components, src/lib/api.ts batched approval UI
docker-compose.yml full stack (backend :8000 + frontend :3000)
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
liveness |
GET |
/auth/xero/connect → /auth/xero/callback |
OAuth consent + token storage, syncs connected orgs |
GET |
/entities |
connected orgs (+ snapshot age) |
GET |
/entities/{id}/organisation |
live read proving the connection |
POST |
/changes |
{change_type, payload, target_entity_ids} → fans out mapping, returns proposals |
POST |
/ingest |
multipart file (CSV/JSON/xlsx) → schema inference → per-entity journal proposals |
GET |
/runs, /runs/{id} |
run history / detail |
POST |
/runs/{id}/approve |
per-row include/exclude/edit → execute → per-row results |
POST |
/demo/seed, /demo/seed-ingest |
dev-only offline demo runs (gated by DEMO_MODE) |
- Python 3.12+, Node 22+
- A Xero developer app (auth-code flow, redirect
http://localhost:8000/auth/xero/callback) and one or more UK trial organisations - An Azure AI Foundry (or any OpenAI-compatible) endpoint + key
cp backend/.env.example backend/.env
# fill in XERO_CLIENT_ID / XERO_CLIENT_SECRET, the LLM endpoint + key,
# and a CANOPY_FERNET_KEY:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"# backend
cd backend
python -m venv .venv && .venv/Scripts/activate # (Windows: .venv\Scripts\activate)
pip install -r requirements.txt
python -m uvicorn app.main:app --port 8000
# frontend (new terminal)
cd frontend
npm install
npm run dev # http://localhost:3000On Windows, run plain
uvicorn(no--reload) — the file-watcher's parent/worker process model can keep serving stale code after edits. Hard-restart after changes.
docker compose up --build # backend :8000, frontend :3000, persistent SQLite volumeSecrets are read from backend/.env at runtime and are never baked into an image. Set
DEMO_MODE=false for a real-org deployment so the offline demo-seed path is disabled.
cd backend && python -m pytest -q # 45 passing, no credentials required- Batched, approve-first — one review for the whole fan-out, not per-org approval fatigue.
- Per-row confidence, reasoning, and refusal-to-guess — "'It's the computer what did it' isn't a defence": every write is explainable and audited.
- Human-in-the-loop is mandatory —
needs_humanis a first-class blocking state, not an error. - No money movement; full run-history audit of every request and response.
While building Canopy we found that Xero's official MCP server
(XeroAPI/xero-mcp-server) hardcodes the
first tenant returned by the connections endpoint — in both bearer-token and
client-credentials modes. With one app or token authorised for several organisations, every
server instance silently operates on org #1 with no way to target another.
We fixed it: an optional XERO_TENANT_ID environment variable honoured by both auth modes,
failing loudly (with the list of available tenant IDs) if it doesn't match an authorised
connection rather than silently falling back. Non-breaking when unset, covered by unit tests.
Upstream PR: XeroAPI/xero-mcp-server#208
Canopy is a hackathon build. Live secrets belong only in the gitignored backend/.env.