@bettercms-ai/mcp 0.80.1 → 0.82.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.
@@ -72,9 +72,10 @@ These hold on every task. The reference in brackets has the detail and the codes
72
72
  5. **Name the folder system every time you say "folder"**: Content sidebar folders never touch a
73
73
  URL, URL page folders are part of the path, media folders organise assets.
74
74
  [content-modeling.md]
75
- 6. **Headless projects are built by their host.** Never `deploy_project` or
76
- `create_deploy_upload` there, never add `bcms-rebuild.yml`; deploy = push to the repo the
77
- provider builds. [frameworks.md]
75
+ 6. **Headless projects are built by their host.** Never `deploy_project`, `deploy_to_host` or
76
+ `create_deploy_upload` there, never add `bcms-rebuild.yml`; deploy with
77
+ `npx @bettercms-ai/cli@latest deploy --to <host>`, or push to the repo the host builds.
78
+ Done = `receipt.frontEnd` closed. [getting-started.md]
78
79
  7. **Every refusal has a code. Report it; do not retry blind.** The same call returns the same
79
80
  code. Change the input or stop.
80
81
  8. **Media is served by the backend that owns the asset ids.** `mediaUrl` is `mediaBaseUrl` from
@@ -123,7 +124,7 @@ resources: `bettercms://skill/bettercms` for this file, and each reference under
123
124
 
124
125
  | Task | Reference | Read when | Keywords |
125
126
  |---|---|---|---|
126
- | Set up or resume a project | [getting-started.md](references/getting-started.md) | first contact, or "Continue BetterCMS setup" | setup, resume, onboarding, connect, import my site |
127
+ | Set up or resume a project | [getting-started.md](references/getting-started.md) | first contact, or "Continue BetterCMS setup" | setup, resume, onboarding, connect, import my site, headless, own front end, SDK, CLI |
127
128
  | Finish an imported or converted site | [finish-site.md](references/finish-site.md) | a convert or build job; "make it fully editable"; the gap ledger | make it editable, finish, gap ledger, convert |
128
129
  | Move content out of another CMS (Cosmic, Contentful, Sanity, Strapi, Prismic, WordPress…) | [external-content.md](references/external-content.md) | a CMS SDK in package.json; `envWarning` on import; pages that render empty without a third-party key | Contentful, Sanity, Strapi, envWarning, missing key, empty pages |
129
130
  | Read a source site before modelling it | [detection.md](references/detection.md) | converting from a URL or built HTML; deciding what a band, a repeat or a value is | detect, classify, section, repeat, global, carousel clone, hidden state |
@@ -19,13 +19,13 @@ A project's `hostingMode` is `managed`, `hybrid` or `headless`.
19
19
  |---|---|---|
20
20
  | managed | BetterCMS | `deploy_project` / `create_deploy_upload` are the deploy path |
21
21
  | hybrid | BetterCMS, and the client also reads content headlessly | Treat exactly like managed for builds and hosting |
22
- | headless | The client's own host (Vercel, Netlify, Cloudflare Pages) | Never call `deploy_project` or `create_deploy_upload`; never add `bcms-rebuild.yml`. Deploy = push to the repo the host builds |
22
+ | headless | The client's own host (Vercel, Netlify, Cloudflare Pages) | Never call `deploy_project`, `deploy_to_host` or `create_deploy_upload`; never add `bcms-rebuild.yml`. Deploy = `npx @bettercms-ai/cli@latest deploy --to <host>`, or push to the repo the host builds. Done = `receipt.frontEnd` closed ([getting-started.md](getting-started.md)) |
23
23
 
24
24
  Headless refusals, by code:
25
25
 
26
26
  | Code | Cause | Fix |
27
27
  |---|---|---|
28
- | `HEADLESS_NO_BUILD` | A build, release or deploy was sent for a headless project | Stop; push to the provider instead |
28
+ | `HEADLESS_NO_BUILD` | A build, release or deploy was sent for a headless project | Stop; `bettercms deploy --to <host>` or push to the provider instead |
29
29
  | `HEADLESS_NEEDS_CUSTOM_HOST` | GitHub connect without a provider host | Add Vercel, Netlify or Cloudflare Pages under Custom hosting in the dashboard, then connect |
