MBXHub

by HALRAD Research

Charms SDK

Rendered from charms-sdk.md • back to docs

A charm is an extension of MBXHub. The ones that ship with it are pages we wrote; this document is how anyone writes one — how an application registers itself, what it may ask for, how a person grants it, and what MBXHub promises not to break. It is the companion to the REST API and llms.txt, and it describes the inspected source behavior; this does not assert that every feature has been live verified.

Originally written against MBXHub 0.5.5.1. Reconciled on 2026-09-07 against implementation revision 325ed35839b3fd3f3732dde4969fb28284111182. The charm capabilities — verbs, the store and settings — were added on 2026-10-07 against implementation revision 8e61c23d1; the same day the hub's Dice became an in-box charm (dice) like ARiA, and the capabilities are described here without a worked example. Your own hub serves the reference for the build you are actually talking to at /docs, and a terse machine-readable copy at /llms.txt.

New here? Read the integration guide first — how the pieces fit and the whole path walked end to end — then come back here for the detail. Working code for all three kinds is in samples/.

The one thing guaranteed regardless of everything below

The enumerated read endpoints and separately tested player verbs remain open to unregistered callers. Registration adds capabilities. Automation is subject to the explicit compatibility exception below; do not read this as permanence for every currently reachable route.

What a charm is — three separate questions

Three axes, and none of them answers another:

The tests that keep them apart: if it changes what evidence can be gathered about the code, it is the kind; if it answers where the entry appears, it is a placement; if it answers what happens when the entry is activated, it is an action. The verb set stays small and closed — adding a new door into MusicBee adds a placement value, never a verb.

Kind — the substrate

kindWhere the code livesWhat evidence can existEvents reach it by“Alive” means
pageHTML/JS that MBXHub hosts and rendersnone of the executable kind — there is nothing to signa message to the frame, or the page’s own WebSocketa window or frame is open
proca local executable on this machinethe strongest available: the image on disk and its signatureits own connection while it is upthe process is running
endpointsomewhere else that answers HTTP — another machine, a container, a serviceits address and reach; no local imageMBXHub calls a URL the charm declaredits URL answers

A manifest with no kind is a page. Every charm that ships today is a page, so the default is the truth rather than a convenience. An unknown kind is carried verbatim rather than coerced, so nothing is ever described as something it is not.

A background process can register without action entries, but that does not hide it. Every charm still resolves to rail (or rail-menu through display: action-menu). An absent placement is a default, not a no-surface declaration. A manifest-level way to suppress every hub surface is not implemented.

Placements — where an entry appears

PlacementSupport in the inspected sourceRestart for MusicBee registration
railRendered; default charm placementNo
rail-menuRendered; charm-level display: action-menuNo
menuTools → MBXHub; click re-reads eligibility and delivers by kindYes
overlayHUD launch button only; requires a launch target, or a verb and the verbs grantNo
windowNamed; placement-driven rendering not implemented. Shell pop-out is separateNo
node / tabNamed; no MusicBee surface implementedListed in restartRequired; a restart does not implement them
hotkey / statusNamed; no MusicBee surface implementedListed in restartRequired; a restart does not implement them

Named values are not necessarily implemented surfaces. A window placement does not create a window; the existing Shell pop-out is a separate feature. restartRequired reports declared MusicBee-hosted placements, including named values that have no implementation; it is not a support-discovery response.

The placement field is read. An entry that declares none takes the charm’s own placement, and that is rail-menu when the charm’s display is action-menu and rail otherwise — which is why every shipping manifest loads unchanged. Placements are compared case-insensitively, like every other manifest word, and are never coerced: a value this build does not know comes back verbatim and is skipped by whatever reads it, rather than being drawn somewhere else. A placement MusicBee hosts is handed to MusicBee when the plugin loads, so one requested while MusicBee is running appears at the next start — which is what restartRequired says on the registration response.

Actions — what activating an entry does

VerbArgumentMeaning
webappa pathopen this page
iframe-cmda commandsend this command to the page already open
posta hub pathcall this MBXHub endpoint. For a registered charm this is a verb, performed by the hub as the charm — see Verbs, the store and settings below
http… (bare URL)a LAN addresscall this device directly, through MBXHub’s LAN proxy
launchnone — the verb is barestart the program the manifest’s top-level launch field names, under the launch policy below

launch takes no target, and writing one is refused. An expand[] entry whose action is launch thing.exe is answered 400 INVALID_ACTION at registration, naming the entry, before anything is written. What starts is the manifest’s top-level launch field — the exact string on the Charm Manager’s approval detail — so the thing that was approved and the thing that starts cannot come apart. The verb is matched on the first token, case-insensitively, so a later verb spelled launcher is a different one and is untouched.

An entry whose verb this build does not know is skipped, and the rest of the charm works. The dispatcher matches known prefixes and lets anything else fall through without error — so one manifest can target several builds: ship a new verb and a webapp fallback entry and both are satisfied from one file.

The action-menu rail mode takes one verb, and only one. Its items dispatch post <path> — the path is POSTed to this hub with an empty body. A bare path carrying no verb is still honored, with a deprecation warning in the client log. Anything else — webapp, iframe-cmd, an http:// URL — is ignored with a warning by an action-menu item; those verbs belong on the charm button and on expand[] entries rendered as a button row.

The manifest

A charm is a JSON file. Almost every shipping charm is exactly this shape:

{
  "id": "mixer",
  "label": "Mixer",
  "expand": [
    { "icon": "<svg …>", "label": "Mixer",     "action": "webapp /pages/mixer.html", "display": "both" },
    { "icon": "+",       "label": "Volume Up", "action": "iframe-cmd volumeUp",      "msg": "Vol +" }
  ]
}

The documented field set — the loader reads all of these, whether or not every charm uses them:

