Client API

vespr-client.js is the small library the app shell uses to join a workspace. It turns the shared document into plain objects you can draw, and turns a few method calls into changes everyone sees. Use it to build your own front end on the same workspace.

On this page

What it is Built

The client joins a room as an ordinary Yjs participant, the same kind of peer the SDE extension and the agents are. There is no framework and no build step: it is one ES module, app/vespr-client.js. Yjs, y-websocket and a WebSocket class are passed in, so the same file runs in a browser and in Node.

The client reads and reports; it does not edit file text. Editing text is the SDE's job. What it can do is send chat messages, answer approvals, change settings, request runs, upload files and tell your own SDE what to show.

Use it

In Node

Node 20 has no global WebSocket, so pass the ws package. Install the three packages with npm install yjs y-websocket ws.

javascript
import WebSocket from 'ws'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { createClient } from './vespr-client.js'

const c = createClient({
  server: 'ws://127.0.0.1:9611',          // the realtime server
  room: 'demo',
  token: process.env.VESPR_TOKEN,         // your login; keep it out of your code
  name: 'alex',
  deps: { Y, WebsocketProvider, WebSocketPolyfill: WebSocket }
})
await c.ready

c.on('change', what => {                  // 'chat' | 'files' | 'presence' | 'settings' | 'usage' | 'status' | 'runs'
  const snap = c.get()
  if (what === 'chat') console.log(snap.messages.at(-1)?.text)
})
c.on('edit', e => console.log(`${e.by} changed ${e.file} at line ${e.line}`))

c.send('@coder run python3 -m unittest -q')

// the agent asks the room before it runs anything; read the card, then answer it
for (const card of c.get().approvals.pending) console.log(card.id, card.text)
// c.decide(cardId, true)   // approve; the first answer wins

In a browser

Leave deps out and give the page an import map. The app shell does exactly this, with the bundles in app/vendor/, and the browser's own WebSocket is used.

html
<script type="importmap">
{ "imports": { "yjs": "./vendor/yjs.js", "y-websocket": "./vendor/y-websocket.js" } }
</script>
<script type="module">
  import { createClient } from './vespr-client.js'
  const c = createClient({ server: 'ws://127.0.0.1:9611', room: 'demo', token, name: 'alex' })
  await c.ready
</script>

Cross-origin requests to the server's /api/* endpoints (uploads, runners) work: the server answers with CORS headers, by default for any origin. Set MURMIO_CORS_ORIGIN on the server to one origin, or to an empty value to switch it off. A token is still required for every call.

createClient(options)

OptionWhat it is
serverThe realtime server's address, ws:// or wss://. Required.
roomThe workspace's name. Required.
tokenYour login for the server. Required. It travels in the WebSocket's query string and in an Authorization header for HTTP calls.
nameThe name other people see next to your cursor and in the chat. Required.
colorYour color. Default #a5b4fc.
deps{ Y, WebsocketProvider, WebSocketPolyfill }. If you leave it out, the client imports yjs and y-websocket itself, which needs an import map or node_modules, and uses the global WebSocket.
fetchImplA fetch to use for the HTTP calls. Default: the global one.
onErrorCalled with an error thrown inside one of your listeners, so a bug in a listener cannot break the client.

It throws if server, room, token or name is missing. The module also exports loadDeps() and parsePyProblems(output, inRoom, cwd), which turns the Python tracebacks in a run's output into { file, line, message } entries for files that are in the workspace.

Reading state

await c.ready resolves when the first sync with the server is done. After that, c.get() returns one plain snapshot. It is cheap enough to call on every change. Before the client is ready it returns an empty snapshot.

FieldWhat it holds
statusThe connection: connecting, connected or disconnected.
me{ name, color }.
peopleEveryone present, people and agents: { id, name, color, isAgent, model, file, me }.
agentsThe entries of people with isAgent set.
messagesThe whole chat, oldest first. See the message shape below.
threadsConversations, newest first: { id, last, count, agents, people, pending }. Messages with no thread belong to main.
approvals{ pending, all }: the approval messages that still wait for an answer, and all of them.
filesThe workspace's file paths, sorted. Read one with c.read(path), which returns its text or null.
claimsWho is working on which file: { path: { by, ai, since } }.
modeThe agent mode: manual, acceptEdits, auto, plan or bypass.
runnerThe id of the machine commands run on; empty means the sandbox.
usageToken counts, keyed by agent and person.
runs, runsEnabled, problemsRun requests from the app (newest last), whether the operator switched running from the app on, and the Python errors in the newest finished run (a run that passes clears them).
github, githubImportThe GitHub starting point if a repository was opened, and whether an import is under way.
focus, packs, packsCatalogThe first-run question: the fields the agent offers, the answer (null until someone answers) and what the agent set up.

