Skip to content

feat(agent-bus): local loopback command API for agents and scripts - #55

Merged
rosspeili merged 4 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:feature/local-agent-bus-6
Aug 19, 2026
Merged

feat(agent-bus): local loopback command API for agents and scripts#55
rosspeili merged 4 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:feature/local-agent-bus-6

Conversation

@AUDOSt0ck1ng

Copy link
Copy Markdown
Collaborator

Implements the local agent bus discussed in #6: an opt-in loopback command intake so scripts and
agent frameworks can drive the avatar without scraping the UI.

It dispatches the stage commands that already exist rather than introducing a second set of names,
so a panel click, an animation hotkey and a curl all take the same path and produce the same
errors.

curl -X POST http://127.0.0.1:47903/v1/command \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{"command":"animation.play","payload":{"id":"Peace Sign","mode":"once"}}'

{"ok":true,"action":{"kind":"animation.play","animationId":"vrma-03","mode":"once"}}

Design

One dispatch core, two peer transports. POST /v1/command and ws://…/v1/socket both call the
same dispatch in agent-bus.cjs; the socket is not a wrapper over HTTP. Semantics stay in
stageCommands.js below both. One new dependency, ws.

No new IPC shape. This is the one thing that changed since the sketch in the issue. Validation
needs the catalog, and GET /v1/state needs it too — so the window reports it and the main process
answers callers itself, in three one-way hops rather than a request/response channel:

  1. renderer → main: a snapshot of the catalog and the current selection, on mount and whenever
    either changes (custom folder swap, avatar change, …).
  2. main validates the request against that snapshot with resolveStageCommand and answers the
    caller synchronously — same error codes the UI produces.
  3. main → renderer: the resolved StageAction, applied through the same useStageCommands path a
    panel or hotkey uses.

Acceptance is decided in main, application happens in the renderer, which keeps 200 means accepted,
not loaded
honest. useStageCommands grew an applyAction alongside runCommand for step 3;
nothing else about it changed.

main.cjs imports resolveStageCommand from src/ via dynamic import() (ESM from CommonJS)
rather than reimplementing the rules. The file was already free of React and asset imports so
node --test could load it; the three modules involved are now listed in package.json's
build.files so a packaged build ships them.

GET /v1/state returns id and label for animations — a custom folder hashes ids from file
paths, so "Peace Sign" is the only name a caller can be expected to know — plus playableOnce, so
nobody has to earn a 409 to discover which clips can be one-shots. Avatars stay id-only, as agreed.
Environments and audio sources are listed too (the latter are runtime-dependent and otherwise
undiscoverable), along with current, so an agent can toggle rather than only set. runtime
(version, mode) is added by main, which is the only side that knows it.

mode is called out everywhere. Omitted still means select, which persists to config.yaml
exactly like a menu pick. Every example in the docs and the panel's Copy example curl uses
"once".

Security

  • Binds 127.0.0.1 only, and Host must name loopback and the port actually bound.
  • Any request carrying an Origin header is refused — 403 on the HTTP route, and the WS handshake
    is rejected before it completes. That keeps every web page out, including a local dev server, with
    no allowlist to maintain.
  • Authorization: Bearer only; a token in the query string is refused on purpose. Comparison is
    constant-time.
  • The token is not in config.yaml: that file is a renderer-owned snapshot rewritten on every
    stage change, while main needs the secret before a window exists. It lives in agent-bus.json in
    userData, safeStorage-encrypted, 0600, written tmp+rename — same shape as the VRoid Hub
    credentials store. Minted on first enable, reused thereafter. Without an OS keychain there is
    nowhere safe to keep it, so it is held in memory for that run and the panel says so.
  • Body cap 16 KB. An oversized body is answered and then drained rather than reset, so the caller
    reads a 413 instead of a connection error.
  • Fixed port, no auto-increment: silently moving would make the copied curl lie.

