{"id":"dd27bf44-294c-435d-bf71-d80eb32b6610","entityType":"agent","slug":"clawhub-chrischall-jobber-mcp","name":"jobber-mcp","canonicalUrl":"https://www.xpersona.co/agent/clawhub-chrischall-jobber-mcp","canonicalPath":"/agent/clawhub-chrischall-jobber-mcp","generatedAt":"2026-10-11T11:26:22.797Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":null},"description":"Read your Jobber Client Hub — the customer portal a service business (pest control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running the jobber-mcp server. Use when you want your Jobber data without the MCP, in a script, or on a machine where the MCP isn't installed.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.1K downloads reported by the source. Last updated 10/11/2026.","installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:jobber-mcp","sourceUrl":"https://clawhub.ai/chrischall/jobber-mcp","homepage":"https://clawhub.ai/chrischall/skills/jobber-mcp","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/chrischall/jobber-mcp","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/chrischall/skills/jobber-mcp","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":61,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"jobber-mcp technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":null},"stars":null,"forks":null,"downloads":1107,"packageName":null,"latestVersion":"1.0.7","tractionLabel":"1.1K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:53:29.672Z","emptyReason":null},"lastUpdatedAt":"2026-10-11T08:53:29.687Z","lastCrawledAt":"2026-10-11T08:53:29.672Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-12T08:53:29.672Z","lastVerifiedAt":null,"highlights":[{"version":"1.0.7","createdAt":"2026-10-09T23:25:06.464Z","changelog":"- Output contract change: All record URLs (.url) are now relative to your hub (e.g. appointments/2236612358) instead of including the hub UUID. Prefix $JOBBER_HUB/ to open or fetch. - Updated documentation in SKILL.md and references/recipes.md to clarify new URL handling and prevent misuse. - Removed outdated skill-card.md file.","fileCount":6,"zipByteSize":12191},{"version":"1.0.6","createdAt":"2026-10-07T13:35:23.814Z","changelog":"- Removed the file: skill-card.md - No changes to functionality; this update only deletes documentation (a markdown file).","fileCount":6,"zipByteSize":11040},{"version":"1.0.5","createdAt":"2026-10-05T02:55:40.922Z","changelog":"- Removed the redundant file: skill-card.md - No changes to functionality or documentation in SKILL.md","fileCount":6,"zipByteSize":11131},{"version":"1.0.4","createdAt":"2026-10-03T01:45:08.624Z","changelog":"- Removed the file skill-card.md. - No changes to core functionality or documentation content.","fileCount":6,"zipByteSize":10923},{"version":"1.0.3","createdAt":"2026-09-28T13:55:29.081Z","changelog":"- Updated instructions to require the new ContextMint Bridge browser extension (replacing the previous Transporter/fetchproxy extension). - Provided direct source and release links for the ContextMint Bridge extension, with Chrome-only support noted. - Added instructions on verifying extension releases via SHA256. - Removed the `skill-card.md` file. - No changes to the core usage or JSON parsing flow.","fileCount":6,"zipByteSize":11094},{"version":"1.0.2","createdAt":"2026-09-23T21:39:53.653Z","changelog":"- Removed the skill-card.md file. - No changes to functionality or documentation content.","fileCount":6,"zipByteSize":10959},{"version":"1.0.1","createdAt":"2026-09-23T15:44:30.843Z","changelog":"## Version 1.0.1 Changelog - Removed skill-card.md file. - No changes to user-facing features or functionality.","fileCount":6,"zipByteSize":10956},{"version":"1.0.0","createdAt":"2026-09-20T02:50:25.168Z","changelog":"jobber-mcp 1.0.0 - Removed the sample file: skill-card.md. - No changes to core functionality or user experience. - Documentation and usage remain unchanged.","fileCount":6,"zipByteSize":11120}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:jobber-mcp","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:jobber-mcp` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/chrischall/jobber-mcp before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-11T11:26:22.794Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-jobber-mcp/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":null},"readme":"Skill: jobber-mcp\n\nOwner: chrischall\n\nSummary: Read your Jobber Client Hub — the customer portal a service business (pest control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running the jobber-mcp server. Use when you want your Jobber data without the MCP, in a script, or on a machine where the MCP isn't installed.\n\nTags: latest:1.0.7\n\nVersion history:\n\nv1.0.7 | 2026-10-09T23:25:06.464Z | auto\n\n- Output contract change: All record URLs (.url) are now relative to your hub (e.g. appointments/2236612358) instead of including the hub UUID. Prefix $JOBBER_HUB/ to open or fetch.\n- Updated documentation in SKILL.md and references/recipes.md to clarify new URL handling and prevent misuse.\n- Removed outdated skill-card.md file.\n\nv1.0.6 | 2026-10-07T13:35:23.814Z | auto\n\n- Removed the file: skill-card.md\n- No changes to functionality; this update only deletes documentation (a markdown file).\n\nv1.0.5 | 2026-10-05T02:55:40.922Z | auto\n\n- Removed the redundant file: skill-card.md\n- No changes to functionality or documentation in SKILL.md\n\nv1.0.4 | 2026-10-03T01:45:08.624Z | auto\n\n- Removed the file skill-card.md.\n- No changes to core functionality or documentation content.\n\nv1.0.3 | 2026-09-28T13:55:29.081Z | auto\n\n- Updated instructions to require the new ContextMint Bridge browser extension (replacing the previous Transporter/fetchproxy extension).\n- Provided direct source and release links for the ContextMint Bridge extension, with Chrome-only support noted.\n- Added instructions on verifying extension releases via SHA256.\n- Removed the `skill-card.md` file.\n- No changes to the core usage or JSON parsing flow.\n\nv1.0.2 | 2026-09-23T21:39:53.653Z | auto\n\n- Removed the skill-card.md file.\n- No changes to functionality or documentation content.\n\nv1.0.1 | 2026-09-23T15:44:30.843Z | auto\n\n## Version 1.0.1 Changelog\n\n- Removed skill-card.md file.\n- No changes to user-facing features or functionality.\n\nv1.0.0 | 2026-09-20T02:50:25.168Z | auto\n\njobber-mcp 1.0.0\n\n- Removed the sample file: skill-card.md.\n- No changes to core functionality or user experience.\n- Documentation and usage remain unchanged.\n\nv0.4.0 | 2026-09-17T23:38:28.712Z | auto\n\n- Removed the sample skill card file (skill-card.md).\n- No user-facing or functional changes to the code or documentation.\n\nv0.3.3 | 2026-09-15T19:23:41.431Z | auto\n\n- Removed the file: skill-card.md\n- No changes to functionality or documentation other than file removal.\n\nv0.3.2 | 2026-09-14T14:10:43.572Z | auto\n\n- Removed the file: skill-card.md\n- No functional or documentation changes to the skill itself.\n\nv0.3.1 | 2026-09-10T17:54:02.833Z | auto\n\n- Removed the file skill-card.md.\n- No changes to existing functionality or documentation.\n- This update is a maintenance/cleanup release.\n\nv0.3.0 | 2026-09-04T22:24:21.515Z | auto\n\n- Removed the file skill-card.md.\n- No changes to feature functionality or documentation content.\n- This update cleans up repository files without modifying user experience.\n\nv0.1.0 | 2026-08-11T16:23:43.864Z | auto\n\nInitial release of skill jobber-fpx for accessing Jobber Client Hub from the shell without MCP:\n\n- Allows fetching appointment, quote, invoice, and work request data for Jobber customers using @fetchproxy/cli (`fpx`) and the Transporter extension in Chrome.\n- Uses your existing, signed-in Chrome tab to bypass Cloudflare protection; no server-side method or cookie access required.\n- Supports read-only access to your Jobber customer portal data in scriptable JSON form via a provided parser.\n- Strongly protects your unique hub URL as a credential; each vendor provides a separate hub.\n- Includes clear error handling, exit codes, and ready-to-use recipes for common queries.\n- No write capabilities or file downloads; payments and form submissions are not supported.\n\nArchive index:\n\nArchive v1.0.7: 6 files, 12191 bytes\n\nFiles: references/parse-clienthub.mjs (6863b), references/recipes.md (4405b), references/why-not-the-api.md (3570b), skill-card.md (2246b), SKILL.md (6505b), _meta.json (129b)\n\nFile v1.0.7:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n### Record `url`s are hub-relative\n\nEvery record's `url` is relative to your hub — `appointments/2236612358`,\n`invoices/150208512` — because the hub UUID is a credential and stays out of\nthe output. Prefix `$JOBBER_HUB/` to open or fetch one:\n\n```sh\nnode \"$PARSE\" invoices < page.html | jq -r --arg base \"$JOBBER_HUB/\" '.[] | $base + .url'\n```\n\n> **Changed in 1.0.7 (output contract).** Earlier versions printed\n> `/client_hubs/<hub-uuid>/appointments/N` (and the same for invoice, quote and\n> work-request cards). A script that prefixed\n> `https://clienthub.getjobber.com` to `.url` must now prefix `$JOBBER_HUB/`\n> instead, or it builds a URL with no hub in it.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.7:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.7\",\n  \"publishedAt\": 1791588306464\n}\n\nFile v1.0.7:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"appointments/2236612358\"  // hub-relative: the hub id never appears\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base \"$JOBBER_HUB/\" 'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"invoices/150208512\"       // hub-relative, like appointments\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base \"$JOBBER_HUB/\" \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.7:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.7:skill-card.md\n\n## Description:\n\nReads a customer's Jobber Client Hub appointments, invoices, quotes, and work requests through a signed-in browser tab and returns structured records for shell-based workflows.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers and developers use this skill to look up their own Jobber Client Hub appointments, invoices, quotes, and work requests from a shell without running an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A Jobber hub URL grants access to customer records if exposed.\n\nMitigation: Treat each hub URL like a password; keep it out of committed files and shared shell history.\n\nRisk: The browser bridge receives fetch access to getjobber.com.\n\nMitigation: Review the CLI package and browser extension source and release before installation, and grant only the fetch capability required by the skill.\n\nRisk: Account-changing actions or payments require separate safeguards.\n\nMitigation: Use this skill only for read-only lookup; handle payments and account changes directly in the hub.\n\n## Reference(s):\n\n- [Jobber MCP ClawHub release](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Usage recipes](artifact/references/recipes.md)\n- [Client Hub access and API distinction](artifact/references/why-not-the-api.md)\n- [ContextMint Bridge source](https://github.com/nullnet-app/contextmint-bridge)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n\n## Skill Output:\n\n**Output Type(s):** [JSON, Shell commands, Guidance]\n\n**Output Format:** [JSON records and Markdown with shell commands]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Record URLs are hub-relative and omit the hub credential; prefix the user's hub URL to open a record.]\n\n## Skill Version(s):\n\n1.0.7 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.6: 6 files, 11040 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2206b), SKILL.md (5836b), _meta.json (129b)\n\nFile v1.0.6:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.6:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.6\",\n  \"publishedAt\": 1791380123814\n}\n\nFile v1.0.6:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.6:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.6:skill-card.md\n\n## Description:\n\nHelps customers read appointments, invoices, quotes, and work requests from their Jobber Client Hub using a signed-in browser tab and shell commands instead of an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers and developers use this skill to retrieve and inspect their own Jobber Client Hub appointments, invoices, quotes, and work requests from shell scripts without installing the Jobber MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The browser bridge can read signed-in Jobber Client Hub pages.\n\nMitigation: Use it only if you trust the fpx CLI and ContextMint Bridge, and limit the profile to getjobber.com fetch access.\n\nRisk: A shared hub URL or saved page may expose customer information.\n\nMitigation: Treat the hub URL and page dumps as sensitive; avoid committing or sharing them, and remove local dumps when finished.\n\nRisk: An incorrect page type or changed page layout can make an empty result misleading.\n\nMitigation: Match the parser mode to the page and check empty-result warnings before treating a list as complete.\n\n## Reference(s):\n\n- [Jobber MCP skill listing](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Recipes](references/recipes.md)\n- [Why not the Jobber API](references/why-not-the-api.md)\n- [Client Hub parser](references/parse-clienthub.mjs)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance, Code]\n\n**Output Format:** [Markdown with shell commands and JSON examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only; the bundled parser emits JSON records for selected Client Hub pages.]\n\n## Skill Version(s):\n\n1.0.6 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.5: 6 files, 11131 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2484b), SKILL.md (5836b), _meta.json (129b)\n\nFile v1.0.5:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.5:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.5\",\n  \"publishedAt\": 1791168940922\n}\n\nFile v1.0.5:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.5:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.5:skill-card.md\n\n## Description:\n\nGuides agents to read and parse a customer's Jobber Client Hub appointments, invoices, quotes, and work requests using a signed-in browser session without an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers and developers use this skill to inspect their own Jobber Client Hub appointments, invoices, quotes, and work requests from a shell or script without installing the MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A Client Hub URL and fetched invoice or appointment data can expose private account information.\n\nMitigation: Treat the URL and output as credentials or private data; avoid shared shell history and committed files, and remove saved HTML when no longer needed.\n\nRisk: Fetching through a signed-in browser requires trusting the CLI and browser extension with access to Jobber pages.\n\nMitigation: Install only if you trust @fetchproxy/cli and ContextMint Bridge, and limit extension site access to getjobber.com.\n\nRisk: Selecting the wrong parser kind can return an empty list that appears to mean there are no records.\n\nMitigation: Match the parser kind to the fetched page and investigate empty-result warnings before relying on the result.\n\n## Reference(s):\n\n- [Jobber Client Hub recipes](references/recipes.md)\n- [Client Hub parser](references/parse-clienthub.mjs)\n- [Why not the Jobber API](references/why-not-the-api.md)\n- [ContextMint Bridge source](https://github.com/nullnet-app/contextmint-bridge)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/jobber-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Code, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell examples and JSON parsing guidance]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [The parser yields JSON records from read-only Client Hub pages; each business has a separate hub.]\n\n## Skill Version(s):\n\n1.0.5 (source: server-resolved ClawHub release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.4: 6 files, 10923 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (1875b), SKILL.md (5836b), _meta.json (129b)\n\nFile v1.0.4:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.4:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.4\",\n  \"publishedAt\": 1790991908624\n}\n\nFile v1.0.4:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.4:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.4:skill-card.md\n\n## Description:\n\nHelps customers read their Jobber Client Hub appointments, invoices, quotes, and work requests from the shell without running an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers and developers use this skill to retrieve their own Jobber Client Hub visits, invoices, quotes, and work requests as JSON for review or scripts.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A hub URL or saved page output could expose private billing and account data.\n\nMitigation: Treat hub URLs and saved HTML or JSON as private; do not commit or share them.\n\nRisk: The browser bridge can fetch data through a signed-in Jobber tab.\n\nMitigation: Verify the extension release before installing, limit access to getjobber.com, and remove the profile or extension when no longer needed.\n\n## Reference(s):\n\n- [Jobber MCP on ClawHub](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Usage recipes](references/recipes.md)\n- [Why not the API](references/why-not-the-api.md)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, JSON, Guidance]\n\n**Output Format:** [Markdown instructions with shell examples and parsed JSON records]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only; requires a signed-in Jobber Client Hub tab and browser bridge.]\n\n## Skill Version(s):\n\n1.0.4 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.3: 6 files, 11094 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2352b), SKILL.md (5836b), _meta.json (129b)\n\nFile v1.0.3:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.3:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.3\",\n  \"publishedAt\": 1790603729081\n}\n\nFile v1.0.3:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.3:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.3:skill-card.md\n\n## Description:\n\nHelps agents read a customer's Jobber Client Hub appointments, invoices, quotes, and work requests from a signed-in browser tab without running an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers of businesses using Jobber can ask an agent to retrieve and summarize their own Client Hub visits, invoices, quotes, and requests, or prepare commands to inspect that data in scripts.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A hub URL or saved page output can expose private billing and appointment details.\n\nMitigation: Keep hub URLs out of shared history and committed files; protect saved HTML and JSON like private billing data.\n\nRisk: The browser bridge can read data from the signed-in Jobber tab.\n\nMitigation: Verify the CLI and extension sources, approve only the needed fetch capability, and limit extension site access to getjobber.com.\n\nRisk: An empty parsed list may reflect a mismatched page and parser, not an absence of records.\n\nMitigation: Match the parser kind to the fetched page and check its warning output before treating an empty list as complete.\n\n## Reference(s):\n\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [ContextMint Bridge source](https://github.com/nullnet-app/contextmint-bridge)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n- [Usage recipes](references/recipes.md)\n- [Client Hub parser](references/parse-clienthub.mjs)\n- [Why not the Jobber API](references/why-not-the-api.md)\n\n## Skill Output:\n\n**Output Type(s):** [Text, Shell commands, Code, Guidance]\n\n**Output Format:** [Text or Markdown with shell commands and JSON examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only customer hub data; parsed lists can include appointments, invoices, quotes, and work requests.]\n\n## Skill Version(s):\n\n1.0.3 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.2: 6 files, 10959 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2382b), SKILL.md (5459b), _meta.json (129b)\n\nFile v1.0.2:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the Transporter\nextension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **Transporter** extension installed in Chrome, an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's Chrome\n**Site access** allowing `getjobber.com`.\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.2:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.2\",\n  \"publishedAt\": 1790199593653\n}\n\nFile v1.0.2:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.2:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.2:skill-card.md\n\n## Description:\n\nRead Jobber Client Hub appointments, quotes, invoices, and work requests from a signed-in browser tab using fpx, returning shell-friendly JSON without running the MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nExternal users and developers use this skill to retrieve their own Jobber Client Hub records into scripts or command-line workflows when the documented Jobber API is not available to them as customers.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill depends on fpx and the Transporter extension reading through a signed-in Jobber browser session.\n\nMitigation: Install only when comfortable granting that read path, keep the fpx profile fetch-only for getjobber.com, and review Chrome site access before use.\n\nRisk: A Jobber Client Hub URL and saved hub HTML can expose private appointment, quote, invoice, and request data.\n\nMitigation: Store hub URLs in environment variables, avoid committing URLs or captured pages, and delete local page captures when no longer needed.\n\nRisk: Wrong parser mode or changed Client Hub markup can produce empty or misleading results.\n\nMitigation: Check parser stderr, match the parser kind to the fetched page, and re-verify selectors before relying on an unexpected empty result.\n\n## Reference(s):\n\n- [ClawHub skill page](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Recipes](references/recipes.md)\n- [Why this skill does not use Jobber's documented API](references/why-not-the-api.md)\n- [Client Hub parser](references/parse-clienthub.mjs)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, code, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown guidance with shell commands and a JavaScript parser that emits JSON]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Parser output is structured JSON for appointments, invoices, quotes, and work requests.]\n\n## Skill Version(s):\n\n1.0.2 (source: server release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.1: 6 files, 10956 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2464b), SKILL.md (5459b), _meta.json (129b)\n\nFile v1.0.1:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the Transporter\nextension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **Transporter** extension installed in Chrome, an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's Chrome\n**Site access** allowing `getjobber.com`.\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.1:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.1\",\n  \"publishedAt\": 1790178270843\n}\n\nFile v1.0.1:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.1:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.1:skill-card.md\n\n## Description:\n\nRead Jobber Client Hub appointments, invoices, quotes, and work requests from a signed-in browser tab using fpx, then parse the hub pages into JSON for shell workflows.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nExternal users and developers use this skill to retrieve their own Jobber customer portal records when the Jobber Developer API is not available to them as customers. It supports read-only shell workflows for checking appointments, invoices, quotes, and work requests.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The Jobber hub URL and fetched portal data can expose private billing, appointment, quote, and customer information.\n\nMitigation: Keep hub URLs in local environment variables, do not commit saved HTML or JSON outputs, and delete local invoice or hub page captures when no longer needed.\n\nRisk: The skill depends on fpx/Transporter fetch access from a signed-in browser tab for getjobber.com.\n\nMitigation: Install only when that browser bridge access is acceptable, and keep the granted capability limited to the documented fetch access for getjobber.com.\n\nRisk: The parser can return an empty list if the wrong page kind is supplied or if the live Client Hub markup changes.\n\nMitigation: Check parser stderr warnings, use the matching page kind for each hub page, and re-verify selectors against the live hub before relying on empty results.\n\n## Reference(s):\n\n- [Recipes](references/recipes.md)\n- [Why this skill does not use Jobber's documented API](references/why-not-the-api.md)\n- [Client Hub parser](references/parse-clienthub.mjs)\n\n## Skill Output:\n\n**Output Type(s):** [Guidance, Shell commands, Configuration, Code, JSON]\n\n**Output Format:** [Markdown guidance with shell commands and JSON parser output]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [The included parser reads HTML from standard input and writes structured JSON records for appointments, invoices, quotes, and work requests.]\n\n## Skill Version(s):\n\n1.0.1 (source: server release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.0: 6 files, 11120 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2903b), SKILL.md (5459b), _meta.json (129b)\n\nFile v1.0.0:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the Transporter\nextension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **Transporter** extension installed in Chrome, an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's Chrome\n**Site access** allowing `getjobber.com`.\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v1.0.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.0\",\n  \"publishedAt\": 1789872625168\n}\n\nFile v1.0.0:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v1.0.0:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA and\n  TLS fingerprint, so a cookie captured on a laptop is dead when replayed from\n  a server — which is why the matching MCP cannot be hosted remotely today.\n\n## If you *are* the business\n\nThen the Developer API is the right answer and this skill is not: register an\napp at `developer.getjobber.com`, take the `authorization_code` grant against\n`https://api.getjobber.com/api/oauth/authorize`, and send\n`X-JOBBER-GRAPHQL-VERSION` with every request. That is a different integration\nwith a different archetype — a bearer/direct-API client, no browser bridge.\n\nFile v1.0.0:skill-card.md\n\n## Description:\n\nRead your Jobber Client Hub from a shell with the fpx CLI, parse appointments, invoices, quotes, and work requests into JSON, and use that data without running the jobber-mcp server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and technically comfortable Jobber Client Hub customers use this skill to fetch their own hub pages through a signed-in browser tab and convert appointments, invoices, quotes, and work requests into JSON for shell scripts or jq workflows.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The Jobber hub URL and fetched outputs can expose private customer, billing, invoice, quote, and appointment data.\n\nMitigation: Keep the hub URL out of committed files and shared shell history, store saved HTML or JSON securely, and treat parsed outputs as private data.\n\nRisk: Using this skill grants fpx/Transporter fetch access to getjobber.com from a signed-in browser tab.\n\nMitigation: Install only when that access is acceptable, scope the fpx profile to getjobber.com, and revoke or remove the profile or extension access when finished.\n\nRisk: Empty parser output can mean either no records or changed page markup, depending on whether the parser emitted a warning.\n\nMitigation: Check stderr warnings and re-verify selectors against the live page before trusting unexpected empty results.\n\nRisk: The skill is read-only and cannot safely automate payments, approvals, quote responses, work requests, PDFs, or file downloads.\n\nMitigation: Use the browser-based Jobber Client Hub directly for payments, approvals, form submissions, and downloads.\n\n## Reference(s):\n\n- [Skill Page](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Parser Script](references/parse-clienthub.mjs)\n- [Recipes](references/recipes.md)\n- [Why this skill does not use Jobber's documented API](references/why-not-the-api.md)\n- [Jobber Client Hub](https://clienthub.getjobber.com)\n- [Jobber GraphQL API endpoint](https://api.getjobber.com/api/graphql)\n\n## Skill Output:\n\n**Output Type(s):** [guidance, shell commands, code, configuration, text]\n\n**Output Format:** [Markdown guidance with shell commands and a JavaScript parser that emits JSON]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Parser output is JSON records for appointments, invoices, quotes, and work requests; saved hub HTML or parsed JSON can contain private customer, billing, and schedule data.]\n\n## Skill Version(s):\n\n1.0.0 (source: server release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v0.4.0: 6 files, 11072 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2657b), SKILL.md (5459b), _meta.json (129b)\n\nFile v0.4.0:SKILL.md\n\n---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the Transporter\nextension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **Transporter** extension installed in Chrome, an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's Chrome\n**Site access** allowing `getjobber.com`.\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no combined view and no account that spans them.\n\n## Core call\n\nFetch the page, pipe it through the parser, and you have JSON for `jq`:\n\n```sh\nPARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'\n```\n\nThe parser is dependency-free — a bare `node` runs it, no install step.\n\n`jq` alone cannot do this job: the hub is server-rendered HTML, and its two page\nfamilies store data two different ways (JSON islands for appointments, plain\ncards for everything else). The parser hides that split behind one interface.\n\n| Command | Reads |\n| --- | --- |\n| `node \"$PARSE\" appointments` | visits — Today / Upcoming / Past |\n| `node \"$PARSE\" invoices` | invoices, with section state (`Paid`, …) |\n| `node \"$PARSE\" quotes` | quotes |\n| `node \"$PARSE\" work_requests` | requests you raised |\n\nReady-to-run recipes — next visit, unpaid invoices, totals, a single record —\nare in `references/recipes.md`.\n\n## Pass the right kind — the failure is silent otherwise\n\n`appointments` reads embedded JSON; the other three read HTML cards. Point the\nappointments reader at the invoice page and it finds the page's one island — an\nunrelated *referral widget* — which parses cleanly and contains no invoices. It\nlooks like \"you have no invoices\" rather than like a bug.\n\nThe parser warns on stderr whenever it returns an empty list, for exactly this\nreason. An empty result with no warning means the page genuinely had none.\n\n## Exit codes\n\nThe parser follows the `fpx` convention, so a pipeline can branch on either:\n\n| Code | Meaning |\n| --- | --- |\n| `0` | parsed (possibly an empty list — check stderr) |\n| `3` | Cloudflare interstitial, not a hub page — the request missed the tab |\n| `64` | bad usage (unknown kind) |\n| `65` | empty input — the upstream `fpx` call produced nothing |\n\nFrom `fpx` itself: `2` bridge down, `3` bot wall, `4` upstream non-2xx.\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html\n```\n\n## What this cannot do\n\nRead-only, by design and by capability:\n\n- **No writes.** Submitting a work request, approving a quote or confirming an\n  appointment are form POSTs with CSRF and, on some flows, a Turnstile token\n  read from the DOM. `fpx` has no DOM-read verb, so it cannot complete them.\n- **No PDF or file downloads.** Invoice and quote PDFs are served as\n  `Content-Disposition: attachment`; the bridge does `fetch()`, not navigation,\n  so these URLs can only be *resolved* for you to open, never fetched.\n- **No payments.** Paying an invoice means entering card or bank details. Never\n  automate that — open the hub and do it yourself.\n\nFile v0.4.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"0.4.0\",\n  \"publishedAt\": 1789688308712\n}\n\nFile v0.4.0:references/recipes.md\n\n# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"/client_hubs/<uuid>/appointments/2236612358\"\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base https://clienthub.getjobber.com \\\n  'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"/client_hubs/<uuid>/invoices/150208512\"\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `title`, `number`, `details`, `url`:\n\n```sh\nhub quotes         | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.title)\"'\nhub work_requests  | jq -r '.[] | \"\\(.section)\\t\\(.title)\"'\n```\n\nQuotes awaiting your response:\n\n```sh\nhub quotes | jq '[.[] | select(.section | test(\"await|pending|review\"; \"i\"))]'\n```\n\n## A single record\n\nDetail pages are HTML too, and their layout differs from the list cards. The\nparser targets lists; for one record, take the URL from the list and open it:\n\n```sh\nhub invoices | jq -r --arg base https://clienthub.getjobber.com \\\n  '.[] | select(.number==\"#15313\") | $base + .url'\n```\n\nTo read a detail page's raw HTML yourself:\n\n```sh\nfpx get -p jobber \"$JOBBER_HUB/invoices/150208512\" > invoice.html\n```\n\n## Several vendors\n\nOne hub per business; there is no combined view. Loop over the hubs you hold:\n\n```sh\nfor hub_url in \"$QUEENBEE_HUB\" \"$GREENWORX_HUB\"; do\n  JOBBER_HUB=\"$hub_url\"\n  echo \"== $(fpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n          | grep -oiE '<title>[^<]*' | head -1 | cut -c8-)\"\n  hub appointments | jq -r '.[] | select(.group!=\"Past\") | \"  \\(.date) \\(.location)\"'\ndone\n```\n\n## Health check\n\n```sh\nfpx health -p jobber   # is the bridge up at all?\nfpx get -p jobber \"$JOBBER_HUB/appointments\" | head -c 200\n```\n\nA `<title>Just a moment` in that output means the request did not go through the\ntab — the parser exits `3` on it rather than returning an empty list.\n\nFile v0.4.0:references/why-not-the-api.md\n\n# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is aut\n\nArchive v0.3.3: 6 files, 11037 bytes\n\nFiles: references/parse-clienthub.mjs (5147b), references/recipes.md (4399b), references/why-not-the-api.md (3570b), skill-card.md (2709b), SKILL.md (5459b), _meta.json (129b)","readmeExcerpt":"Skill: jobber-mcp Owner: chrischall Summary: Read your Jobber Client Hub — the customer portal a service business (pest control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running the jobber-mcp server. Use when you want your Jobber data without the MCP, in a script, or on a machine where the MCP isn't installed. Tags: la","codeSnippets":[],"executableExamples":[{"language":"sh","snippet":"npm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed"},{"language":"text","snippet":"https://clienthub.getjobber.com/client_hubs/<hub-uuid>/"},{"language":"sh","snippet":"export JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'"},{"language":"sh","snippet":"PARSE=\"$(dirname \"$0\")/references/parse-clienthub.mjs\"   # or an absolute path\n\nfpx get -p jobber \"$JOBBER_HUB/appointments\" \\\n  | node \"$PARSE\" appointments \\\n  | jq '.'"},{"language":"sh","snippet":"node \"$PARSE\" invoices < page.html | jq -r --arg base \"$JOBBER_HUB/\" '.[] | $base + .url'"},{"language":"sh","snippet":"fpx get -p jobber \"$JOBBER_HUB/invoices\" > page.html || {\n  echo \"fpx failed ($?) — is Chrome running with a signed-in hub tab?\" >&2; exit 1; }\nnode \"$PARSE\" invoices < page.html"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: jobber-fpx\ndescription: >-\n  Read your Jobber Client Hub — the customer portal a service business (pest\n  control, lawn care, HVAC, cleaning) uses to send you appointments, quotes and\n  invoices — from a shell with the fpx CLI (@fetchproxy/cli), instead of running\n  the jobber-mcp server. Use when you want your Jobber data without the MCP, in\n  a script, or on a machine where the MCP isn't installed.\n---\n\n# Jobber Client Hub via fpx (no MCP)\n\nThis reads the **customer** side of Jobber: the hub a business shares with you,\nat `clienthub.getjobber.com`. It is not the Jobber Developer API — that one\nserves the business running on Jobber and needs an OAuth app you cannot register\nas their customer. See `references/why-not-the-api.md`.\n\n`clienthub.getjobber.com` sits behind a Cloudflare managed challenge that\nfingerprints the **TLS client**, so plain `curl` and Node get `403 Just a\nmoment` even with a current Chrome User-Agent and the full browser header set.\n`fpx` issues the request from inside your own signed-in tab, which has already\ncleared the challenge. There is no server-side path; the bridge is not optional\nhere.\n\n## One-time setup\n\n```sh\nnpm install -g @fetchproxy/cli               # provides `fpx`\nfpx profile add jobber --domain getjobber.com # fetch capability only — no cookie scope needed\n```\n\nThe first fetch prints a pair code to **stderr**; approve it in the ContextMint\nBridge extension popup. Pairing persists — every later call reuses it.\n\nRequirements: the **ContextMint Bridge** extension (from\nhttps://github.com/nullnet-app/contextmint-bridge/releases — Chrome: load the\nchrome zip unpacked; Safari isn't available yet, so use Chrome for now), an open\n`clienthub.getjobber.com` tab signed into the hub, and the extension's\n**Site access** allowing `getjobber.com`. (ContextMint Bridge is the fetchproxy\nextension renamed, same maintainer; source at\nhttps://github.com/nullnet-app/contextmint-bridge — verify a release zip with\n`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.)\n\n> Only the fetch capability is declared, deliberately. Cookies ride the tab\n> automatically, so no cookie scope is needed — and widening scope *after* the\n> first approval leaves fetches working on the old grant while the new\n> capability errors. Everything this skill does is covered by the grant above.\n\n## Your hub URL is a credential\n\nEach business gives you a **different** hub, identified by a UUID:\n\n```\nhttps://clienthub.getjobber.com/client_hubs/<hub-uuid>/\n```\n\nAnyone holding that URL can read the hub, so treat it like a password: keep it\nin an env var, never in a committed file or a shell history you share.\n\nGet it from any email that vendor sent you — the \"View Details\" / \"View\nInvoice\" button — or from the address bar of an open hub tab.\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\n```\n\nOne export per business. If two vendors both use Jobber, they are two hubs with\nnothing in common; there is no co"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"jobber-mcp\",\n  \"version\": \"1.0.7\",\n  \"publishedAt\": 1791588306464\n}"},{"path":"references/recipes.md","content":"# Recipes\n\nEvery recipe assumes the setup from `SKILL.md`:\n\n```sh\nexport JOBBER_HUB='https://clienthub.getjobber.com/client_hubs/<hub-uuid>'\nPARSE=references/parse-clienthub.mjs        # adjust to where the skill lives\nhub() { fpx get -p jobber \"$JOBBER_HUB/$1\" | node \"$PARSE\" \"$1\"; }\n```\n\n`hub` takes the page name, which is also the parser kind — they are the same\nword on purpose, so the two can never drift apart.\n\n## Appointments\n\nRecord shape:\n\n```jsonc\n{\n  \"group\": \"Today\" | \"Upcoming\" | \"Past\",\n  \"id\": \"2236612358\",\n  \"date\": \"Jun 28, 2026\",\n  \"weekday\": \"Sunday\",\n  \"time\": \"9:00am\",        // null when the vendor hides times (canViewTime:false)\n  \"arrivalWindow\": null,   // e.g. \"8:00am - 10:00am\" when the vendor sets one\n  \"duration\": null,\n  \"location\": \"123 Elm St, ...\",\n  \"confirmed\": true,\n  \"url\": \"appointments/2236612358\"  // hub-relative: the hub id never appears\n}\n```\n\nEverything upcoming:\n\n```sh\nhub appointments | jq '[.[] | select(.group != \"Past\")]'\n```\n\nThe next visit, as one line:\n\n```sh\nhub appointments \\\n  | jq -r 'map(select(.group != \"Past\")) | first\n           | if . == null then \"no upcoming visits\"\n             else \"\\(.weekday) \\(.date)\\(if .time then \" at \\(.time)\" else \"\" end) — \\(.location)\"\n             end'\n```\n\nVisit history, most recent first (the hub already returns `Past` newest-first):\n\n```sh\nhub appointments | jq -r '.[] | select(.group==\"Past\") | \"\\(.date)\\t\\(.location)\"'\n```\n\nAbsolute URL for one visit:\n\n```sh\nhub appointments | jq -r --arg base \"$JOBBER_HUB/\" 'first | $base + .url'\n```\n\n## Invoices\n\nRecord shape:\n\n```jsonc\n{\n  \"section\": \"Paid\",                 // the list heading this card sat under\n  \"id\": \"150208512\",\n  \"title\": \"For Services Rendered\",\n  \"number\": \"#15313\",\n  \"details\": [\"Sent Mar 23, 2026 | Due Apr 07, 2026\", \"$135.00 & paid in full\"],\n  \"url\": \"invoices/150208512\"       // hub-relative, like appointments\n}\n```\n\n`details` is an ordered list of the card's metadata rows, kept raw rather than\nparsed into fields. The rows the vendor shows vary by invoice state — an unpaid\ninvoice carries a balance row a paid one does not — so a fixed schema would\ninvent fields for some invoices and drop rows for others.\n\nEverything not yet paid:\n\n```sh\nhub invoices | jq '[.[] | select(.section != \"Paid\")]'\n```\n\nOne line per invoice:\n\n```sh\nhub invoices | jq -r '.[] | \"\\(.number)\\t\\(.section)\\t\\(.details[0] // \"\")\"'\n```\n\nPull the amounts out of the detail rows:\n\n```sh\nhub invoices | jq -r '.[] | . as $i\n  | ($i.details[] | select(test(\"\\\\$\")) ) // \"no amount\"\n  | \"\\($i.number)\\t\\(.)\"'\n```\n\nSum what is outstanding — note this parses money out of display strings, so\nsanity-check it before trusting it for anything that matters:\n\n```sh\nhub invoices \\\n  | jq '[.[] | select(.section != \"Paid\") | .details[] | select(test(\"\\\\$\"))\n         | capture(\"\\\\$(?<amt>[0-9,]+(\\\\.[0-9]{2})?)\").amt | gsub(\",\";\"\") | tonumber]\n        | add // 0'\n```\n\n## Quotes and work requests\n\nSame card shape as invoices — `section`, `titl"},{"path":"references/why-not-the-api.md","content":"# Why this skill does not use Jobber's documented API\n\nJobber publishes a clean, well-documented GraphQL API. It is the wrong surface\nfor a customer, and the reason is worth writing down because the API looks so\nmuch more appetising than scraping a portal.\n\n## The two surfaces\n\n| | Developer API | Client Hub |\n| --- | --- | --- |\n| Host | `api.getjobber.com/api/graphql` | `clienthub.getjobber.com` |\n| Serves | the business running on Jobber | that business's customers |\n| Auth | OAuth2 against an app you register | a secret hub URL + session |\n| Node-reachable | yes | **no** — Cloudflare |\n\nThe Developer API is a **seller** surface. To use it you register an app in\nJobber's Developer Center, and the OAuth grant is authorized *by a Jobber\naccount* — the business's. As their customer you have no such account and\nnothing to authorize. There is no consumer tier, and no scope that exposes \"the\ninvoices sent to me\".\n\n## Probes that establish it\n\nVerified 2026-08-09. The API accepts the client identity immediately, which is\nwhat makes it tempting:\n\n```sh\n# unauthenticated: the field is hidden, not rejected\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-JOBBER-GRAPHQL-VERSION: 2025-04-16' \\\n  -d '{\"query\":\"{ account { id name } }\"}'\n# -> \"The field account on an object of type Query was hidden because you are\n#     unauthenticated\"  (HTTP 200)\n\n# bogus bearer: the token is checked, so the transport is fine\ncurl -s -X POST https://api.getjobber.com/api/graphql \\\n  -H 'Authorization: Bearer nope' ... \n# -> {\"message\":\"Token not recognized\"}  (HTTP 401)\n\n# the OAuth token endpoint exists and validates client credentials\ncurl -s -X POST https://api.getjobber.com/api/oauth/token \\\n  -d 'grant_type=authorization_code&client_id=x&client_secret=y&code=z'\n# -> \"The provided client id and secret do not match an existing application\"\n```\n\nEverything works except the one thing that matters: the account those tokens\nwould reach is the vendor's, not yours.\n\nIntrospection is open unauthenticated and returns **410 queries and 629\nmutations** — the full staff schema. That breadth is a trap, not an\nopportunity: it is the surface Jobber's own web app uses, and every field of it\nis gated on a staff session.\n\n`clienthub.getjobber.com/api/graphql` answers introspection too, and returns\nthat *same* staff schema. It is not a client-facing API and not a shortcut.\n\n## Cloudflare fingerprints the TLS client\n\nThe hub pages 403 with `<title>Just a moment...` from Node and curl, and keep\ndoing so when given a current Chrome User-Agent plus the full browser `Accept*`\nset. What clears the challenge is being a real browser at the TLS layer, which\nis why the request has to originate in the tab.\n\nTwo consequences worth stating plainly:\n\n- **Do not add UA spoofing.** It does not work here, and code that spoofs a UA\n  reads as though someone verified that it did.\n- **A lifted cookie will not travel.** `cf_clearance` is bound to IP, UA"},{"path":"skill-card.md","content":"## Description:\n\nReads a customer's Jobber Client Hub appointments, invoices, quotes, and work requests through a signed-in browser tab and returns structured records for shell-based workflows.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nCustomers and developers use this skill to look up their own Jobber Client Hub appointments, invoices, quotes, and work requests from a shell without running an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A Jobber hub URL grants access to customer records if exposed.\n\nMitigation: Treat each hub URL like a password; keep it out of committed files and shared shell history.\n\nRisk: The browser bridge receives fetch access to getjobber.com.\n\nMitigation: Review the CLI package and browser extension source and release before installation, and grant only the fetch capability required by the skill.\n\nRisk: Account-changing actions or payments require separate safeguards.\n\nMitigation: Use this skill only for read-only lookup; handle payments and account changes directly in the hub.\n\n## Reference(s):\n\n- [Jobber MCP ClawHub release](https://clawhub.ai/chrischall/skills/jobber-mcp)\n- [Usage recipes](artifact/references/recipes.md)\n- [Client Hub access and API distinction](artifact/references/why-not-the-api.md)\n- [ContextMint Bridge source](https://github.com/nullnet-app/contextmint-bridge)\n- [ContextMint Bridge releases](https://github.com/nullnet-app/contextmint-bridge/releases)\n\n## Skill Output:\n\n**Output Type(s):** [JSON, Shell commands, Guidance]\n\n**Output Format:** [JSON records and Markdown with shell commands]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Record URLs are hub-relative and omit the hub credential; prefix the user's hub URL to open a record.]\n\n## Skill Version(s):\n\n1.0.7 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":1843,"uniquenessScore":42,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-11T08:53:29.687Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-11T11:26:22.797Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}