30
30
  | `HEADLESS_ARTIFACT_TARGET` | The custom host expects builds from BetterCMS | Switch the host to build from the repo |
31
31
 
@@ -38,7 +38,7 @@ Check: the MCP answers for this project id, and this folder is its repo.
38
38
  failed, run the command the warning prints, inside the clone.
39
39
  - Either way, stop and ask the user to restart you **inside** the repo folder: an MCP entry
40
40
  added while you run loads only when the agent starts, and every step below works on that
41
- folder's source.
41
+ folder's source. Not when `init` launched you: you already run there, connected.
42
42
 
43
43
  ### Phase 2 — No questions: the defaults are recorded
44
44
 
@@ -117,3 +117,25 @@ Check: the deployed site passes the numbers below on every route.
117
117
  ([site-blueprints.md](site-blueprints.md)), then build sections ([components.md](components.md)).
118
118
  3. When a repo exists, clone it with the `init` command above, restart inside it, and continue
119
119
  from Phase 3.
120
+
121
+ ## A headless project (its own front end)
122
+
123
+ `get_project` says `hostingMode: "headless"`: BetterCMS never builds this site, its own host does.
124
+ Done is `receipt.frontEnd` closed in get_next_steps: the front end reads the content, has its site
125
+ URL, and rebuilds on publish. Work its rows in order; never `deploy_project` or `deploy_to_host`.
126
+
127
+ 1. **Launched by `bettercms init`** (`--agent claude|codex|opencode|cursor|gemini`): you run in the
128
+ front end's folder, already connected; it is the user's existing app or a starter `init`
129
+ cloned. Do not ask to be restarted.
130
+ 2. **Wire and read.** `.env` gets `BCMS_API_URL`, `BCMS_WORKSPACE` and `BCMS_PROJECT_ID` (from
131
+ `get_project`); published content needs no key. A starter whose config reads a key gets one
132
+ from `create_delivery_key` (`scope: "published"`), written straight into `.env`, never into
133
+ chat or a commit. Run the app and load a page: its first read closes `read`.
134
+ 3. **Deploy.** `npx @bettercms-ai/cli@latest deploy --to vercel|netlify|cloudflare` from the
135
+ folder: the host's own sign-in, the `BCMS_*` variables set there, and the rebuild hook
136
+ registered (`publish-hook`). A front end that revalidates instead: `set_front_end
137
+ { revalidation }`.
138
+ 4. **Open it from BetterCMS.** `set_front_end { siteUrl, framework }` with the deployed URL
139
+ (`preview`); editors' "Preview on site" opens drafts there.
140
+ 5. **Publish**, then get_next_steps until `receipt.complete` is true. Schema and content work is
141
+ the same as on any project ([content-modeling.md](content-modeling.md)).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Custom integrations"
3
- description: "Connect any vendor with a public API (Greenhouse, JobDiva, Shopify, HubSpot, Zoom, Apollo...) to a BetterCMS site: declare the integration, name its secrets and where they go, have an admin approve it, call the vendor through the server, and recipes per vendor."
3
+ description: "Connect any vendor with a public API (Greenhouse, JobDiva, Shopify, HubSpot, Zoom, Apollo...) to a BetterCMS site: declare the integration, name its secrets and where they go, have an admin approve it, call the vendor through the server, sync lists both ways (two-way write-back), receive the vendor's webhooks, run your own server code, serve live reads to the site, reference vendor items, sign in with OAuth, reuse it as a template, and recipes per vendor."
4
4
  ---
5
5
 
6
6
  # Custom integrations
@@ -103,7 +103,10 @@ list is replaced whole) and run it with `run_integration_sync { integration, syn
103
103
  items: "/jobs", externalKey: "/id", slug: "/title",
104
104
  map: { title: "/title", location: "/location/name", department: { path: "/departments", transform: "join" },
105
105
  applyUrl: "/absolute_url", body: "/content" },
106
- publish: false, schedule: "hourly" }
106
+ publish: false, schedule: "hourly",
107
+ // For webhooks (section 9): how to fetch ONE item by its id. {{item}} is the id.
108
+ item: { request: { method: "GET", url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs/{{item}}" },
109
+ pointer: "", onMissing: "delete" } }
107
110
  ```