FieldWhereMeaning
idtop levelunique, stable, the charm’s identity
kindtop levelthe substrate. Parsed; absent means page, unknown is kept verbatim
schemaVersiontop levelthe manifest format’s version. Parsed; absent means 1
labeltop level and per entrywhat a person sees
expand[]top levelthe charm’s actions
icontop level or per entryinline SVG, or a character — passed through the icon contract below
actiontop level or per entryverb + argument
displaytop level or per entrythe rail sub-mode — see below
msgtop level or per entryshort confirmation text after the action runs
contexttop levelfree text for the charm’s own use; the loader carries it and does not interpret it
publishertop levelwho you are, in your own words
versiontop levelyour release
scopestop levelthe capabilities you ask for. Parsed and recorded — asking is not being granted
scopeReasonstop levelone sentence per scope, in your words, saying why you want it — an object keyed by scope name. Shown verbatim beside the capability’s own sentence on the approval row, and again on the runtime prompt labeled They say:. One line, at most 200 characters, trimmed, the same set of names as scopes. Required from schemaVersion 2; a manifest declaring 1, or nothing, may omit it
endpointtop levelwhere an endpoint charm answers — and where MBXHub POSTs its activations. Also the fallback route for a proc charm with no socket connected
launchtop levelwhat to start for a proc charm — its own executable, or a URL scheme. Absent is normal: an application whose own launcher starts it is still a proc. Read by the launch verb, under the policy below
launchFallbacktop levelan optional second target, in the same format as launch. Tried only when the first is unavailable (a missing executable, an uninstalled scheme handler) or its dispatch fails — a successful dispatch never starts both. Judged by the same policy, shown on the approval row beside the first, and a policy refusal ends the attempt rather than moving on to it
registrationtop levelthe hub’s own record for a registered charm. Written by the hub, never by you
placementper entrywhere the entry appears. Read; absent means the charm’s own placement, which is rail unless display is action-menu. There is no top-level placement field in this build — a charm’s own placement comes from its display
sourceper entryfor a placement that shows pixels (tab / window / overlay): a path or a render source such as spout:<sender>. A window or overlay source is hosted by the MBXHub Shell, which opens the named Spout sender and presents it. It requires the render:spout capability: without a current grant the hub withholds the value and reports blocked, and the Shell re-checks authorization every five seconds while a source is hosted. tab is parsed and carried with nothing filling it yet
bodyper entryon a post <path> verb: the JSON object a press sends, {} when absent. {settings.<key>} inside a string value is replaced with that setting’s value at the press
sayper entryon a post <path> verb: one line, at most 200 characters, shown after a press that succeeded. {key} is filled from the route’s data, one level deep
confirmper entryon a post <path> verb: one line, at most 200 characters, asked before the press. The surface sends on a second tap within 3 seconds
settingstop levelthe knobs you offer: {key, type, default, options, min, max, step, description, tier} per entry. Needs the settings scope; shown and written only while you hold it

Top-level icon / action / display / msg are the single-action shorthand; expand[] is the list. A charm may use either.

display — the rail sub-modes:

ValueMeaning
bothclick opens in place; shift-click opens a new tab
standalonealways a new tab
inlineopens in place only
action-menuone trigger, a menu of the expand[] entries — its items take one verb

Unknown display values fall back to a plain button. That is a documented rule of the loader, not an accident.

The icon contract: an icon is either plain text or one inert <svg>. Text with no < in it passes through; a single <svg> element carrying nothing that executes passes through; anything else is HTML-encoded, so it renders inert and visible rather than vanishing. You see the mistake instead of a blank button.

Minimal manifest shapes

These examples show manifest syntax; they do not install pages, start a service, or obtain approval. Use the integration walkthrough and working samples for executable examples. Registration is local for every kind. Top-level placement is not supported; declare non-rail entries in expand.

Hosted page: place the page at the declared path. Absent entry placement inherits the rail.

{"id":"com.example.page","schemaVersion":2,"kind":"page","label":"Example page","publisher":"Example","version":"1.0","scopes":[],"scopeReasons":{},"expand":[{"label":"Open","action":"webapp /pages/example.html","display":"both"}]}

Background process: no launch action and no application window required. This registers successfully as a process shape, but it still has the hub's default rail placement; this is not an invisibility declaration.

{"id":"com.example.worker","schemaVersion":2,"kind":"proc","label":"Worker","publisher":"Example","version":"1.0","scopes":[],"scopeReasons":{},"expand":[]}

Menu-launched process: replace the path with the executable making the registration request. The bare launch action reads the top-level target.

{"id":"com.example.app","schemaVersion":2,"kind":"proc","label":"Example app","publisher":"Example","version":"1.0","scopes":[],"scopeReasons":{},"launch":"C:\\Example\\App.exe","expand":[{"label":"Open","placement":"menu","action":"launch"}]}

Endpoint: a local registrar sends this service's private address in the manifest it registers. A menu click delivers the activation to its endpoint; the remote service cannot use the local ticket from its own machine. The blank action adds no launch operation.

{"id":"com.example.service","schemaVersion":2,"kind":"endpoint","label":"Example service","publisher":"Example","version":"1.0","scopes":[],"scopeReasons":{},"endpoint":"http://192.168.1.50:9000/activated","expand":[{"label":"Activate","placement":"menu","action":""}]}

HUD launch tile: this is a locally installed scheme-launch manifest. The scheme handler must exist and pass launch policy. No registration record means file-installed provenance; if registered instead, it must be approved.

{"id":"com.example.hud","schemaVersion":2,"kind":"proc","label":"Example HUD app","publisher":"Example","version":"1.0","scopes":[],"scopeReasons":{},"launch":"steam://run/4813240","expand":[{"label":"Start","placement":"overlay","action":"launch"}]}

What is promised, and what is merely true

The one commitment

The endpoints an integration reads stay open to callers that do not register, permanently. They are frozen in our test suite and re-checked on every run:

/ping · /system/uptime · /system/status · /nowplaying · /queue/current · /player/shuffle · /player/repeat · /playlists · /playlists/{url}/files · /library/file/{url}

— together with the player verbs, frozen in a suite of their own. If your integration reads something not on that list, tell us and it goes on it. A promise nobody tests is a hope.

What the commitment does not cover, said out loud. The automation surface — input simulation, wake, library scan — is governed today by a single switch the person at the console controls, not by registration, and some of those routes answer to GET as well as POST. If any of that ever moves behind a granted capability, or a side-effecting GET form is retired, that is a break: it arrives with an apiVersion change and notice, never silently.

The rest is loader behavior, not a promise

These are properties we like and intend to keep. They are stated so you can rely on them knowingly, not offered as a contract:

The charm format has no outside consumers yet, and that has a consequence worth stating plainly rather than hiding behind reassuring language: the format is still free to change, and while that is true we intend to get it right rather than only get it compatible. If something here is wrong, now is when it costs nothing to say so.

Registration — sending your manifest

An application registers by sending its manifest — and a charm can also arrive as a file: the hub reads the manifests in its own charms folder when it starts, which is how the charms shipped with it get there, and a manifest dropped in beside them is read the same way. What registering gets you that a file cannot is this section: a registration, an approval a person gave it, and the ticket that follows.

Neither route has the hub going looking. MBXHub never scans your machine for applications. It does identify the process on the other end of your registration connection — its executable path and its signature — because that is what places a proc charm in a trust tier. This happens at registration, once, for a proc charm only; a page or endpoint charm is not observed, because there is no local process to observe. Nothing else is read, and nothing leaves the machine.

Finding the hub first

Walk the even ports of the ladder — 8080, 8082, and so on to 8098 — and treat a port as MBXHub only when GET /ping answers with service: "MBXHub". A 200 from something else on a ladder port is somebody else’s server, not a quiet hub. Stop probing at the first port that answers that way. service is the only field to recognize us by — apiVersion tells you what you found, never that you found us.

Registering

POST /charms/register
Content-Type: application/json

{ "id": "com.example.thing", "kind": "proc", "label": "Thing", "publisher": "Example Ltd",
  "version": "1.4.7", "schemaVersion": 2,
  "scopes": [ "playback:control" ],
  "scopeReasons": { "playback:control": "to pause the music when a call comes in" },
  "expand": [ … ] }

→ 200 { "charmId": "…", "status": "pending", "restartRequired": [], "apiVersion": "1" }

Where your registration lives, and what an approval is

Your manifest file is your definition and nothing else. The hub keeps it as charms/<id>.json. What the hub knows about you — your status, the scopes the person granted in the Charm Manager, the hub id, the approval stamp and the manifest hash it covers, whether your ticket was collected, and any per-scope declines and bans — is kept beside it in charms/registrations/<id>.json, one record per id. A registration member in your manifest is ignored, whether you send it or somebody types it into the file. Deleting the manifest file does not delete the record: while it is gone you are not drawn and your ticket opens nothing, and registering again picks the record up.

An approval is a stamp over your manifest file’s bytes. When a person approves you, the hub records the hash of your manifest file’s text and a stamp over it, minted with a key that hub keeps beside its own secret. Every read checks the stamp against the file as it is on disk. Change one byte, a new version included, and you wait for Approve again, and your ticket stops working until the person approves the new bytes. License grants are not part of the stamped text, so a renewed, added or dropped license never asks for Approve again.

Revoked and banned are two lists, revoked.json and banned.json in the same folder, each keyed by your publisher and id. A charm on either list is not loaded anywhere — not drawn, not listed, and a press is refused by name — and the lists are shown on their own in the Charm Manager, with Un-revoke and Unban, whether or not your manifest file is on disk:

Reading your record back

GET /charms/com.example.thing

→ 200 { "id": "com.example.thing", "kind": "proc", "publisher": "Example Ltd",
        "version": "1.4.7", "status": "pending",
        "grantedScopes": [], "requestedScopes": [ … ] }

It is ungated and read-only, because it reports a decision already made and changes nothing. grantedScopes is stated as empty rather than omitted, so you can tell nothing granted from field missing. tier is absent until something has computed one — an unknown tier is left unsaid rather than asserted. What is never returned is the hub’s private half of the record: the observed image path and its digest are not on this response under any name, and an id that was never registered is a 404 — the same answer an id that could never be valid gets.

status is pending, active, revoked or banned. pending means you wait for Approve, and so does an approval whose stamp no longer verifies over your manifest file as it is on disk. revoked and banned come from the two lists and are answered with or without your manifest on disk: a listed id with no manifest file still answers 200, naming the publisher on the list and no scopes. While the revoked or ban list cannot be read, this read answers 500 REGISTRY_RECORD_REFUSED (a person has to repair the list) or 503 REGISTRY_BUSY (ask again), never a status. Your registration record is answered the same way when the hub cannot read it, with or without your manifest file on disk: 503 REGISTRY_BUSY while the record, or the manifest file, is held by another process (ask again), and 500 REGISTRY_RECORD_REFUSED when the record is damaged or a newer build wrote it (a person on that machine has to repair or remove it).

How a registration is refused

A refusal is always a stated code, never a silent drop.

SituationResponse
The caller is not on the machine MBXHub runs on403 NOT_LOCAL — checked first, ahead of everything below, so a remote caller is told this whatever it posted
Content-Type is not application/json415 UNSUPPORTED_MEDIA_TYPE — parameters such as ; charset=utf-8 are fine; declaring nothing is not. This is what keeps a page the person happens to be visiting from registering on your behalf
The install is in read-only mode403 API_READ_ONLY — the owner’s statement, made at the console, that this install is not to be changed, and registration writes a file
A party is running, or the hub is in kiosk mode302 to the guest surface. These sit in front of the route rather than in it, so the answer is a redirect and not an error body. A local caller is exempt from the party redirect
This hub already holds 500 registration records403 REGISTRY_FULL — the ceiling on distinct ids. An id already registered still updates at the ceiling, so a full hub cannot stop you shipping a new version
The body is missing, is not a JSON object, or has no id400 INVALID_REQUEST
The id is not publisher-namespaced (no dot)400 INVALID_ID — com.example.thing, never thing
The body is larger than 64 KB413 REGISTER_TOO_LARGE — a manifest is a page of JSON, not a payload
A scope name this build does not know400 UNKNOWN_SCOPE, naming the scope — never silently dropped
A scopes entry with no sentence in scopeReasons, or a blank one400 SCOPE_RATIONALE_MISSING — the offending scope is named and nothing is written. The one refusal the schema version gates: raised only for a manifest declaring schemaVersion 2 or higher, because a version-1 manifest predates the field. It pays for the silence on the approval row instead, where every capability it asks for reads no reason given
A sentence that is not one line, or is longer than 200 characters once trimmed400 SCOPE_RATIONALE_INVALID — the offending scope is named and nothing is written. Refused at every schema version: a sentence that cannot be shown on the one row it gets is not made showable by the version it arrived under
A scopeReasons key naming a scope this manifest does not ask for400 SCOPE_RATIONALE_UNKNOWN — refused rather than dropped, for exactly UNKNOWN_SCOPE’s reason: you wrote words nobody would ever read, and you are here now to fix them. Refused at every schema version
An expand[] entry writes a target after the launch verb — "action": "launch thing.exe"400 INVALID_ACTION — naming the entry. The verb is bare; the target belongs in the manifest’s top-level launch field, which is the string the person approves and the only one that is launched
A verb’s say or confirm is blank, not one line, or over 200 characters; its body is not a JSON object400 VERB_SAY_INVALID, VERB_CONFIRM_INVALID or VERB_BODY_INVALID — the verb is named and nothing is written
A settings[] entry is malformed400 SETTING_KEY_INVALID, SETTING_TYPE_UNKNOWN, SETTING_TIER_UNKNOWN, SETTING_RANGE_INVALID or SETTING_DEFAULT_INVALID — the first fault, judged per entry in that order, naming the key; nothing is written. SETTING_RANGE_INVALID is an int or double setting whose min, max or step is NaN, infinite, or of magnitude 7.9e28 or more
The manifest declares settings[] without asking for the settings scope400 SETTINGS_SCOPE_MISSING — settings that could never be granted could never be shown, so you are told now
The id is claimed by another publisher, or it is an id MBXHub ships409 ID_CLAIMED — the first record is unchanged; for a shipped id the message says it ships with this hub
Your publisher and id are on this hub’s ban list403 CHARM_BANNED — nothing is written, and a manifest file for you placed by hand is not loaded either. Only the person at the console can lift it, with Unban
Your registration record, or the hub’s revoked or ban list, will not parse or was written by a newer build500 REGISTRY_RECORD_REFUSED — nothing is written, and a person has to repair or remove the file first. While either list cannot be read, every registration is refused, because nothing can say you are not on it
The hub could not read its own registry just now (a scanner or indexer was holding your record or one of the lists, and one retry did not clear it)503 REGISTRY_BUSY — nothing was written; your approval, credential and charmId stand; register again

