simplepractice-mcp 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { asBoolean } from '../jsonapi.js';
4
+ const PAGE_SIZE_MAX = 50;
5
+ /** Statuses that mean the client has nothing left to do. */
6
+ const SETTLED = new Set(['completed', 'locked']);
7
+ export function registerDocumentTools(server, client) {
8
+ server.registerTool('simplepractice_list_document_requests', {
9
+ description: 'Paperwork the practice has sent — consents, questionnaires, contact and insurance forms, Good Faith Estimates, shared files. Use outstandingOnly to see just what still needs the client\'s attention.',
10
+ annotations: toolAnnotations({ readOnly: true }),
11
+ inputSchema: {
12
+ outstandingOnly: z
13
+ .boolean()
14
+ .default(false)
15
+ .describe('Return only requests that are not completed or locked.'),
16
+ pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
17
+ includeBody: z
18
+ .boolean()
19
+ .default(false)
20
+ .describe('Include the full document body/questions. Off by default — these are long.'),
21
+ },
22
+ }, async ({ outstandingOnly, pageSize, includeBody }) => {
23
+ const { records, meta } = await client.list('/document-requests', {
24
+ page: { size: pageSize },
25
+ });
26
+ const filtered = outstandingOnly
27
+ ? records.filter((r) => !SETTLED.has(String(r.status)))
28
+ : records;
29
+ const items = filtered.map((r) => {
30
+ const base = {
31
+ id: r.id,
32
+ // The subtype IS the type field — documentRequestQuestionnaires etc.
33
+ kind: r.type,
34
+ title: r.documentTitle,
35
+ status: r.status,
36
+ createdAt: r.createdAt,
37
+ updatedAt: r.updatedAt,
38
+ // Arrives as the STRING "true"/"false" on the wire, so a plain
39
+ // truthiness test would report every row as having a PDF.
40
+ hasDocumentPdf: asBoolean(r.hasDocumentPdf) ?? false,
41
+ };
42
+ if (includeBody) {
43
+ base.documentBody = r.documentBody;
44
+ base.templateQuestions = r.templateQuestions;
45
+ base.userAnswers = r.userAnswers;
46
+ }
47
+ return base;
48
+ });
49
+ return textResult({
50
+ count: items.length,
51
+ outstanding: records.filter((r) => !SETTLED.has(String(r.status))).length,
52
+ welcomeText: meta?.welcomeText ?? null,
53
+ documentRequests: items,
54
+ });
55
+ });
56
+ server.registerTool('simplepractice_get_document_request', {
57
+ description: 'One document request in full, including its body or its questions and the answers already given.',
58
+ annotations: toolAnnotations({ readOnly: true }),
59
+ inputSchema: { id: z.string().min(1).describe('The document request id.') },
60
+ }, async ({ id }) => {
61
+ const { records } = await client.list(`/document-requests/${encodeURIComponent(id)}`);
62
+ const record = records[0];
63
+ if (!record)
64
+ return textResult({ found: false, id });
65
+ return textResult({
66
+ ...record,
67
+ hasDocumentPdf: asBoolean(record.hasDocumentPdf) ?? false,
68
+ });
69
+ });
70
+ server.registerTool('simplepractice_list_documents', {
71
+ description: 'Files the practice has shared through the Client Portal.',
72
+ annotations: toolAnnotations({ readOnly: true }),
73
+ inputSchema: {
74
+ pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
75
+ },
76
+ }, async ({ pageSize }) => {
77
+ const { records } = await client.list('/documents', { page: { size: pageSize } });
78
+ return textResult({ count: records.length, documents: records });
79
+ });
80
+ server.registerTool('simplepractice_list_announcements', {
81
+ description: 'Announcements the practice has posted to the Client Portal. readAt is null on unread ones.',
82
+ annotations: toolAnnotations({ readOnly: true }),
83
+ inputSchema: {
84
+ pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
85
+ },
86
+ }, async ({ pageSize }) => {
87
+ const { records } = await client.list('/announcements', { page: { size: pageSize } });
88
+ return textResult({
89
+ count: records.length,
90
+ unread: records.filter((r) => r.readAt === null || r.readAt === undefined).length,
91
+ announcements: records,
92
+ });
93
+ });
94
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Single source of truth for the server version. release-please rewrites the
3
+ * literal below; every other file imports VERSION rather than repeating it.
4
+ */
5
+ export const VERSION = '0.0.0'; // x-release-please-version
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "simplepractice-mcp",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "mcpName": "io.github.chrischall/simplepractice-mcp",
6
+ "description": "SimplePractice Client Portal MCP server for Claude — developed and maintained by AI (Claude Code)",
7
+ "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/chrischall/simplepractice-mcp.git"
11
+ },
12
+ "type": "module",
13
+ "engines": {
14
+ "node": ">=22.5.0"
15
+ },
16
+ "bin": {
17
+ "simplepractice-mcp": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ ".claude-plugin",
22
+ "skills",
23
+ ".mcp.json",
24
+ "server.json"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc && npm run bundle",
28
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
29
+ "dev": "node --env-file=.env dist/index.js",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:coverage": "vitest run --coverage",
33
+ "test:watch": "vitest"
34
+ },
35
+ "dependencies": {
36
+ "@chrischall/mcp-utils": "^0.15.0",
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
38
+ "dotenv": "^17.4.2",
39
+ "zod": "^4.4.3"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^26.0.0",
43
+ "@vitest/coverage-v8": "^4.1.7",
44
+ "esbuild": "^0.28.0",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^4.1.7"
47
+ }
48
+ }
package/server.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.chrischall/simplepractice-mcp",
4
+ "description": "SimplePractice Client Portal — appointments, billing, documents, announcements",
5
+ "repository": {
6
+ "url": "https://github.com/chrischall/simplepractice-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "0.0.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "simplepractice-mcp",
14
+ "version": "0.0.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "SIMPLEPRACTICE_PRACTICE",
21
+ "description": "Your practice's Client Portal address — the slug (\"achievebalancetherapy\") or the full host (\"achievebalancetherapy.clientsecure.me\").",
22
+ "isRequired": true,
23
+ "format": "string"
24
+ },
25
+ {
26
+ "name": "SIMPLEPRACTICE_SESSION_FILE",
27
+ "description": "Where to persist the signed-in session (default ~/.simplepractice-mcp/session.json, written 0600).",
28
+ "isRequired": false,
29
+ "format": "string"
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: simplepractice
3
+ description: >-
4
+ Read a SimplePractice Client Portal through the simplepractice-mcp server —
5
+ upcoming appointments, invoices/statements/superbills/receipts, balance and
6
+ saved cards, paperwork waiting to be signed, and practice announcements.
7
+ Use when the user asks about their therapy or healthcare appointments,
8
+ what they owe a practice, a superbill for insurance, or forms their
9
+ provider has sent them.
10
+ ---
11
+
12
+ # SimplePractice Client Portal
13
+
14
+ `simplepractice-mcp` reads the Client Portal a practice gives its clients —
15
+ the patient side, not the clinician side. It is **read-only**: nothing here
16
+ cancels an appointment, signs a form, or pays a bill.
17
+
18
+ ## Signing in
19
+
20
+ The portal has **no password**. SimplePractice emails a one-time link (or a
21
+ 6-digit PIN), and that is the only way in.
22
+
23
+ 1. `simplepractice_session_status` — check first; a session persists between
24
+ runs, so most of the time there is nothing to do.
25
+ 2. `simplepractice_request_sign_in_link` with the user's portal email. It is
26
+ confirm-gated because it sends a real email and the endpoint is rate-limited
27
+ **per address and per IP** — a retry loop locks the user out of the only
28
+ auth path there is. Ask before sending, and never send twice.
29
+ 3. The user opens the email and gives you the link. Pass it whole to
30
+ `simplepractice_verify_sign_in_token` — it takes the token out of the
31
+ fragment itself. Tokens are single-use and last 24 hours.
32
+
33
+ `SIMPLEPRACTICE_PRACTICE` must name the practice's portal — the slug or the
34
+ full `<practice>.clientsecure.me` host, from the link the provider emailed.
35
+ If it is unset, every tool says so on its first call.
36
+
37
+ There is no refresh token. When a session lapses the tools say to sign in
38
+ again; that means another email.
39
+
40
+ ## Reading
41
+
42
+ - `simplepractice_get_account` — the practice, the current client, and the
43
+ clients this login covers. **Start here**: one portal login can act for
44
+ several people (a parent for two children), so confirm *whose* record you
45
+ are about to report on before you report on it. It also returns the
46
+ practice's real cancellation policy and the client's feature permissions.
47
+ - `simplepractice_list_appointments` — `status: "scheduled"` for confirmed and
48
+ upcoming, `"requested"` for ones the practice has not confirmed yet.
49
+ - `simplepractice_list_document_requests` — paperwork. `outstandingOnly: true`
50
+ answers "is anything waiting for me?", which is the usual question.
51
+ - `simplepractice_get_billing_overview` — balance due and per-category counts.
52
+ Cheaper than listing the billing collections to find out they are empty.
53
+ - `simplepractice_list_billing_items` — invoices, statements, **superbills**
54
+ (the receipt to claim out-of-network insurance), receipts, or account
55
+ history. Pages by cursor: pass the returned `nextCursor` back as `before`.
56
+ - `simplepractice_list_payment_methods`, `simplepractice_list_documents`,
57
+ `simplepractice_list_announcements`.
58
+
59
+ ## Reading the results honestly
60
+
61
+ - **An empty billing list is a real answer.** Plenty of practices invoice
62
+ entirely outside the portal. "No invoices in the portal" is the true
63
+ statement; "you owe nothing" is not one you can make from it.
64
+ - **Do not tell someone they can cancel an appointment.** `isCancellable` and
65
+ the practice's `clientMayCancelAppointments` / `cancellationNoticeHours`
66
+ are what govern it, and cancelling has to happen in the portal anyway.
67
+ - **This is medical information.** Report what was asked. Don't volunteer
68
+ diagnoses, session notes, or a family member's records into a conversation
69
+ that wasn't about them.
70
+
71
+ ## The shell alternative
72
+
73
+ `simplepractice-fpx` does the same reads with `curl` and no server, for
74
+ scripts or a machine without the MCP installed.
@@ -0,0 +1,260 @@
1
+ ---
2
+ name: simplepractice-fpx
3
+ description: >-
4
+ Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a
5
+ shell — appointments, invoices/statements/superbills/receipts, documents to
6
+ sign, announcements, practice and clinician info — with plain `curl` against
7
+ its JSON:API, instead of running the simplepractice-mcp server. Sign in
8
+ headlessly with an emailed magic link, or capture the session cookie from an
9
+ already-signed-in browser tab with `fpx`. Use when you want Client Portal
10
+ data without the MCP, in a script, or on a machine where the MCP isn't
11
+ installed.
12
+ ---
13
+
14
+ # SimplePractice Client Portal via curl (+ optional fpx)
15
+
16
+ The Client Portal is an Ember app whose backend is a plain **JSON:API** at
17
+ `https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**
18
+ — every endpoint below answers ordinary server-side `curl` once you hold a
19
+ session cookie. So this skill is curl-first; `fpx` appears only as an optional
20
+ one-time way to lift the cookie out of a browser you're already signed into.
21
+
22
+ There is **no password**. Sign-in is passwordless: SimplePractice emails you
23
+ either a magic link or a 6-digit PIN, and you trade that for a session cookie.
24
+ That flow carries **no captcha** (reCAPTCHA guards only the new-client request,
25
+ waitlist and contact forms), so §1 below works headlessly with nothing but
26
+ `curl` and access to your inbox.
27
+
28
+ > This is protected health information — your own therapy/medical record.
29
+ > Treat the cookie jar as a credential: it is a full-access bearer token for
30
+ > the portal. Keep it `chmod 600`, out of git, and off shared machines.
31
+
32
+ ## Your practice subdomain
33
+
34
+ Every URL is scoped to one practice. Take the host from the portal link your
35
+ provider sent you and export it once:
36
+
37
+ ```sh
38
+ export SP_HOST='achievebalancetherapy.clientsecure.me' # <-- yours
39
+ export SP_API="https://$SP_HOST/client-portal-api"
40
+ export SP_JAR="$HOME/.simplepractice-cookies"
41
+
42
+ # curl creates a cookie jar world-readable (644). This one holds a live
43
+ # session for a medical record, so create it 0600 BEFORE curl ever writes it.
44
+ [ -e "$SP_JAR" ] || ( umask 077; : > "$SP_JAR" )
45
+ chmod 600 "$SP_JAR"
46
+ ```
47
+
48
+ ## The four headers — all of them, on every call
49
+
50
+ ```sh
51
+ sp() { curl -s -b "$SP_JAR" -c "$SP_JAR" \
52
+ -H 'Api-Version: 2026-05-25' \
53
+ -H 'Application-Build-Version: 0.0.0' \
54
+ -H 'Application-Platform: web' \
55
+ -H 'Accept: application/vnd.api+json' "$@"; }
56
+ ```
57
+
58
+ Omit `Application-Build-Version` and the API rejects the call with
59
+ `400 {"errors":[{"title":"Application build version is missing"}]}` — verified.
60
+ `Api-Version` is the API's own dated contract version, unrelated to any package
61
+ version; send it as-is.
62
+
63
+ ## 1. Sign in with a magic link (no browser)
64
+
65
+ **a. Request the link.** One call, to your own portal address:
66
+
67
+ ```sh
68
+ sp -X POST "$SP_API/sign-in-tokens" \
69
+ -H 'Content-Type: application/vnd.api+json' \
70
+ --data '{"data":{"type":"sign-in-tokens","attributes":{"email":"you@example.com","expiresIn":"15 minutes"}}}'
71
+ ```
72
+
73
+ `202 Accepted` means it was sent. The response echoes `expiresIn: "24 hours"`
74
+ regardless of what you asked for — that is the real token lifetime, and it is
75
+ also what the API returns for an *unknown* email, deliberately, so that a 202
76
+ never reveals whether an address has an account.
77
+
78
+ **Do not retry a failed sign-in.** `429` is a real limit with two distinct
79
+ titles — `Email request limit reached` and `IP request limit reached` — and
80
+ hammering it locks you out of the only auth path there is. Wait it out.
81
+
82
+ **b. Take the token out of the emailed link.** The link looks like
83
+
84
+ ```
85
+ https://<practice>.clientsecure.me/sign-in/token/verify#<TOKEN>
86
+ ```
87
+
88
+ The token is the **URL fragment**, after the `#`. Because it is a fragment it
89
+ is never sent to the server by a browser navigation — the app reads it in JS
90
+ and posts it. So you must copy it yourself; following the link with `curl`
91
+ does nothing.
92
+
93
+ **c. Trade it for a session cookie.**
94
+
95
+ ```sh
96
+ sp -X POST "$SP_API/sessions/token" \
97
+ -H 'Content-Type: application/vnd.api+json' \
98
+ --data '{"data":{"type":"sessions","attributes":{"type":"token","token":"'"$TOKEN"'"}}}' \
99
+ | jq '.data.meta.status'
100
+ ```
101
+
102
+ `"verified"` means the cookie jar is now authenticated. The other statuses are
103
+ `"expired"` and `"merged"`; a `401`/`422` means the token was already used —
104
+ they are single-use.
105
+
106
+ **PIN variant.** If your portal mails a 6-digit code instead of a link, post it
107
+ to `sessions/pin` with the address it was sent to:
108
+
109
+ ```sh
110
+ sp -X POST "$SP_API/sessions/pin" \
111
+ -H 'Content-Type: application/vnd.api+json' \
112
+ --data '{"data":{"type":"sessions","attributes":{"type":"pin","email":"you@example.com","pin":"123456"}}}'
113
+ ```
114
+
115
+ ## 2. Or lift the cookie from a signed-in browser tab (fpx)
116
+
117
+ Only worth it if you're already signed in and would rather not wait on an
118
+ email. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.
119
+
120
+ ```sh
121
+ fpx profile add simplepractice --domain clientsecure.me
122
+ fpx profile declare simplepractice \
123
+ --cookie simplepractice-session --cookie client-portal-session \
124
+ --local-storage client-portal-session --local-storage stored-email \
125
+ --capture-header cookie@$SP_HOST
126
+ fpx get "https://$SP_HOST/" -p simplepractice >/dev/null # prints a pair code → approve in Transporter
127
+ ```
128
+
129
+ Declare **every** scope before that first pairing. Widening it afterwards
130
+ leaves fetches working on the old grant while the new capability errors
131
+ `capability "read_cookies" not granted`, and the fix is to remove the profile
132
+ and re-pair from scratch.
133
+
134
+ Then seed the jar from the browser's cookie:
135
+
136
+ ```sh
137
+ SESSION=$(fpx cookies simplepractice-session -p simplepractice \
138
+ --storage-subdomain "${SP_HOST%%.*}" | jq -r '.["simplepractice-session"]')
139
+ printf '#HttpOnly_%s\tFALSE\t/\tTRUE\t0\tsimplepractice-session\t%s\n' "$SP_HOST" "$SESSION" > "$SP_JAR"
140
+ chmod 600 "$SP_JAR"
141
+ ```
142
+
143
+ `fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.
144
+
145
+ ## 3. Who am I, and which client am I looking at
146
+
147
+ ```sh
148
+ sp "$SP_API/environment?include=currentPractice,currentClient,currentClientOptions" | jq '{
149
+ practice: (.included[] | select(.type=="practices") | .attributes.fullName),
150
+ timeZone: (.included[] | select(.type=="practices") | .attributes.timeZone),
151
+ clients: [.included[] | select(.type=="clients") | {id, name: (.attributes.firstName+" "+.attributes.lastName)}]
152
+ }'
153
+ ```
154
+
155
+ One portal login is a **client access**, and it can cover more than one client
156
+ — a parent seeing two children, say. `currentClientOptions` is always an array;
157
+ `currentClient` is the one whose data the other endpoints return. Don't assume
158
+ there is exactly one. (On a login that acts for someone else, the client
159
+ record's own `email` is `null` — the sign-in address lives on the access, not
160
+ the client, so don't reach for `clients[].email` to find out who you are.)
161
+
162
+ `401 {"title":"You have no access to this client"}` on any endpoint below means
163
+ the cookie is stale or absent — go back to §1.
164
+
165
+ ## 4. Reads
166
+
167
+ All of these are verified live. Collections are JSON:API, so records live under
168
+ `.data[]` with fields under `.attributes`; `include=` pulls related records into
169
+ a sibling `.included[]` array that you join on
170
+ `.relationships.<name>.data.id`.
171
+
172
+ ```sh
173
+ # Upcoming appointments (and the requested-but-unconfirmed ones)
174
+ sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1"
175
+ sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1"
176
+
177
+ # Billing — one endpoint, switched by filter[thisType]
178
+ sp "$SP_API/billing-items?filter[thisType]=invoice&page[size]=50"
179
+ sp "$SP_API/billing-items?filter[thisType]=statement&page[size]=50"
180
+ sp "$SP_API/billing-items?filter[thisType]=superbill&page[size]=50"
181
+ sp "$SP_API/billing-items?filter[thisType]=receipt&page[size]=50"
182
+ sp "$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50"
183
+
184
+ # Documents to review or sign, and files shared with you
185
+ sp "$SP_API/document-requests?page[size]=50"
186
+ sp "$SP_API/documents?page[size]=50"
187
+
188
+ # Practice announcements
189
+ sp "$SP_API/announcements?page[size]=50"
190
+
191
+ # Balance summary and saved cards hang off the CLIENT record, not collections
192
+ # of their own — see the warning below.
193
+ CLIENT_ID=$(sp "$SP_API/environment?include=currentClient" \
194
+ | jq -r '.data.relationships.currentClient.data.id')
195
+ sp "$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards" \
196
+ | jq '{balance: (.included[] | select(.type=="clientBillingOverviews") | .attributes),
197
+ cards: [.included[] | select(.type=="cards")
198
+ | {brand: .attributes.brand, last4: .attributes.last4,
199
+ expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'
200
+ ```
201
+
202
+ > **A 200 is not proof an endpoint exists.** The portal is a single-page app,
203
+ > so *any* path it does not define comes back as `200 text/html` with the app
204
+ > shell (a constant ~7.5 KB) rather than a 404. `/cards` and
205
+ > `/client-billing-overviews` are the obvious guesses for the two above, and
206
+ > both answer 200 that way — they are not API paths at all. Check the
207
+ > `content-type`, not the status:
208
+ >
209
+ > ```sh
210
+ > sp -o /dev/null -w '%{http_code} %{content_type}\n' "$SP_API/whatever"
211
+ > ```
212
+ >
213
+ > Anything other than `application/vnd.api+json` means the path is wrong.
214
+
215
+ A readable next-appointment line:
216
+
217
+ ```sh
218
+ sp "$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1" \
219
+ | jq -r '.data[0] as $a
220
+ | (.included[]? | select(.type=="clinicians")) as $c
221
+ | "\($a.attributes.startTime) \($a.attributes.serviceDescription // "—") with \($c.attributes.firstName) \($c.attributes.lastName)"'
222
+ ```
223
+
224
+ ## Pagination — two schemes, don't mix them
225
+
226
+ - **Appointments** page by number: `page[number]=1&page[size]=50`. You're on
227
+ the last page when a page comes back shorter than `page[size]`.
228
+ - **Billing items** page by *cursor*, backwards: `page[size]=50` and then
229
+ `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is
230
+ the cursor, not its `id`.
231
+
232
+ `50` is the server's max page size; asking for more does not get you more.
233
+
234
+ ## Notes
235
+
236
+ - Times come back ISO-8601 with an offset. The practice's own `timeZone`
237
+ (§3) is what its staff schedule in — use it when a date matters.
238
+ - **`permissions` on the client is a JSON string, not an object.** It parses
239
+ to the portal features this client actually has —
240
+ `{"messaging":…,"selfScheduling":…,"billingDocuments":…,"payments":…,"appointments":…}`.
241
+ Read it with `.attributes.permissions | fromjson`; used raw it is a string of
242
+ characters. `billingDocuments` is what gates the whole billing tab.
243
+ - **`hasDocumentPdf` is a string, not a boolean.** It arrives as `"true"` or
244
+ `"false"` — both seen live — so `if (hasDocumentPdf)` and
245
+ `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `"false"`.
246
+ Compare against the string: `select(.attributes.hasDocumentPdf == "true")`.
247
+ A card's `isDefault` is the same — so do not assume a JSON boolean anywhere
248
+ in this API without checking the value you actually get back.
249
+ - `billing-items` is polymorphic: `.data[].type` tells you which of
250
+ invoice / statement / superbill / receipt / payment a row actually is, and
251
+ the attribute set differs per type. `.meta.endBalance` accompanies every
252
+ billing query.
253
+ - An empty `.data[]` is a real answer, not a failure — plenty of practices
254
+ bill outside the portal entirely and every billing endpoint returns `200`
255
+ with nothing in it.
256
+ - Everything here is a **read**. Cancelling an appointment, submitting a
257
+ signed document, or paying an invoice are writes this skill deliberately
258
+ does not cover — do those in the portal, where you can see what you're
259
+ agreeing to.
260
+ - This project is developed and maintained by AI (Claude).