Deviation to flag: minting on first enable means an enabled bus always has a token, which is
stricter than the "token optional, default off" wording in the issue. The bus itself is still off by
default, and requireToken: false remains as the escape hatch for a quick curl.

Errors

status codes
400 bad-payload, unknown-command
401 unauthorized
403 forbidden-host, forbidden-origin
404 unknown-animation, unknown-avatar, unknown-environment, unknown-audio-source, not-found
405 / 413 / 415 wrong method, body too large, not JSON
409 not-playable-once
500 internal-error — a hop threw, most plausibly a window torn down mid-request
503 not-ready — the window has not reported a catalog yet (startup, or a reload)

The stage codes are the resolver's own, returned verbatim.

WebSocket

Every client frame needs an id, echoed on the reply — null when the frame had none to echo, so
every reply carries the field. If outbound state events are added later they will carry no id
at all and a type instead, which a client written today can ignore. That one rule is the only
future-proofing here; no event work in v1, since what the events should be will be much clearer once
something is actually driving this.

Settings and config.yaml

Settings → Agents (desktop): enable toggle, port, require-token with Copy / Regenerate, Copy
example curl
, and a status line (listening / port in use / off). Toggling starts and stops the
server live, no restart.

agentBus:
  enabled: false
  port: 47903
  requireToken: true

Schema version 2 → 3. Nothing to migrate: an older file simply has no section, and an absent one
means off.

Tests

npm test — 130 passing, 24 of them new.

  • electron/agent-bus.test.cjs drives the real server over real sockets with the real
    resolveStageCommand (loaded the same way main.cjs loads it, so a broken import shows up here):
    every status mapping, host and origin rejection, token present/absent/near-miss, query-string
    token, method/route/content-type, malformed JSON, oversized body, not-ready, a hop that throws
    (500 rather than a socket nobody answers), and the WS id contract.
  • electron/agent-bus-token.test.cjs: mint-once-and-reuse, rotation, corrupt/missing file.
  • src/config/agentBus.test.mjs: the config.yaml section — off unless a real boolean says
    otherwise, token on unless explicitly off, and a port that cannot bind falling back rather than
    reaching listen as NaN.
  • stageCommands.test.mjs stays the source of truth for semantics; the server tests cover transport
    only.

Not unit-tested, per what this repo already does: the main-process wiring in main.cjs, the
useAgentBus hook and AgentBusPanel. Those paths are what the manual run below exercised.

Manual verification

Built and run as the packaged renderer (npm run build + electron .), driven with curl and a
ws client:

  • GET /v1/state listed the custom environments folder and all four catalogs, with runtime and
    current.
  • animation.play by label with "mode":"once" → 200, resolved to vrma-03.
  • avatar.set → 200, and the next /v1/state showed current.avatarId had actually changed —
    the full round trip, not just acceptance.
  • 404 unknown-animation, 409 not-playable-once, 403 forbidden-origin as specified.
  • WebSocket reply echoed its id.
  • With requireToken: true, no token and a wrong token both 401, and agent-bus.json was written
    encrypted.

Not verified live: the accepted-token 200 path. The token is DPAPI-encrypted and only the Settings
panel can read it back, so that one is covered by the unit tests.

Out of scope