Registering again, after a person approves you

Nothing in a request can ask for active: you wait for Approve until a person approves you. (The one other approval is the signed plugin’s, for a charm MBXHub ships.) Approval happens in the Charm Manager (MBXHub settings → Charm Manager tab), on the machine, and there is no REST route that grants anything. Registration is how you find out: the next registration from the local machine under the same id, once a person has approved you, carries your credential.

→ 200 { "charmId": "…", "status": "active", "grantedScopes": [ … ], "ticket": "…",
        "restartRequired": [], "apiVersion": "1" }

The plaintext ticket is handed over once. It is held in the hub’s memory until it is claimed — only its digest is written — so a later registration answers active and grantedScopes with no ticket, and a hub restart between the approval and your next registration loses the parked plaintext and costs a re-approval. Store it the moment you receive it; nothing re-reads it.

A caller the hub could not resolve does not collect it either. When the peer lookup runs and cannot say which process is calling, the answer is still active and still states grantedScopes, the plaintext stays parked, and the body carries "ticketWithheld": "caller-unresolved". Register again from a connection the hub can resolve and the next answer delivers it, exactly once as before. That field is what tells this apart from the ordinary you already collected it answer, which is otherwise byte-identical and has the opposite remedy; it is absent from every other answer rather than sent as null, so its presence is the whole signal. It applies to every kind, not only proc — what failed is who is calling, which is not a question about substrate. The same lookup is what records your executable and your tier, so a registration nothing could resolve records neither: a charm no lookup has ever answered for carries no tier at all rather than unsigned, and one resolved before keeps what that lookup found.

Any changed byte of your manifest takes the approval back. The approval is a stamp over your manifest file’s bytes, so a registration whose manifest text differs from the text that was approved sends you back to waiting for Approve, whatever moved: a new version, a label, an icon, a launch or launchFallback target, a placement, a sentence in scopeReasons, a scope added, a verb path, or a verb’s body, say or confirm. One rule replaces the separate per-field compares earlier builds made, and it is stricter than they were: a field dropped and the same field sent as "" are different bytes, and a version bump is a change. Ship a manifest change in a release you would expect a re-approval in. The same happens when somebody edits your manifest file on disk: the stamp stops verifying, so you are not drawn and your ticket opens nothing until a person approves the file as it now is.

Everything that sends an approval back to waiting for Approve, each with the same disposition — status pending, ticket cleared, an approval nobody collected discarded, and a person approves again — and your grants kept unless the item says otherwise:

Clearing the ticket is part of the disposition rather than a consequence of it: the old credential resolves to nothing, so a gated call presenting it answers 401, and a charm cannot move its own launch gate and keep the yes. A registration whose caller could not be resolved is not an observation at all, so it can neither move that gate nor erase what is on the record.

The first registration in which an already-approved charm starts sending scopeReasons costs it its approval once — the sentences are new bytes, and the row goes from no reason given to your words. Adopt scopeReasons in a release you would expect a re-approval in.

A pending registration has no usable ticket and receives no privileged access. First registration has no grants; approval-invalidating updates can retain stored grants for re-approval, as the lifecycle table specifies. Rail surfaces hide a registration that waits for Approve; a menu built earlier may remain visible but refuses activation.

Registration and credential lifecycle

EventRegistration after eventPersistent grantsTicket
First registrationpendingNoneNone
Console approvalactiveOperator-selected requested scopesMinted; plaintext collected once on a later local registration the hub can resolve
Registering the same manifest bytes againUnchangedUnchangedRetained
Any changed byte of the manifest file (a new version included), a different executable, or the manifest file missingactive → pendingRetained, subject to requested-scope pruning; unusable while pendingCleared; console re-approval required
Different hub identity at registrationpending (a revoked id stays revoked)ClearedCleared; console re-approval required
Console revokerevoked, and on the revoked list for those bytesClearedCleared; registering the same bytes again does not undo revocation, and new bytes wait for Approve
Console ban of the charmNot loaded; registering answers 403 CHARM_BANNEDClearedCleared
Un-revoke or Unbanpending; the id stays claimedNone (cleared by the revoke or ban)None until a console approval mints one
Hub restart after ticket collectionUnchangedUnchangedCollected ticket survives; no time-based expiry
Hub restart before ticket collectionactiveUnchangedParked plaintext lost; console approval can mint a replacement
Console approval with an already delivered live ticketactiveUpdated by operatorRetained; no refresh credential is issued

Permissions — what you may ask for

A capability is either granted or it is not, and when it is not there are four distinct reasons — never one undifferentiated no. These are the words used everywhere: here, on the wire, and in what a person is shown.

Statestate on the wireMeaning
granted(no error — the call succeeds)a person authorized it; your ticket carries it
not-grantednot-grantednever asked for, or asked for and not yet answered. Asking again is the remedy
declineddeclineda person was asked and said no, and that is remembered. Re-asking on retry does nothing; a new version may ask again
reservedreservedpolicy withholds it from every caller, ours included, with a reason. Temporary by intent
bannedbannedrevoked for a specific charm and capability. The only permanent refusal