A chat message

Each entry of messages is { id, ts, author, isAgent, role, model, text, thread, quote, attachments, kind, detail, status, decidedBy }.

Events

c.on(type, listener) returns a function that removes the listener.

Acting

CallWhat it does
send(text, { thread, quote, attachments })Posts a message as you and returns its id (null if the text is empty). Mention an agent to talk to it: @coder …. thread is a name (default main); quote is { src, text, by }; attachments are the objects upload returned.
decide(approvalId, true | false)Answers an approval card. The first answer wins: it returns true for the call that decided and false for a card that is already decided or unknown. Your name is recorded.
setMode(mode)Sets the workspace's agent mode. Throws on an unknown name. A line in the chat says who changed it.
setRunner(id, label)Chooses where commands run. An empty id means the sandbox.
setPresence({ file })Tells others which file you are looking at. Pass nothing to clear it.
run('file', path)
run('tests')
Asks the lead agent to run python3 <path> or python3 -m unittest discover -v on the workspace's runner, and returns the run's id. Everyone sees the output in runs. See below.
importRepo(input)Opens a GitHub repository in the workspace: owner/repo or a link. The agent answers in chat. See the tour.
setFocus(ids, { other })Answers the first-run question with field ids from packsCatalog (for example ['vibe', 'backend']) and, optionally, something else in your own words: { other: 'rust firmware' } (cleaned to one line, 200 characters at most). [] with no text records "nothing". The agent then sets up the matching skills; outside ones wait for the room's approval.
command(cmd)Tells your own SDE what to show: { type: 'open', file, line? }, { type: 'follow', agent } or { type: 'unfollow' }. See below.
upload(file)Uploads a file that is too big or too binary for the shared text. file is a browser File or Blob, or { name, type, bytes }. Returns { id, name, size, type, sha256 }, ready for send(…, { attachments: [meta] }). It throws a readable error for an empty file, a file over the upload limit (25 MB by default, set by the operator) and a full workspace.
uploads(), download(id)Lists the workspace's uploads, and gets one back as an ArrayBuffer.
runners()The machines currently connected to run commands (an HTTP call with your token).
history()Everything that was done, newest first, in one shape. See below.
destroy()Leaves the room and removes all listeners.

Run from the app

The request is a small entry in the shared runs list that carries a kind and a file name, never a command line. The agent checks that the file is a .py file in the workspace, runs it, and writes the output back as it arrives. Each entry of runs is { id, by, kind, file, cmd, status, out, code, started, ended, runner, changed }, where status goes from requested to running to done, or ends as denied.

It is off unless the operator sets MURMIO_HUMAN_RUNS=1 on the agent; runsEnabled tells you. A remote runner (a teammate's computer) is refused for these requests: those runs need an approval through the agent. Why it is off by default: the sandbox caveat.

The command bus

command writes a short entry into the shared document. The SDE extension carries it out only if it was written by the same person as that SDE, the file exists in the workspace and is not editor configuration or git, and the agent is really in the room. It can only open or follow, never run anything. Entries older than 15 seconds, or from before the SDE connected, are ignored.

The History shape

Every item of history() is { id, ts, who, kind, model, type, verb, target, detail, file, plus, minus, extra }. kind is person, agent or system; type is edit, run, approval or access. It is derived from the shared document, so it needs no storage of its own.

What it does not do yet

How it is tested

agents/test/client.test.js runs the client against a real server and the agent's real room code: presence and agents, a message with a thread, a quote and attachments reaching the agent, streamed agent text, a created file and a replaced line arriving as edit events with the removed and added text and the author, approval cards where the first answer decides, mode changes reaching the agent, History, runners, and a wrong token never connecting. Run requests are covered in agents/test/runs.test.js, and the command bus in agents/test/client.test.js and extensions/murmio-collab/test/uibus.test.js.