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 announces 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. 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 targetNo
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
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

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 announces this service's private address. 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 block 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 — announcing yourself

An application announces itself — 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 announcing 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.

Announcing

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" }

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.

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 registrations403 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_IDcom.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
The id is claimed by another publisher409 ID_CLAIMED — the first record is unchanged
The hub could not read its own registry (a scanner or indexer was holding the file, and one retry did not clear it)503 REGISTRY_BUSY — nothing was written; your approval, credential and charmId stand; announce again

Announcing again, after a person approves you

pending is the only status the hub assigns — nothing in a request can ask for active. 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 announcement 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 announcement answers active and grantedScopes with no ticket, and a hub restart between the approval and your next announcement 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". Announce 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.

Five things take an approval back, all of them on your next announcement, and all with the same disposition — back to pending, ticket cleared, and a person approves again. Four are about what you declared and keep your grants:

The fifth is about the hub, not you: a record approved under a different hub install — a charms folder carried to another machine — goes back to pending on its first announcement there, and this one also releases the grants, because the person who granted them is not the person running this hub.

The rationale trigger compares only scopes the record already carried: asking for a new capability is the grants-pruning path’s business, not this one, so adding a scope does not knock you back on a rule about words that were never read. It does mean that the first announcement in which an already-approved charm starts sending sentences costs it its approval once — the row goes from no reason given to your words, which is the row reading differently, exactly as the rule says. Adopt scopeReasons in a release you would expect a re-approval in.

The placement set is compared case-insensitively as a set, so order, duplicates, labels, icons and actions move nothing and an ordinary version bump is untouched: adding a second rail entry to a charm that is already a bar button changes no set, and adding a menu entry to a charm that declared only rail entries does. Removal counts too — the row said one thing and now says another. 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.

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 pending registrations; a menu built earlier may remain visible but refuses activation.

Registration and credential lifecycle

EventRegistration after eventPersistent grantsTicket
First announcementpendingNoneNone
Console approvalactiveOperator-selected requested scopesMinted; plaintext collected once on a subsequent resolvable local announcement
Ordinary manifest updateUnchangedIntersected with requested scopes; new scopes ungrantedRetained
Changed executable, launch target, placement set, or rationale for an existing scopeactivependingRetained, subject to requested-scope pruning; unusable while pendingCleared; console re-approval required
Different hub identity on announcementpendingClearedCleared; console re-approval required
Console revokerevokedClearedCleared; announcing again does not undo revocation
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 reservednot 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:verbproxy:lan, library:read, automation:wake — and 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, which is the fourth trigger above.

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: announced, 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. Announcing yourself 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. A manifest carrying no registration block at all renders as it always did; absence means not a registration, never pending. 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. 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 block is also eligible. 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.

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.

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. Describes the inspected implementation revision above; anything marked not in this build is designed and not yet shipped, with no date attached. See also the REST API and Charms.