Why declined is its own state. The two call for opposite behavior from you: not-granted means ask; declined means a person has already answered and asking again is noise they will resent. An integration that cannot tell them apart either nags or gives up, and both are wrong. The same reasoning separates reserved — not available to anyone yet — from banned, which is the only one that means no and stays no.

What you may ask for is a named list, not a guess. A capability is named area:verb — proxy:lan, library:read, automation:wake — except the three a charm uses for itself, verbs, store and settings, which are one word each (see Verbs, the store and settings below); the authoritative list for the build you are talking to is readable at GET /charms/capabilities, with each entry’s current maturity and whether it is reserved. A scope name the build does not know is refused by name at registration, never silently dropped.

Every scope you request carries one sentence of your own saying why. Write it as the truth, for the person, not for us; a rationale that does not match what the application then does is what gets an extension revoked. The carrier is a top-level scopeReasons object, scope name to sentence, beside scopes rather than inside it, so no manifest that has ever shipped changes shape:

{
  "id": "com.example.thing",
  "schemaVersion": 2,
  "scopes": [ "playback:control", "library:read" ],
  "scopeReasons": {
    "playback:control": "to pause the music when a call comes in",
    "library:read": "to show what you are listening to on the lock screen"
  }
}

Declare schemaVersion: 2 and give every scope its sentence — that is how a charm written to this document registers. Version 1, declared or absent, is tolerated for manifests that predate the field: such a charm registers exactly as it always did, and pays for it on the approval row, where each capability it asks for reads no reason given. A sentence that is present is judged at every version, because one that cannot be shown is not made showable by the version it arrived under.

The shape: one line, at most 200 characters, trimmed — a row beside a capability’s name, not a paragraph and not a layout you control. The same set as scopes: a scope with no sentence is SCOPE_RATIONALE_MISSING (the one refusal the version gates), a sentence that will not fit is SCOPE_RATIONALE_INVALID, and a sentence for a scope you did not ask for is SCOPE_RATIONALE_UNKNOWN — refused, not dropped, because words nobody will ever read are worth being told about while you are still here. All three name the offending scope, and all three are answered before a byte is written.

Where it is read. Verbatim on the Charm Manager’s approval row, beside the capability’s own sentence, which is ours; and again on the runtime prompt, on its own line and labeled They say:, in quotes. The two are never merged: ours says what the capability lets anybody do, yours is a claim you make about your own application, and an unlabeled sentence of yours would borrow the standing of ours. Nothing is composed on your behalf — a record carrying no sentence shows only ours, and says so. Rewording it takes the approval back, like any changed byte of your manifest (see Registering again, after a person approves you).

Automation classes

Split against what they actually do, because “automation” is too coarse for a person to authorize honestly — waking a machine and typing on it are not the same decision:

ClassWhat a person is agreeing to
Automation statussee whether automation is available
Remote wakewake this computer
Library maintenancetrigger a library scan
Saved automationsrun a routine the person wrote themselves
Remote controltype and click on this computer — the heavy one

Remote control is withheld from nobody — and it is not open to anyone either. It is a deliberate grant made at the console: named plainly, granted at the MusicBee machine, visible and revocable. Any caller may ask for it and no publisher is privileged in asking; what it never is, for us or for you, is on by default.

How this meets the surface that exists today. These routes are currently governed by one switch the person at the console controls. The classes above apply to registered callers presenting a ticket — they are not yet enforced against an unregistered caller, whose behavior is the open switch and is unchanged. Whether that changes is the break described above: said in advance, versioned, never silent.

Enforcement — the ticket and the errors

SituationResponse
No ticketunchanged behavior
Unknown or revoked ticket401
Valid ticket, capability not granted403 with "state": "not-granted", naming the capability
Valid ticket, capability declined by the person403 with "state": "declined" — re-asking on retry does nothing; a new version may ask again
Capability reserved403 with "state": "reserved" and a reason — not available yet, never confused with declined or banned
Capability banned403 with "state": "banned" — the one answer that means no, and says so

Every 403 carries "capability": "<name>" so an integration problem is a five-minute fix rather than a support thread, and state so the four outcomes are distinguishable on the wire and not only in a person’s head.

The runtime prompt

A consent prompt is rendered on the MusicBee machine, by a path that accepts requests only from that machine. That is a security property, not a convenience: a prompt that could be raised from the network would be a consent dialog with a Trust button that anyone on the network could put in front of the person. The prompt cannot be relayed to a user somewhere else, precisely because it must not be forgeable from somewhere else.

It is raised only for a charm whose tier is pinned or unsigned — a signed-and-known charm’s enable is its consent — only for an outward-acting capability, and never for a page or endpoint charm. A proc charm with no recorded tier is asked as well, for every outward-acting call: nothing has looked at an image, and an absence of evidence is not evidence of quiet. The kind is what settles a page, not the tier — a page carries no tier either, and a rule keyed on tier alone would be relying on that staying true. It leads with the tier, names one capability in the capability’s own words, and offers Not now (first, and the default) or Trust.

Known limit today: for the network-devices capability a Trust is remembered per capability, not per device — the destination lives in the request body, which the dispatch check does not read — so a second device does not re-prompt within the session. Stated so nobody relies on the finer grain.

The prompt can be switched off, and a charm cannot switch it off. Show runtime prompts sits in the Charm Manager tab, on by default. Off means every question that would have been asked answers not now, silently and immediately: the call gets the same 403 not-granted a person clicking Not now would have produced, nothing is granted, and nothing is declined for the session — the answer is held as an absence, on a short cooldown, so turning prompts back on lets the question reach a person again without a restart. What you may do is identical either way: the switch sets how loud MBXHub is, never what is granted.

Grants live at the console, and only there. Approving, declining, revoking and banning happen in MBXHub settings → Charm Manager tab, on the MusicBee machine. There is no REST route and no web page that grants a capability. The route that writes settings answers 403 for any key in the console-only categories, naming the offending keys, and applies nothing for that request — pairing a protected key with an ordinary one writes neither. A control that decides whether a person is consulted must not be reachable by the software that wants their answer.

Revocation is survivable by design. When a person revokes you, your next privileged call returns 401 and everything unprivileged keeps working. The expected behavior is that you degrade to what you did before you registered. An extension that breaks on revocation is treating a person’s decision as a fault.