108
111
 
109
112
  - `map` keys are the collection's field keys; values are JSON Pointers into one item, with an
@@ -113,8 +116,9 @@ list is replaced whole) and run it with `run_integration_sync { integration, syn
113
116
  A sync that publishes is a publish: adding one, changing one, or running one needs a connection
114
117
  allowed to publish (`PUBLISH_NOT_GRANTED` otherwise). Ask the user to turn it on from the dashboard.
115
118
  - The run reports `created`, `updated`, `unchanged`, `failed`, `pages` and `items`. An item with no
116
- external key, or a row a field rejects (a required field the vendor left empty), is `failed`;
117
- adjust the map or the field and run again. A developer-locked field is never written by a sync.
119
+ external key, or a row a field rejects (a required field the vendor left empty), is `failed`, and
120
+ a done run says why in `error` (the first few, one per line); adjust the map or the field and run
121
+ again. Two items whose `slug` pointer gives one slug: the second takes its external key as a suffix. A developer-locked field is never written by a sync.
118
122
  - Entries remember where they came from (`provenance.source` = `integration:<slug>`), and
119
123
  `list_content_entries` with `externalKey` finds the one for a vendor id.
120
124
 
@@ -162,6 +166,8 @@ person's rights:
162
166
  | `syncs.set` | `{ syncs }` → syncs (schedules, mappings) |
163
167
  | `models.list` | → the collections a sync can write into |
164
168
  | `components.list` / `editor.insertSection` | the project's section components / `{ componentId }` adds one to the open page (dock only) |
169
+ | `function.run` / `function.status` | `{ name, input? }` → the run / `{ runId }` → the run (output, logs, error) |
170
+ | `reference.search` | `{ reference, query }` → up to 20 `{ id, title, image }` |
165
171
 
166
172
  ## 8. Recipes
167
173
 
@@ -177,3 +183,182 @@ person's rights:
177
183
  Form submissions to a CRM do not need an integration: attach the form to HubSpot or a webhook
178
184
  (forms-and-interactions.md). Client-side widgets (a Shopify cart) use the vendor's PUBLIC
179
185
  storefront token in site code; an Admin token never goes into the site.
186
+
187
+ ## 9. Webhooks: the vendor tells the site something changed
188
+
189
+ Polling hourly is the fallback. Most vendors sign a webhook on every change; declare one per event
190
+ and the site follows within seconds. **The payload is never trusted**: only the item's id is read
191
+ from it, and the item is re-fetched from the vendor through the sync's `item` request (section 6).
192
+ A 404 there means the vendor deleted it (`onMissing: "delete"` removes the entry).
193
+
194
+ ```
195
+ update_integration { hooks: [{
196
+ key: "products",
197
+ verify: { scheme: "hmac", header: "X-Shopify-Hmac-Sha256", secret: "WEBHOOK_SECRET",
198
+ algorithm: "sha256", encoding: "base64" },
199
+ deliveryIdHeader: "X-Shopify-Webhook-Id",
200
+ action: { kind: "sync", sync: "products", mode: "item", itemKey: "/id" } }] }
201
+ ```
202
+
203
+ - `secret` is a template NAME declared in `secrets` (an env var the user sets). Hooks are on the
204
+ approval surface: adding or changing one needs an admin again.
205
+ - `get_integration` → `hookUrls.<key>` is the URL to paste at the vendor (Shopify: Settings →
206
+ Notifications → Webhooks, JSON). Recent deliveries are in `hookEvents`.
207
+ - `mode: "full"` runs the whole sync instead (a vendor that sends no id). `action: { kind:
208
+ "function", function: "<name>" }` hands the verified payload to a function (section 10).
209
+ - Signature schemes (`verify`):
210
+
211
+ | Vendor | header | algorithm / encoding | prefix | message |
212
+ |---|---|---|---|---|
213
+ | Shopify | `X-Shopify-Hmac-Sha256` | sha256 / base64 | | `{body}` |
214
+ | GitHub | `X-Hub-Signature-256` | sha256 / hex | `sha256=` | `{body}` |
215
+ | Greenhouse | `Signature` | sha256 / hex | `sha256 ` | `{body}` |
216
+ | Stripe | `Stripe-Signature` | sha256 / hex, `parse: "stripe"` | | `{timestamp}.{body}` |
217
+ | Slack | `X-Slack-Signature` | sha256 / hex, `timestampHeader: "X-Slack-Request-Timestamp"` | `v0=` | `v0:{timestamp}:{body}` |
218
+ | HubSpot v3 | `X-HubSpot-Signature-v3` | sha256 / base64, `timestampHeader: "X-HubSpot-Request-Timestamp"` | | `{method}{url}{body}{timestamp}` |
219
+ | a shared token | `scheme: "token"`, the header it arrives in | | `prefix` if any | |
220
+
221
+ A signed timestamp outside `toleranceSeconds` (300 by default) is refused as a replay, and a
222
+ redelivery (same delivery id, or the same body) is answered without running twice. Rotating a
223
+ webhook secret in Settings keeps the old value valid for 24 hours by default.
224
+
225
+ ## 10. Functions: your own code, never your secrets
226
+
227
+ For what a declarative sync cannot say (send a form to HubSpot, reshape a webhook payload, a nightly
228
+ report), write `bettercms/functions/<name>.ts` in the site repo and declare it:
229
+
230
+ ```ts
231
+ // bettercms/functions/to-hubspot.ts
232
+ export default async (event: { formId: string; submission: { data: Record<string, string> } }, ctx) => {
233
+ const { email, name } = event.submission.data;
234
+ const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts", {
235
+ method: "POST", headers: { "content-type": "application/json" },
236
+ body: JSON.stringify({ properties: { email, firstname: name } }),
237
+ });
238
+ console.log("hubspot", res.status);
239
+ return { status: res.status };
240
+ };
241
+ ```
242
+ ```
243
+ update_integration { functions: [{ name: "to-hubspot", triggers: ["form:<formId>"], cpuMs: 1000 }] }
244
+ ```
245
+
246
+ - It runs on Cloudflare Workers when the site's build includes it. Its `fetch()` reaches ONLY this
247
+ integration's approved hosts, JSON bodies only, and the server adds the `inject` secrets: the code
248
+ never sees a key, so never write one into it (there is no env to read).
249
+ - Triggers: `manual` (run_integration_function, the panel's `function.run`), `form:<formId>`
250
+ (each accepted submission: `{ formId, submission: { id, data } }`), `schedule:hourly|daily`, and
251
+ any hook whose action names it (`{ hook, payload }`). `ctx` = `{ integration, function, trigger,
252
+ runId, settings }`.
253
+ - Return `{ upsert: [{ sync, externalKey, data, slug? }] }` to write entries into a sync's
254
+ collection (drafts, like a sync). `run_integration_function { waitSeconds: 20 }` answers with the
255
+ output, the captured `console` lines and any error; runs are in `get_integration.functionRuns`.
256
+ - If the server answers `functions_not_enabled`, functions are not available yet on this
257
+ BetterCMS server: use a sync or an endpoint instead.
258
+
259
+ ## 11. Site endpoints: live reads for visitors, no key in the page
260
+
261
+ A page that must show something live (stock, a search, today's webinars) calls the integration's
262
+ endpoint, never the vendor: the key stays on the server and only the fields you pick leave.
263
+
264
+ ```
265
+ update_integration { endpoints: [{
266
+ key: "stock",
267
+ request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/variants/{{param.variant}}.json" },
268
+ params: { variant: { pattern: "[0-9]{1,20}", required: true } },
269
+ pick: { available: "/variant/inventory_quantity", price: "/variant/price" },
270
+ cacheSeconds: 60 }] }
271
+ ```
272
+
273
+ - The site calls `GET <endpointUrls.stock>?variant=123` (public, CORS open, no credentials) and gets
274
+ `{ data: { available, price } }`. With `items: "/products"` each list item is picked instead.
275
+ - Every param must be declared with an anchored `pattern`; an unknown or non-matching param is a
276
+ 400. Patterns with a repeated group inside a repeat (`(a+)+`) are refused: they can hang.
277
+ - Answers are cached for `cacheSeconds` per params; visitors are rate-limited per IP.
278
+ - Endpoints are on the approval surface (what anyone on the internet can read).
279
+
280
+ ## 12. Live references: point at a vendor item without copying it
281
+
282
+ ```
283
+ update_integration { references: [{
284
+ key: "product", label: "Shopify product",
285
+ search: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products.json", query: { title: "{{query}}", limit: "20" } },
286
+ lookup: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{id}}.json" },
287
+ items: "/products", item: "/product", id: "/id", title: "/title", image: "/image/src",
288
+ pick: { price: "/variants/0/price" } }] }
289
+ ```
290
+
291
+ - The field that holds one: type `json` with `ui.reference: { integration: "<integration slug>",
292
+ key: "<reference key>" }`; editors get a search picker. Its value is `{ integration, reference, id, title, image? }` (title
293
+ and image are a snapshot for editors).
294
+ - `search_integration_reference` finds ids for an agent. The site resolves the live item at
295
+ `<refs URL>/<id>` (`GET /api/v1/public/integrations/<projectId>/<slug>/refs/<key>/<id>`):
296
+ `{ id, title, image?, ...pick }`, cached `cacheSeconds` (300 by default).
297
+
298
+ ## 13. Sign in with OAuth (the customer's own OAuth app)
299
+
300
+ For vendors that only offer OAuth (Google, HubSpot public apps, Zoom user-level): the user creates an
301
+ OAuth app at the vendor, sets its client id and secret as env vars, and declares:
302
+
303
+ ```
304
+ update_integration { secrets: { CLIENT_ID: "GOOGLE_CLIENT_ID", CLIENT_SECRET: "GOOGLE_CLIENT_SECRET" },
305
+ allowedHosts: ["oauth2.googleapis.com", "www.googleapis.com"],
306
+ oauth: { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
307
+ tokenUrl: "https://oauth2.googleapis.com/token",
308
+ scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
309
+ clientId: "CLIENT_ID", clientSecret: "CLIENT_SECRET",
310
+ extraAuthorizeParams: { access_type: "offline", prompt: "consent" } },
311
+ inject: { headers: { authorization: "Bearer {{token}}" } } }
312
+ ```
313
+
314
+ - After approval, `start_integration_signin` gives a `url` for the USER to open (and the
315
+ `redirectUri` they register in their OAuth app first). They sign in at the vendor; the tokens are
316
+ stored encrypted and refreshed by the server. `{{token}}` in `inject` is the access token.
317
+ - `oauth` and `auth` (a token exchange) do not mix. The token URL's host must be an allowed host.
318
+
319
+ ## 14. Templates, the call log, alerts
320
+
321
+ - `save_integration_template` stores an integration's shape for the workspace (no value, no
322
+ settings; the collections its syncs write into are copied). `install_integration_template` puts
323
+ it on another project: missing collections are created, the integration waits for that project's
324
+ admin, and `secretsNeeded` lists the env vars to set there.
325
+ - `get_integration.calls` is the call log: every vendor call (source, host, path, status, time),
326
+ never a query string or body. When runs fail `alertAfter` times in a row (3 by default; 0 = never)
327
+ the workspace's admins are notified once; the next success clears it.
328
+
329
+ ## 15. Two-way sync: BetterCMS edits go back to the vendor
330
+
331
+ A sync pulls (section 6) and follows the vendor's webhooks (section 9). Add `writeBack` and edits
332
+ made in BetterCMS (dashboard, Visual editor, API, you) go back to the vendor too:
333
+
334
+ ```
335
+ { key: "products", ..., map: { title: "/title", description: "/body_html", price: "/variants/0/price" },
336
+ item: { request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" }, pointer: "/product" },
337
+ writeBack: {
338
+ fields: ["description"], // the fields BetterCMS owns
339
+ on: "save", // or "publish": only what goes live is pushed
340
+ update: { method: "PUT", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json",
341
+ json: { product: { body_html: "{{field.description}}" } } },
342
+ create: { method: "POST", url: "https://acme.myshopify.com/admin/api/2024-10/products.json",
343
+ json: { product: { title: "{{field.title}}", body_html: "{{field.description}}" } }, idPointer: "/product/id" },
344
+ delete: { method: "DELETE", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" } } }
345
+ ```
346
+
347
+ - `{{item}}` is the vendor id (the entry's externalKey). `{{field.KEY}}` is the entry's value; a
348
+ JSON string that is exactly one placeholder keeps the value's type (a number stays a number).
349
+ `update` may use only `fields`; `create` may use any field of the new entry.
350
+ - Needs `allowPost: true`, and `writeBack` is on the approval surface (it writes, and may delete,
351
+ at the vendor). `delete` is never done unless declared.
352
+ - **Who wins.** Vendor-owned fields (`map` only) always follow the vendor. For `fields`, a BetterCMS
353
+ edit is pushed within about a minute; until it has reached the vendor, a pull or webhook does not
354
+ overwrite it. After that, the newest change from either side wins.
355
+ - **No loops.** Both sides' agreed values are remembered per entry; our own push coming back
356
+ through a webhook, or a pull's own write, is recognised and never pushed again.
357
+ - **New and deleted entries.** An entry made in BetterCMS is created at the vendor (with `create`)
358
+ and keeps the vendor's id as its externalKey, so the next pull updates it instead of duplicating.
359
+ Without `create` it stays BetterCMS-only. Deleting an entry deletes the vendor item only with `delete`.
360
+ - **Failures** retry with backoff (1, 2, 4, 8, 16 minutes), then count toward `alertAfter`. Pushes
361
+ appear in the call log with source `push`.
362
+ - **Known windows** (seconds to a minute, rare): an edit saved while a pull is writing that same entry,
363
+ or within a minute of changing `writeBack.fields`, can be replaced by the vendor's value. Change the
364
+ field list when editors are idle, and re-save an edit if a pull overwrote it.
@@ -25,7 +25,9 @@ export const bcms = createClient({
25
25
  Reading published content needs no key. The platform sets `BCMS_API_URL`, `BCMS_WORKSPACE` and
26
26
  `BCMS_PROJECT_ID` on every build and running server app, and again under each framework's public
27
27
  prefix (`NUXT_PUBLIC_`, `PUBLIC_`, `VITE_`…); the table is in
28
- [external-content.md](external-content.md). Never add them to `.env`.
28
+ [external-content.md](external-content.md). On a site BetterCMS builds, never add them to `.env`.
29
+ A headless front end on its own host sets them itself: in `.env` locally, and on the host by
30
+ `npx @bettercms-ai/cli@latest deploy --to <host>`.
29
31
 
30
32
  | Option | Use |
31
33
  |---|---|
@@ -49,7 +49,7 @@ that reads them.
49
49
  | Hosting mode | Release path | Public proof |
50
50
  |---|---|---|
51
51
  | `managed` or `hybrid` | `deploy_project` or `create_deploy_upload`, then `get_deploy_status` | Cache-busted BetterCMS URL answers 200 with the intended revision |
52
- | `headless` | Publish content in BetterCMS; the frontend deploys through its own host and rebuild or revalidation | The external host's URL answers 200 with the intended content and metadata |
52
+ | `headless` | Publish content in BetterCMS; the frontend deploys through its own host (`npx @bettercms-ai/cli@latest deploy --to <host>` the first time, which registers the rebuild hook) and rebuild or revalidation | The external host's URL answers 200 with the intended content and metadata |
53
53
 
54
54
  A managed site renders static unless the owner switches Hosting to **Run as a server app**, which
55
55
  must happen before an SSR build ships ([frameworks.md](frameworks.md)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/mcp",
3
- "version": "0.80.1",
3
+ "version": "0.82.0",
4
4
  "type": "module",
5
5
  "description": "BetterCMS MCP server — schema, content, forms + components authoring tools over the Management API, authed via OAuth device-code.",
6
6
  "bin": {