Files
hub/docs/remotes.md
T

5.3 KiB

Remotes

Overview

A session is one authenticated /ws connection. Authentication is uniform — token or username/password — and every session is a user connection that receives event broadcasts and can send any action.

A remote is a named set of functions served by a session:

  • Connected by that session with the remote-connect action (backed by the App.connectRemote method), stored in App.remotes scoped to the user.
  • Lifetime: connected until remote-disconnect (manual, allowed from any session of the same user) or until the serving session closes — session close disconnects every remote it connected. Nothing is persisted.
  • No overriding: connecting a name that is already connected for that user fails with an error reply.

Local Remotes — purely user-side constructs, entirely implemented and stored in the frontend (Remotes.vue). They never touch the backend.

Agents never link to remotes. A prompt passes remote names in options.remotes; ExpertAgent.run expands them into run-scoped tools. Every function of a remote becomes a tool named exactly after the function, prefixed only when the run input asks for it.

This also implements the existing server.py TODO ("remotes aren't separate connections — any connection can create a remotes and listen for requests sent to them").

The frontend is already implemented and out of scope; the backend serves the two actions it uses (remote-list, remote-call) with the shapes below.

Wire protocol

Every message in both directions uses the existing action envelope. There is no separate type-keyed protocol.

Upstream (session → backend):

action payload success reply error reply
remote-connect body {remotename, tools} {'message': 'remote connected', 'remotename', 'nonce'} {'error': "remote 'x' is already connected", 'nonce'}
remote-disconnect body {remotename} {'message': 'remote disconnected', 'remotename', 'nonce'} {'error': "no connected remote named 'x'", 'nonce'}
remote-list — {'remotes': [...], 'nonce'} —
remote-call body {remotename, function, args} {'result': <JSON value>, 'nonce'} {'error': '...', 'nonce'}
remote-call-result top-level {requestId, result} or {requestId, error} none none

tools entries (function specs) and the remote-list entries share one shape: {name, description, parameters} with parameters a full OpenAI tool-parameter schema dict.

Downstream (backend → the session serving the remote):

{"action": "remote-call", "requestId": "weather-service#0", "function": "get_forecast", "args": {"city": "Rome"}}

Result routing: requestIds are '<remotename>#<n>', unique among a user's remotes. A remote-call-result is fanned out over the receiving session's own connections; resolve/reject ignore unknown ids, so one session can serve several remotes without ambiguity. Fan-out is session-scoped, so identical id strings belonging to another user's remote can never be resolved from this session.

Example connect + call round trip:

{"action": "remote-connect", "nonce": "c1", "body": {"remotename": "weather-service", "tools": [{"name": "get_forecast", "description": "Retrieve weather forecast for a location.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "City name."}}, "required": ["city"]}}]}}
{"action": "remote-call", "nonce": "c2", "body": {"remotename": "weather-service", "function": "get_forecast", "args": {"city": "Rome"}}}
{"action": "remote-call-result", "requestId": "weather-service#0", "result": {"forecast": "sunny", "city": "Rome"}}

Calls not answered within REMOTE_CALL_TIMEOUT (30s, constant in remote.py) fail; calls pending when the remote disconnects fail immediately. All failures surface as RemoteError, which becomes an error tool-result string for the model — never an exception that kills a run, and never a killed caller session.

The prompt contract

{
  "action": "send-agent-prompt",
  "body": {
    "agentId": 7,
    "instruction": "What's the weather in Rome? Use your tools.",
    "options": { "remotes": ["weather-service", { "name": "code-repo-indexer", "prefix": "repo_" }] }
  }
}
  • Agent.prompt already splats options into the run input (input={"instruction": ..., "input": ..., **kwargs}), so options.remotes lands in run.input["remotes"]. No server or app change needed.
  • Each entry is a remote name or {"name": ..., "prefix": ...}. Every function of the remote becomes one tool named prefix + function_name (empty prefix by default).
  • Collisions: the agent's native tools are listed before remote tools and the expert lookup takes the first match, so native tools win. Pass a prefix to disambiguate.
  • Remotes are resolved once, at run start. A remote not connected at that moment is skipped with a console warning and the run proceeds (remotes disconnect independently of prompts). If a remote disconnects mid-run, its pending calls fail and the model sees error tool results.
  • Existing expert rule unchanged: if the run input already carries its own tools, those replace the agent toolset entirely and remotes are not in play.
  • Users only see their own remotes — the registry key includes user_id.