Pages we host are subject to the same rules. If MBXHub displays your page inside one of its own surfaces, that page reaches privileged actions only within the capabilities you were granted. The convenience of being hosted does not carry our authority.

Surfaces — where a charm appears

Inside MBXHub’s own pages is where a charm renders by default. When the MBXHub Shell is running, a charm can also be popped out to a desktop window of its own — that needs no MusicBee surface and no restart. Without the Shell there is no window to pop out to and the charm stays inside the pages; a plugin-only install deliberately shows no pop-out.

A registration is not on a surface until a person approves it. Registering puts a manifest in the folder the charm surfaces read, so without this rule registering would put a button on somebody’s bar that nobody said yes to. A registration that is pending or revoked is therefore not rendered — not on the dashboard bar, not on the Charm Bar rail, not as a window — while the record itself is untouched and still listed in the Charm Manager, which is where a person sees what is waiting on them. An approval renders only while its stamp verifies over the manifest file as it is on disk, and a charm on the revoked or ban list is not rendered anywhere, record or no record. A manifest with no registration record at all renders as it always did; absence means not a registration, never waiting for Approve. Rail approval takes effect on the next render without a restart. MusicBee menus are built at startup; revoked items can remain visible until restart, but their clicks are refused.

A framed charm’s page is not isolated from the page that frames it, and that is a convention rather than a boundary. A charm rendered inside one of our pages is loaded in an ordinary un-sandboxed frame: what stops it reaching a privileged action is the host page’s fixed list of open verbs — a host page performs only those for a page it frames, and anything else is refused with one log line naming the verb — not the browser. That is honest for the pages we ship and for a page somebody with access to the machine dropped into their own charms folder, but it is not a boundary we can offer for a page we did not author. Treat the frame as a rendering arrangement between parties who already trust each other, and read the enforcement section for the boundary that is real: the dispatch check, which no page can talk its way past, because no page holds a ticket.

A charm can be a button in the HUD

A charm that declares a launch target and claims the overlay placement is drawn as a tile in the HUD's Services drawer. Pressing it starts what the manifest declares, under the same launch policy as the Tools menu — the deny-list, the operator's allow-list, and the observed-image gate all still decide.

GET  /charms/services      → { "local": true, "services": [ { "id", "label", "icon", "launchKind" } ] }
POST /charms/{id}/launch   → 200 { "id", "launched", "raised", "launchKind" }, or a named refusal

launchKind is scheme or exe, which is all a button needs; your launch target is never returned, because it is the operator's to read on the approval row. A charm with no launch target that declares a verb, holds the verbs grant and claims overlay is a tile too: launchKind is post, and the row adds verbIndex and, when the verb has one, confirm — see Verbs, the store and settings below for what its press answers. The press is local-only, so from another machine the list answers empty with local: false rather than drawing tiles that cannot work. The launch route takes no target parameter — the same structural binding that keeps what was approved and what starts from coming apart.

Declaring the placement is how you ask for that surface. Without the condition, every Tools-menu launch charm would appear in the HUD as a side effect of existing.

Refusals: 403 NOT_LOCAL, 403 API_READ_ONLY when the API is in read-only mode (the same refusal registration gives; the Tools-menu item and the Charm Manager still launch, because the console is not the API), 404 NOT_FOUND, 403 LAUNCH_REFUSED carrying the policy's own reason, and 409 LAUNCH_NOT_STARTED when the plan was allowed and nothing happened.

A second press raises, it does not start a second copy. When the charm's executable is already running, its window is restored and brought to the front, and the answer says which happened: { "id", "launched": false, "raised": true, "launchKind" } — exactly one of launched and raised is true. So 409 LAUNCH_NOT_STARTED does not mean already running. It means the shell refused the start, the process being tracked has gone, or the application is running with no window to raise. A scheme launch is handed to Windows: a repeat press is the scheme handler's business, and the hub does not wait for a window.

The menu placement

Eligible menu entries appear under Tools → MBXHub at MusicBee’s next start, grouped by charm label (or id if blank). A registration, if present, must be active. A file-installed manifest with no registration record is also eligible. A charm on the revoked or ban list is not loaded, so it gets no item. The same eligibility rule applies to menu clicks, HUD services, and launching; an unregistered file can launch a URL scheme under policy, but cannot launch an executable because it has no observed image.

The status is re-checked at the click, against the charm as it is on disk at that moment, so a charm revoked after the menu was built is refused when its item is clicked and the reason is logged. MusicBee has no API for removing a menu item, so the item stays visible until MusicBee next starts — what is gated is the click, not the drawing. A click that passes delivers the activation by your kind, and starts your launch target as well when the entry’s action is the bare launch verb.

Delivery — how an activation reaches you

Delivery is part of the contract. A menu item whose click reaches nothing is worse than no menu item. When a person activates a surface you registered, the event is delivered to you the way your kind is reached, and registering a surface and receiving its events are one feature, not two. The event is CharmActivated, and its payload is the same either way:

{ "event": "CharmActivated",
  "data": { "charmId": "com.example.thing", "event": "activated", "placement": "menu",
            "entryIndex": 0, "entryLabel": "Open Thing", "atUtc": "…" } }

Launching — what a launch target may start

A menu entry whose action is the bare verb launch starts the manifest’s launch field when a person clicks it. The target is always that field — never anything in the entry, never anything a caller supplies — so the string the person approved is the string that runs, and a registration, if present, must be active. Two kinds of target exist, and they are judged differently.

An executable is judged by identity. It must be the image the hub watched register — the same file after path normalization, compared case-insensitively — so a charm may launch itself and nothing else, and a sibling file in a folder it can already write to is not itself. Arguments after the image are passed through verbatim. No list applies to an executable.

A URL scheme is judged by policy, in a fixed order, and the order is the whole shape.

What it does, and what it does not. At * it does not make a stock install safer — the deny-list is still the only thing standing, as before. What it does is make a deliberate install lockable. It is a security setting, so it is absent from the web settings page and from GET /system/settings/schema, refused by PUT /system/config with 403 SETTING_CONSOLE_ONLY, and answered in one place: MBXHub settings → API Access tab → Remote Control → “Launch allow-list (schemes)”.

What you see, and what you do not. None of this reaches the wire: registration answers the same whether or not the list would refuse you, because the launch is judged at the click and not at registration. A refused click starts nothing and writes one warning to the hub’s log naming the scheme and which list refused it. The person approving you sees it earlier — the Charm Manager’s approval detail reads Refused: … is not on the launch allow-list before the yes, so an approval that would start nothing is visible at the one moment that is worth knowing. So name the schemes your charm relies on in your own documentation: the operator who has narrowed the list is the only one who can add yours.

