-
Notifications
You must be signed in to change notification settings - Fork 6
Connecting QuickBooks Locally
This tutorial walks through connecting a QuickBooks Online company to a local RoboSystems stack and using the full close + reporting workflow against the synced data. It covers two distinct setup paths, the QB sync pipeline architecture, the mapping workflow, and how to generate your first financial report.
There are two ways to develop against QuickBooks data locally. Pick the path that matches what you need:
| Path | Companies | Setup | When to use |
|---|---|---|---|
| A. Sandbox + localhost | Intuit Sample Company (pre-populated canned data) | Minutes — no tunnel, no production keys | Validating the OAuth handshake, exploring the sync flow, demoing the close/report UX without your own books |
| B. Production + public tunnel | Your own real QB Online companies | ~15 minutes — tunnel install + Intuit production setup | Real-data smoke tests, customer-facing demos against your own QB, debugging adapter edge cases that only surface in messy real data |
| Neither — synthetic data only | N/A | One command | When you don't need Intuit at all — see the synthetic demo walkthrough (separate guide) for the just demo-roboledger workflow which generates fake books for testing close-period, reports, and rules without OAuth |
Both QB paths use the same RoboSystems stack, the same sync pipeline, and the same reporting surface. The only differences are which Intuit OAuth credentials you use and whether the redirect URI points at localhost or a public tunnel.
Path B has two interchangeable tunnel implementations:
| Tunnel | Setup | When to use |
|---|---|---|
| ngrok | Free; reserve one *.ngrok-free.dev static domain |
You don't already own a domain; want the lowest-friction first run |
| cloudflared | Requires a domain managed by Cloudflare (cloudflared login, named tunnel, DNS route) | You already own a Cloudflare-managed domain; want your own branded hostname with no interstitial warning |
Pick whichever you prefer — Intuit and the RoboSystems backend don't care which produced the public HTTPS URL.
- Overview
- Prerequisites
- Quick Start
- Path A — Sandbox + Localhost
- Path B — Production + public tunnel
- Running the Stack
- Connecting Your First Company
- What Happens Under the Hood
- Sync Patterns
- The Mapping Workflow
- Generating Reports
- Inspecting the Graph
- Troubleshooting
- Costs and Cleanup
When you connect QuickBooks to RoboSystems, four things happen end-to-end:
- Sync — A Dagster job pulls accounts, transactions, customers, vendors, employees, invoices, bills, payments, and purchases from the QB API, normalizes them with dbt, and loads them into a per-tenant OLTP database.
- Materialize — A sensor detects the stale graph state and projects the OLTP data into LadybugDB, the columnar graph backend used for analytical reads.
-
Map — The MappingOperator proposes associations between your QB Chart of Accounts and canonical
rs-gaap:*reporting concepts. You can accept, remap, or extend these. -
Report —
create-reportgenerates facts across Balance Sheet, Income Statement, Cash Flow, and Statement of Equity. The renderer walks the active Reporting Style's structures and stamps every reportable fact onto the right line item.
By the end of this guide you'll have all four working against either a QB sandbox or your own real company.
Both paths require an Intuit Developer account with a registered app:
- Sign up at developer.intuit.com
- Create an app under My Apps → Create an app
- Note the two relevant pages for your app:
- App Settings → Redirect URIs — where you register callback URLs
- Keys & OAuth → Production Keys / Development Keys — where you grab Client ID + Secret
Intuit treats Development and Production as separate environments with their own keys, their own redirect URI lists, and their own consent flows. Sandbox companies authenticate against Development keys; real companies authenticate against Production keys.
Path-specific prerequisites:
| Path | Additional requirements |
|---|---|
| A. Sandbox | A QB sandbox company (Intuit auto-creates a "Sample Company" for new developer accounts; additional sandboxes via developer.intuit.com/app/developer/sandbox) |
| B. Production | A public tunnel — either an ngrok account + reserved static domain, OR a Cloudflare-managed domain + cloudflared — and a real QB Online company you own |
# 1. Get sandbox credentials
# Intuit Developer Portal → your app → Keys & OAuth → Development Keys
# Copy Client ID and Client Secret
# 2. Register the localhost callback URL
# Intuit Developer Portal → your app → App Settings → Redirect URIs
# Switch to "Development" tab → add: http://localhost:3001/connections/qb-callback
# 3. Fill in robosystems/.env
echo 'INTUIT_CLIENT_ID=<sandbox client id>' >> .env
echo 'INTUIT_CLIENT_SECRET=<sandbox client secret>' >> .env
echo 'INTUIT_ENVIRONMENT=sandbox' >> .env
echo 'INTUIT_REDIRECT_URI=http://localhost:3001/connections/qb-callback' >> .env
# 4. Start the stack
just start # backend (terminal 1)
cd ../roboledger-app && npm run dev # frontend (terminal 2)
# 5. Open localhost:3001 in your browser, log in (just demo-user), navigate to
# Connections → Connect QuickBooks → sign in with sandbox credentials →
# pick "Sample Company" → Connect.The sandbox company comes pre-populated with synthetic QB data. After connect, trigger a sync and you'll have ~100 transactions to explore.
# 1. Get production credentials
# Intuit Developer Portal → your app → Keys & OAuth → Production Keys
# 2. Set up a public tunnel (pick one):
# a) ngrok:
brew install ngrok
ngrok config add-authtoken <your-token>
# Then visit dashboard.ngrok.com/domains and reserve a free static domain
# b) cloudflared (requires a Cloudflare-managed domain):
brew install cloudflared
cloudflared tunnel login
cloudflared tunnel create roboledger-local
cloudflared tunnel route dns roboledger-local qb.your-domain.com
# 3. Register the public callback URL in Intuit Developer Portal
# App Settings → Redirect URIs → Production tab →
# add: https://<your-domain>/connections/qb-callback
# 4. Fill in robosystems/.env (production keys + tunnel URL + CORS allowlist)
# 5. Fill in roboledger-app/.env:
# PUBLIC_TUNNEL_DOMAIN=<your-domain>
# (cloudflared also needs CLOUDFLARED_TUNNEL_NAME)
# 6. Start the stack (three terminals: backend, frontend, tunnel)
just start # terminal 1
cd ../roboledger-app && npm run dev # terminal 2
cd ../roboledger-app && npm run tunnel:ngrok # terminal 3 (or)
cd ../roboledger-app && npm run tunnel:cloudflared # terminal 3
# 7. Open the *tunnel URL* (not localhost) → log in → Connections → Connect
# QuickBooks → pick your real company.The longer-form sections below walk through each step in detail.
The sandbox path is the simplest local-dev experience. Intuit accepts http://localhost:3001 as a redirect URI for Development keys, and Chrome doesn't enforce Private Network Access against same-origin localhost calls, so no tunnel is needed.
In your Intuit app:
-
App Settings → Redirect URIs → Development tab → add:
http://localhost:3001/connections/qb-callbackSave.
-
Keys & OAuth → Development tab → copy:
- Client ID
- Client Secret
In robosystems/.env:
INTUIT_CLIENT_ID=<development client id>
INTUIT_CLIENT_SECRET=<development client secret>
INTUIT_ENVIRONMENT=sandbox
INTUIT_REDIRECT_URI=http://localhost:3001/connections/qb-callbackNo additional roboledger-app/.env changes needed for sandbox.
INTUIT_ENVIRONMENT=sandbox tells the QB API client to hit sandbox-quickbooks.api.intuit.com rather than quickbooks.api.intuit.com. The wrong value here produces unauthorized_client errors that look like credential problems but are environment mismatches.
Each Intuit developer account gets one "Sample Company" sandbox auto-provisioned, pre-populated with ~100 transactions across normal business categories (sales, expenses, payroll, etc.). Useful for end-to-end testing but not customizable — you can't add your own historical data, just void or duplicate existing entries.
For additional sandboxes (e.g. to test on a clean book), use developer.intuit.com/app/developer/sandbox → Add a sandbox company. Free tier allows up to 5.
Required for connecting your own real QuickBooks Online companies. Intuit's Production OAuth flow rejects localhost callbacks, so we need a public HTTPS hostname that proxies to your local frontend.
The tunnel is only needed for the OAuth handshake itself. Once the connection is established and tokens are stored, all subsequent work — syncing, mapping, reports, close-period — runs against
localhost:3001directly. You can shut down the tunnel after each successful connect; only spin it up again when adding another QB company or re-authenticating.
Intuit's redirect URI must be:
- Publicly resolvable HTTPS (no localhost)
- Registered in advance in the Developer Portal
- Stable across sessions (otherwise you re-register every time)
Both options below satisfy these requirements. They're interchangeable — pick one and follow that sub-section.
The lowest-friction option if you don't already own a domain. Free tier includes a single static *.ngrok-free.dev hostname. Browsers see a one-time interstitial warning page (per session, per browser); paid plans remove it.
brew install ngrokSign up at dashboard.ngrok.com/signup, then:
ngrok config add-authtoken <YOUR_TOKEN>Get the token from dashboard.ngrok.com/get-started/your-authtoken.
Free tier includes one static domain. Without it, ngrok randomizes the URL on each restart and you'd re-register the redirect URI in Intuit every session.
- Go to dashboard.ngrok.com/domains
- Click New Domain
- ngrok auto-assigns something like
cuddly-otter-1234.ngrok-free.dev - Copy the exact hostname (no
https://prefix)
-
App Settings → Redirect URIs → Production tab → add:
https://<your-domain>.ngrok-free.dev/connections/qb-callbackSave. Note: this entry is independent of any prod/staging URLs you may have registered for deployed environments.
-
Keys & OAuth → Production tab → copy:
- Client ID
- Client Secret
In robosystems/.env:
INTUIT_CLIENT_ID=<production client id>
INTUIT_CLIENT_SECRET=<production client secret>
INTUIT_ENVIRONMENT=production
INTUIT_REDIRECT_URI=https://<your-domain>.ngrok-free.dev/connections/qb-callback
# CORS allowlist for the public origin
EXTRA_CORS_ORIGINS=https://<your-domain>.ngrok-free.devIn roboledger-app/.env:
# Public hostname — read by `npm run tunnel:ngrok` (to know what static domain
# to forward to) and by next.config.js (to wire the dev-server API proxy).
PUBLIC_TUNNEL_DOMAIN=<your-domain>.ngrok-free.devPUBLIC_TUNNEL_DOMAIN is the hostname only (no https://).
Use this option if you already own a domain managed by Cloudflare (e.g. robofinsystems.com). You get a stable, branded hostname (e.g. qb.robofinsystems.com), no interstitial warning page, and free DNS routing through Cloudflare's edge.
brew install cloudflared
cloudflared tunnel loginThe login command opens a browser to the Cloudflare dashboard; pick the zone (robofinsystems.com) you want to attach the tunnel to. This drops a cert into ~/.cloudflared/cert.pem.
cloudflared tunnel create roboledger-local
cloudflared tunnel route dns roboledger-local qb.robofinsystems.comtunnel create provisions a new tunnel + credentials file in ~/.cloudflared/<tunnel-id>.json. tunnel route dns creates a proxied CNAME in your Cloudflare zone so the public hostname resolves to the tunnel.
-
App Settings → Redirect URIs → Production tab → add:
https://qb.robofinsystems.com/connections/qb-callbackSave.
-
Keys & OAuth → Production tab → copy Client ID + Client Secret.
In robosystems/.env:
INTUIT_CLIENT_ID=<production client id>
INTUIT_CLIENT_SECRET=<production client secret>
INTUIT_ENVIRONMENT=production
INTUIT_REDIRECT_URI=https://qb.robofinsystems.com/connections/qb-callback
# CORS allowlist for the public origin
EXTRA_CORS_ORIGINS=https://qb.robofinsystems.comEXTRA_CORS_ORIGINS is a comma-separated list, so if you keep ngrok configured too, just add the cloudflared host alongside it.
In roboledger-app/.env:
# The public hostname next.config.js uses to set up the dev-server API proxy.
PUBLIC_TUNNEL_DOMAIN=qb.robofinsystems.com
# The cloudflared tunnel name from `cloudflared tunnel create` —
# read by npm run tunnel:cloudflared.
CLOUDFLARED_TUNNEL_NAME=roboledger-localPUBLIC_TUNNEL_DOMAIN is the hostname only (no https://).
The stack runs in your terminals — counts depend on path:
| Terminal | Path A (sandbox) | Path B (production) |
|---|---|---|
| 1 | cd robosystems && just start |
same |
| 2 | cd roboledger-app && npm run dev |
same |
| 3 | (not needed) |
npm run tunnel:ngrok (Option B.1) or npm run tunnel:cloudflared (Option B.2) — only during OAuth establishment; shut down after each successful connect |
Wait for just start to bring up Postgres, Valkey, LadybugDB, the API, and Dagster (the dashboard at localhost:8002 is a useful sign the stack is up).
npm run dev uses Turbopack and is the normal choice. Switch to npm run dev:webpack if either of these applies:
- You have the TypeScript SDK installed locally as a symlinked
file:dependency (npm install ../robosystems-typescript-client) — Turbopack doesn't resolve symlinkedfile:deps cleanly. The published SDK from npm works fine with Turbopack. -
You're on Path B and the tunnel proxy isn't working — i.e.
/v1/*calls still hit the public host or 404 even thoughPUBLIC_TUNNEL_DOMAINis set. Thenext.config.jsrewrites that make the tunnel work haven't proven reliable under Turbopack; webpack proxies them deterministically. See the troubleshooting note below.
For Path B, verify the tunnel is reachable:
# ngrok (Option B.1) — the header skips the interstitial for the curl probe
curl -s -o /dev/null -w "%{http_code}\n" \
-H "ngrok-skip-browser-warning: true" \
https://<your-domain>.ngrok-free.dev/login
# Expect: 200
# cloudflared (Option B.2) — no interstitial header needed
curl -s -o /dev/null -w "%{http_code}\n" https://qb.your-domain.com/login
# Expect: 200Open the app:
- Path A: http://localhost:3001
-
Path B: the tunnel URL —
https://<your-domain>.ngrok-free.devorhttps://qb.your-domain.com, not localhost (the OAuth redirect URI has to match the user-facing origin)
On Path B with ngrok, click through the interstitial warning page once per browser session on first visit. Cloudflared has no equivalent interstitial.
- Log in (run
just demo-userfromrobosystems/for fresh credentials; they're written to.local/config.json) - Create or select an entity / graph
- Navigate to Connections
- Click Connect QuickBooks
- Authenticate with the appropriate Intuit account (sandbox for Path A, real for Path B)
- On the consent screen, pick which QB Online company to connect
- Click Connect
Intuit redirects back to your callback URL with the OAuth code parameter. The frontend exchanges it for tokens via the backend, stores them in connection_credentials, and creates a Connection row scoped to the graph.
Trigger the initial sync from the Connections page UI or via the API:
API_KEY=$(jq -r .api_key .local/config.json)
GRAPH_ID=<your graph id>
CONN_ID=<your connection id>
curl -X POST "http://localhost:8000/v1/graphs/$GRAPH_ID/connections/$CONN_ID/sync" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"full_rebuild": true}'full_rebuild: true pulls all history from 2000-01-01. Omit for an incremental 60-day sync.
The response is a pending operation envelope with a status URL. The sync runs async in the worker; track progress at localhost:8002 (Dagster UI) or poll /v1/operations/{op_id}/status.
The QB sync is a three-step Dagster job (qb_sync):
┌────────────┐ ┌──────────────┐ ┌─────────┐
│ qb_extract │ ──▶ │ qb_transform │ ──▶ │ qb_load │
└────────────┘ └──────────────┘ └─────────┘
│ │ │
raw parquet dbt models OLTP rows
/tmp/qb_pipeline DuckDB staging PostgreSQL
+ marts (tenant schema)
Step 1: qb_extract pulls everything via the QB Python SDK:
-
Account(paginated, active + inactive — historical journal lines may reference deactivated accounts) -
JournalReport(the unified transaction stream — all entry types in double-entry shape) -
Customer,Vendor,Employee(the party / agent list) -
Invoice,Bill,Payment,BillPayment,SalesReceipt,Purchase(per-class headers for richer event type detection and agent linkage) -
CompanyInfo(entity metadata: name, address, fiscal year, primary contact)
Flatteners normalize the nested Intuit objects into flat dicts and write parquet files under /tmp/qb_pipeline/<graph_id>/extract/.
Step 2: qb_transform runs dbt build against the parquet files using DuckDB as the local engine. The dbt project has:
- Staging models (
stg_qb_*) that cast types and normalize column names - Marts (
accounts,transactions,entries,line_items,agents, etc.) that join the staging tables into the canonical event shape - Data tests including
assert_accounting_equation(every transaction balances),assert_debits_equal_credits(trial balance check),assert_unique_identifiers(no dupes)
If any data test fails, the entire transform fails — surfacing data-quality issues early rather than letting them silently corrupt the OLTP load.
Step 3: qb_load reads the marts and writes to the tenant's PostgreSQL schema:
- Inserts
Elementrows (Chart of Accounts as graph elements) - Inserts
Agentrows (customers, vendors, employees as parties) - For each transaction, creates an
Eventwithstatus='captured'and rich metadata - For QB-authoritative connections (default), immediately fires the event handler which creates
Transaction → Entry → LineItemrows in one transaction - Triggers
_bootstrap_fiscal_calendar_if_needed(so close-period UX works on a fresh sync) - Triggers
_trigger_auto_map_if_needed(the MappingOperator walks unmapped CoA elements) - Marks the graph stale (
graph_stale=true) so the materializer knows there's new data
After qb_load returns, the stale graph sensor (a Dagster sensor running every 60 seconds) picks up the stale flag, submits an extensions_materialize_job, and the materializer projects the OLTP data into LadybugDB:
PostgreSQL LadybugDB
(extensions schema) (graph)
───────────────────── ──────────────────
events ─────────────────────▶ Event nodes (inbox)
transactions ──────────────▶ Transaction nodes
entries ───────────────────▶ Entry nodes
line_items ────────────────▶ LineItem nodes
elements ──────────────────▶ Element nodes
associations ──────────────▶ Association nodes
+ relationships:
ENTITY_HAS_TRANSACTION
TRANSACTION_HAS_ENTRY
ENTRY_HAS_LINE_ITEM
LINE_ITEM_RELATES_TO_ELEMENT
ASSOCIATION_HAS_FROM_ELEMENT
ASSOCIATION_HAS_TO_ELEMENT
The graph is now queryable via Cypher. Reports, AI agents, and analytical views read from LadybugDB; the OLTP database remains the source of truth for transactional reads and writes.
| Pattern | Trigger | Date range | Use when |
|---|---|---|---|
| Incremental | UI button or POST without flags | Last 60 days from today | Day-to-day; default |
| Full rebuild | {"full_rebuild": true} |
2000-01-01 to today |
First sync, after schema changes, after fixing an importer bug |
| Since-date | {"since_date": "2024-01-01"} |
From explicit date to today | Catching up after a gap |
The sync is idempotent: events are upserted by (source, external_id), so re-syncing the same data updates rather than duplicates. For full rebuilds, the loader first deletes existing CoA elements and associations scoped to the connection, then re-inserts — safe for QB-authoritative connections where the GL is a mirror of QB.
Auto-sync can be enabled on the connection (auto_sync_enabled=true); a Dagster schedule then runs incremental syncs on a cadence.
Your QB Chart of Accounts uses tenant-specific account names ("Truist Checking 6764", "Consulting Services", "Subscription Services"). Financial reports need canonical concepts (rs-gaap:CashCashEquivalentsAndShortTermInvestments, rs-gaap:RevenueFromContractWithCustomerExcludingAssessedTax, etc.). The mapping workflow bridges the two.
On first sync, RoboSystems auto-triggers the MappingOperator (_trigger_auto_map_if_needed in the QB loader). The agent:
- Reads each unmapped CoA element along with its classification (asset / liability / equity / revenue / expense / gain / loss — derived from QB's
AccountType) - Asks the suggester (
suggest-mapping) for renderablers-gaap:*candidates that match the classification AND appear on the active Reporting Style's rendering structures - Uses the LLM to pick the best candidate, with a confidence score
- Confidence ≥0.90 → auto-creates the association
- Confidence 0.70–0.89 → creates the association flagged for review
- Confidence <0.70 → skips (surfaces as unmapped in the CoA UI)
Inspect via MCP:
# Through the MCP server (e.g., from Claude Code):
list-mapping-structures # find the mapping structure id
get-mapping-summary --mapping_id=… # see coverage stats + unreachable count
get-unmapped-elements # find CoA elements still needing mappingOr via the API:
curl "http://localhost:8000/v1/graphs/$GRAPH_ID/mappings/$MAPPING_ID/summary" \
-H "X-API-Key: $API_KEY"Key metric: unreachable_count — mappings to rs-gaap concepts that don't render under the active Reporting Style. Should be 0 after a clean auto-map run. If non-zero, either the mappings were created before the renderable filter was added or the Reporting Style structures have changed since.
When the agent picks the wrong target — e.g., Precious Metals mapped to InventoryNetOfAllowancesCustomerAdvancesAndProgressBillings instead of LongTermInvestmentsAndReceivablesNet — fix via the MCP:
# Get the source element id
list-mapping-structures
get-unmapped-elements # or query the CoA UI / database
# Delete the wrong mapping (the REST endpoint /delete-mapping-association)
# Create the correct one via MCP
create-mapping-association \
--from_element_id elem_… \
--to_element_id <rs-gaap concept id> \
--mapping_id struct_… \
--confidence 1.0 \
--suggested_by manual_correctionThe corrected mapping is durable across re-syncs (delete + re-insert of CoA elements doesn't disturb the mappings).
The active Reporting Style (Default Style — Composition by default; alternatives: Small Private Company, Banking) determines which rendering structures the renderer walks. The four standard structures are:
rs-gaap — Balance Sheet — Classifiedrs-gaap — Income Statement — Multi-steprs-gaap — Cash Flow Statement — Indirectrs-gaap — Statement of Changes in Equity — Roll Forward (Total)
Each carries a curated set of rs-gaap concepts (~78 total across the four). The Default Style is filing-grade aggregation — Cash and Cash Equivalents and Short Term Investments is one line for all bank accounts; Selling, General and Administrative Expense is one line for many expense categories. Detail belongs in supporting Information Blocks below the statement face, not in the statement itself.
Once mapping coverage is non-zero, generate a report:
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/create-report" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "FY2025 Annual",
"mapping_id": "<your mapping structure id>",
"period_start": "2025-01-01",
"period_end": "2025-12-31",
"period_type": "annual",
"comparative": false
}'taxonomy_id defaults to "rs-gaap" — accepts either the standard name (resolved server-side to the latest rs-gaap reporting taxonomy) or an exact tenant UUID. The operation runs synchronously and returns the Report with its facts already generated.
To inspect what the report produced:
-- Facts grouped by statement type
SELECT
s.block_type AS statement,
REPLACE(e.qname, 'rs-gaap:', '') AS concept,
ROUND(f.value::numeric, 2) AS value
FROM facts f
JOIN fact_sets fs ON fs.id = f.fact_set_id
JOIN elements e ON e.id = f.element_id
JOIN structures s ON s.id = fs.structure_id
WHERE fs.report_id = '<report id>'
ORDER BY s.block_type, ABS(f.value) DESC;Expected output: 20–30 facts per annual report, distributed across BS / IS / CF / SE.
Sanity check: total assets should equal total liabilities + equity. If off, the most common cause is a CoA account mapped to a flow concept (dividend, distribution) without the close logic netting it into Retained Earnings — see the troubleshooting section below.
The materialized graph is queryable via Cypher and via the MCP server's read-graph-cypher tool.
# Trial balance check via OLTP
docker exec robosystems-pg psql -U postgres -d extensions -tAc "
SET search_path TO $GRAPH_ID;
SELECT
'debits: ' || SUM(debit_amount) FROM line_items UNION ALL
SELECT
'credits: ' || SUM(credit_amount) FROM line_items;"
# Year-by-year transaction count via Cypher
just lbug-query $GRAPH_ID "
MATCH (t:Transaction)
WHERE t.date IS NOT NULL
RETURN substring(string(t.date), 1, 4) AS year, count(t) AS txns
ORDER BY year"
# Top accounts by activity volume
just lbug-query $GRAPH_ID "
MATCH (li:LineItem)-[:LINE_ITEM_RELATES_TO_ELEMENT]->(e:Element)
WHERE starts_with(e.qname, 'qb:')
WITH e.name AS account, count(li) AS lines,
sum(li.debit_amount) AS dr, sum(li.credit_amount) AS cr
RETURN account, lines, round(dr, 2) AS debits, round(cr, 2) AS credits
ORDER BY (dr + cr) DESC LIMIT 10"If you have an MCP client connected to the RoboSystems server (Claude Code, custom agents, etc.):
# Set the active graph
switch-workspace --workspace_id <graph_id>
# Inspect mapping state
get-mapping-summary --mapping_id <mapping_struct_id>
get-unmapped-elements
# Build a fact-grid pivot over BS line items
build-fact-grid --period_start 2025-01-01 --period_end 2025-12-31
# Ad-hoc graph query
read-graph-cypher --cypher "MATCH (e:Entity) RETURN e.name, e.fiscal_year_end"The MCP tools are designed for agent-driven workflows — they enforce the same auth + tenant-isolation as the REST API and return structured envelopes the LLM can reason over.
The URI you sent in the OAuth init request doesn't match anything registered in the Developer Portal. Verify:
- Spelling is exact (case-sensitive, no trailing slash)
-
https://for Production,http://is fine for Development localhost - Registered under the matching tab — Development tab for sandbox keys, Production tab for production keys
- Saved (the Save button is easy to miss after editing)
Symptom: login fails silently when you open the ngrok URL in Chrome, console shows:
Access to fetch at 'http://localhost:8000/v1/auth/...' from origin
'https://<your-domain>.ngrok-free.dev' has been blocked by CORS policy:
Permission was denied for this request to access the `loopback` address space.
Chrome 146+ enforces Private Network Access on cross-origin calls to localhost. The backend grants it via allow_private_network=is_development() on the CORS middleware, but Chrome caches the preflight result aggressively and the usual cache-clearing tricks (DevTools "Disable cache", site-data delete, incognito) often don't actually flush the PNA preflight cache. Chrome 146 also removed the chrome://flags override that used to disable PNA.
roboledger-app's next.config.js handles this automatically when PUBLIC_TUNNEL_DOMAIN is set in roboledger-app/.env: rewrites under /v1/* and /extensions/* proxy through the Next dev server to http://localhost:8000, and NEXT_PUBLIC_ROBOSYSTEMS_API_URL is overridden to the tunnel origin so the browser only ever talks to one host. From the browser's perspective every request is same-origin, so PNA never fires.
This is dev-server-only — it works because next dev re-reads next.config.js at startup and the env override is applied to the bundle in dev mode. Running roboledger-app from inside Docker (docker compose with the roboledger-app service) uses next start against a pre-built bundle where NEXT_PUBLIC_* is baked at build time, so the override has no effect. Run the frontend with npm run dev on the host for the OAuth connect step.
Fallback workaround: if npm run dev isn't an option, use Firefox for the OAuth connect step, then go back to Chrome for normal use. Firefox doesn't enforce PNA the same way, so the OAuth callback completes cleanly. Once the connection is established, day-to-day use against http://localhost:3001 (Path A — same-origin localhost) doesn't trigger PNA and Chrome works fine.
Only affects Path B (public tunnel → localhost) during the OAuth callback moment. Path A is same-origin localhost and bypasses PNA entirely.
Symptom: on Path B the proxy described above is configured correctly — PUBLIC_TUNNEL_DOMAIN is set and the rewrites() block is present in next.config.js — but API calls still fail. Requests to /v1/* or /extensions/* aren't proxied to localhost:8000, so login or the OAuth callback hangs, even though the same config has worked in another run.
The default dev server (npm run dev) uses Turbopack, which has been observed not to honor the next.config.js rewrites() proxy to http://localhost:8000 reliably during tunnel sessions. Switch to the webpack dev server:
npm run dev:webpackThe rewrites are a standard Next feature and webpack proxies them deterministically. If a tunnel setup that previously worked breaks after a Next upgrade, try webpack before assuming the tunnel or env config is wrong.
If you're driving the login via Puppeteer or similar, ensure the form fill dispatches React input events — puppeteer_fill alone doesn't trigger React's onChange for controlled inputs:
const setNativeValue = (el, value) => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
setNativeValue(document.querySelector('input[type="email"]'), 'demo_user_…@example.com');
setNativeValue(document.querySelector('input[type="password"]'), '…');Open the Dagster UI at localhost:8002, find the failed run, drill into the qb_transform step's stdout. Common causes:
-
assert_accounting_equationtest failed — line items reference an account the extract didn't pull. The most common cause is inactive accounts in QB that the API doesn't return by default; the adapter handles this withWHERE Active IN (true, false), so if you see this on a fresh stack, you may be on a stale image —just rebuild. -
New unseen transaction types — drop counters in
qb_loadsurface asunbalanced_entriesandempty_transactions. Inspect parquet under/tmp/qb_pipeline/<graph_id>/extract/to see what came through. -
Trait coverage gap — some staging columns expected by the model aren't in the raw extract. Update
flatten_company_infoand the dbt staging model in tandem.
The mappings may all be unreachable — targeting rs-gaap concepts that aren't in the active Reporting Style's rendering structures. Check via get-mapping-summary:
get-mapping-summary --mapping_id <id>
# Look for unreachable_count > 0If unreachable, the MappingOperator ran against an earlier version of the renderable filter. Either:
- Re-run auto-map (delete the existing mappings first via SQL, then trigger
auto-map-elements) - Manually remap the unreachable accounts to renderable targets via
create-mapping-association
The renderable filter walks reporting_style_networks → presentation arcs to compute the actual set of concepts the renderer will walk. Mappings to concepts outside that set are durable but invisible to the report renderer.
Common causes:
- A CoA account mapped to a calculated subtotal (denylisted concept). The renderer ignores these to prevent double-counting; the account's facts won't show up at all.
- Manual journal entries that don't balance (one debit without matching credit). The trial balance test should catch this in dbt; if it leaked through, query line_items for the offending entry.
A previous tunnel session is still running. Find and kill:
pkill -f 'ngrok http' # ngrok
pkill -f 'cloudflared tunnel' # cloudflaredRe-launch with npm run tunnel:ngrok or npm run tunnel:cloudflared.
Intuit's Development and Production environments don't share keys or redirect URI lists. A common mistake:
- Sandbox
INTUIT_CLIENT_ID+ Production-tab redirect URI →redirect_uri_mismatch - Production
INTUIT_CLIENT_ID+ Development-tab redirect URI →redirect_uri_mismatch
Both halves of the OAuth handshake have to come from the same environment tab.
| Component | Cost |
|---|---|
| Intuit Developer account | Free |
| Intuit sandbox companies | Free (up to 5) |
| ngrok free tier | $0/month, 1 static domain, occasional browser interstitial |
| ngrok paid plans | ~$10/month, removes interstitial, more domains |
| Cloudflare Tunnel (cloudflared) | $0/month for the tunnel itself; you bring your own domain (~$10/yr) |
| QuickBooks Online subscription | Paid (whatever you already pay for QBO) |
To clean up:
# Stop the tunnel (whichever you ran)
pkill -f 'ngrok http'
pkill -f 'cloudflared tunnel'
# Stop the stack (preserves data)
cd robosystems && just stop
# Full reset — wipes all local data, drops local databases
just reset-localDisconnecting a QB connection from RoboSystems revokes the OAuth tokens but leaves synced data in place. To remove everything, delete the graph itself from the platform.
Intuit Developer Portal entries (apps, registered redirect URIs, keys) are durable. Keep them around for future sessions — they cost nothing and re-registering is a hassle.
Now that you have a connected company, real data flowing, and a working report:
- Explore the close-period workflow — close the fiscal year, see what blocks the close (open AR, draft entries, missing mappings). See RoboLedger Operations.
- Try the rule engine —
evaluate-rulesruns structural and arithmetic checks against your statements - Drive an AI-assisted close — use the MCP close co-pilot (e.g. Claude Desktop on the AI Operators & MCP surface) to propose period-end accruals, depreciation, and reclassification entries
- Connect a second company and explore multi-entity reporting
- Read how the synced data is modeled — the Event-Driven Ledger and GraphQL Reads over the operational graph
Each of these is covered in its own guide. For testing these surfaces without a connected QB account, see the synthetic demo walkthrough (which uses just demo-roboledger to generate fake books).
© 2026 RFS LLC
- Authentication & API Keys
- Graphs & Multi-Tenancy
- Shared Repositories
- Graph Operations
- Querying the Analytical Graph
- Credits & Billing
- AI Operators & MCP
- Pipeline Guide
- Extensions Surface Overview
- GraphQL Reads
- RoboLedger Operations
- RoboInvestor Operations
- Connecting QuickBooks Locally