fieldtwin
# fieldtwin
[](https://github.com/nickgeorgeseo/fieldtwin/actions/workflows/ci.yml)
[](LICENSE)
[](#quick-start)
**Jobber, Housecall Pro, and QuickBooks Online, running on your laptop: real API shapes, a believable fake business, and every failure on demand. Build and test integrations and AI agents without touching a customer's account.**
[Watch the two-minute demo](https://nickgeorgeai.com/fieldtwin#demo): the dashboard drives a twin, an agent's changes are listed and rewound, and a sync brings drifted books back into line.
Point your code at `http://localhost:4010` instead of `api.getjobber.com` or `quickbooks.api.intuit.com` (or at `http://localhost:4010/housecallpro` instead of `api.housecallpro.com`). It gets the same paths, headers, and JSON back, from a fake trades business with customers, technicians, and months of jobs, quotes, invoices, and payments, kept in every system at once. Then make it hard: throttle QuickBooks, lose the answer to a write, deliver a webhook twice, jump the clock past every token's expiry. Afterwards, see exactly what your code changed, and undo it.
```bash
curl -X POST localhost:4010/_twin/checkpoints/before-agent # name this moment
# ...let your integration or agent loose on http://localhost:4010...
curl -s "localhost:4010/_twin/checkpoints/before-agent/changes?source=api" | jq -r '.[].summary'
```
```text
2026-07-13T11:00:00.000Z quickbooks CustomerCreated Id=26 "Dana Whitfield" SyncToken=0 (api)
2026-07-13T11:00:00.000Z quickbooks InvoiceCreated Id=29 DocNumber=1001 "Dana Whitfield" TotalAmt=185.00 Balance=185.00 (api)
2026-07-13T11:00:00.000Z quickbooks CustomerUpdated Id=3 "Harper Alder Jr" SyncToken=1 (api)
```
```bash
curl -X POST localhost:4010/_twin/checkpoints/before-agent/rewind # and it never happened
```
Every run starts from a seed, so the same requests give the same results, byte for byte. A bug you hit once, you can hit again.
## Why fieldtwin
If you build software for field-service and trades businesses, you know the options. Test against a client's live books and hope. Wait for vendor sandbox access, then fill an empty account by hand. Or write mocks that return exactly what you expected, and so never catch what you didn't. None of these suits an AI agent, which needs somewhere it can make mistakes many times over, in parallel.
| | A client's live account | Vendor sandbox | Hand-written mocks | **fieldtwin** |
|---|---|---|---|---|
| Safe to break | No | Yes | Yes | **Yes** |
| Ready to use | When the client trusts you | After a developer account, an app, and setup | After you write each endpoint | **`npx fieldtwin`** |
| Realistic data | Yes | Generic samples, or none | What you typed | **A seeded business with months of history** |
| Jobber, Housecall Pro, and QuickBooks describe the same business | Yes | No | No | **Yes, or drifted on purpose** |
| Back to a clean start | Never | By hand | Restart | **`POST /_twin/reset`** |
| 429s, 5xx, lost answers, duplicate webhooks on demand | No | No | Only the ones you thought of | **Yes, in the vendor's own error format** |
| Skip ahead in time (token expiry, retries, scheduled events) | No | No | Rarely | **Yes, instantly** |
| See what your code changed, and undo it | No | No | No | **Checkpoints, change lists, rewind** |
| Offline, in CI, many copies in parallel | No | No | Yes | **Yes** |
| Exactly like production | Yes | Nearly | No | **No. Modeled from the public docs, and [FIDELITY.md](FIDELITY.md) says how closely** |
fieldtwin doesn't replace the vendors' sandboxes; it covers what they can't, and a final check in one before you go live is still worth doing. When you have a sandbox, fieldtwin's [conformance suite](conformance/README.md) records what the real API does and scores the twin against it.
## Who it's for
**Developers building integrations for trades and field-service software.** The bugs that cost you are the ones you can't make happen on purpose: a throttled API, a write whose answer never came back, a webhook that arrives twice, a token that expires halfway through a sync. fieldtwin makes them happen whenever you ask, in a business that looks real in every system, and twin time skips the waiting. The [reference integration](#a-worked-example-jobber-to-quickbooks) gets through more than a hundred retries and two hours of token expiry in a couple of seconds. Every run starts from a seed, so a failure replays exactly, in CI or on a teammate's laptop. You ship sooner, and you find the bad night in testing instead of in a customer's books.
**Teams building AI agents that touch a business's money.** An agent needs somewhere to make mistakes, many times over, and you need proof of what it actually did. Mark a checkpoint, let the agent work, and judge it on the exact list of changes it made, not on what it says it did. Then rewind and run it again. Load `mismatched-books` to see whether it notices the systems disagree, or `flaky-apis` to see whether it gives up. The [dashboard](#the-dashboard) shows all of it as it happens.
**Agencies and integrators who sell to trades businesses.** "We test against a full simulation of your Jobber or Housecall Pro and your QuickBooks before anything touches your account" is a promise you can keep, and a reason a cautious owner says yes.
**The businesses themselves**, though they never run it. They get syncs that don't create duplicate customers, leave invoices out of the books, or record a payment twice, and AI assistants that made their mistakes in a copy of the business instead of in theirs.
### What you get
| You get | Instead of |
|---|---|
| A working fake business, in Jobber, Housecall Pro, and QuickBooks at once, from one command | Developer accounts, app setup, and test data typed in by hand |
| Throttling, outages, lost answers, and duplicate webhooks whenever you ask | Meeting them for the first time in production |
| Hours of retry waits and token expiry, in seconds | Tests that sleep, or that never cover it |
| The same results from the same seed, every run | "Works on my machine" |
| An exact list of what your code or agent changed, and an undo | Guessing from logs, on an account you can never reset |
| [Tests that run on every commit](#testing-on-every-commit), offline, with no vendor account | Checking by hand in a sandbox before each release |
fieldtwin is built by Nick George. Its page, with both demo videos, is at [nickgeorgeai.com/fieldtwin](https://nickgeorgeai.com/fieldtwin); more of his work, and how to reach him, at [nickgeorgeai.com](https://nickgeorgeai.com).
## What's modeled
| | QuickBooks Online | Jobber | Housecall Pro |
|---|---|---|---|
| Records | Customers, items, invoices, payments | Clients, jobs, visits, quotes, invoices, users | Customers and their addresses, employees, jobs with appointments and line items, estimates, invoices |
| Reading | By Id; the query language (`WHERE` with `=`, `<`, `>`, `LIKE`, `IN`, joined by `AND`; `ORDERBY`; paging; `COUNT(*)`); change data capture | GraphQL, with Relay pagination both ways, encoded ids, and dated API versions | REST lists with `page` and `page_size`, sorting, customer search, and the documented job and invoice filters |
| Writing | Customers and invoices created and updated with SyncTokens (5010 when stale), sparse or full; payments created and applied; `requestid` for safe retries | `clientCreate` | Customers created and updated, addresses added; jobs created, scheduled, and dispatched |
| Rate limits | 500 requests a minute per company: HTTP 429 | The query-cost budget: 10,000 points refilling 500 a second, `THROTTLED` | Not published: fault rules make 429s, with `RateLimit-Reset` |
| Webhooks | CloudEvents, signed, retried on Intuit's schedule | HMAC-signed, delivered at least once, retried | The whole record, signed with `Api-Timestamp` and `Api-Signature`, tried once |
| OAuth 2.0 | Authorize, tokens, refresh (rolling 100 days), revoke | Authorize with PKCE, tokens, refresh tokens that rotate | Authorize, tokens (30 days), refresh tokens that rotate; API keys as `Authorization: Token` |
Anything not listed fails clearly in the vendor's own error format rather than returning made-up data. [FIDELITY.md](FIDELITY.md) has every behavior, how closely it matches, and the docs page it comes from.
## Quick start
Requires Node 22+.
```bash
npx fieldtwin
```
```text
fieldtwin: Rowntree Electric is open for business at http://localhost:4010
fieldtwin: auth is any-token: ANY Bearer token is accepted. Set FIELDTWIN_AUTH=oauth for real OAuth checks.
```
Open http://localhost:4010 in a browser for where everything is. Read a customer from the QuickBooks twin (by default any bearer token works; see [Authentication](#authentication)):
```bash
curl -H "Authorization: Bearer dev" \
http://localhost:4010/v3/company/9341000000000001/customer/1
```
List clients from the Jobber twin:
```bash
curl -X POST http://localhost:4010/api/graphql \
-H "Authorization: Bearer dev" \
-H "X-JOBBER-GRAPHQL-VERSION: 2025-04-16" \
-H "Content-Type: application/json" \
-d '{"query":"{ clients(first: 5) { nodes { id name } totalCount } }"}'
```
List scheduled jobs from the Housecall Pro twin, with an API key (any key works):
```bash
curl -H "Authorization: Token dev" \
"http://localhost:4010/housecallpro/jobs?work_status[]=scheduled&page_size=5"
```
Start it in a ready-made situation instead: `npx fieldtwin flaky-apis` (see [Scenarios and faults](#scenarios-and-faults)). `npx fieldtwin --help` lists the scenarios and settings.
To work on fieldtwin itself, or run the [dashboard](#the-dashboard) and the [worked example](#a-worked-example-jobber-to-quickbooks), clone it instead: `git clone https://github.com/nickgeorgeseo/fieldtwin && cd fieldtwin && npm ci && npm start`.
## Using it with your code
Change only the base URL. Paths, headers, and body shapes match the real APIs.
| Vendor | Real base URL | Twin base URL |
|---|---|---|
| QuickBooks Online | `https://quickbooks.api.intuit.com` | `http://localhost:4010` |
| Jobber | `https://api.getjobber.com` | `http://localhost:4010` |
| Housecall Pro | `https://api.housecallpro.com` | `http://localhost:4010/housecallpro` |
The QuickBooks twin serves one company, realm `9341000000000001`. The Housecall Pro twin serves the real paths under `/housecallpro`, because some of them (`/api/invoices/{uuid}`) start the way Jobber's do.
The QuickBooks and Jobber twins enforce the vendors' documented rate limits in twin time: Jobber answers `THROTTLED` when a query costs more than its budget has left, and QuickBooks answers HTTP 429 after 500 requests in a minute. Advancing the clock lets them recover; a reset starts them full. Housecall Pro doesn't publish its limits, so its twin throttles only when a fault rule says so. Faults can add more throttling on top of any of them.
### Configuration
| Variable | Default | What it does |
|---|---|---|
| `FIELDTWIN_PORT` | `4010` | The port to listen on |
| `FIELDTWIN_HOST` | `127.0.0.1` | The address to listen on. `0.0.0.0` reaches it from a container or another machine: only on a network you trust, since anyone who can reach the twin can control it ([SECURITY.md](SECURITY.md)) |
| `FIELDTWIN_AUTH` | `any-token` | `oauth` makes the vendor APIs check tokens the way the real ones do |
| `FIELDTWIN_SEED` | `42` | Which fake business to generate. The same seed always builds the same one |
## Testing on every commit
Your tests start a twin of their own and reach it the way your app does. Each test loads a fresh world, runs your code against it, and checks the change list: what your code really did, not what it logged. Twin time skips the waiting, so retries and token expiry take seconds, and every world comes from a seed, so a test that fails in CI fails the same way on your laptop.
**1. Write a test.** This one checks for the bug a lost answer causes. QuickBooks can accept two invoices with the same number, so code that retries a create after a timeout, without a `requestid`, books the same work twice:
```ts
// test/sync.test.ts in your project, with fieldtwin installed: npm install --save-dev fieldtwin
import assert from "node:assert/strict";
import { after, beforeEach, test } from "node:test";
import { startTwin } from "fieldtwin";
import { syncPaidInvoices } from "../src/sync.ts"; // your code
const running = await startTwin(); // a twin of this file's own, on a free port
after(() => running.close());
/** Something the twin recorded: what changed, in which vendor, with the whole record as that vendor's API returns it. */
interface Change {
vendor: string;
type: string;
data: any;
}
/** A request to the twin's control plane, failing the test with the twin's reason if it refuses. */
async function twin(method: string, path: string) {
const response = await fetch(`${running.url}/_twin${path}`, { method });
if (!response.ok) throw new Error(`${method} /_twin${path}: ${await response.text()}`);
return response.json();
}
beforeEach(async () => {
await twin("POST", "/scenarios/flaky-apis"); // a fresh world for each test: 429s, 503s, and writes whose answer is lost
await twin("POST", "/checkpoints/before-sync");
});
test("books each invoice once, even when QuickBooks loses the answer to a write", async () => {
await syncPaidInvoices({ jobberUrl: running.url, quickbooksUrl: running.url });
const changes: Change[] = await twin("GET", "/checkpoints/before-sync/changes?source=api"); // what your code did
const booked = changes
.filter((change) => change.vendor === "quickbooks" && change.type === "InvoiceCreated")
.map((change) => change.data.invoice.DocNumber);
assert.ok(booked.length > 0, "the sync booked no invoices");
assert.equal(new Set(booked).size, booked.length, `an invoice was booked twice: ${booked.join(", ")}`);
});
```
It uses Node's built-in test runner, and Vitest works the same way. Jest needs its [ESM mode](https://jestjs.io/docs/ecmascript-modules), since fieldtwin is an ES module. Against a sync that retries without a `requestid`, it fails with the duplicate it found:
```text
✖ books each invoice once, even when QuickBooks loses the answer to a write
AssertionError [ERR_ASSERTION]: an invoice was booked twice: 41, 41
```
**2. Run it.** `node --test` (or `npm test`). There's nothing else to start: the test file starts its twin and closes it. Node runs a `.ts` test file as it is from 22.18 (and 23.6); on an older Node, run `node --test --experimental-strip-types`, or `node --test` finds no tests.
**3. Run it on every commit.** The same command runs in CI, since fieldtwin is a dev dependency like any other. If your tests aren't in Node, or you'd rather keep one twin running beside them, start it as a step of its own and wait until it answers. In GitHub Actions:
```yaml
# .github/workflows/test.yml in your project
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Start the twin
run: |
nohup npx -y fieldtwin@0.5 > twin.log 2>&1 & # pin the version for repeatable runs
timeout 60 bash -c 'until curl -sf localhost:4010/_twin > /dev/null; do sleep 1; done'
- run: npm ci && npm test # your tests, with the twin at http://localhost:4010
- name: Keep the twin's event log
if: failure()
run: curl -s localhost:4010/_twin/log.jsonl > fieldtwin-log.jsonl
- if: failure()
uses: actions/upload-artifact@v7
with:
name: fieldtwin-log
path: fieldtwin-log.jsonl
```
When a run fails, its event log is kept with it: everything your code did to the twin, to read or to replay.
A few things to know:
- **Tests that share a twin share its world.** Each file that calls `startTwin()` gets its own, so files can run in parallel; tests within a file take turns, as Node's runner and Vitest run them by default. One twin started beside the tests is shared by all of them: run the files one at a time (`node --test --test-concurrency=1`, Vitest's `--no-file-parallelism`, Jest's `--runInBand`).
- **`startTwin()` takes the settings the command does**: `seed`, `auth` (`"any-token"` or `"oauth"`), `scenario` (a ready-made one's name or a YAML file's path), and `port` (a free one unless you say). It answers `url`, `port`, and `close()`.
- **`flaky-apis` checks OAuth tokens for real**, so your code connects through the twin's [OAuth endpoints](#authentication), as it will in production. To test retries alone, follow the scenario with `PUT /_twin/auth` and `{"mode": "any-token"}`.
- **For a stronger check**, the reference integration's [flaky test](examples/sync-jobber-invoice-to-quickbooks/test/sync-flaky.test.ts) syncs through `flaky-apis` and checks the books end exactly as a clean run leaves them.
## A worked example: Jobber to QuickBooks
[`examples/sync-jobber-invoice-to-quickbooks`](examples/sync-jobber-invoice-to-quickbooks) is a reference integration written like production code, and you're welcome to copy it. It books every paid Jobber invoice into QuickBooks (customer, invoice, payment), on a schedule and from Jobber's webhooks as customers pay. It handles everything the twins throw at it: 429s and 5xx with backoff, answers lost after a write, Jobber's query-cost throttle, expiring and rotating OAuth tokens, stale SyncTokens, customers spelled differently in each system, webhooks that come twice or late, and two syncs at once.
```bash
npm start # terminal 1
npm run example:sync # terminal 2
```
Its tests show what testing against a twin buys you: the same sync under `flaky-apis` settles to exactly the books a clean run produces, through more than a hundred retries, and the two hours it waits for its tokens to expire take no real time.
## Testing an AI agent
The twin gives an agent a whole business to work in and you a precise record of what it did. Give each test a checkpoint, let the agent work, then judge it on the change list rather than on what it says it did:
```bash
curl -X POST localhost:4010/_twin/checkpoints/before-agent
# ...run your agent against the twin...
curl "localhost:4010/_twin/checkpoints/before-agent/changes?source=api"
curl -X POST localhost:4010/_twin/checkpoints/before-agent/rewind
```
Load `flaky-apis` to see how it copes when the APIs fight back, or `mismatched-books` to see whether it notices that the field-service systems and QuickBooks disagree. To let a coding agent drive the twin itself, use the [MCP server](#using-it-from-a-coding-agent-mcp).
## Authentication
> **By default the twin accepts any bearer token.** That keeps the quick start to one command, and it is the
> wrong setting for testing your OAuth code. To make the twin check tokens the way the real APIs do, start it
> with `FIELDTWIN_AUTH=oauth npx fieldtwin`, put `auth: oauth` in a scenario, or switch a running twin:
> `curl -X PUT localhost:4010/_twin/auth -H "Content-Type: application/json" -d '{"mode":"oauth"}'`.
Each vendor has a twin OAuth 2.0 server at the vendor's own paths (under `/housecallpro` for Housecall Pro), so your OAuth client only needs new URLs:
| Endpoint | Real URL | Twin URL |
|---|---|---|
| QuickBooks authorize | `https://appcenter.intuit.com/connect/oauth2` | `http://localhost:4010/connect/oauth2` |
| QuickBooks tokens | `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` | `http://localhost:4010/oauth2/v1/tokens/bearer` |
| QuickBooks revoke | `https://developer.api.intuit.com/v2/oauth2/tokens/revoke` | `http://localhost:4010/v2/oauth2/tokens/revoke` |
| Jobber authorize | `https://api.getjobber.com/api/oauth/authorize` | `http://localhost:4010/api/oauth/authorize` |
| Jobber tokens | `https://api.getjobber.com/api/oauth/token` | `http://localhost:4010/api/oauth/token` |
| Housecall Pro authorize | `https://pro.housecallpro.com/oauth/authorize` | `http://localhost:4010/housecallpro/oauth/authorize` |
| Housecall Pro tokens | `https://api.housecallpro.com/oauth/token` | `http://localhost:4010/housecallpro/oauth/token` |
The twin approves every authorization request at once (there is no consent page) and accepts any `client_id` and `client_secret`. Everything after that follows the vendors' docs: codes work once, access tokens last 60 minutes (30 days for Housecall Pro), Jobber and Housecall Pro rotate refresh tokens and reject the old one, QuickBooks keeps the refresh token on a rolling 100 days. Tokens expire on twin time, so `POST /_twin/clock/advance {"minutes": 60}` tests your refresh path in a second. (Jobber's access tokens are JWTs whose `exp` is in twin time too, so compare it with the twin's clock, not your own.)
A Pro's own Housecall Pro integration uses an API key instead, sent as `Authorization: Token <key>`. The twin can't know which keys a Pro made, so it takes any key, in either auth mode.
Getting a Jobber token from the command line:
```bash
CODE=$(curl -s -o /dev/null -w '%{redirect_url}' \
"http://localhost:4010/api/oauth/authorize?response_type=code&client_id=my-app&redirect_uri=http://localhost:3000/callback" \
| sed -E 's/.*code=([^&]+).*/\1/')
curl -s -X POST http://localhost:4010/api/oauth/token \
-d client_id=my-app -d client_secret=dev -d grant_type=authorization_code \
-d code=$CODE -d redirect_uri=http://localhost:3000/callback
```
## Webhooks
Every twin sends webhooks the way its vendor does. Each vendor sets them up per app (in a developer portal, or in a Pro's own settings for Housecall Pro); the twin's equivalent is the control plane (or the `twin_set_webhooks` MCP tool):
```bash
curl -X PUT localhost:4010/_twin/webhooks -H "Content-Type: application/json" -d '[
{"vendor":"jobber","url":"http://localhost:3000/webhooks/jobber","topics":["INVOICE_UPDATE"],"clientId":"my-app","clientSecret":"dev"},
{"vendor":"quickbooks","url":"http://localhost:3000/webhooks/qbo","topics":["qbo.payment.created.v1"],"clientId":"my-app","clientSecret":"my-verifier-token"},
{"vendor":"housecallpro","url":"http://localhost:3000/webhooks/hcp","topics":["invoice.paid"],"clientId":"my-app","clientSecret":"my-signing-secret"}
]'
curl -X POST localhost:4010/_twin/actions/jobber/payInvoices -H "Content-Type: application/json" -d '2'
curl -X POST localhost:4010/_twin/actions/housecallpro/payInvoices -H "Content-Type: application/json" -d '2'
```
**Jobber** deliveries carry Jobber's documented payload (`{ "data": { "webHookEvent": { "topic", "appId", "accountId", "itemId", "occurredAt" } } }`) and an `X-Jobber-Hmac-SHA256` signature made with the subscription's client secret, so your verification code runs unchanged. Like Jobber, the twin delivers at least once: paying an invoice fires `INVOICE_UPDATE` twice, and a delivery your app doesn't accept within a second is sent again later in twin time. `CLIENT_CREATE` and `INVOICE_UPDATE` are modeled so far.
**QuickBooks** deliveries are CloudEvents, the format Intuit sends today: a JSON array of events like `{ "specversion": "1.0", "type": "qbo.invoice.updated.v1", "intuitentityid": "130", "intuitaccountid": "9341000000000001", ... }`, signed in `intuit-signature` with the subscription's `clientSecret` standing in for your app's verifier token. Customers, invoices, and payments created or updated through the API are announced (`qbo.customer.created.v1`, `qbo.customer.updated.v1`, `qbo.invoice.created.v1`, `qbo.invoice.updated.v1`, `qbo.payment.created.v1`), and a payment also announces each invoice it pays. Your app has 3 seconds to answer HTTP 200; otherwise the twin tries again on Intuit's schedule, from 10 seconds up to every 6 hours of twin time.
**Housecall Pro** deliveries carry the documented shared fields (`event`, `company_id`, `event_occurred_at`, `event_created_at`) and the whole record, as the event left it: `{ "event": "job.created", ..., "job": { ... } }`. `Api-Timestamp` and `Api-Signature` follow the docs' verification steps, an HMAC-SHA256 of the timestamp, a dot, and the payload, keyed with the subscription's `clientSecret` as your signing secret. `customer.created`, `customer.updated`, `job.created`, `job.scheduled`, `job.completed`, `job.paid`, `invoice.created`, `invoice.sent`, `invoice.payment.succeeded`, and `invoice.paid` are modeled so far. Jobs get finished, invoiced, and paid by the `completeJobs`, `invoiceJobs`, and `payInvoices` actions, in a scenario's timeline or through the control plane: invoicing a job sends `invoice.created` and then `invoice.sent`, and paying an invoice sends `invoice.payment.succeeded`, `invoice.paid`, and `job.paid`, in that order. The docs promise no retries, so the twin tries each delivery once.
Each URL gets its deliveries one at a time, in order; an app that is slow to answer doesn't hold up deliveries to another. `GET /_twin/webhooks` shows every recent delivery and how your app answered it.
Fault rules with a `webhook` topic (`INVOICE_UPDATE`, `qbo.invoice.updated.v1`, `job.created`, or `"*"`) make deliveries misbehave instead of requests: sent twice, or late enough that later ones overtake them. Faulted deliveries carry `X-Twin-Fault`.
```yaml
faults:
- name: jobber-webhooks-twice
vendor: jobber
webhook: INVOICE_UPDATE
rate: 0.3
effect: { duplicate: true }
- name: jobber-webhooks-late
vendor: jobber
webhook: "*"
rate: 0.2
effect: { delayMs: 300000 } # five minutes of twin time late
```
## Scenarios and faults
A scenario is a YAML file: seed, start time, world size, faults, and what happens as time passes. Start the twin with a ready-made one by name, or with your own file:
```bash
npx fieldtwin heat-wave-monday
npx fieldtwin my-scenario.yaml
```
| Scenario | What it tests |
|---|---|
| `heat-wave-monday` | A busy HVAC Monday: 120 customers, throttled QuickBooks writes, a slow Jobber, and new clients calling in all morning |
| `mismatched-books` | The field-service systems (Jobber and Housecall Pro) and QuickBooks disagree: customers spelled differently (same email), customers only in the field with unsynced work, customers only in QuickBooks, and a customer QuickBooks has as inactive who has paid for new work since |
| `flaky-apis` | Retry testing: real OAuth checks plus layered faults (QuickBooks 429s, 503s, and writes whose answer is lost; Jobber 429s, 502s, and webhooks sent twice or late; Housecall Pro 429s with `RateLimit-Reset`, 502s, writes whose answer is lost, and webhooks sent late) while customers keep paying |
A fault rule matches requests by vendor, method, and path (`pathIncludes`, and `pathExcludes` to leave some alone, like a vendor's OAuth endpoints), and fires at a rate:
```yaml
faults:
- name: qbo-throttled-writes
vendor: quickbooks
method: POST
rate: 0.2 # one in five matching requests
effect: { status: 429, message: "ThrottleExceeded" }
```
Injected failures come back in the vendor's own error format, with the headers it sends (a Housecall Pro 429 has `RateLimit-Reset`, in twin time), and carry an `X-Twin-Fault` header, so you can tell them apart from real bugs. An `effect` can also slow a request down (`latencyMs`), or lose its answer: with `lostResponse: true` the twin does the work first and then answers with the error anyway, the way a gateway timeout hides a write that succeeded. That is the failure a retried write has to survive without happening twice (for QuickBooks, send a `requestid`):
```yaml
- name: qbo-lost-answers
vendor: quickbooks
method: POST
rate: 0.1
effect: { status: 504, lostResponse: true }
```
A scenario can also schedule what happens as twin time passes. Advancing the clock runs each entry at its own time, recorded with source `scenario`:
```yaml
timeline:
- at: "08:00" # business time; also "+90m", "+2h", or "2026-07-13T15:00:00Z"
jobber: { newClients: 12 } # new clients call in
- at: "13:00"
jobber: { payInvoices: 4 } # the longest-open invoices get paid
- at: "16:00"
housecallpro: { completeJobs: 2, invoiceJobs: 2, payInvoices: 1 } # techs finish work, the Pro invoices it, a customer pays
```
Housecall Pro has `newCustomers` too, like Jobber's `newClients`. A job is finished only once all of its visits have begun, so `completeJobs` never marks work done before its time. Finishing a job doesn't invoice it: `invoiceJobs` invoices finished work, the work finished longest ago first, and from then on `payInvoices` can pay it.
A scenario with a typo, an unknown key, or a time without a UTC offset is refused when it loads, with the reason, and the twin stays as it was.
## The control plane
Everything under `/_twin` controls the fake world rather than imitating a vendor.
| Request | What it does |
|---|---|
| `GET /_twin` | Company, seed, loaded scenario, twin time, event count, auth mode, active faults, webhook subscriptions, checkpoints, what's scheduled next |
| `POST /_twin/reset` | Back to the start: the seeded world (the loaded scenario's, if any), with its timeline and faults |
| `POST /_twin/clock/advance` `{"minutes": 60}` | Move time forward (rate limits refill, tokens expire, scheduled events and their webhooks happen) |
| `POST /_twin/actions/:vendor/:action` (body: params) | Make something happen now, as if done in the vendor's own app: `jobber/payInvoices` with `2`, `jobber/newClients` with `3`, `housecallpro/invoiceJobs` with `1` |
| `POST /_twin/checkpoints/:name` | Name this moment |
| `GET /_twin/checkpoints/:name/changes?source=api` | What your code changed since then (or `seed`, `scenario`, `control`; everything without `source`): each event, with a one-line `summary`. Like the log, it takes `after=` to read on from an earlier read |
| `POST /_twin/checkpoints/:name/rewind` | Undo everything after the checkpoint. The same requests then meet the same faults |
| `PUT /_twin/faults` | Replace the active fault rules |
| `PUT /_twin/auth` `{"mode": "oauth"}` | Check bearer tokens for real (`oauth`) or accept any (`any-token`) |
| `GET /_twin/scenarios` | The ready-made scenarios and what each one tests |
| `POST /_twin/scenarios/:name` | Load a ready-made scenario |
| `POST /_twin/scenario` (YAML body) | Load your own scenario |
| `GET /_twin/log.jsonl?since=:checkpoint` | The event log, one event per line (all of it without `since`). With the seed, it is everything a bug report needs. Its `X-Twin-Log-Position` header says where the log ends |
| `GET /_twin/log.jsonl?after=:position` | Only what was recorded after a position an earlier read gave (or after an event's `seq`): how to follow the log as it grows. A 409 means the log has changed in between (a reset, a rewind, a scenario, or a restarted twin): read it again from the start |
| `PUT /_twin/webhooks` | Replace the webhook subscriptions (see [Webhooks](#webhooks)) |
| `GET /_twin/webhooks` | The subscriptions, and each recent delivery with how your app answered |
## The dashboard
[`dashboard/`](dashboard/README.md) is a web dashboard for a running twin: everything in the control plane from a browser, the event log as it grows, and QuickBooks side by side with Jobber or Housecall Pro, every customer and invoice marked as matching or drifted.
```bash
npm start # terminal 1: the twin
cd dashboard && npm ci && npm run dev # terminal 2: http://localhost:4020
```
It is a Next.js package of its own and a client of the twin like any other: it uses only `/_twin`, so watching your code spends no rate limit and draws no fault.
## Using it from a coding agent (MCP)
`npx fieldtwin mcp` starts the twin and an MCP server over stdio (`npm run --silent mcp` in a clone, so npm's own lines stay off stdout). Its tools mirror the control plane:
| Tool | What it does |
|---|---|
| `twin_status` | Company, seed, scenario, twin time, auth mode, active faults, checkpoints, what's scheduled next |
| `twin_reset` | Back to the start of the loaded scenario (or the default world) |
| `twin_advance_clock` | Move time forward; scheduled scenario events happen on the way |
| `twin_checkpoint`, `twin_changes`, `twin_rewind` | Mark a moment, list what changed since (optionally only your API calls), undo it |
| `twin_list_scenarios`, `twin_load_scenario` | Pick a ready-made scenario by name, or load your own YAML |
| `twin_set_faults` | Make the APIs misbehave on purpose |
| `twin_set_auth` | Accept any token, or check OAuth tokens for real |
| `twin_export_log` | The event log as JSON Lines, all of it or since a checkpoint |
| `twin_run_action` | Make something happen now: customers pay, clients call in, techs finish jobs, finished work is invoiced |
| `twin_set_webhooks`, `twin_webhooks` | Subscribe your app to webhooks; see what was delivered and how it answered |
Each tool carries MCP's tool annotations, so a client that asks before running a tool that changes things knows which ones do. The five that only look are marked read-only. The ones that replace or throw away what's there are marked destructive: resetting, rewinding, loading a scenario, setting faults or webhooks, and reusing a checkpoint's name. Only `twin_advance_clock` and `twin_run_action` reach outside the twin, by delivering webhooks to the URLs you subscribed.
Claude Code:
```bash
claude mcp add fieldtwin -- npx -y fieldtwin mcp
```
Other MCP clients (Cursor, Claude Desktop, VS Code) take the same command in their JSON config:
```json
{ "mcpServers": { "fieldtwin": { "command": "npx", "args": ["-y", "fieldtwin", "mcp"] } } }
```
It is in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.nickgeorgeseo/fieldtwin) as `io.github.nickgeorgeseo/fieldtwin`, so clients and directories that read the registry install it with the same command and its settings.
Add a scenario's name after `mcp` to start in it. The MCP server starts a twin of its own, on port 4010 unless `FIELDTWIN_PORT` says otherwise, so stop any other twin there first (`claude mcp add -e FIELDTWIN_PORT=4011 ...` picks another port). It exits when the agent closes the session.
The MCP tools only control the test world. Your agent still calls the vendor APIs over HTTP, the same way production code will.
## When something looks wrong
| You see | What it means |
|---|---|
| `fieldtwin: port 4010 is already in use` | Another twin (or something else) has the port. `FIELDTWIN_PORT=4011 npx fieldtwin` |
| HTTP 401 from any vendor | The twin is checking tokens (`auth` is `oauth`, from `FIELDTWIN_AUTH`, a scenario, or `PUT /_twin/auth`). Get a token from the twin's OAuth endpoints, or switch back to `any-token`. Housecall Pro also answers 401 for an `Authorization` header not written exactly `Token <key>` or `Bearer <token>` |
| Jobber `THROTTLED` on every try | The query asks for more than the whole 10,000-point budget, so it can never run. Ask for fewer nodes with `first` or `last` |
| QuickBooks 5010 `Stale Object` | Your update sent an old `SyncToken`, as the real API would refuse too. Read the record again and retry |
| An `X-Twin-Fault` header | A fault rule fired, on purpose. `GET /_twin` lists the active ones |
| A 500 that mentions a bug in fieldtwin | Please [open an issue](https://github.com/nickgeorgeseo/fieldtwin/issues) with the twin's log and `GET /_twin/log.jsonl` |
## Project layout
```text
src/
core/ the twin runtime: event log, clock, seeded randomness, OAuth server, vendor contract
world/ the vendor-neutral fake business: customers, price list, months of work history, drift
vendors/
quickbooks/ one folder per vendor, all with the same shape
jobber/
housecallpro/
faults/ makes vendor APIs misbehave on purpose
control/ /_twin HTTP routes, MCP tools, scenarios (one shared operations.ts)
app.ts wires it together
start.ts startTwin(): a twin on its own port, from code
index.ts what `import { startTwin } from "fieldtwin"` gets
cli.ts the fieldtwin command
server.ts entry point for `npm start`
mcp.ts entry point for `npm run mcp` and `fieldtwin mcp`
scenarios/ ready-made situations (heat-wave-monday, mismatched-books, flaky-apis)
examples/ code that uses the twins the way yours will: over HTTP, never importing src/
sync-jobber-invoice-to-quickbooks/ the reference integration, its demo, and its tests
conformance/ the same scripted sessions against a real sandbox and the twin, compared and scored
dashboard/ a web dashboard for a running twin (Next.js, a package of its own), over HTTP like examples/
test/ tests by vendor and area, plus core and the architecture guards
```
[ARCHITECTURE.md](ARCHITECTURE.md) explains how it fits together and the three rules that keep it easy to change. Read it before changing anything in `core/`.
## Contributing
Contributions are welcome, especially ones that make the twins more faithful. [CONTRIBUTING.md](CONTRIBUTING.md) walks through adding an endpoint, from the vendor's docs page to the test to the FIDELITY.md row; every vendor folder has the same shape, so once you've read one you can read them all. If you have a QuickBooks sandbox company, a Jobber testing account, or a Housecall Pro account on the MAX or XL plan that you keep for testing, the most valuable thing you can do is record the [conformance suite](conformance/README.md) against it: `npm run conformance:record` turns the twin's guesses into evidence. Check first that the vendor's API terms allow it ([links to each](conformance/README.md#recording)). Build only from public documentation; never paste vendor code, private docs, or real customer data.
Where it's going: more Jobber writes as their inputs reach the public docs, recordings from real sandboxes, more of Housecall Pro, then more vendors (Samsara or Motive, EDI trading partners) and a benchmark for dispatch and operations agents. [ARCHITECTURE.md](ARCHITECTURE.md#roadmap) has the roadmap.
## Disclaimer and license
fieldtwin is an independent project. It is not affiliated with, endorsed by, or supported by Jobber, Intuit, or Housecall Pro. Jobber, QuickBooks, and Housecall Pro are trademarks of their respective owners.
Copyright 2026 [Nick George](https://nickgeorgeai.com). Licensed under the [Apache License 2.0](LICENSE).
TDQS
Scored across 14 tools
Most tools target clearly distinct operations (load scenario, set faults, advance clock, rewind), and descriptions actively disambiguate close pairs, e.g. twin_export_log points to twin_changes for a summary and twin_reset points to twin_rewind for partial undo. The main residual confusion is the read/write pair twin_webhooks vs twin_set_webhooks, and the three 'restore world' tools (reset, rewind, load_scenario), which descriptions clarify but which still sit close together.
Every tool uses the same 'twin_' prefix and snake_case, giving a strong shared identity. The verb position is mostly predictable (twin_set_*, twin_list_*, twin_export_*, twin_advance_*), but read-only getters break the pattern by using bare nouns (twin_status, twin_webhooks, twin_changes, twin_checkpoint), creating an asymmetry with twin_set_webhooks.
Fourteen tools is well within the sweet spot and each maps to a distinct responsibility in the simulation lifecycle: world setup, environment knobs, time control, observation, and rollback. Nothing feels padded or bolted on.
Lifecycle coverage is strong: build/load a world, configure faults, auth and webhooks, move time, trigger actions, observe via status/log/changes, and roll back via checkpoint/rewind/reset. The notable gap is there is no way to inspect the twin's actual records (customers, invoices, jobs) directly—only event streams and status—and no way to delete or list a single checkpoint.