The residual, stated rather than hidden: a vendor scheme can be registered to anything on the machine. That is the shell’s contract rather than ours, and a person approving Launch: steam://run/4813240 is approving that string. The Charm Manager says so on the row.

The remaining MusicBee-side doors — a navigator node, a tab, a hotkey, a background-task status line — are not in this build, and none of them ships without its delivery path.

Verbs, the store and settings

Three capabilities a charm asks for in its own name. They are granted like any other scope — asked for in scopes, each with its sentence in scopeReasons, approved in the Charm Manager — and all three are lab.

ScopeName on the approval rowWhat a person is agreeing to
verbsVerbspost to hub routes its manifest names
storeStorekeep a small document on the hub
settingsSettingsoffer knobs the hub renders

Verbs

A verb is an expand[] entry whose action is post <path> — post in lower case, one space. It may carry body, say and confirm (see the field table). Registration refuses a malformed one by name: VERB_SAY_INVALID, VERB_BODY_INVALID, VERB_CONFIRM_INVALID.

A verb runs as your charm, and the hub runs it. A person presses it on the HUD, the Charm Bar or the dashboard; the surface calls POST /charms/{id}/launch?verb=<n>; the hub POSTs your path to its own REST port over loopback with your body, and the dispatch check judges that request against your grants — never the standing of the page or the person who pressed it. The press needs an active registration, the verbs grant and a path the hub allows; otherwise it is 403 LAUNCH_REFUSED with the reason.

X-MBXHub-Verb is the hub’s, not yours. The hub mints a single-use value for each press, good for 15 seconds, puts it on its own loopback POST, and spends it before the route runs. A client never sends it and your charm never sees it; there is nothing for you to set or check. What you get back is the launch answer:

POST /charms/{id}/launch?verb=0
→ 200 { "id", "launchKind": "post", "status", "data" | "error", "say" }

It is 200 whatever the route said, because the press happened. status is the route’s own HTTP status; data is the route’s data (or the route’s whole answer when it is JSON without the hub’s envelope) and error its error; an answer that is not JSON is error.code VERB_ANSWER_UNREADABLE. say is your say with {key} filled from data, and only when status is 2xx. The hub waits up to 10 seconds. Other refusals: 400 VERB_INDEX_INVALID (?verb= out of range), 409 VERB_IN_FLIGHT (your last press is still running), 502 VERB_UNREACHABLE, 503 VERB_UNAVAILABLE, 400 VERB_BODY_INVALID.

?verb=<n> counts your post entries only, 0-based, in manifest order. Without it a press starts your launch target when you declare one, and performs your first verb otherwise.

The paths a verb may name. A plain rooted path on the hub — no query, fragment, backslash, control character, dot segment, // or encoded backslash, shorter than 512 characters; /api/v1/ is stripped and case is ignored for the comparison. These are refused with everything beneath them, because the hub answers them differently for a caller on its own machine and a verb comes from that machine: /app/exit, /app/restart, /system/open-settings, /system/shell-bridge, /system/config, /system/settings, /system/default-page, /system/theme, /charms/register, /queue/projection, /partymode/start, /system/events, /rpc, and /charms/{id}/launch. The path is judged at the press, not at registration.

Changing a verb takes the approval back, like any changed byte of your manifest — see Registering again, after a person approves you above.

The store

GET /charms/{id}/store   → 200 { "success": true, "data": <your document, or {}> }
PUT /charms/{id}/store   → 200 { "success": true, "data": { "id", "bytes" } }

One JSON object per charm, at most 65536 bytes of UTF-8, written atomically by each PUT: a charm holding settings replaces the whole document, and for a charm without it the hub-written settings member is kept and the body's own settings member dropped. From the hub’s own machine only. With your ticket, the dispatch check asks for store (403 not-granted otherwise) and the ticket opens your store and no other. Without a ticket a local caller is treated as the console, and is let in only for a charm that is active on this hub, not banned, reserved or declined for store, and holds it.

The settings member is where the hub keeps your setting values. A PUT keeps the settings member the hub wrote (the operator's choices) unless your charm holds the settings scope; a charm that holds it writes the whole document. On the keep path the hub stores every member value exactly as you sent it, byte for byte, with nothing re-escaped (an emoji stays its four bytes), drops your body’s own settings member and adds back the one on file. So the stored document, which is what the 65536-byte limit and the answer’s bytes measure, is the members you sent (the whitespace between them aside) plus the kept settings member. Member names are written by the hub’s JSON writer, which may escape a few characters in a name, a character outside the Basic Multilingual Plane among them. Refusals: 403 STORE_NOT_GRANTED, 404 CHARM_NOT_FOUND, 500 REGISTRY_RECORD_REFUSED (your registration record is damaged or a newer build wrote it; nothing was changed, and a person on that machine has to repair or remove it), 503 REGISTRY_BUSY (the record could not be read just now; try again), 413 STORE_TOO_LARGE, 400 STORE_INVALID_JSON, 500 STORE_WRITE_FAILED, 500 STORE_READ_FAILED.

Settings

A top-level settings[], one entry per knob:

FieldMeaning
keyone lower-case letter, then up to 39 letters or digits; unique; never settings
typebool, int, double, string or options
defaultrequired; a value of the type
optionsthe choices, for options
min, max, stepfor int and double; each finite and of magnitude under 7.9e28
descriptionshown beside the control
tierbasic, advanced or dangerous; absent is basic

Declaring settings[] requires the settings scope (SETTINGS_SCOPE_MISSING otherwise). Registration refuses the first fault, naming the key: SETTING_KEY_INVALID, SETTING_TYPE_UNKNOWN, SETTING_TIER_UNKNOWN, SETTING_RANGE_INVALID, SETTING_DEFAULT_INVALID.

Where they appear. Only while your registration is active on this hub and holds settings: the web settings page draws one row per setting, keyed charm.<id>.<key>, and the Charm Manager draws them on your row. They are grouped under your label when it is plain ASCII (letters, digits, space, ., _, -) and, ignoring case, matches no hub settings category, no Object.prototype member name and no other charm’s label; otherwise under your id.

How you read them. In a verb, write {settings.<key>} inside a string value of body; the hub replaces it at the press with the stored value, or the default when nothing valid is stored. Without the settings grant your verb gets your manifest defaults and still fires. A charm holding store can also read them from the store’s settings member.

How the person writes them. On the web settings page or the Charm Manager row, which send charm.<id>.<key> to PUT /system/config. Values are judged strictly by JSON kind; an undeclared key is 400 CHARM_SETTING_UNDECLARED, a value the setting does not accept is 400 CHARM_SETTING_INVALID, both naming the keys, and nothing in that request is applied.

The in-box Dice

MBXHub's own Dice (dice) is an in-box charm, not a registered one, like ARiA. One entry opens its page, /pages/dice.html, where the two dice settings live. Another, post /autoq/dice, is a Roll button that the dashboard posts itself, the way ARiA's entries post theirs: an in-box post entry needs no grant and sends no body, and the route then rolls with the hub's own dice settings. A registered charm reaches a hub route only through a verb, under the verbs grant, as described above.

A charm MBXHub ships. A manifest inside the signed plugin that declares scopes is approved at first start with every scope it declares except one the operator banned or declined for it: nobody is asked to approve a charm MBXHub ships, the signed plugin is the approver. A charm from anyone else, yours included, still waits for a person to approve it. A shipped charm can be revoked or banned in the Charm Manager like any charm and stays that way across upgrades; after Un-revoke or Unban the next MusicBee start approves it again. An upgrade that changes it is approved on the start that brings it, a file somebody edited by hand is not approved, a hub id reset is followed by the same approval under the new id, and POST /charms/register for its id answers 409 ID_CLAIMED. No charm the current build ships declares scopes.

Trust — it sets the noise, never the capability

Signature and catalog status decide how loudly you have to ask, never what you may hold.

TierEvidence required
halrad-signedshipped by us, or signed by our key
verifiedan Authenticode signature that chains to a trusted root — a code-signing certificate from a public CA
pinneda signature that does not chain, first seen at install and continuous since. Not reachable in this build — see below
unsignedno signature at all — and, today, any signature that does not chain

Why pinned is not reachable, stated rather than hidden. The operating system check this build relies on reports one state for “the signature does not chain to a trusted root” whether the cause is a self-signed certificate, an expired one, or a tampered image — and a patched binary must never be quieter than an honest unsigned one. So until the check can tell those apart, every non-chaining signature tiers as unsigned. A self-signed publisher is exactly as loud as an unsigned one today, and reaches quiet only through a certificate that chains.

Certificate renewal must not cost you a tier. Continuity is judged on the certificate subject, not the key: a renewed certificate with the same subject keeps your tier, and a change of subject is treated as a new publisher and asks the person once — one re-consent, not a drop to unsigned. The successor mechanism for a subject change is not in this build.

The ladder is evidence about an executable, and that is the only kind it applies to. A page is a file on disk, and something with access to that machine put it there — that is its provenance, and a signature would re-prove locally what local access already established. An endpoint has no local image and is not asked for one; what can be known about it is where it answers from — the local machine, this subnet, or beyond — and whether that has changed since first sight. So a page or an endpoint carries no tier at all, rather than sitting at the bottom of the ladder: unsigned is a statement about an executable that was not signed, and neither of them is an executable.

The hub applies the same check to itself. The plugin verifies its own image against the Halrad signing certificate the way it verifies a proc charm — a valid signature and our certificate, since either alone can be satisfied by a file that should not pass — and reports the result on GET /system/version as signature and signatureVerified. If you want to know you are talking to a shipped, unaltered build, read that. “Could not determine” is its own state and is never reported as verified.

License grants

Verification is implemented, but no verification key ships in the inspected build: grants fail closed with unknown keyId. A signed grant {publisher, class, expiry} may unlock a license-gated class once an issuing arrangement and verification key are available. Verification is offline, at registration, and re-checked on every start; a verified grant is recorded on the registration as a licensed class and the dispatch check honors it.

Versioning — what evolves, on separate clocks

Named separately, because merging any two of them is how a format rots.

WhatFieldWho owns itMoves whenIn this build
Manifest formatschemaVersionusa field or verb is added — including a new kind valueparsed; absent means 1
Your releaseversionyouyou shipparsed and recorded
The contractapiVersionusthe API surface changes"1", answered by /ping and by every registration response
Capability maturitylab / optional / defaultus, per capabilitya capability becomes safe to offerevery capability carries one and GET /charms/capabilities states it; this build uses lab and default

What apiVersion is intended to promise: under a fixed value we will not remove a field, change a response type, or retire a route; we may add. A change gets a version bump and notice two releases ahead. It is deliberately not our product version, and it is the one to pin against. That promise is proposed rather than committed — the one commitment on this page is the open read surface, and it says so on purpose.

Verification and limits

The documentation reconciliation ran 270 targeted Release tests, all passing, covering placement resolution (including the 18-manifest, 32-entry seeded corpus), menu eligibility, launching, runtime prompts, registry transitions, callbacks, dispatch enforcement, and refusal vocabulary. These are automated checks of the implementation, not proof of deployment or end-to-end operation on your installation.

Still outstanding: a real MusicBee Tools-menu click, a launch on an install with a narrowed allow-list, page activation forwarding in a browser, and long wall-clock ticket survival. The internal implementation ledger owns the detailed evidence inventory.

The verbs, store and settings section was written from the source and its automated tests at 8e61c23d1. Not yet observed on a running install: a charm with all three scopes approved in the Charm Manager and pressed from the HUD, its settings drawn on the web settings page, and its store opened on a build that carries the store route.

Where a registration lives, approvals as stamps over the manifest bytes, the revoked and ban lists, and a shipped charm approved by the signed plugin were written from the source and its automated tests at 601668fa4. Not yet observed on a running install: an upgrade from a build that kept the registration in the manifest file, the Charm Manager’s revoked and banned section, and a shipped charm approved at first start on a live hub (none ships with scopes now).

Top-level placement shorthand, source-driven rendering, MusicBee doors other than the menu, no-surface declaration, certificate-renewal continuity, and endpoint-reach presentation remain unimplemented. The existing Shell pop-out and HUD launch tile do not imply those features exist.

Telling us it is wrong

The most useful thing you can send back is the endpoint your integration reads that is not on the frozen list, or the clause here you would not implement. A clause nobody adopts is worse than a missing one, because it looks like a contract.

Charms SDK — reconciled September 7, 2026; verbs, the store and settings added October 7, 2026. Describes the inspected implementation revisions above; anything marked not in this build is designed and not yet shipped, with no date attached. See also the REST API and Charms.