MCP (an adapter can sit on the HTTP side later — the bus stays the one control path underneath),
outbound state events, window control, VRoid Hub loading (#18), browser build, remote bind.

Keyword → animation (#7) should call runCommand in-process when it lands, not HTTP to ourselves.

Refs #6

🤖 Generated with Claude Code

AUDOSt0ck1ng and others added 4 commits August 20, 2026 01:00
Adds the opt-in local bus from #6: HTTP and WebSocket as peers on
127.0.0.1, both dispatching the stage commands that already exist rather
than a second set of names.

Validation happens in the Electron main process against a catalog the
window reports, so there is no request/response IPC channel — three
one-way hops instead. The window reports what is on stage, agent-bus.cjs
answers the caller with the same error codes the UI produces, and the
accepted action goes back to the window to apply. That keeps "200 means
accepted, not loaded" honest, and it is why resolveStageCommand is
imported from src/ by main (the file was already React- and asset-free
for node --test).

GET /v1/state carries id and label for animations, because a custom
folder hashes ids from file paths and a caller cannot invent them, plus
playableOnce so nobody has to earn a 409. Avatars stay id-only.

The token is minted on first enable, reused after, and stored encrypted
beside the VRoid Hub credentials rather than in config.yaml, which the
renderer rewrites on every change. Bearer header only. Anything carrying
an Origin header is refused on both transports, which keeps every web
page out without an allowlist to maintain.

Fixed port, no silent fallback: the copied curl example names one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… calls

Review pass over the previous commit.

A throw anywhere in the request path — realistically `applyAction` reaching
a window torn down mid-request — left the caller holding a socket that was
never answered. The handler is now a named async function whose rejection
becomes a 500, and the socket path replies with the same code.

The WebSocket comment claimed the server never sends a frame without an id,
which the three malformed-frame replies contradicted. The contract is the
one the code actually keeps: every reply carries the field, null when there
was nothing to echo, and a future event will carry no id at all. Docs and
CHANGELOG say the same.

Removed: `clearAgentBusToken` and the exported `writeAgentBusToken` (no
caller), `MAX_BODY_BYTES` / `STATUS_BY_CODE` from the module's exports, the
`agent-bus:status` IPC handler with its preload and hook wrappers (status
already comes back from configure and rotate), and three status fields
Settings never read.

Also: `reject` inside the factory forced both promise callbacks to be
renamed around it, so it is `refuse` now; the Host port check is justified
by what it actually does rather than by a hand-wave about DNS rebinding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`src/config/agentBus.js` was the one new pure module without a test, while
its sibling `environmentSelection.js` has one. Covers what a hand-edited
file can do to it: the bus stays off unless `enabled` is a real boolean,
`requireToken` stays on unless explicitly false, and a port that cannot be
bound falls back instead of reaching `listen` as NaN — which it would read
as "any free port".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.panel-button--danger` carries `width: 100%` and a top margin, because it
was written for VRoid Hub's Disconnect, which owns its row. Dropped into a
flex row beside Copy token it took the whole width, wrapped its neighbour
onto two lines and sat 10px lower than it.

The actions are a two-column grid now, so both buttons are the same width
whatever their label, and the single-button row spans. The token gets the
Port field's box instead of the dimmed caption treatment — it is a value to
be read and copied, not a footnote — while the "generated when you enable
the bus" placeholder goes back to being prose rather than monospace.

Also trims the status line to the address: the route list wrapped to three
lines in a column this narrow, and Copy example curl already hands over a
working request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AUDOSt0ck1ng

Copy link
Copy Markdown
Collaborator Author

@rosspeili

Copy link
Copy Markdown
Contributor

Thanks for the thorough work on this, @AUDOSt0ck1ng <3

A few docs / housekeeping items are better as a small follow-up PR so we don’t block the feature:

No need to rework the implementation for any of the above.

Ideal follow-ups / next steps (existing issues + a couple we may want to open):

Priority Track Notes
Next #7 Keyword → animation Thin consumer of runCommand in-process — not HTTP to localhost
Soon MCP adapter (new issue?) Thin wrapper on POST /v1/command + GET /v1/state; explicitly out of v1 here
Soon #26 SECURITY.md Reporting policy + pointer to Local agent bus security section
Later Bus: window/file audio (new issue?) audio.source id-only today; window pick + file upload stay in Voice
Later WebSocket state events (new issue?) Push/events when we know what agents actually need
Later Linux without keychain (new issue?) Only if users hit “token changes every launch” in the wild

Once the follow-up lands (or even in parallel after merge), we can close #6.

Thanks again, this is a big step toward 🙏

@rosspeili
rosspeili merged commit ca1d6d2 into ARPAHLS:main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants