Skip to main content

MCP Reference

The tool-by-tool reference for both noBGP MCP surfaces: the public server that noBGP hosts at https://mcp.nobgp.com/mcp, and the local server that any registered node can serve on 127.0.0.1.

They are one registry, not two. A tool that appears on both takes the same parameters and returns the same shape on both, so this is one catalogue with each entry marked for where it is available.

How to read this

Every entry carries a Surfaces line directly under its heading:

LineMeans
Surfaces: public · local (observe)On both. A node granted either tier may call it.
Surfaces: public · local (manage)On both, but a node needs the manage tier.
Surfaces: public onlyNot reachable through a node, at any tier.
Surfaces: local onlyServed by the agent itself, never proxied. Present at both tiers.

The local surface is a deliberate subset: 24 proxied tools, of which 17 are observe-tier, plus the two local-only tools. What it excludes — provisioning, network and organization management, service publishing, network_directory, and the labelling and granting tools that would let a node widen its own reach — is listed on the public server page.

tools/list is the authoritative inventory

On both surfaces the advertised list is already filtered to what the caller will actually be allowed to do — by organization role on the public server, by granted tier on a node. Treat this page as the map and tools/list as the territory.

Concepts

Addressing a node

Every tool that acts on one existing node — command, file, the fs_* tools, the net_* diagnostics, service_publish — takes the same node reference: either node_id or the network_name + node_name pair. Sending both, or neither, returns invalid_args.

  • node_id — the node's UUID. Stable across network moves, so prefer it when you have one. network_directory returns them on the public surface; status and net_peers return them on the local one.
  • network_name + node_name — human names.

node_name on its own is enough whenever the network is unambiguous: when you own exactly one network, or when the call is proxied through a node's local server, which is bounded to that node's own network. A caller with several networks gets an invalid_args asking which one — with the one qualification below, which matters once you belong to a team organization.

When you belong to more than one organization

Every tool that lets you omit the network — the node reference above, the selector fields, provision_node, register_node, network_delete — resolves an omitted name the same way, and the rule is written to make a shared organization's network something you name rather than something you land in:

  • An omitted name means your sole network, judged inside your personal organization when your networks span several orgs. One personal network wins outright, even if you also belong to team orgs holding a dozen more. Only if your personal side is itself ambiguous — or if you have no personal networks at all and several across shared orgs — do you get the invalid_args asking which one. If every network you can see lives in one org, personal or shared, nothing changes: a single one is still picked automatically.
  • A name that exists in more than one of your organizations resolves to the personal one first, then to the oldest. Network names are unique per creator, not across an org boundary, so two orgs you belong to can each hold a home.
  • An id binds exactly. network_id is checked for membership by id and never resolved through its name, so passing the id of a shared org's home reaches that network and not your personal namesake.

Node context is a boundary, not a default. A call proxied through a node can only reach that node's own network — naming another one fails rather than widening. Other networks need the public server.

Addressing storage instead of a node

The file tools also reach noBGP's own file storage — a network's shared drive, and each node's storage area — without going through any agent (router 0.4.56+). What you put in target decides which of three places you land in:

What you sendWhere it goes
target.node_id, or target.network_name + target.node_namethat node's own disk, through its agent — unchanged
target.network_name alone, no nodethat network's shared drive
target.node_id and storage: truethat node's storage area

storage: true needs node_id. A network_name + node_name pair is refused with invalid_args rather than resolved, because reaching a node by name is a Member-tier check on the way to a surface gated at Owner/Admin — two authority questions in one call, weaker one first. Take the id from network_directory.

The router serves these bytes itself. No file session is opened, nothing has to be online, and no node's bandwidth is billed for a file it never held. It also means the node's owner vetoes never see the call — allow-roots and allow-tools bound what an agent serves from its own disk, and there is no agent in the path — which is exactly why a node's storage area is gated a step higher than its filesystem.

Who may reach what:

  • A network's shared drive — membership of the organization that owns the network, the same access its URL carries. Any Member. The name is resolved among your own networks, so one you are not in is not addressable.
  • A node's storage areaOwner or Admin in the organization that owns the node. A Member gets forbidden naming the requirement; someone outside that organization gets not_found, since the node's existence is not theirs to learn. A node you have deleted keeps its area, and the people who owned it still reach it — the storage outlives the machine, which is the point of it.

What works on a storage tree, and what does not:

  • read, write, list, stat, mkdir and delete — so fs_read, fs_write, fs_list, fs_stat, fs_mkdir, fs_delete, and file with those op values. Anything else is refused with unsupported naming the six, rather than quietly reading the node's disk instead.
  • path is relative to that tree's own root, not to any filesystem: /reports/q3.csv is that file in the share and / is the top of it.
  • Reads and writes are single-call and capped at 1 MiB — the same ceiling a node read has. There is no file_id to poll: the one response carries the bytes and done: true. Past the cap the call fails with too_large and points at the tree's HTTPS URL, which streams a file of any size and is the right tool for one.
  • admin is ignored. There is no second identity to choose when no agent runs the operation, so nothing is elevated and nothing is gated on elevation.
  • list is one directory deep — recursive does nothing. delete removes a directory with everything under it, and the depth-guard that protects a node's disk does not apply here. mode is not applied.
  • fs_grep and fs_glob take no storage field at all: searching is still a node operation.

Roles and tiers

On the public server, authorization is your organization role; the per-tool notes below say which roles may call what, and Roles & Permissions is the full matrix.

On the local server, authorization is the node's granted tier. An Owner or Admin sets it with node_grant (role: "observe" | "manage"), which also takes:

  • targets — label pairs ("role=edge,site=irvine") restricting which peers the node may act on, evaluated live, so relabelling a peer changes reach with no re-grant. Omitted, the grant covers every node in the network.

Observe grants the read-only set — 17 tools as of this writing; manage grants all 24. The node's own two local tools (status, resolve) are present at either tier, whenever the server runs.

node_revoke takes the grant back and stops the server. Neither tool is callable from a node, which is what prevents a granted node from widening its own reach.

Authority is evaluated per call, not at session start: a grant changed mid-session takes effect on the node's next call, and connected clients are told to refetch their tool list.

Owner vetoes

Independently of the caller's role or tier, each node's owner bounds what their machine serves to MCP callers, to event-bus sources, and to published terminal services:

KeyDefaultGoverns
allow-toolsfs, commandCapability domains served (fs = all file tools and fs watches; command = command execution).
allow-roots/Filesystem roots the fs tools and watches may touch (symlink-resolved). The agent's own configuration directory is excluded whatever the value.
allow-admintrueWhether execution or file access as the superuser is permitted.

A veto refuses, never downgrades: an explicit elevated request against allow-admin: false fails — or reports a refused event — rather than silently running unprivileged.

A vetoed tool call comes back as permission_denied (HTTP 403, not retryable), with a message naming which of the three refused it, so a veto is never mistaken for a transient failure worth retrying. Router 0.4.37 is the release that made that reliable at every point a refusal can land — mid-read, mid-write and mid-search included; before it these arrived as internal and clients retried them forever. See When the failure came from the node.

allow-admin gates the resolved uid 0, not the name root. No caller can name an account at all now, so what this catches is a node whose configured user is itself uid 0 — root, or a second superuser account an operator made — where unelevated work would otherwise run elevated without anyone asking for it. It bounds the superuser identity, not privilege in general: an account with passwordless sudo, or one that can write systemd units, is effectively root and no uid check sees that.

These vetoes apply to every execution surface — the command tools, event-bus command sources, and published terminal services. A terminal published to run as root is refused on a node whose owner set allow-admin: false, the same as any other request.

One path is refused independently of all three: the agent's own configuration directory (/etc/nobgp, /usr/local/etc/nobgp on macOS, C:\ProgramData\nobgp on Windows). The fs tools and fs watches never serve it, no allow-roots value opens it, and symlinks are resolved before the check — it holds the node's key and the config these vetoes are read from. command is unaffected, so a session running as an account that can read those files still can.

The tools that walk a tree — fs_list with recursive, fs_glob, fs_grep — ask the same question at every directory they descend into and prune the subtree on a refusal, rather than vetting only the root they were handed (agent 0.4.37+). On earlier agents a recursive walk rooted at a permitted path reported what a single-path call on the same node refuses.

allow-admin: false can lock you out

It is meant to. On a Windows node that captured no install account, and on a root-running Unix node with no usable user configured, the two halves close on each other: allow-admin: false refuses admin: true, and unelevated never means root refuses admin: false — so remote execution is off entirely, terminal included. Undoing it then needs console, RDP or ssh access to the machine. On a node with a working user, sessions keep running as that account. The agent warns about this when the setting is applied.

Full treatment, including how to set them, is in Node Access Control.

Execution identity

A node offers exactly two identities, and one boolean picks between them: admin. Four rules cover the whole model, and they hold on every tool that carries the field — command, command_subscribe, file and the fs_* tools, service_publish and service_update:

  1. You never run as admin unless you ask.
  2. The node decides what unelevated work runs as — not the caller, not us.
  3. A node can refuse admin entirely.
  4. A node that refuses admin must name an account, or it cannot run anything.

Roles & Permissions explains each in prose and is the page to send someone who is meeting the model for the first time. What follows is the per-tool contract.

adminRuns asWho may ask
omitted / falsethe node's configured user — captured from the installing account at registration, so a normal sudo install runs as that person, not rootanyone who can reach the node
omitted / false, where that identity would be uid 0nothing — the call is refused, rather than run as the superuser
truethe node's ambient identity — gated twice, and both gates refuse rather than downgradeorg Owner/Admin, or a node holding a manage grant

Choosing which to send

Choose by what the work needs. Send admin: true for anything that requires the superuser — system paths (/etc, /var, /root, the Windows system directories), services, package managers, another account's files. Omit it otherwise.

Windows is no exception to that rule, and there is deliberately no platform rule here to learn: a Windows node has a second identity only where its owner configured an account, and where none is configured admin: false is refused exactly as on any other node whose only identity is the superuser. Which of the two you are on is what the refusal below tells you.

The rule is stated that way round on purpose. Sending admin: false first and treating the refusal as the signal to elevate costs a guaranteed wasted call on every node whose only identity is the superuser, and those are common: service installs, bare-root containers, and any Windows node that captured no install account.

Each refusal names one retry, so a wrong guess costs one call and never a loop:

ErrorWhat it meansThe one retry
failed_preconditionYou omitted admin on a node whose unelevated identity would be the superuser — unelevated never means rootRetry with admin: true
forbiddenYou asked to elevate without the authority — see gated twiceRetry with admin omitted. The same call never succeeds on a retry, and it is never quietly run unelevated

Both halves of the gate are also answerable before you call, which is worth one lookup on a large fan-out rather than a refusal per node:

  • whoami reports can_elevate — your half. false means every elevated call returns forbidden, so never send admin: true.
  • network_directory reports each node's user, allow_admin and user_is_admin — the node's half.

Unelevated never means root

Read that second row twice, because it is the one that surprises people: unelevated is not the same thing as unprivileged. The node resolves what admin: false would run as — the configured account if there is one, its own ambient identity otherwise — and if that comes out uid 0, it refuses the call instead of running it as root. The refusal is failed_precondition (HTTP 403) and is not retryable: the request is not denied, it is incoherent, because "unelevated" would mean the superuser on that box.

Three shapes reach it:

Node stateWhy it refuses
No account configured, on a node whose agent runs as root — a service install, a bare-root container, and a Windows node offering only LocalSystem, which reports as uid 0there is nothing lesser to fall back to, and the old fallback was root: silent, and in the escalating direction
A configured account that no longer resolves — deleted, or a config copied off another machinesame fallback, same answer
user: root spelled outthe config asked for the superuser; this call did not, and the call decides

A fourth shape reached the same code on Windows, and it was not about uid 0: on agents 0.4.44 and 0.4.45, a node with a configured account had a second identity it acted as for file operations but could not spawn a process as, so command, terminal sessions and dispatched bus commands refused an unelevated call there rather than quietly running it as LocalSystem. Agent 0.4.46 runs execution as that account too, closing the split. It is a per-node answer that changes as a fleet upgrades rather than a property of the platform, and the code and the single retry are the same on either side of it — admin: true.

A Windows node on 0.4.46 or later can still reach this code, for a reason that is about the node rather than the caller: it fails closed, so an account whose token cannot be obtained, whose profile cannot be loaded, or whose environment cannot be built refuses the call instead of running the work as LocalSystem.

Both remedies are named in the message, and either one works:

  • Configure an account on the nodesudo nobgp config --user deploy. Pick one that can actually do the work: naming an account purely to clear the refusal trades one clear error for a run of confusing permission failures.
  • Retry with admin: true, if the superuser is genuinely what you meant. That path is gated, which is the point — an elevation then appears in the record as an elevation, for whoever reads it later.

An empty user is a warning, not a verdict. A node whose agent is already unprivileged — a rootless container, a nobgp agent started by an ordinary account — serves admin: false as itself, account or no account. Refusing there would prevent no elevation, since admin: true on such a node resolves to the very same uid. So an empty user does not by itself mean you must elevate — choose by what the work needs, and let a failed_precondition be the one retry that says otherwise.

This is not a privilege reduction, and it is worth being exact about that: a node with no account moves from everything implicitly root to only admin: true, explicitly root. The same privilege is reachable. What changes is that asking for it is now visible.

The rule is the node's, evaluated from its own config at the moment of the operation — the router forwards the flag and resolves nothing. Agent 0.4.41 is the release that introduced it, replacing a silent fall-through to root.

network_directory reports each node's user and allow_admin so you can tell which shape you are dealing with before choosing.

A name reported there is not a promise. The node decides at the moment of the operation, so root — or any second superuser account — is reported by name and refused just the same, and an empty value is not a verdict in the other direction either. From agent 0.4.41 the name is the one the node resolved, so an account since deleted reports empty rather than reading as usable. A Windows node reports empty unless it has a configured account, which since agent 0.4.44 is its second identity — a name there is what file operations run as, and from agent 0.4.46 what execution runs as too (on 0.4.44 and 0.4.45 an unelevated execution call was still refused).

Absent — the field missing rather than empty — means only that the agent predates the field. Such a node also predates admin itself: it ignores the flag and runs the work as its configured user, so admin: true there does not elevate. Agent 0.4.34 is the release that honours it; both directions of that skew fail closed, and neither elevates on its own.

The gate is on the resolved uid 0, not a spelling, so any superuser account is covered.

Elevation is gated twice

admin: true must pass the router and the node. Either one can refuse, and an omitted or false call passes both trivially — it is the default and needs no authority at all.

GateAsksWhat a refusal looks like
Router (caller authority)May this caller ask? Org Owner or Admin on the public server; a manage grant on a node's local server. A Member, and an observe-tier node, cannot.forbidden, before the request ever leaves the router — "execute.elevated requires a higher role in this organization", or one naming node_grant role=manage
Node (allow-admin)May anyone be the superuser here? The node owner's setting, on the box.permission_denied, carrying the node's own wording — or a refused event, on the bus

A Member keeps every ordinary capability: they run commands, read and write files, and publish services, all as the node's configured account. What they cannot do is choose the superuser. Handing a caller root on a machine is an administrative act rather than an operational one — the same line node_grant draws when it lets a Member run a command themselves but not delegate that authority to a machine.

Before this, admin was checked only at the node. allow-admin defaults to true, so on a stock node anyone who could reach it could be root on it.

A forbidden on an elevated call is not worth retrying

Retry it with admin dropped — that runs as the node's configured account, and is what most work wants anyway. If the operation genuinely needs the superuser (a 0600 file the service account cannot open, a service that must bind a privileged port), the remedy is one of: an org Owner or Admin running it, an operator raising the node's grant with node_grant role=manage, or the node's owner clearing allow-admin: false on the machine. None of them is something a client can fix by trying again.

Inheriting an elevated service is a raise

A published terminal service is a standing grant: it runs at the identity stored on it, and it hands that identity to whatever command you later point it at. So service_update asks what the service will run as after your change, not whether you sent admin — updating a service already published with admin: true needs the same authority whether or not you send the field. Lowering it to the configured account never needs authority.

Elevation also sheds allow-roots (agent 0.4.37+; earlier agents confine both identities). The node owner's path confinement binds the configured account; the ambient identity is not bound by it, so an elevated call reaches paths an ordinary one is refused. What it never reaches is the agent's own configuration directory — that refusal is unconditional at both identities, because it holds the node's key, its JWT, and the settings these vetoes are read from. Elevating is not a way to read a node's credentials.

The decision follows the admin flag you sent, not the uid the work resolves to. That matters on Windows, where a default node's two values both land on LocalSystem: keyed on the uid, every Windows file call would shed allow-roots and the owner's confinement would evaporate on the whole platform.

To run as some third account, use su or runas inside the command itself. There is no way to name one, deliberately: an arbitrary username shipped for a single release and every way it went wrong came from the expressiveness — whether root was a spelling or a uid, and a Windows contract that said one thing while the agent did another.

A Windows node with no configured account has one identity. The service runs as LocalSystem, so both values land in the same place and allow-admin: false is an off switch rather than a downgrade there. Since agent 0.4.44 a configured user gives it a second identity: fs_* and file run as that account, and from agent 0.4.46 so do command, terminal sessions and dispatched bus commands (on 0.4.44 and 0.4.45 those refused an unelevated call rather than running as LocalSystem). Which of the two shapes a node is, is the node's answer and not the platform's. See A second identity on Windows.

File operations run as the same identity, and it genuinely changes who acts. The agent drops privilege before opening the file, so permissions and ownership apply as they would to that account — a file written with admin=false is owned by the configured user, not by root. (Before this, the file tools always ran as the agent's own account, which is why a path readable via fs_read could be denied via command on the same node. That gap is closed.)

Every file tool takes the flag. file and the per-op fs_* tools all carry admin, and it means the same thing on each. Set it on the first call of a read/write session; continuations carry the session's identity. Two deliberate exceptions: fs_glob (REST-only) has no admin at all, and fs_grep_subscribe always walks as the node's configured account — a subscription outlives the call that created it, so its identity is not the caller's to choose.

The per-op fs_* tools gained the field in router 0.4.36, alongside the caller-authority gate. Before that they did not carry it, so every fs_* call was unelevated whatever you sent; and before agent 0.4.34, the file tools ran as the agent itself regardless of either.

Tool annotations

Every tool advertises MCP tool annotations on tools/list, so a client that auto-approves on hints can decide without asking you.

HintMeaning here
readOnlyHintThe call changes nothing — not on a node, not in the router.
destructiveHintThe call can overwrite or destroy something: file contents, a service, a network, a running process.
idempotentHintRepeating the call with the same arguments has the same effect as making it once.
openWorldHintThe tool reaches an endpoint outside your own fleet.

destructiveHint and openWorldHint are always sent explicitly, including when false — under the MCP specification an absent openWorldHint means true, so staying silent would advertise the opposite of what these tools do.

file is the only open-world tool, because its op=fetch_url takes a caller-supplied URL that the agent then fetches. Everything else operates strictly on nodes in your own networks — including command, where the fact that a shell command could itself curl something is authority destructiveHint already covers.

Three annotations worth knowing about before you auto-approve on them:

  • fs_subscribe is not read-only. Every matching node stands up a real file watcher — that is state, not a read.
  • event_tail is read-only but not idempotent. It touches only your own subscription (draining its queue, resetting its idle timer), but draining is once-only: a repeated call returns different events, never the same batch twice.
  • event_unsubscribe is destructive. It kills commands still running under the subscription and tears down watchers on every node in the cohort.

On REST and OpenAPI the same flags ride as vendor extensions: each operation carries x-nobgp-mode and x-nobgp-capabilities (read_only, destructive, idempotent, open_world), and GET /api/v1/tools returns them per tool.

Result content

A successful MCP tools/call returns up to three things:

  1. A one-line human-readable summary, as the first TextContent block.
  2. For tools whose response is the payload you asked for — command, file, fs_read, fs_list, fs_stat, the net_* diagnostics, network_directory, event_tail, event_subscriptions, service_publish, service_share, whoami — a second TextContent block holding compact JSON of the response, prefixed with --- result ---. Tools whose response is an acknowledgement (network_create, service_delete, register_node, …) send the summary line only, so a trivial call doesn't pay twice for the same information.
  3. structuredContent, carrying the full typed response.

The second text block is capped at 32 KiB. Past that it truncates on a UTF-8 boundary and ends with ... [truncated; full N bytes in structuredContent] — the partial JSON is there for a model to read, not to parse. Read structuredContent when you need the whole payload programmatically: it is never truncated.

Argument validation

The input schema each tool advertises on tools/list is the same schema your arguments are validated against, so a schema-honouring client cannot construct a call the server will reject.

  • Parameter types are concrete. An array parameter is "type": "array", a number is "type": "integer" — never a union with "null". Clients that can't represent a type union used to degrade such fields to untyped and send arrays and numbers as strings ("[\"macbook\"]" instead of ["macbook"]), which validation then rejected. Send real arrays and numbers: node_names, node_ids, labels, watches, idle_timeout, max_seconds and every other typed parameter are callable directly.
  • Optional means omit it. Optionality is expressed by absence from the schema's required list. Leave an optional parameter out rather than passing an explicit null — an explicit null is rejected.
  • Unknown argument names are rejected. A misspelled parameter (nmaes for node_names) fails with invalid_args instead of being silently dropped, so a typo surfaces immediately rather than as a call that quietly did the wrong thing.

Available tools

provision_node and deprovision_node only appear for callers with provisioning access.

ToolPublicLocalWhat it does
This node
statusboth tiersThis node's own agent status, answered on the box
resolveboth tiersResolve a peer name in this node's overlay zone
Network management
network_directoryDiscovery and listing
network_createCreate networks
network_deleteDelete networks
Organization management
org_createCreate an organization
org_updateRename an organization (REST/OpenAPI only)
org_sso_setupMint a WorkOS Admin Portal link (Owner only)
org_sso_set_enforcedTurn SSO login enforcement on or off (Owner only)
Node management
provision_nodeCreate compute resources
deprovision_nodeRemove compute resources
register_nodeGenerate install commands for existing machines
node_labelSet, remove, or read a node's labels
node_grantStart a node's local server and let it reach its peers
node_revokeTake that reach back
Service publishing
service_publishExpose services publicly
service_updateModify service settings
service_deleteRemove services
service_shareManage authorized email lists
Command execution
commandmanageRun commands and manage interactive shell sessions
Filesystem
filemanageUnified file super-tool (ten ops)
fs_readobserveStream a file from the agent
fs_writemanageStream a file to the agent
fs_editmanageAtomic diff-style edits
fs_listobserveDirectory listing
fs_statobserveSingle-entry metadata
fs_deletemanageRemove a file or directory
fs_mkdirmanageCreate a directory
fs_grepobserveRE2 search across one node's files
Network diagnostics
net_peersobserveAgent's peer directory
net_interfacesobserveHost network interfaces
net_metricsobserveGo runtime metrics
net_routesobserveKernel routing table (Linux)
net_dnsobserveResolver configuration (Unix)
Event bus
fs_subscribeobserveWatch files and directories across a cohort
command_subscribemanageDispatch a command across a cohort and collect reports
presence_subscribeobserveNode online / offline / registered transitions
fs_grep_subscribeobserveRE2 search across a whole cohort
event_tailobserveRead pending events (and keep the subscription alive)
event_unsubscribeobserveEnd a subscription and stop the work
event_publishobservePublish your own event into a subscription
event_subscriptionsobserveList your live subscriptions
Identity
whoamiobserveCaller identity, organizations, and elevation authority

fs_glob exists on the REST surface only and is not catalogued here — see REST-only tools.


status

Surfaces: local only

Snapshot of this node: identity, router link, overlay network, MCP state, vetoes. No parameters. Answered on the box, so it still works while the control channel is down.

Response: pid, uptime_secs; registration (node_id, key ids); router (url, connected, transportquic or wss — QUIC endpoint and pin details, fallback and reconnect counters); network (local_ip, gateway_ip, tun_device, domain, dns, ipv6, targets[] with each peer's name, node_id, node-local address and local flag, and sessions[] with node_id, name, encrypted, compressed); mcp (granted, running, portport only while running); allow (tools, roots, admin, unelevated or unelevated_refusal, an optional unelevated_is_admin, and an optional warning); optional fs mount state and environment block.

The targets[].node_id values are the handles every proxied tool takes.

allow answers the identity question for this node (agent 0.4.42+), which matters here because network_directory — the tool that reports it fleet-wide — is organization-scoped and deliberately absent from the local surface, so a peer's user and allow_admin cannot be read from this endpoint at all:

  • unelevated is what an admin: false operation runs as, resolved by the same gate the real call uses — not the string in the config file.
  • unelevated_refusal appears in its place when unelevated work cannot run here, which means every call on this node needs admin: trueunelevated never means root.
  • unelevated_is_admin (agent 0.4.46+) appears only when true, and says that account is itself administrative — a member of the local Administrators group on Windows — so admin: false here drops from the ambient identity to Administrator and no further. It is the on-box half of network_directory's info.user_is_admin. Absent is not false: a node that did not check says nothing.
  • allow.admin: false is the other half: elevation is refused too. With both refused, nothing runs remotely on the node.

For a peer, the rule stands in for the lookup: choose by what the work needs and let the refusal name its one retry.

Served by the agent itself, never proxied. Both local tools are pure reads of on-box state and advertise that (readOnlyHint, idempotentHint, closed world), so a client that auto-approves on hints does not stop to ask about them.


resolve

Surfaces: local only

Resolve a peer name in this node's overlay zone.

Parameter: name (string, required — bare or fully-qualified in this node's zone; names under other zones are refused).

Response: name (normalized), node_id, address (a node-local overlay handle — never valid on another machine), fqdn, found. found: false is an authoritative miss; found: true with an empty address means no data yet, and is retryable.


network_directory

Surfaces: public only

List networks, nodes, and published services accessible to the authenticated user.

Purpose

Primary discovery tool - shows the current state of all infrastructure.

Input Schema

{
"network_name": "string (optional)",
"online": "boolean (optional)",
"node_name_glob": "string (optional)",
"hostname_glob": "string (optional)",
"platform_glob": "string (optional)",
"brief": "boolean (optional)"
}

Parameters:

All filters compose with AND — supply any combination to narrow the result.

  • network_name - Specific network name to filter results. If omitted, returns all networks.
  • online - When set, restrict to nodes with this online state. true = currently connected only; false = disconnected only. Omit for all nodes.
  • node_name_glob - Shell-glob (path.Match syntax) matched against each node's name: *, ? and […] character classes. Brace expansion is not part of that syntax{web,db}* is refused with invalid_args naming the cause rather than matching a node literally named {web,db}…, which is what it used to do, and it read as "none of your nodes are online". List the alternatives, or use a broader pattern and filter the result. Malformed patterns return invalid_args.
  • hostname_glob - Shell-glob matched against each node's reported hostname.
  • platform_glob - Shell-glob matched against each node's platform string (e.g. raspbian, ubuntu, darwin, openwrt, synology).
  • brief - When true, omit each node's services array. Shrinks responses sharply for "is X online?" probes.

A network no node matched is omitted entirely. Once online or any glob is set, a network whose nodes all fail the filter drops out of the response rather than appearing with an empty nodes array — a glob aimed at one node used to return every network you belong to, all but one of them empty, and you paid context for each. Two deliberate exceptions:

  • A network you named with network_name is returned even when the other filters leave it empty. Its presence is the answer that the network exists and simply holds no matching node; a name you don't own is already a distinct not_found.
  • brief on its own prunes nothing. It narrows what is reported about a node, never which nodes match, so a brief call still lists every network.

Output Schema

{
"networks": [
{
"id": "string",
"name": "string",
"org_id": "string",
"files_url": "string (WebDAV base URL for this network's shared drive)",
"nodes": [
{
"id": "string",
"name": "string",
"online": "boolean",
"offline_at": "string (RFC3339 UTC; absent while the node is online, and for one that has never connected)",
"info": {
"hostname": "string",
"agent_version": "string",
"version_status": "string (current | behind | ahead | empty)",
"channel_version": "string",
"platform": "string",
"platform_version": "string",
"kernel_arch": "string",
"hardware_model": "string (absent when the node can't name its hardware)",
"user": "string (may be empty; absent on older agents)",
"allow_admin": "boolean (absent on older agents)",
"user_is_admin": "boolean (absent unless the node checked)",
"virtualization": "string"
},
"task_id": "string (provisioning UUID, if applicable)",
"services": [
{
"id": "string",
"title": "string",
"url": "string",
"public_url": "string",
"command": "string"
}
]
}
]
}
]
}

org_id is the organization that owns the network, and therefore its nodes. It is the join key for whoami's orgs[]: elevation authority is granted per organization, so a caller who holds different roles in different organizations reads can_elevate off the orgs[] row whose id matches the org_id of the network holding the node.

files_url is the network's shared drive as a URLhttps://files.nobgp.com/networks/<network-id>/. It is a WebDAV endpoint: PROPFIND to list, GET to download, PUT to upload, MKCOL / MOVE / COPY / DELETE to manage, authenticated with the same bearer token that authenticated this call and carrying the same access. Router 0.4.54 is the release that made it resolve: earlier routers advertised a name-keyed form that had never served anything.

It is keyed on the network id, not its name, and that is not cosmetic — the same network name can appear in more than one of the organizations a caller belongs to, so a name-keyed URL answers differently depending on who opens it. Each node also has a storage area of its own at https://files.nobgp.com/nodes/<node-id>/, separate from every network's share; the directory advertises no URL for it, so build it from the node's id in this same response. Reaching a node's area needs Owner or Admin in the organization that owns the node — a step above the network share and above the fs_* tools on a node's disk, since a write landing there passes none of the node's own owner vetoes.

The URL is for bytes; the tools are for everything else (router 0.4.56+). Both trees are also addressable from file and the fs_* tools without an HTTP client and without going near a node — network_name alone for a share, node_id + storage: true for a node's area — at the same two authority tiers. See Addressing storage instead of a node; the URL is still what streams a file past the tools' 1 MiB per-call ceiling.

The info.virtualization field is emitted as system/role (e.g. docker/guest, kvm/host) and is omitted on bare-metal nodes. The info block is absent for nodes that have never connected.

offline_at is how long it has been down (router 0.4.50+). online: false is the same two bytes for a node dead six months and one that blipped ten seconds ago, so the question most often asked of the directory — is this thing coming back? — could not be answered from it. offline_at is RFC3339 in UTC and names the moment the node's most recent connection ended; it is cleared on every reconnect, so it is never the first time the node ever dropped. Read it before telling someone a node is down, and before deciding one is worth waiting for.

It is absent whenever there is no such instant — the node is connected right now, it has never connected at all, or its row predates the field. Absence is never an error and never means "long ago". No agent release is involved: the router has always recorded the disconnect, and this only reports it. It survives brief: true, which trims the services arrays and nothing else — the brief-plus-glob probe is exactly the call that wants it.

info.hardware_model is the machine as it names itself — DS918+, Raspberry Pi 5 Model B Rev 1.0, Macmini9,1, NUC11TNHi5 — reported from agent 0.4.45. Nothing else in info answers it: platform and kernel_arch describe the software and the instruction set, so a fleet of arm64 Debian nodes is a row of identical-looking machines whose boards range from a Pi Zero to a NAS. Read it when deciding where a workload belongs.

Absence is a normal answer, not an error. The agent reads it from whatever the machine exposes — the DSM product config on a Synology, the device tree on an ARM board, DMI on x86, the hw.model sysctl on macOS, the cached SMBIOS product name on Windows — and a VM, a container, or a whitebox board whose vendor never filled in its DMI fields genuinely has nothing to say. Placeholder strings that mean nothing (To be filled by O.E.M., Default string, System Product Name) are dropped rather than passed through as though they named the hardware. Whenever there is nothing to report the field is omitted from info entirely, which is also what every node below agent 0.4.45 does — so treat it as a hint, never as a filter that must match. Unlike user, there is no third state here: a missing hardware_model says the hardware is unreported, never that it is unknowable.

info.version_status compares agent_version against the node's effective release-channel target:

ValueMeaning
currentAgent matches the channel target — no action.
behindAgent is older than the channel target — surface as a nudge to run nobgp upgrade.
aheadAgent is newer than the channel target — happens when a node was on a faster channel that has since been slowed mid-rollout. No user action needed.
"" (empty)Comparison couldn't be made (no channel target, channel disabled, missing agent metadata, or unparseable version) — fall back to agent_version.

info.channel_version is the effective channel's target version, echoed alongside so callers don't need a second lookup to see what behind means in concrete terms.

info.user, info.allow_admin and info.user_is_admin are the node's own answers about what admin will mean on it, reported at registration and set only on the node itself (nobgp config --user / --allow-admin) — no tool changes them:

FieldMeaning
user present, non-emptyThe account unelevated work runs as on that node. Still not a promise — the node re-resolves it at the moment of the operation, and root spelled out here is refused like any other superuser.
user present, emptyNo account this node can actually become, so admin: false falls through to the node's own identity — and is refused wherever that is the superuser, which is the service-install and bare-root-container shape. A warning, not a verdict: a node whose agent is already unprivileged serves admin: false as itself. A Windows node reports empty until it has a configured account — from agent 0.4.44 a name there is its second identity, covering file operations, and from agent 0.4.46 execution as well (0.4.44 and 0.4.45 refused an unelevated execution call).
allow_admin: falseThe node's owner refused elevation, so admin: true fails there. On a node that also cannot run unelevated, the two refusals cover everything between them.
user_is_admin: trueThe account named in user is administrative on that node — see below. It qualifies user; it never changes what a call is allowed to do.
Any field absentNobody said. For user and allow_admin that means an agent predating the field; for user_is_admin it also covers a node with no second identity to describe. Absent is not the same claim as empty or false, and must not be rendered as one.

Empty and absent are different answers, and router 0.4.42 is the release that stopped collapsing them: before it, a modern agent reporting "I have no account" was stored the same way as an agent too old to say, so a UI had no way to tell "nothing configured here" from "no idea".

Since agent 0.4.41 a node reports the account it resolved, not the string in its config file. A user that was deleted — or a config copied off another machine — reports empty, which is the answer that matches how the node will behave, rather than a name that reads as "unelevated works here" while nothing on that box can become it. The two differ exactly when it matters.

user_is_admin says how much admin: false is worth on that node (router 0.4.47+, from agent 0.4.46+ — the release that answers it). A name alone overstates it: where this is true the account is administrative — a member of the local Administrators group on Windows — so an unelevated call there drops from the node's ambient identity to Administrator and no further. That is still a real reduction and still the right default, but it is not the isolation the word "unelevated" suggests. On Windows it is the common shape rather than an exotic one, because the installer captures the interactive desktop user, who on a personal machine usually is an administrator.

Three states, like its neighbours: true, present-and-false (the node checked and the account is ordinary), and absent (nobody checked — an agent too old to report it, or a node with no second identity to describe). It is never a policy input: nothing refuses on it, and it is never a reason to avoid admin: false. It tells you what that choice buys, so a caller weighing a risky operation can see the difference between dropping to an ordinary account and dropping to an administrative one.

Read these before choosing admin on a command, command_subscribe, or file call — a cohort is rarely uniform. What the router deliberately does not report is a derived "can this node run unelevated" verdict: it would be wrong in both directions (an explicit user: root is a non-empty name that still refuses; an empty one on an already-unprivileged agent is served), and the node is the only party that can answer it.

Example Usage

Request:

{
"network_name": "production"
}

Response:

{
"networks": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production",
"org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"files_url": "https://files.nobgp.com/networks/550e8400-e29b-41d4-a716-446655440000/",
"nodes": [
{
"id": "8a1f2d5c-7b3a-4e9f-9c1b-2e5a0d8f4c12",
"name": "web-server-1",
"online": true,
"info": {
"hostname": "web-server-1",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64"
},
"services": [
{
"id": "svc_xyz789",
"title": "staging-app",
"public_url": "https://abc123.nobgp.com"
}
]
},
{
"id": "3c7b91ae-55d2-4f08-8a6d-1b9c4e70f233",
"name": "web-server-2",
"online": false,
"offline_at": "2026-08-04T21:14:07Z",
"info": {
"hostname": "web-server-2",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64"
}
}
]
}
]
}

web-server-1 is connected, so it carries no offline_at; web-server-2 dropped at the timestamp shown and has not been back since.

Filtered probe ("is any Raspberry Pi online?"):

{
"online": true,
"platform_glob": "raspbian",
"brief": true
}

The response omits the services array on each node so the payload stays small. Flip online to false and each node still carries its offline_at, so the same compact call answers "how long has it been gone?" as well as "is it gone?".

Use Cases

  • Initial discovery when starting a conversation
  • Checking current infrastructure state
  • Finding available nodes for operations
  • Listing all published services
  • Compact online/offline probes via online + brief without pulling the full directory
  • Telling a brief outage from a long one — offline_at dates the disconnection that online: false only reports
  • Relocating a node after a not_found or offline response — node-to-network mappings can change when devices move between VLANs
Recovery hints on not_found

Node-targeted tools (command, file, fs_*, net_*, service_publish) point you back here when a name doesn't resolve:

  • Node moved networks. Called with network_name + node_name, and the node isn't in the requested network but you own another network containing a node by that name? The not_found message is enriched with (currently in network "..."). Re-issue the call against the network named in the hint, or call network_directory to inspect the new mapping.
  • Unknown network or node name. An unrecognized network_name returns not_found with (use network_directory to list your networks); an unrecognized node_name within a valid network returns (use network_directory to list nodes).

Passing node_id instead of the name pair sidesteps both cases — a node id is stable across network moves.


network_create

Surfaces: public only

Create a new isolated network.

Purpose

Provision a new network namespace for connecting nodes.

Input Schema

{
"name": "string (required)",
"org_id": "string (optional)"
}

Parameters:

  • name - Unique name for the network (DNS-compatible, unique per creator)
  • org_id - Organization to create the network in. Defaults to your personal organization when omitted. Most accounts operate within a single auto-created personal org and never need to set this.
Role requirement

Creating a network requires the Owner or Admin role in the target organization. In a personal account you are the Owner of your personal org, so this always succeeds; in a shared organization a plain Member is refused with forbidden.

Output Schema

{
"network_id": "string",
"name": "string",
"org_id": "string",
"created_at": "timestamp"
}

Fields:

  • network_id - Identifier for the newly created network.
  • name - Network name (normalized to the DNS-compatible form).
  • org_id - Organization that owns the new network.
  • created_at - Creation timestamp in RFC3339 format.

Example Usage

Request:

{
"name": "staging"
}

Response:

{
"network_id": "net_def456",
"name": "staging",
"org_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2025-11-04T14:35:00Z"
}

Use Cases

  • Creating isolated environments (dev, staging, prod)
  • Separating different projects
  • Provisioning shared networks within an organization (pass org_id)
  • Multi-tenancy within a single account

network_delete

Surfaces: public only

Delete an existing network.

Purpose

Remove an empty network.

warning

Destructive operation. All nodes must be removed before the network can be deleted.

Role requirement

Deleting a network requires the Owner or Admin role in the network's organization. A plain Member is refused with forbidden.

Input Schema

{
"network_name": "string (optional)"
}

Parameters:

note

A legacy force field is accepted for backwards compatibility but deprecated and ignored — networks with remaining nodes are always refused. Clean up via deprovision_node (or agent-side register_node removal) first.

Output Schema

{
"network_id": "string",
"name": "string",
"nodes_removed": "number",
"message": "string"
}

Example Usage

Request:

{
"network_name": "old-testing"
}

Response:

{
"network_id": "net_abc123",
"name": "old-testing",
"nodes_removed": 0,
"message": "Deleted network \"old-testing\" (id: net_abc123)"
}

Safety Features

  • AI assistants typically ask user for confirmation first
  • Deletion is rejected if any nodes remain — remove or deprovision all nodes first
  • Cannot delete network with active provisioned nodes (must deprovision first)

org_create

Surfaces: public only

Create a new organization owned by the authenticated user.

Purpose

Create a shared organization — a tenancy boundary that can hold networks, nodes, and services accessible to all of its members. The caller becomes the organization's Owner.

Personal organizations

Every account already has a personal organization created automatically at signup. Use org_create only when you need a separate, shared organization (e.g. for a team). Networks default to your personal org unless you pass org_id to network_create.

Input Schema

{
"name": "string (required)"
}

Parameters:

  • name - Display name for the new organization (1–100 characters)

Output Schema

{
"org_id": "string",
"name": "string",
"role": "string",
"created_at": "timestamp"
}

Fields:

  • org_id - Identifier for the newly created organization. Pass this as org_id to network_create to create networks inside it.
  • name - Organization name.
  • role - The caller's role in the new org — always owner.
  • created_at - Creation timestamp in RFC3339 format.

Example Usage

Request:

{
"name": "Acme Corp"
}

Response:

{
"org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Acme Corp",
"role": "owner",
"created_at": "2026-06-05T14:35:00Z"
}

Roles

Organization membership is tiered. Roles gate which operations a member may perform:

RoleCapabilities
ownerEverything, including billing and organization lifecycle
adminManage members and create/delete networks, label nodes and delegate node roles, ask for a node's superuser identity, and turn a service's authentication off, plus all Member capabilities
memberView infrastructure, run nodes/commands/files, and publish/manage services — all at the node's configured account, never elevated, and never unauthenticated

A Member is fully operational; what they cannot do is elevate, publish something the whole internet can reach, or relabel a node. Roles & Permissions is the full matrix and the single source of truth — including why two of those are gated on a field inside a tool a Member may otherwise call. See also Execution identity for what admin: true needs and what a refusal looks like.

Three things sit above the Member tier because they change who can reach something rather than what gets done: asking for a node's superuser identity (admin: true), turning a service's authentication off, and node_label — labels are the selector node_grant writes its targets in, so relabelling a node moves it in or out of a granted node's reach. Each returns forbidden below Admin; none is quietly downgraded.


provision_node

Surfaces: public only

Provision a new compute instance on a cloud provider.

Access Required

Requires provisioning permissions. Contact support if you don't have access.

Purpose

Create on-demand compute resources with automatic noBGP agent installation.

Input Schema

{
"node_name": "string (required)",
"network_name": "string (optional)",
"provider": "string (optional)",
"cpu": "number (optional)",
"memory": "number (optional)"
}

Parameters:

  • node_name - Name for the provisioned node
  • network_name - Target network. If omitted, it resolves to your sole network — judged inside your personal organization if you belong to several, so a shared org's network is never provisioned into by accident. Name it to use one.
  • provider - Cloud provider: "aws" (default)
  • cpu - CPU units (1024 = 1 vCPU). Default: 256 (0.25 vCPU). Range: 256–4096.
  • memory - Memory in MiB. Default: 512. Range: 512–16384.

Values outside the range are clamped, and non-power-of-two values rounded up, to the nearest allowed shape. resource_summary reports the effective allocation the provider gave you, not the shape you asked for — that is what you pay for, so read it back rather than assuming the request was honoured verbatim.

Output Schema

{
"task_id": "string",
"provider": "string",
"network_name": "string",
"node_name": "string",
"resource_summary": "string",
"notes": ["string"]
}
  • task_id - The deprovision handle. Keep it: the node runs, and bills, until deprovision_node is called with it.
  • network_name - The resolved network the node will register into — the network to subscribe to for readiness, and half the network_directory filter for finding it.
  • node_name - The normalized name the container will register as. It may differ in case from the name you asked for, so match on this one rather than on your input.
  • resource_summary - The effective allocation, post-rounding.
  • notes - Present only when there is something to report about the outcome.
The node does not exist yet

There is deliberately no node_id in this response. The node row is minted when the container's agent self-registers, which happens after this call returns — typically within a minute. A network_directory call issued immediately will not find it, and that is not a failure.

Waiting for the node

Subscribe before the node exists, then read the bus:

  1. presence_subscribe with {network_name} and no node_names — a node_names selector resolves against nodes that already exist, so it cannot name one that does not.
  2. event_tail until a presence event carries the returned node_name: registered first, then online.

Polling also works, and is the only option on an isolated network, which has no event bus — repeat this network_directory call until the node appears:

{
"network_name": "production",
"node_name_glob": "api-server-2",
"brief": true
}

Once the node is online, network_directory reports its user and allow_admin — read those before the first command or file call to pick the admin bool. A provisioned container now arrives with an unprivileged account already configured, so it serves admin: false from its first boot; before that it came up with root as its only identity and every unelevated call was refused until someone created an account by hand. Read the field rather than assuming either shape — a node you installed the agent on yourself reports whatever that machine has.

Example Usage

Request:

{
"node_name": "api-server-2",
"network_name": "production",
"cpu": 2048,
"memory": 4096
}

Response:

{
"task_id": "3f8c1a92-5d47-4b0e-9c21-7a6e0d5f3b18",
"provider": "aws",
"network_name": "production",
"node_name": "api-server-2",
"resource_summary": "2048 CPU units / 4096 MiB RAM"
}

Provisioning Flow

  1. Request received and validated
  2. Container instance created on cloud provider — this call returns here
  3. noBGP agent installed and configured automatically
  4. Agent registers with your network — the node row is created, registered presence event
  5. Node appears as "online" in network_directory

Typical duration: 1-2 minutes


deprovision_node

Surfaces: public only

Terminate and remove a provisioned compute instance.

warning

Destructive operation. All data on the node will be permanently lost.

Purpose

Remove provisioned infrastructure and stop billing.

Input Schema

{
"task_id": "string (required)"
}

Parameters:

  • task_id - Identifier of the provisioning task to deprovision (the UUID returned by provision_node)

This stops the container and its billing. The node's directory entry goes offline rather than vanishing.

Deprovisioning is idempotent

A task the provider no longer runs — stopped out of band, or aged out of the provider's task store — succeeds with a note saying so, rather than failing. Already-stopped is the desired state, reached earlier; returning an error there described a failed teardown of something that was already not billing, and invited retries that could never succeed. The call still does the bookkeeping the out-of-band stop skipped, so it is safe to issue when you are unsure.

Output Schema

{
"task_id": "string",
"reference": "string",
"provider": "string",
"notes": ["string"]
}

notes carries the human-readable detail: the node the task was associated with, whether the provider reported it already stopped, and the task's total lifetime.

Example Usage

Request:

{
"task_id": "task_provisioning_123"
}

Response:

{
"task_id": "task_provisioning_123",
"reference": "arn:aws:ecs:...",
"provider": "aws",
"notes": ["Task ran for 2h 15m before deprovisioning."]
}

Safety Features

  • AI assistants typically ask user for confirmation first
  • Creates audit log entry

service_publish

Surfaces: public only

Publish a proxy or terminal service with a public HTTPS URL.

Purpose

Expose HTTP applications or terminal access through secure public endpoints.

Service Mode

The service mode is determined by which parameter is provided:

  • Proxy mode: Provide proxy_target_url (plain URL by default, e.g. http://127.0.0.1:8080).
  • Terminal mode: Provide terminal_command (command to run in the terminal)
  • Shell mode: Omit both to create a terminal running the default user shell

Input Schema

{
"node_id": "string (optional)",
"network_name": "string (required unless node_id provided)",
"node_name": "string (required unless node_id provided)",
"title": "string (optional)",
"proxy_target_url": "string (optional, plain URL by default)",
"proxy_target_encoding": "utf8|base64 (optional, default: utf8)",
"terminal_command": "string (optional)",
"admin": "boolean (optional)",
"workdir": "string (optional)",
"enabled": "boolean (optional, default: true)",
"auth_required": "boolean (optional, default: true)",
"authorized_emails": ["string (optional)"]
}

Parameters:

  • node_id - Node UUID. Alternative to network_name + node_name.
  • network_name - Network where the agent resides
  • node_name - Agent name where the service will be published
  • title - Human-readable service name
  • proxy_target_url - Host/URI/port to proxy. Plain URL by default (e.g. http://127.0.0.1:8080). Set proxy_target_encoding: "base64" to pass a base64-encoded value (legacy form). Mutually exclusive with terminal_command.
  • proxy_target_encoding - Encoding of proxy_target_url: "utf8" (default — value is a plain URL) or "base64" (legacy — value is decoded before use). Omit unless you have a specific reason to encode.
  • terminal_command - Command to expose via a web terminal. Mutually exclusive with proxy_target_url.
  • admin - run the terminal elevated (terminal/shell services only). Omit it to run as the node's configured user; see Execution identity. A node whose unelevated identity would be the superuser refuses an unelevated terminal rather than publishing a root one — publish it with admin: true if that is what you mean, or configure an account on the node. Publishing one elevated needs org Owner/Admin — this tool is public-surface only, so an org role is the only authority in play: a published terminal is a standing grant that outlives this call, so authority is checked when it is created and allow-admin is re-checked by the node at every session open
  • workdir - Working directory for command execution (terminal/shell services only)
  • enabled - Whether the service starts enabled (default: true)
  • auth_required - Whether OAuth is required to access (default: true). Only set to false if the user explicitly asks for unauthenticated access. Setting it to false needs org Owner/Admin — a plain Member is refused with forbidden ("service.unauthenticated requires a higher role in this organization"), because it publishes something anyone with the URL can reach. Publishing with authentication on needs no special role, and passing authorized_emails forces it back on. See Roles & Permissions.
  • authorized_emails - Email addresses or patterns to authorize for access (e.g., user@example.com, *@company.com). Implies auth_required: true.

Output Schema

{
"service_id": "string",
"public_url": "string",
"mode": "proxy|terminal"
}

Verifying your work

A successful publish proves the route was recorded — not that the backend is alive. proxy_target_url is fetched from the node, so 127.0.0.1 there means the node itself, and nothing in this call reaches it.

Fetching public_url cannot confirm the backend either. With authentication on (the default) the proxy answers 307 to the login flow whether the backend responds or not, so the status code you get back says nothing about what is behind it.

Verify the backend where it lives instead:

  • Run the target fetch on the node, via command:

    curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080
  • Once a browser has traversed the proxy, the backend's own log shows the request.

Example Usage

Proxy Service:

Request:

{
"network_name": "production",
"node_name": "web-server-1",
"proxy_target_url": "http://localhost:8080",
"title": "staging-app",
"auth_required": true
}

Response:

{
"service_id": "svc_abc123",
"public_url": "https://a1b2c3d4.nobgp.com",
"mode": "proxy"
}

Terminal Service:

Request:

{
"network_name": "production",
"node_name": "raspberry-pi",
"terminal_command": "bash",
"admin": false,
"auth_required": true
}

Response:

{
"service_id": "svc_def456",
"public_url": "https://x9y8z7w6.nobgp.com",
"mode": "terminal"
}

Default Shell Service:

Request:

{
"network_name": "production",
"node_name": "my-server",
"auth_required": true
}

Response:

{
"service_id": "svc_ghi789",
"public_url": "https://p1q2r3s4.nobgp.com",
"mode": "terminal"
}

service_update

Surfaces: public only

Modify an existing service's configuration.

Purpose

Change service settings without deleting and recreating.

Input Schema

{
"service_id": "string (required)",
"auth_required": "boolean (optional)",
"enabled": "boolean (optional)",
"title": "string (optional)",
"proxy_target_url": "string (optional, plain URL by default)",
"proxy_target_encoding": "utf8|base64 (optional, default: utf8)",
"terminal_command": "string (optional)",
"admin": "boolean (optional)",
"workdir": "string (optional)"
}

Parameters:

  • service_id - ID of the service to update
  • auth_required - Change authentication requirement. The gate is on the transition: turning it from true to false needs org Owner/Admin and a Member is refused with forbidden, because it turns an endpoint only your organization can reach into one anyone with the URL can reach. Turning it back on is never gated, and neither is editing any other field of a service that is already unauthenticated — a service that was public before your change is public after it. Omitting the field leaves the current setting alone; it never means false. See Roles & Permissions.
  • enabled - Enable or disable service
  • title - Update service display name
  • proxy_target_url - Update proxy target. Plain URL by default (e.g. http://127.0.0.1:8080). Set proxy_target_encoding: "base64" to pass a legacy base64-encoded value. Switches service to proxy mode.
  • proxy_target_encoding - Encoding of proxy_target_url: "utf8" (default) or "base64". Ignored when proxy_target_url is omitted.
  • terminal_command - Update terminal command. Switches service to terminal mode.
  • admin - Update whether the terminal runs elevated (terminal/shell services only). The gate is on the identity the service will run as after this update, not on whether you sent the field: changing a service already published admin: true — its command, workdir, or whether it requires authentication — needs org Owner/Admin just as raising it does, because the stored identity carries over to whatever you put in it. Lowering to the configured account, or switching the service to a proxy target, needs no authority beyond service.write. Converting a proxy service into a terminal starts it unelevated unless you send admin: true with the change
  • workdir - Update working directory (terminal/shell services only)

Output Schema

{
"service_id": "string",
"public_url": "string",
"mode": "proxy|terminal",
"title": "string",
"enabled": "boolean",
"auth_required": "boolean"
}

Example Usage

Request:

{
"service_id": "svc_abc123",
"auth_required": false,
"enabled": false
}

Response:

{
"service_id": "svc_abc123",
"public_url": "https://a1b2c3d4.nobgp.com",
"mode": "proxy",
"title": "staging-app",
"enabled": false,
"auth_required": false
}

service_delete

Surfaces: public only

Delete a published service.

Purpose

Remove a service and free up its public URL.

Input Schema

{
"service_id": "string (required)"
}

Parameters:

  • service_id - ID of the service to delete

Output Schema

{
"service_id": "string",
"mode": "string",
"public_url": "string"
}

Example Usage

Request:

{
"service_id": "svc_abc123"
}

Response:

{
"service_id": "svc_abc123",
"mode": "proxy",
"public_url": "https://a1b2c3d4.nobgp.com"
}

service_share

Surfaces: public only

Manage the authorized email list for a published service.

Purpose

Add, remove, list, or revoke email addresses that are authorized to access a service. Use this instead of recreating the service when you need to change who has access.

Input Schema

{
"service_id": "string (required)",
"action": "string (required)",
"emails": ["string (conditional)"]
}

Parameters:

  • service_id - Unique identifier for the service
  • action - Action to perform: list, add, remove, or revoke
  • emails - Email addresses (required for add and remove actions)

Output Schema

{
"service_id": "string",
"authorized_emails": ["string"]
}

Example Usage

List authorized emails:

Request:

{
"service_id": "svc_abc123",
"action": "list"
}

Response:

{
"service_id": "svc_abc123",
"authorized_emails": ["alice@example.com", "*@company.com"]
}

Add emails:

Request:

{
"service_id": "svc_abc123",
"action": "add",
"emails": ["bob@example.com", "*@partner.com"]
}

Revoke all access:

Request:

{
"service_id": "svc_abc123",
"action": "revoke"
}

Response:

{
"service_id": "svc_abc123",
"authorized_emails": []
}

Actions

ActionDescriptionRequires emails
listShow current authorized emailsNo
addGrant access to specified emailsYes
removeRevoke access for specified emailsYes
revokeClear all authorized emailsNo

Cross-replica session forwarding

Session calls may land on any replica; the router transparently forwards them to the replica that owns the session, verifies the session belongs to the caller, and returns an identical response shape:

The owning replica verifies the session belongs to the caller — sessions cannot be hijacked by another authenticated user even if they guess the id.

A dead session returns not_found. A forwarding failure returns target_unreachable (retryable).


command

Surfaces: public · local (manage)

Execute commands and manage interactive shell sessions on remote nodes.

Purpose

Run one-shot commands or manage stateful interactive sessions. Use the session parameter to start a new command, then use the returned command_id for subsequent interactions (sending input, reading more output, or sending signals).

Input Schema

{
"command_id": "string (optional)",
"session": {
"node_id": "string (optional)",
"network_name": "string (required unless node_id provided)",
"node_name": "string (required unless node_id provided)",
"command": "string (required)",
"shell": "string (optional)",
"admin": "boolean (optional)",
"workdir": "string (optional)",
"env": "object (optional)"
},
"input": "string (optional)",
"raw": "boolean (optional)",
"signal": "string (optional)",
"idle_timeout": "number (optional)"
}

Parameters:

  • command_id - Session identifier from a previous call. Omit to create a new session.
  • session - Session creation parameters. Required when command_id is omitted.
    • node_id - Node UUID. Alternative to network_name + node_name — provide exactly one of the two forms. A node id is stable across network moves, so prefer it when you already have it (e.g. from network_directory).

    • network_name - Network where the node resides. Pair with node_name; omit when using node_id. May be omitted when you own exactly one network — judged inside your personal organization if you belong to several — or when the call is proxied through a node's local MCP server, where the network defaults to that node's own.

    • node_name - Agent name to connect to. Pair with network_name; omit when using node_id.

    • command - Command to execute (e.g., "bash", "df -h", "python3 script.py")

    • shell - Optional interpreter wrapper applied to command before dispatch. Omit (or "auto") for verbatim pass-through to the agent's native shell (the session user's login shell on Linux/macOS, cmd.exe /c on Windows). Set to "powershell" on Windows to wrap as powershell.exe -NoProfile -EncodedCommand <UTF16LE-base64> so cmd.exe quoting can't mangle the script — required for non-trivial PowerShell (pipelines, embedded quotes, $_, $ / & / | / % inside quotes). Unknown values return invalid_args.

    • admin - run elevated. Omitted or false runs as the node's configured user, which is not uniformly root. True runs as the node's ambient identity, and is gated twice: the router refuses a caller below org Owner/Admin (or a node below the manage tier) with forbidden, and the node's owner may refuse everyone with allow-admin: false. Both fail the call rather than downgrading it, so a forbidden here is answered by dropping admin, not by retrying. The refusal also runs the other way: a node whose unelevated identity would be uid 0 — no account configured on a root-running agent, an account that no longer resolves, or user: root — returns failed_precondition for an unelevated call rather than running it as root, so retry that node with admin: true or configure an account on it. network_directory reports each node's user and allow_admin so you can tell the shapes apart. See Execution identity.

    • workdir - Working directory (defaults to user's home directory)

    • env - Additional environment variables (e.g., {"DEBUG": "1"})

  • input - Text to send to stdin. C-style escape sequences (\n, \t, \xHH) are resolved by default. Set raw: true to disable.
  • raw - Send input bytes as-is without resolving escape sequences.
  • signal - Signal to send to the process: SIGINT, SIGTERM, SIGHUP, SIGQUIT, or SIGKILL.
  • idle_timeout - Seconds to wait with no output before returning (default: 5). Use 0 for non-blocking. Use higher values (e.g., 30 or 60) for slow commands. The command keeps running regardless.

Output Schema

{
"command_id": "string",
"output": "string (omitted if empty)",
"exit_code": "number (absent if still running)",
"state": "running | exited",
"duration_ms": "number"
}

Fields:

  • command_id - Use this in subsequent calls to send more input or read more output
  • output - Captured stdout/stderr since the last call. Omitted if no output was produced.
  • exit_code - Present only when the command has exited. 0 = success, non-zero = failure. A process killed by a signal reports 128 + the signal number on Linux/macOS — 130 for SIGINT, 143 for SIGTERM, 137 for SIGKILL — the encoding every shell already uses, so the number names the signal; on Windows a killed process returns 1. Absence means the process is still running. Agents before 0.4.38 reported -1 for a signalled process with nothing else to distinguish it, which a caller could not tell apart from a program that genuinely returned -1.
  • state - Always present. "running" while the command is still executing (poll again with command_id); "exited" once the agent reports exit. Prefer branching on state rather than inferring from exit_code's nil-ness.
  • duration_ms - Wall-clock duration of this tool call in milliseconds.

Session Lifecycle

Each command call runs one specific command. The session closes automatically when the command exits. A 30-second grace period allows reading the final exit code. For one-shot commands (ls, df -h, etc.), the session closes as soon as the command finishes.

Usage Patterns

One-Shot Commands

Start a command and read output in a single call:

Request:

{
"session": {
"network_name": "production",
"node_name": "web-server-1",
"command": "df -h"
}
}

Response:

{
"command_id": "cmd_abc123",
"output": "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 12G 36G 25% /\n",
"exit_code": 0,
"state": "exited",
"duration_ms": 312
}

Interactive Shell Sessions

Start bash as the command to get an interactive shell, then send commands via input:

Start shell:

{
"session": {
"network_name": "production",
"node_name": "web-server-1",
"command": "bash"
}
}

Response:

{
"command_id": "cmd_xyz789",
"output": "user@web-server-1:~$ ",
"state": "running",
"duration_ms": 145
}

Send a command:

{
"command_id": "cmd_xyz789",
"input": "cd /var/log && ls -lt | head -5\n"
}

Response:

{
"command_id": "cmd_xyz789",
"output": "total 1024\n-rw-r--r-- 1 root root 45234 Nov 4 14:32 syslog\n...\nuser@web-server-1:/var/log$ ",
"state": "running",
"duration_ms": 89
}

Close the shell:

{
"command_id": "cmd_xyz789",
"input": "exit\n"
}

Polling Long-Running Commands

For commands that take a while, poll with command_id until exit_code appears:

Start:

{
"session": {
"network_name": "production",
"node_name": "build-server",
"command": "make build"
},
"idle_timeout": 30
}

Poll:

{
"command_id": "cmd_build123",
"idle_timeout": 30
}

Sending Signals

Interrupt a running process:

{
"command_id": "cmd_xyz789",
"signal": "SIGINT"
}

Shell semantics by platform

Linux / macOS: the command string runs through the session user's login shell, not a fixed sh/bin/bash on a typical Debian host, /bin/sh on OpenWrt, /bin/zsh on macOS. A bashism may therefore work on one node and fail on the next; POSIX syntax is the portable choice.

Who it runs as: the node's configured user (the user key in its profile config), which is not uniformly root — it may be the owner's login on one node, a service account on another, and nothing at all on a container or bare-root install, where the node refuses the unelevated call rather than running it as root. Set session.admin=true to run elevated instead, gated by allow-admin. One consequence before you conclude something is broken: the same command can succeed on one node and hit permission denied on the next. The file / fs_* tools now share this identity and take the same admin flag — they used to run as the agent itself regardless, which is why fs_read of a root-owned path could succeed where cat of it through command was denied. See Execution identity.

Windows: the command runs under cmd.exe /c <command>not PowerShell. Use dir instead of ls, type instead of cat, set instead of printenv. Append .exe when calling executables by name (nobgp.exe, not nobgp). Backslashes in paths are fine (dir C:\Windows); quote paths with spaces.

For non-trivial PowerShell on Windows (pipelines, ForEach-Object blocks, $_ references, embedded quotes, anything with $ / & / | / % inside quotes), set session.shell: "powershell" instead of putting powershell -Command "…" directly in command. The router lowers the script to powershell.exe -NoProfile -EncodedCommand <UTF16LE-base64> so cmd.exe sees only base64 characters and has nothing to mangle. Pass the script as-is — do not pre-escape quotes. Trivial one-liners (Get-Date, Get-Process) work fine without shell.

Signals by platform

SignalLinux / macOSWindows
SIGINTsent to process groupwritten as Ctrl+C (0x03) to stdin — only interrupts processes that read stdin
SIGTERMsent to process groupmapped to graceful close where possible
SIGKILLsent to process groupterminates reliably
SIGHUPsent to process groupnot supported — returns invalid_args
SIGQUITsent to process groupnot supported — returns invalid_args

On Linux and macOS a process that dies from one of these reports exit_code as 128 + the signal number (130, 143, 137 for SIGINT, SIGTERM, SIGKILL), so a signalled command is distinguishable from one that exited on its own. Windows reports 1.

Cross-replica behaviour

Subsequent calls with a command_id may land on a peer replica; the router transparently forwards them to the replica that owns the session — see Cross-replica session forwarding. The response shape is identical regardless of which replica handled it.


file

Surfaces: public · local (manage)

Unified super-tool for reading, writing, editing, and managing files on a remote agent's local filesystem.

Purpose

A single tool that dispatches to ten filesystem operations via the op field. Use this when you want one multiplexed handle; otherwise prefer the narrower per-op fs_* tools — their argument schemas evolve independently. Both carry admin and it means the same thing on each, so an elevated file operation no longer has to come through this tool. What only file has is op=batch and op=fetch_url.

Operations

opPurpose
readStream a file from the agent (supports encoding=utf8 or base64, and offset resume)
writeStream a file to the agent (atomic via tmp+rename on done=true; missing parent directories are created)
editSingle old_stringnew_string replacement (atomic)
multi_editOrdered batch of replacements (atomic, all-or-nothing)
listDirectory listing (recursive=true walks subdirs)
statSingle-entry metadata (size, mode, mtime, is_dir)
deleteRemove a file or directory (recursive=true for dirs)
mkdirCreate a directory (recursive=true for mkdir -p)
batchRun N one-shot ops against the same target in one round-trip (best-effort; not transactional)
fetch_urlAgent does an HTTP(S) GET and atomic-writes the bytes to path — router never sees the bytes

Input Schema

{
"file_id": "string (optional, continues a read/write session)",
"target": {
"node_id": "string (optional)",
"network_name": "string",
"node_name": "string"
},
"storage": "boolean (address the node's own storage area instead of its disk; needs target.node_id)",
"op": "read|write|edit|multi_edit|list|stat|delete|mkdir|batch|fetch_url",
"path": "string (absolute path on the agent)",
"encoding": "base64|utf8 (read/write only; base64 default)",
"offset": "number (read: resume byte offset; list: entries to skip)",
"max_bytes": "number (read only; default 262144, max 1048576)",
"limit": "number (list only; default 200, hard max 5000)",
"chunk_b64": "string (write, encoding=base64)",
"chunk": "string (write, encoding=utf8)",
"done": "boolean (write only; finalize + fsync + rename)",
"mode": "string (write/mkdir; octal e.g. \"0644\")",
"recursive": "boolean (list/delete/mkdir)",
"admin": "boolean (perform the operation elevated; default false)",
"allow_missing": "boolean (stat only; return exists=false instead of not_found)",
"force": "boolean (delete only; bypass the recursive depth-guard)",
"old_string": "string (edit)",
"new_string": "string (edit)",
"replace_all": "boolean (edit)",
"edits": [{"old_string": "string", "new_string": "string", "replace_all": "boolean", "expected_sha256": "string"}],
"expected_sha256": "string (edit/multi_edit/fetch_url; sha-256 guard)",
"ops": "array (batch only; ordered list of sub-ops)",
"url": "string (fetch_url only; http or https)",
"headers": "object (fetch_url only; HTTP headers — Host / Content-Length filtered)",
"fetch_max_bytes": "number (fetch_url only; default 256 MiB, hard cap 1 GiB)"
}

Path format. path must be absolute. Both POSIX form (/etc/nginx.conf) and Windows drive form (C:\Users\Public\config.json or C:/Users/Public/config.json) are accepted; the router doesn't see the agent's OS at validation time so either is fine on either side.

Key behaviors:

  • Provide target on the first call (via node_id OR network_name + node_name). Continuations pass only file_id.
  • target also selects noBGP's own storage (router 0.4.56+): network_name alone reaches that network's shared drive, and node_id with storage: true reaches that node's storage area — see Addressing storage instead of a node. On those two trees only read, write, list, stat, mkdir and delete exist; edit, multi_edit, batch and fetch_url are node operations, and batch in particular ignores storage and addresses the node.
  • Read/write are streaming: first call returns file_id; poll with the same file_id until done=true.
  • Write lands in a sibling .nobgp-tmp file and is atomically renamed on finalize — aborted writes leave the destination untouched.
  • op=write and op=fetch_url create a missing parent chain for path (agent 0.4.45+), so neither needs an op=mkdir ahead of it. It saves a round-trip and grants nothing new: the same recursive mkdir was already available to the caller, the destination is still vetted against its deepest existing ancestor before anything is created, and the new directories are owned by the identity admin selected. An agent below 0.4.45 fails these calls with the parent's not_found instead.
  • Router-computed SHA-256 is cumulative and returned on every response.
  • Use edit/multi_edit (not read+write) when changing a few lines of a text file — it sends ~200 bytes instead of the whole file.
  • admin picks which of the node's two identities the operation runs as, exactly as it does on command. The agent drops privilege before touching the file, so ownership and permissions are the ones that identity would get — a file written with admin: false is owned by the node's configured user. Asking for true needs org Owner/Admin (or a manage grant) and the node's allow-admin; either gate fails the call rather than downgrading it. Asking for false can fail too: a node whose unelevated identity would be uid 0 returns failed_precondition rather than acting as root. An elevated call is also not bound by allow-roots (agent 0.4.37+) — but never reaches the agent's own configuration directory, at either identity. See Execution identity. Set it on the first call of a read/write session; continuations carry the session's identity.
  • Paths inside the agent's own configuration directory (/etc/nobgp, /usr/local/etc/nobgp, C:\ProgramData\nobgp) are refused on every op, including with admin: true — see Owner vetoes. Widening the node's allow-roots will not help; that refusal names itself so you can tell it apart from an out-of-roots one.
  • op=list responses are capped at 200 entries by default (hard max 5000). Page with limit / offset and read total_entries / truncated / next_offset — same contract as fs_list. With recursive: true the walk re-checks allow-roots at every directory it enters (agent 0.4.37+), naming a refused directory and then pruning it.
  • op=stat with allow_missing: true returns success with entry: null and exists: false for a missing path instead of not_found, so probes don't need a try/catch path.
  • op=delete with recursive: true is refused on paths shallower than three segments (/, /etc, /home/user, C:\Users\bob). Set force: true when you really do mean it — fs_delete takes the same field.
  • expected_sha256 guards edits against lost updates. A mismatch fails with HTTP 412 (precondition_mismatch) and returns the observed hash in details.observed_sha256.
  • op=batch runs N one-shot sub-ops against the same target in one round-trip. Each entry in ops[] carries its own op + per-op fields (no nested target, no file_id). Sub-ops run serially and are best-effort — a failing sub-op doesn't abort siblings; per-op outcome lands in batch_results[i]. Allowed sub-ops: mkdir, stat, list, delete, edit, multi_edit, write (implicit done=true), fetch_url. read and nested batch are rejected.
  • op=fetch_url makes the agent fetch an HTTP(S) URL directly from its own network position and atomic-write the bytes to path — the router never sees the body. Useful for internal LAN mirrors / authenticated proxies the router can't reach. Optional expected_sha256 verifies the fetched bytes pre-rename; a mismatch leaves the destination untouched and surfaces as precondition_mismatch with details.observed_sha256. A path whose parent directories don't exist yet gets them created, same as op=write.

Use Cases

  • Binary-safe file transfer (uploads/downloads) with resume and integrity verification
  • Surgical config edits (edit/multi_edit) — no read-modify-write round-trip
  • Directory walks and metadata queries (list/stat)
Use file (not command) for byte movement

The command tool description explicitly points here for file transfers. Don't pipe cat | base64 through a shell — file is atomic, checksummed, and binary-safe.


fs_read

Surfaces: public · local (observe)

Stream a file from a remote agent to the client.

Input Schema

{
"file_id": "string (optional)",
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"offset": "number (optional, resume)",
"encoding": "base64|utf8 (default: base64)",
"max_bytes": "number (default: 262144, max: 1048576)",
"storage": "boolean (read the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

Behavior:

  • First call returns a file_id. Pass it back on subsequent calls until done=true.
  • target.network_name alone reads the network's shared drive, and storage: true reads the node's storage area (router 0.4.56+). Either way the router answers from its own storage: one response, done: true, no file_id, and a hard 1 MiB ceiling — past it the call is too_large and the tree's HTTPS URL is what streams the file.
  • encoding=utf8 returns chunk as a plain string; default base64 returns chunk_b64.
  • UTF-8 mode does not support offset-based resume (partial-codepoint state is per-session).
  • Small files fit in a single response with done=true.

Output Schema

{
"file_id": "string",
"path": "string",
"chunk_b64": "string (base64 mode)",
"chunk": "string (utf8 mode)",
"offset": "number",
"size": "number",
"total": "number",
"sha256": "string (cumulative, router-computed)",
"done": "boolean",
"duration_ms": "number"
}

total is the bytes delivered so far in this read session — a running total, not the file's size. On a partial read (max_bytes, or a chunked resume) it equals what you have received, and sha256 likewise covers the bytes delivered rather than the whole file. Both converge on the complete file once done: true.


fs_write

Surfaces: public · local (manage)

Stream a file from the client to a remote agent.

Input Schema

{
"file_id": "string (optional)",
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"mode": "string (octal, default: \"0644\")",
"encoding": "base64|utf8 (default: base64)",
"chunk_b64": "string (base64 mode)",
"chunk": "string (utf8 mode)",
"done": "boolean (finalize)",
"storage": "boolean (write to the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

Behavior:

  • First call opens a session and returns a file_id. Stream chunks and call with done=true to finalize.
  • target.network_name alone writes to the network's shared drive, and storage: true to the node's storage area (router 0.4.56+). Both are one call, not a session: send the whole file in chunk / chunk_b64 and it lands, up to 1 MiB. A larger file goes through the tree's HTTPS URL with a PUT. Writing to a node's storage area needs Owner or Admin in the organization that owns the node — see Addressing storage instead of a node.
  • Finalize triggers fsync + chmod + atomic rename on the agent. Aborted writes leave the destination untouched.
  • chunk and chunk_b64 are mutually exclusive — pick one encoding per session.
  • A missing parent directory is created (agent 0.4.45+). Writing to /opt/app/config/settings.json on a node where /opt/app/config does not exist creates the chain and proceeds, instead of failing on the first call and requiring an fs_mkdir with recursive: true first. No authority is added — that fs_mkdir was always available to the same caller, and the destination is still vetted against its deepest existing ancestor — so a path outside the node's allow-roots is refused exactly as before. The directories are created by the same identity admin selects, so they are owned by whoever will own the file. On an agent below 0.4.45 the call still fails with the parent's not_found; issue the fs_mkdir yourself.

Output Schema

{
"file_id": "string",
"path": "string",
"offset": "number",
"total": "number",
"sha256": "string",
"done": "boolean",
"duration_ms": "number"
}

fs_edit

Surfaces: public · local (manage)

Apply atomic old_stringnew_string replacements to a file on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"old_string": "string (flat form)",
"new_string": "string (flat form)",
"replace_all": "boolean (flat form)",
"edits": [
{"old_string": "string", "new_string": "string", "replace_all": "boolean", "expected_sha256": "string"}
],
"expected_sha256": "string (pre-edit guard)",
"storage": "boolean (accepted for schema uniformity; the storage trees have no edit operation)",
"admin": "boolean (optional; run elevated)"
}

Behavior:

  • Provide either {old_string, new_string, replace_all} (single edit) OR edits[] (ordered batch). Mixing them is an error.
  • There is no edit on noBGP's own storage trees. storage: true, or a target.network_name with no node, is refused with unsupported naming the six operations those trees do have. Read the file, change it, write it back.
  • When edits[] has more than one element, the tool dispatches to multi_edit semantics: later edits see earlier edits' results.
  • All-or-nothing: if any edit fails (old_string not found, or non-unique without replace_all), no edits land.
  • expected_sha256 fails with HTTP 412 (precondition_mismatch) when the agent-observed file hash differs; the observed hash is returned in old_sha256 so callers can rebase and retry.

Output Schema

{
"path": "string",
"edits_applied": "number",
"total": "number (file size after)",
"sha256": "string (after)",
"old_size": "number (before)",
"old_sha256": "string (before)",
"duration_ms": "number"
}

fs_list

Surfaces: public · local (observe)

List a directory on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute directory)",
"recursive": "boolean (walk subtrees)",
"limit": "number (default 200, hard max 5000)",
"offset": "number (entries to skip)",
"storage": "boolean (list the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

Unreadable subentries surface as entries with the error field set rather than failing the whole call.

target.network_name alone lists the network's shared drive, and storage: true the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. Entries come back sorted by name and limit / offset / truncated / next_offset work exactly as below, but recursive does not: a storage listing is one directory deep, so walk the tree a directory at a time.

A recursive walk is bounded by the node's allow-roots at every directory it enters, not just at path: a subtree the node will not serve — including the agent's own configuration directory — is named and then pruned, so you see the directory entry but nothing beneath it. Agents before 0.4.37 checked only the root and listed the contents.

A symlinked path. The walk descends the directory the link points at — it has to, since a walk never follows a symlink, including the one it is handed — but every entry is named under the path you sent. fs_list /tmp on macOS reports /tmp/…, not /private/tmp/…, and the same holds for fs_glob, fs_grep and fs_stat. Agent 0.4.39+; 0.4.38 descended correctly but named the results under the resolved path, and agents before that did not descend at all.

Pagination. A single response is capped at 200 entries by default (hard max 5000 — larger limit values are silently clamped). Page through a large walk by re-calling with offset advanced by the number of entries you received; read truncated and next_offset on the response to drive the loop. Entries come back in the agent's own order — lexical for a non-recursive listing, walk order for recursive: true. The file super-tool's op=list accepts the same limit / offset fields with identical semantics.

A recursive walk is built whole before it is paged

limit and offset page a listing the node has already assembled in full — there is no entry cap on the agent side of a recursive walk. A tree big enough to produce more than 64 MiB of listing is therefore refused outright with too_large, and paging does not get you past it: every page asks for the same whole listing. Narrow path, or walk the subtrees one at a time instead of setting recursive at the top.

That ceiling is agent 0.4.40+, where it replaced one set so high (256 MiB) it could never fire. It binds admin: true and admin: false identically — it is a property of the machine, sized so a 512 MB single-board node survives its own answer, not of the account the node runs work as.

Output Schema

{
"path": "string",
"entries": [
{
"name": "string",
"path": "string (absolute, recursive only)",
"size": "number",
"mode": "string (octal)",
"mtime": "number (unix timestamp)",
"is_dir": "boolean",
"error": "string (walk error, if any)"
}
],
"count": "number (entries in this response)",
"total_entries": "number (entries walked, before limit/offset)",
"truncated": "boolean (more entries exist past this window)",
"next_offset": "number (offset for the next page; set when truncated)",
"duration_ms": "number"
}

fs_stat

Surfaces: public · local (observe)

Return metadata for a single path on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"allow_missing": "boolean (optional)",
"storage": "boolean (stat inside the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

target.network_name alone stats inside the network's shared drive, and storage: true inside the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. allow_missing behaves identically there.

Symlinks are not followed — the link's own metadata is returned, and the response's path is the one you asked about rather than whatever it resolves to. Agent 0.4.38 reported the resolved path here, so fs_stat /tmp/x on macOS came back as /private/tmp/x.

allow_missing: true returns success with entry: null and exists: false for a missing path instead of not_found, so an existence probe branches on a bool rather than on an error. Default false keeps the error-on-missing behaviour. Same field, same semantics, as file with op: "stat".

Output Schema

{
"path": "string",
"entry": {
"name": "string",
"size": "number",
"mode": "string (octal)",
"mtime": "number (unix timestamp)",
"is_dir": "boolean"
},
"exists": "boolean (only when allow_missing was set)",
"duration_ms": "number"
}

exists is present only when you passed allow_missing: truetrue means entry is populated, false means the path is absent. Callers that didn't opt in see no change to the response.


fs_delete

Surfaces: public · local (manage)

Remove a file or directory on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"recursive": "boolean (required for non-empty directories)",
"force": "boolean (optional; bypass the depth-guard)",
"storage": "boolean (delete inside the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

Deleting an already-missing path returns not_found. Callers wanting ensure-absent semantics should treat that as success.

Deleting on a storage tree always takes the whole subtree

target.network_name alone deletes from the network's shared drive, and storage: true from the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. Both remove a directory and everything under it whether or not you sent recursive, and the depth-guard below does not apply — a top-level directory in a share goes in one call. Only / itself is refused, with permission_denied. Check with fs_list first; nothing on these trees passes an agent, so no node-side veto stands between the call and the bytes.

Depth-guard

recursive=true on a path with fewer than 3 segments is refused. This catches catastrophic deletes like /, /etc, /home/user. Segments are counted after / and \ separators, and a drive letter is not a segment — so Windows paths land in the same buckets as their Unix analogues: C:\ is depth 0 like /, C:\Windows\System32 is depth 2 and is refused like /home/user, and C:\Windows\Temp\cache is depth 3 and passes. Single-file deletes are unaffected.

force: true bypasses the guard, for when you really do mean to delete a top-level directory recursively — the same field the file super-tool's op: "delete" takes, and what the refusal message tells you to set.

Output Schema

{
"path": "string",
"duration_ms": "number"
}

fs_mkdir

Surfaces: public · local (manage)

Create a directory on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"mode": "string (octal, default: \"0755\")",
"recursive": "boolean (mkdir -p semantics)",
"storage": "boolean (create inside the node's own storage area instead of its disk; needs target.node_id)",
"admin": "boolean (optional; run elevated)"
}

Without recursive, an existing path returns already_exists. With recursive, the call is idempotent.

target.network_name alone creates in the network's shared drive, and storage: true in the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. recursive works the same way there; mode is not applied.

Output Schema

{
"path": "string",
"duration_ms": "number"
}

fs_grep

Surfaces: public · local (observe)

Search one node's files with an RE2 regex. The walk runs on the agent, so a big tree never crosses the network just to be filtered. For a search across more than one node, use fs_grep_subscribe.

Needs agent 0.4.37+

On earlier agents the search is refused before it starts, with path must be absolute: "". A grep carries its roots in paths, and the agent's path check did not know that — a bug that stayed invisible for as long as the tool was REST-only and in no client's list. Router 0.4.35 listed it; agent 0.4.37 is what makes it run.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"max_count": "number (optional)",
"admin": "boolean (optional; run elevated)"
}

pattern is Go RE2 — no backreferences and no lookaround, which RE2 does not have. ignore_case is equivalent to prefixing the pattern with (?i). max_count defaults to 1000 (hard cap 100 000).

This tool searches a node and only a node. It takes no storage field, and target here always means an agent — the storage trees the other file tools can address have no search operation, so list them with fs_list and read the files you want.

A pattern that does not compile comes back as invalid_args from router 0.4.38; before it, an unbalanced bracket was internal / 500 / retryable: true, so a well-behaved client retried a typo forever. The message quotes the expression back to you — see When the failure came from the node for why that matters.

Skipped automatically: binary files, noise directories (.git, node_modules, .venv, target, dist, …), and any file over the agent's 16 MiB per-file cap. The first two are skipped by design and are not counted anywhere; the size skip is counted in skipped_files, because it is the one that can hide a match.

Every entry in paths must be absolute and inside the node's allow-roots — one outside them refuses the whole call, naming allow-roots, and a permitted root alongside a forbidden one does not smuggle it in. The walk then re-asks at each directory it descends into, so the agent's own configuration directory is pruned even when it sits under a root you were given. Agents before 0.4.37 applied neither check to a search — but nothing was reachable through the gap, because the same release fixed the guard that refused every grep before it ran. No search that returned matches starts being refused on upgrade; searches start working.

How results arrive

This is the part to read. There are two paths, and which one you get is decided by whether you send a progressToken.

matches[]Notifications
No progressTokenevery hitnone
With a progressToken (public endpoint)empty, and streamed_as_progress: trueone notifications/progress per hit, as the walk finds it
Any call to a node's own serverevery hitnone — progress is suppressed at the transport

The matches are never sent twice — on a large result that doubling would be the entire cost of the call.

Read count, not matches.length

A walk that streamed 400 matches returns matches: [] and count: 400. Reading the array length would tell you it found nothing. count, scanned_files, skipped_files, truncated and duration_ms are populated on both paths and are what answers "did this find anything"; streamed_as_progress tells you which path you got.

Sending a progressToken is how a client declares that it consumes notifications. Do not send one unless you do — you would receive the totals and never the matches.

An empty result is a successful call with an empty matches[], not an error.

A zero is only a zero when skipped_files is 0

The agent does not read a file over its 16 MiB per-file cap — it skips it, and the walk carries on. Point a search at a directory holding a rotated log or a database dump and the result comes back count: 0, scanned_files: 0, no error: identical in shape to "nothing matched", and the file whose contents you were asking about was never opened. Measured on a 30 MB corpus: 0 matches; the same bytes split into three 10 MB files: 15 000.

skipped_files is the count of files skipped for size only — binary files and noise directories are skipped by design and are not counted. Read it before believing a zero. When it is nonzero, the result is incomplete however confident it looks: search those files individually, split them, or narrow paths.

The router also puts it in the human-readable message, so the caller least likely to inspect the structured body still sees it: 0 match(es) across 0 file(s), 1 file(s) too large to search.

Reported from agent 0.4.40. The field is always present in the response, so a zero is a real count rather than a silence.

Output Schema

{
"matches": [
{"path": "string", "line": "number (1-indexed)", "text": "string", "truncated": "boolean"}
],
"streamed_as_progress": "boolean (only when true)",
"scanned_files": "number",
"skipped_files": "number",
"truncated": "boolean",
"count": "number",
"duration_ms": "number"
}

text is capped at 2 KiB per line. Top-level truncated means the walk hit max_count before exhausting the inputs — raise it for more. scanned_files counts the files the walker read; skipped_files counts the ones it did not, because they were over the size cap.

Timing

Expect silence on a big tree and then everything at once. The agent still delivers its match set in one payload today; the router fans it out. On a node where the search must run as the node's configured user — the usual case — the agent's privilege-dropped file worker performs it, which answers one reply per request, so incremental delivery is not available there either. The result is identical; only the arrival timing differs.

Narrow paths and set max_count rather than searching / on a busy node. The walk has a 120 s deadline.

The whole match set is one payload, and a node will not send more than 64 MiB of it in one answer (agent 0.4.40+; the same ceiling at either identity). A search that reaches it is refused with too_large naming what to narrow, rather than the node attempting an allocation it may not survive. In practice max_count binds first — 100 000 matches of ordinary source lines is around 15 MB — so lower max_count or tighten pattern and this is not a limit you meet.

A large search against an agent below 0.4.38

On those agents the size ceiling on a reply applied to the worker path only, so the same search over the same files returned its results with admin: true and failed with an over-cap refusal without it — the identity a node runs as was visible in what its file tools could return. Worse, the failure did not end with that call: the oversized reply was left in the worker's socket, and every later unelevated fs_* call on that node — fs_stat, fs_list, fs_read, fs_write, each sub-op of a file batch — read fragments of it as its own answer and failed, until the agent was restarted. Agent 0.4.38 splits a large reply across frames, applies one ceiling to both identities, and retires the worker on any framing failure so a single bad reply cannot outlive the call that produced it.

On a node's own server this is always synchronous

That endpoint forwards to the router over a buffered request/response with no channel for a mid-call push, so it says so and a progressToken is ignored rather than honoured into the void. You always get every match in the response, exactly as a caller that sent no token does — there is nothing to decide and nothing that can silently come back empty.

The two surfaces differ by authority, never by transport. If you want matches as they land from a node's own server, that is fs_grep_subscribe.


fs_grep_subscribe

Surfaces: public · local (observe)

The async half of fs_grep: the same RE2 walk, aimed at a cohort, delivered through the event bus instead of one big response.

Needs agent 0.4.37+

On earlier agents the search is refused before it starts, with path must be absolute: "". A grep carries its roots in paths, and the agent's path check did not know that — a bug that stayed invisible for as long as the tool was REST-only and in no client's list. Router 0.4.35 listed it; agent 0.4.37 is what makes it run.

Returns immediately with a subscription_id. Read the matches with event_tail; end it with event_unsubscribe, which stops any walk still running.

Input Schema

{
"network_name": "string (or network_id)",
"node_names": ["string"],
"platform": "string", "os": "string", "arch": "string",
"labels": {"key": "value"},
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"max_count": "number (optional)",
"subscription_id": "string (uuid, optional)"
}

The selector is the same one every subscribe tool takes — node_ids/node_names, platform, os, arch, labels, all ANDed. paths are walked on every node in the cohort, and max_count is per node (default 1000, hard cap 100 000).

Supplying your own subscription_id makes a retry idempotent: it echoes the run already in flight rather than walking the fleet a second time. A slow call you retried is exactly when you least want a doubled walk.

Output Schema

{
"subscription_id": "string (uuid)",
"nodes": [{"id": "string", "name": "string"}],
"message": "string"
}

nodes is the cohort as resolved at subscription time — see below.

Reading the matches

Each match arrives as an event:

{"source_id": "node-uuid", "at": "timestamp", "payload": {"path": "string", "line": 42, "text": "string"}}

source_id is the node the match is on, so a fleet-wide search is attributable without a second call.

Pass wait_seconds to event_tail so you block instead of polling. The queue holds the newest 256 events — drain it while the walk runs on a search you expect to be large, or cap it with max_count.

Each node closes with a completed event whose reason summarises its own walk — 12 match(es) across 340 file(s) in 1180ms. It names files skipped for size when there were any (, 2 file(s) too large to search), for the same reason fs_grep returns skipped_files: a node that reports no matches and skipped a 2 GB log did not answer your question. Reported from agent 0.4.40.

It always ends

The cohort is pinned when you subscribe, every node reports once, and the subscription closes when the last one settles. event_tail's progress block is how you know: outstanding: 0 means every node finished. A node that cannot serve the search reports a refusal and settles rather than hanging the subscription.

A walk is finite, so there is no repeating mode and nothing is left running behind a caller who walks away.

Not marked read-only

The search reads, but the call stands up a subscription and starts work on every node in the cohort. That is state, not a read, and a client auto-approving on capability hints should be told so.

Which one to use

fs_grepOne node, and you want its matches in one response
fs_grep_subscribeMore than one node · a tree big enough that you would rather read matches as they land · you want to keep working while it runs

Both grep tools work on a node's own server. Reach for this one when the answer spans more than one node, or when you want matches as they land — on the local server fs_grep is always synchronous, so this is the only way to read results incrementally there.


net_peers

Surfaces: public · local (observe)

Read the directory of peers the agent currently knows about.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"}
}

All net_* tools share the same input shape — just a target pointing at the node to read from.

Output Schema

{
"peers": [
{
"name": "string",
"internal_ip": "string",
"dst_ip": "string (local NAT alias)",
"remote_id": "string (node UUID)",
"local": "boolean",
"stale": "boolean"
}
],
"count": "number",
"duration_ms": "number"
}

One entry per peer name, sorted alphabetically. When the agent knows a name through more than one directory entry, the reported address is the one the node's overlay DNS answers for that name, so net_peers, nobgp resolve and nobgp status agree.

Addresses here are node-local

Each node allocates its own overlay handles, so the same peer has a different internal_ip on every node that names it — an address read from one node points at the wrong machine if you use it on another. remote_id is the identifier that is valid everywhere.

net_peers is the diagnostic view: unlike the network.targets list in nobgp status, it also lists peers whose addresses are currently only reserved (offline peers, and names in the middle of moving between nodes), plus entries the router's last directory sync no longer vouches for — a deleted or renamed peer lingering for diagnostics. Those are marked stale: true. A stale entry explains a leftover; it is not a reachability claim, and neither DNS nor nobgp status serves it. Agents older than the release that added the marker simply never set it.


net_interfaces

Surfaces: public · local (observe)

Read the agent host's network interfaces (equivalent to ip -j link / ifconfig).

Output Schema

{
"interfaces": [
{
"name": "string",
"index": "number",
"mtu": "number",
"hardware_addr": "string",
"flags": ["string"],
"addrs": ["string (CIDR)"]
}
],
"count": "number",
"duration_ms": "number"
}

net_metrics

Surfaces: public · local (observe)

Read the agent's Go runtime metrics. Triggers a brief stop-the-world pause on the agent — fine for occasional diagnostics, not a replacement for a metrics pipeline.

Output Schema

{
"uptime_secs": "number",
"goroutines": "number",
"heap_alloc_bytes": "number",
"heap_sys_bytes": "number",
"num_gc": "number",
"gc_pause_total_ms": "number",
"num_cpu": "number",
"gomaxprocs": "number",
"version": "string (Go version)",
"os": "string (GOOS)",
"arch": "string (GOARCH)",
"duration_ms": "number"
}

net_routes

Surfaces: public · local (observe)

Read the agent host's kernel routing table (IPv4 + IPv6). Currently Linux-only; macOS/Windows return HTTP 501 unsupported.

Output Schema

{
"routes": [
{
"family": "ip4|ip6",
"destination": "string (CIDR or \"default\")",
"gateway": "string",
"source": "string",
"interface": "string",
"metric": "number",
"scope": "string",
"protocol": "string"
}
],
"count": "number",
"duration_ms": "number"
}

net_dns

Surfaces: public · local (observe)

Read the agent host's DNS resolver configuration from /etc/resolv.conf. Unix-only; Windows returns HTTP 501 unsupported.

When a loopback nameserver is detected (systemd-resolved, dnsmasq, nscd), stub=true signals that the real upstream servers are hidden behind a local forwarder.

Output Schema

{
"nameservers": ["string"],
"search": ["string"],
"options": ["string"],
"source": "string (e.g. /etc/resolv.conf)",
"stub": "boolean",
"note": "string (hint when snapshot is incomplete)",
"duration_ms": "number"
}

Node selectors

node_label, node_grant, node_revoke, and the three subscribe tools all take the same selector, so one call can address a single node or a whole cohort.

{
"network_id": "string (optional)",
"network_name": "string (optional)",
"node_ids": ["string"],
"node_names": ["string"],
"platform": "string (optional)",
"os": "string (optional)",
"arch": "string (optional)",
"labels": { "key": "value" }
}

Parameters:

  • network_id / network_name - Network to act in, by id or by name. Supply one, not both (invalid_args otherwise). Both may be omitted when you have exactly one network — judged inside your personal organization if you belong to several. network_id binds exactly: membership is checked against the id itself, so a shared org's network id is never rebound to a personal network of the same name.
  • node_ids - Restrict to these nodes by id — the ids network_directory returns.
  • node_names - Restrict to these nodes by name, within the resolved network.
  • platform / os / arch - Restrict to nodes whose reported platform (debian, alpine, darwin, windows…), OS (linux, darwin, windows), or CPU architecture matches. Case-insensitive.
    • arch takes the spellings the rest of the product uses: arm64, amd64, armv7, armv6, 386. Kernel spellings are accepted as synonyms (aarch64 = arm64, x86_64 = amd64), so the value you read from network_directory or net_metrics works here verbatim. An architecture this list doesn't know is compared as given rather than becoming unselectable.
  • labels - Restrict to nodes carrying all of these labels. Values match exactly and by type: 3 matches the number 3, not the string "3".

All fields are ANDed. Omitting every node-narrowing field selects the whole network.

Through a node's local server the network fields may be omitted and default to the node's own network — and naming a different one fails rather than reaching it.

Cohort matching (by name, platform, OS, architecture, or labels) considers currently online nodes only — an offline machine can't start a watcher or run a dispatched command. node_label, node_grant, and node_revoke are the exception when you name nodes by node_ids alone: those resolve directly, so you can label or grant a node that is offline right now.

Selectors are stored, not resolved

fs_subscribe and presence_subscribe store the selector and re-evaluate it continuously — a node that enrolls later, or that gains a matching label later, joins the cohort on its own. The two dispatches are the exception: command_subscribe and fs_grep_subscribe pin the cohort to resolved node ids at subscription time, because each node does its work once and then reports.


node_label

Surfaces: public only

Set, remove, or read operator-defined labels on nodes.

Purpose

Labels are your own persistent metadata about a node, and the durable way to name a cohort: tag the machines once, then address them by meaning (labels: {"role": "edge"}) in any selector instead of re-deriving a node list every session.

Labels are never agent-reported — a node cannot set its own. What the node reports about itself (platform, os, arch) is already available as selector fields.

Labelling is an Owner/Admin action, the same tier as node_grant: labels are the selector a grant's targets are written in, and targets are evaluated live, so relabelling a node changes which peers a granted node may reach without anyone touching a grant. A Member receives forbidden.

Input Schema

{
"set": { "key": "value" },
"remove": ["string"],
"...selector fields": "see Node selectors"
}

Parameters:

  • set - Labels to add or overwrite. Values keep their type: string, number, boolean, or an RFC3339 timestamp string — e.g. {"role":"edge","replicas":3,"drain":true,"last_checked":"2026-07-28T17:04:00Z"}. Nested objects and arrays are refused.
  • remove - Label keys to delete.

Call with neither set nor remove to read the current labels.

Output Schema

{
"nodes": [
{
"node_id": "string",
"name": "string",
"labels": { "key": "value" }
}
]
}

Behavior

  • Requires the Owner or Admin role in the node's organization; a Member receives forbidden. Reading labels back — calling with neither set nor remove — is the same tool, so it carries the same requirement.
  • When a selector spans several organizations, the role is checked once per organization, and a refusal in any one of them fails the whole call before a single label is written.
  • Merges with existing labels; keys you don't mention are untouched.
  • Keys are 1–64 characters; string values are limited to 256 bytes; a node may carry at most 32 labels.
  • Requires the Owner or Admin role in the node's organization; a Member receives forbidden ("node.role requires a higher role in this organization"). The same tier as node_grant, and for the same reason: a grant's targets are label pairs evaluated live, so relabelling a peer moves it into or out of a granted node's reach with no grant touched. See Roles & Permissions.
  • The role.* namespace is reserved for grants and is refused here in both set and remove — use node_grant / node_revoke. That refusal is unconditional and applies at every role.
  • A label change re-evaluates the network's live subscriptions immediately, so labelling a node adds it to an already-running fs_subscribe or presence_subscribe without re-subscribing.

Example Usage

Request:

{
"network_name": "production",
"os": "linux",
"set": { "role": "edge", "replicas": 3 }
}

node_grant

Surfaces: public only

Start a node's local MCP server and let it act on the other nodes in its network.

Purpose

This is how you point an AI client at one machine — a laptop, a Mac mini, a jump box — and have it reach the whole fleet through that machine's local MCP server. The grant is also what turns that server on: the node starts serving 127.0.0.1 within seconds, with nothing to run on the machine itself.

Input Schema

{
"role": "string (required: observe | manage)",
"targets": "string (optional, label pairs)",
"...selector fields": "see Node selectors"
}

Parameters:

  • role - Grant tier:

    • observe — read-only reach over peers: 17 toolswhoami, the net_* diagnostics, fs_read / fs_list / fs_stat / fs_grep, and the read-only side of the event bus (fs_subscribe, presence_subscribe, fs_grep_subscribe, event_tail, event_publish, event_unsubscribe, event_subscriptions).
    • manage24 tools: everything observe grants, plus command, command_subscribe, file, and the writing fs_* tools (fs_write, fs_edit, fs_delete, fs_mkdir). That is arbitrary remote execution on peers, so prefer observe. It is also the tier that may ask for a peer's superuser identity (admin: true) — an observe node is refused, whatever role its owner holds, because the tier bounds the machine rather than the person running it.

    Both grep tools sit at observe because they are searches: they read what fs_read already reads, and neither can change a peer.

    The node's own two tools (status, resolve) are served by the agent itself and are there at either tier, on top of the counts above. tools/list on the node's endpoint is filtered to exactly what the tier allows, so the advertised surface is the enforced one.

  • targets - Restrict which peers the granted nodes may act on, as compact label pairs: "role=edge,site=irvine". Evaluated live, so relabelling a peer changes reach with no re-grant. Omitted: every node in the network.

Output Schema

{
"nodes": [{ "node_id": "string", "name": "string" }],
"role": "string",
"targets": "string"
}

Behavior

  • Requires the Owner or Admin role in the node's organization; a Member receives forbidden. Running a command yourself is a Member action; handing that authority to a machine is a delegation.
  • Granting one tier clears the other, so a "downgrade" from manage to observe really narrows.
  • A grant does not expire. It stands until node_revoke takes it back, so treat granting as a standing delegation and revoke when the machine no longer needs the reach.
  • targets must parse to at least one key=value pair. Values are typed the same way labels are (true is a boolean, 3 is a number).
  • The grant is what starts the node's local MCP server (see Local MCP Server): an ungranted node serves nothing on 127.0.0.1, and the endpoint comes up within seconds of the grant landing — nothing to run on the box. Revoking it stops the server again.
  • The grant is the only control over that server: there is no on-device switch a node's owner can set to refuse it. What the node will actually run once granted is still bounded on the box by its capability settings (allow-tools, allow-roots, allow-admin), which cover the MCP tools, the event-bus sources and published terminal services alike.
  • A granted node's authority is its tier, never its owner's organization role. A node running as an Owner but holding observe cannot elevate on a peer: the local endpoint is reachable by anything that can read the node's token, so it must not inherit what the person who granted it holds.
  • Not callable by a node: a granted node can never widen its own reach or grant a peer.
  • The grant takes effect on the node's next call — there is no session to restart. Clients already connected to the node's local server are told to refetch their tool list.

Example Usage

Request:

{
"network_name": "production",
"node_names": ["studio-mac"],
"role": "observe",
"targets": "role=edge"
}

node_revoke

Surfaces: public only

Take back a node's local MCP server, and its reach over the network, from one node or a whole cohort.

Input Schema

{
"...selector fields": "see Node selectors"
}

Output Schema

{
"nodes": [{ "node_id": "string", "name": "string" }],
"matched": "number"
}

Fields:

  • nodes - Nodes that held a grant and no longer do. Empty when none of the matched nodes had one.
  • matched - Nodes the selector covered, whether or not they held a grant.

Behavior

  • Same Owner/Admin requirement as node_grant.
  • Takes effect on the next call each node makes; there is no session to wait out.
  • Stops the node's local MCP server, since the grant is what runs it — its own tools go with its reach over peers. The endpoint's port and token are kept, so a later re-grant brings the same endpoint back and clients registered with nobgp mcp install resume without a re-install.

Event bus

Instead of polling nodes, subscribe to what you care about and read events as they arrive. The workflow is the same for all four sources:

  1. Subscribe with fs_subscribe, command_subscribe, presence_subscribe, or fs_grep_subscribe — each returns a subscription_id.
  2. Read with event_tail, passing wait_seconds so you block instead of spinning.
  3. End with event_unsubscribe when you're done.

A subscription is addressed by its id only — there are no topics. It expires 10 idle minutes after the last event_tail, and unsubscribing stops the work on every node, so leaving one running is not free. Lost the id? event_subscriptions lists your live ones.

Event shape

Every event is a JSON object:

{
"source_id": "string (the node the event is about or that reported it)",
"status": "string (absent for ordinary data)",
"reason": "string (why, on refused / overflow / failed)",
"at": "timestamp",
"fs | command | presence | payload": "one of these"
}

status absent (or ok) means ordinary data with more to come. Anything else is that node's last word; other nodes on the same subscription carry on.

statusMeaning
completedThe work finished cleanly — a command exited 0, or a node's search walk completed (reason carries the summary)
failedThe work finished badly — a non-zero exit, or a search the node could not complete
timeoutKilled at max_seconds
cancelledThe subscription ended and the run was torn down
overflowThe node could not keep up and stopped — see reason
refusedThe node will not run this source at all — see reason

refused means the node's owner has narrowed what that machine serves: the kind isn't in its allow-tools, the path falls outside its allow-roots, or the run asked for elevation its allow-admin: false vetoes. A caller who was not entitled to ask for elevation is a different failure and never reaches this point — the router returns forbidden on the subscribe call itself and dispatches nothing, so an elevated fan-out is all-or-nothing rather than partly refused. Defaults are permissive, so this is a deliberate local decision rather than a missing opt-in. matched_nodes is who the work was sent to, not who accepted it.

Limits

LimitValue
Idle TTL (refreshed by event_tail)10 minutes
Pending events held per subscriptionnewest 256 (drop-oldest)
Watches per fs_subscribe64
Watch path / command length4096 characters
command_subscribe runtime300s default, 3600s maximum
event_publish payload8192 bytes

A network's combined active source declarations are also bounded (they travel to each node in a single frame); an oversized or over-budget subscription returns invalid_args telling you to narrow it or unsubscribe something.

Isolated networks

Isolated networks have no event bus — their members must not observe one another. Every event tool returns invalid_args there.

Push delivery

Surfaces: local only

On a node's local server a subscription is also an MCP resource, so a client can be told when events land instead of polling event_tail:

  1. resources/subscribe on nobgp://events/<subscription_id>.
  2. The server sends notifications/resources/updated when events land. There is no payload — it only says there is something to read.
  3. resources/read on the same URI returns {"subscription": "<id>", "events": [...]}.

The agent long-polls the router on your behalf, so a push-subscribed client's subscription never idles out, and it keeps working between turns. event_tail remains the fallback for clients that do not consume notifications, and it is the only mechanism on the public server.

Unsubscribing from the resource only stops watching. event_unsubscribe is what ends the subscription and stops the work on the nodes.


fs_subscribe

Surfaces: public · local (observe)

Watch files and directories across a set of nodes and be told when they change.

Purpose

Every matching node stands up the platform's native filesystem watcher instead of you polling it.

Input Schema

{
"watches": [
{
"path": "string (required)",
"recursive": "boolean (optional)",
"ops": ["create" | "write" | "remove" | "rename" | "chmod"]
}
],
"subscription_id": "string (optional UUID)",
"...selector fields": "see Node selectors"
}

Parameters:

  • watches - One entry per path (at least 1, at most 64), each with its own depth and op filter — so a single subscription can watch a tree for writes and one logfile for everything.
    • path - A file or directory, literally. No wildcards: a path containing *, ?, or [ is refused, because glob semantics don't exist here. To watch a tree, name the directory and set recursive.
    • recursive - For a directory, watch everything beneath it too.
    • ops - Only report these changes for this path. Omit for all of them. Filtering happens on the node, so what you exclude costs nothing — worth narrowing on a busy tree, where chmod noise from a package manager can overflow a subscription that only cared about writes.
  • subscription_id - Optional UUID you generate, which makes the call idempotent on retry. Omitted: one is generated and returned.

Output Schema

{
"subscription_id": "string",
"matched_nodes": "number",
"nodes": ["node-uuid"],
"ttl_seconds": "number"
}

nodes is the resolved cohort — what your selector actually matched, so you never have to create a throwaway subscription to find out. Here it is a snapshot: the selector keeps being re-evaluated, so nodes enrolled or relabelled later join on their own and won't appear in this list. A selector matching nothing right now is not an error — the subscription is still created, and the response says so plainly instead of returning a success-shaped matched_nodes: 0.

Event Payload

{
"source_id": "node-uuid",
"at": "2026-07-29T10:15:04Z",
"fs": {
"changes": [
{ "path": "/srv/app/config.yaml", "op": "write", "timestamp_ms": 1785060904123 }
]
}
}

Coarse by design — paths and operations, never file content. Read that with fs_read on the node named by source_id.

Watcher behaviour

Two things hold on every platform, not just macOS:

  • Watched paths are symlink-resolved, so a watch on /tmp/x reports events under /private/tmp/x on macOS. Don't prefix-match against the path you subscribed.
  • Changes are batched on roughly a one-second flush, so a create followed immediately by writes can coalesce into the create alone — an ops: ["write"] filter may miss a file created and written in one go.

A path that resolves outside every allow-roots entry refuses the whole subscription, not just that path. So does one inside the agent's own configuration directory, which no allow-roots value covers.

Two things a node may report instead of changes: refused with a reason (its owner dropped fs from allow-tools, or the path is outside their allow-roots — so matched_nodes is not a promise that every node is watching), and overflow, meaning changes outran what it could report and it stopped, with the reason naming which side overflowed (the node's buffer or the kernel queue). Narrow the paths or the ops and subscribe again.


command_subscribe

Surfaces: public · local (manage)

Run a command across a set of nodes without holding a session open.

Purpose

The call returns immediately; each node runs the command once, detached, and reports back through the returned subscription. Use it for migrations, backups, long builds, and fleet-wide checks. For output you want in your hands right now, on one node, use command instead.

Input Schema

{
"command": "string (required)",
"max_seconds": "number (optional, default 300, max 3600)",
"stream_output": "boolean (optional)",
"admin": "boolean (optional)",
"subscription_id": "string (optional UUID)",
"...selector fields": "see Node selectors"
}

Parameters:

  • command - Command line, run through each node's shell — the same authority the command tool gives you. It sees $NOBGP_SUBSCRIPTION, so a script can publish its own progress back into the subscription with nobgp notify; the agent's own binary directory is on PATH, so that resolves without an absolute path.

  • max_seconds - Kill the command after this many seconds. Every run is bounded; a command that should run forever is a service, not a subscription.

  • stream_output - Publish output lines as they appear, not just the final report.

  • admin - run the command elevated on every node in the cohort. Omitted or false runs as each node's configured user — not uniformly root (typically the installing account; nothing at all on headless installs, where unelevated is therefore root). Caller authority is checked once, against the network's organization, before the cohort is counted: an elevated dispatch is all-or-nothing, because a partly-elevated fan-out — root on some peers, refused on others — is not a state worth reconstructing from event_tail afterwards. Below org Owner/Admin the whole call returns forbidden and nothing is dispatched. Past that gate, a node whose owner set allow-admin: false reports a refused event rather than running the work unprivileged. Nodes differ: read user and allow_admin from network_directory before assuming a cohort is uniform.

  • subscription_id - Optional UUID you generate. A retry with the same id and the same command and execution identity echoes the existing dispatch instead of running it twice; the same id with a different command — or the same command at a different privilege — returns invalid_args.

Output Schema

{
"subscription_id": "string",
"matched_nodes": "number",
"nodes": ["node-uuid"],
"ttl_seconds": "number"
}

Every subscribe tool returns nodes, but here it is fixed: the cohort is pinned at dispatch, so nodes enrolling later are never pulled in and this list stays the exact set that owes you a report.

Event Payload

{
"source_id": "node-uuid",
"status": "completed",
"at": "2026-07-29T10:15:04Z",
"command": {
"lines": [{ "line": "migrated 42 rows", "stderr": false }],
"exit_code": 0,
"duration_ms": 1840
}
}

exit_code is present on the event that ends a run — on any terminal status, including a clean exit_code: 0 — and absent from the streaming output events in between, where there is no exit status yet. A run killed on Unix (timeout / cancelled) reports 128 + the signal number143 for SIGTERM, 137 for SIGKILL — on agent 0.4.38 and later, and -1 on earlier ones; a killed Windows process reports 1.

Behavior

  • Returns not_found when no online node matched the selector.
  • It runs once and always ends — there is no repeating mode.
  • event_tail's progress block tells you where the work stands; outstanding: 0 means every node has finished.
  • event_unsubscribe kills whatever is still running.
  • A node whose owner dropped command from its allow-tools, or vetoed the requested privilege with allow-admin: false, reports refused with the reason instead of running.
  • A node that out-talks the bus ends with overflow. Filter at the source (grep, tail -n) or redirect to a file and read it with fs_read.

presence_subscribe

Surfaces: public · local (observe)

Be told when nodes come online, go offline, or newly register.

Purpose

The cheapest thing on the bus and the right way to wait for a machine to come back: the router publishes these, so the nodes run nothing and pay nothing. Use it instead of polling network_directory.

Input Schema

{
"subscription_id": "string (optional UUID)",
"...selector fields": "see Node selectors"
}

Output Schema

Same SubscribeResult shape: subscription_id, matched_nodes, nodes, ttl_seconds. As with fs_subscribe, nodes is a snapshot — the selector is re-evaluated, so later joiners are included without appearing in this list.

Event Payload

{
"source_id": "node-uuid",
"at": "2026-07-29T10:15:04Z",
"presence": {
"transition": "registered",
"node_name": "edge-01",
"version": "0.4.15",
"platform": "raspbian"
}
}

transition is online, offline, or registered. registered is distinct from online: a re-registration (upgrade, reconnect, transport change) is where version and platform can change, so it is the one to watch when tracking a rollout. source_id is the node the transition is about.

A subscription reports transitions from the moment it exists, so a node that went down before you subscribed produces nothing. For those, network_directory's offline_at already carries the moment the connection ended.


event_tail

Surfaces: public · local (observe)

Read pending events from a subscription, oldest first.

Input Schema

{
"subscription_id": "string (required)",
"max": "number (optional, default 50, cap 256)",
"wait_seconds": "number (optional, cap 30)"
}

Parameters:

  • max - Maximum events to return.
  • wait_seconds - Block up to this many seconds for an event instead of returning an empty result immediately. Use it when you're waiting on something specific — a command you just dispatched — rather than calling in a loop.

Output Schema

{
"events": [{ "...": "see Event shape" }],
"progress": {
"dispatched": "number",
"accepted": "number",
"settled": "number",
"outstanding": "number"
}
}

Fields:

  • events - Pending events, oldest first. Empty when nothing is pending — that is normal.
  • progress - Present for the dispatches — command_subscribe and fs_grep_subscribe. accepted below dispatched means some nodes never took the work (offline since, or refusing the source). outstanding: 0 means every node has finished: the dispatch is done, or the walk is complete.

Behavior

  • Each call resets the subscription's ~10 minute idle timer. A subscription that expires stops the work on every node, so tailing is what keeps it alive.
  • Reading drains the queue, so a repeated call returns the next events rather than the same batch again. If a response can't be delivered, its batch goes back at the head of the queue and the next call redelivers it — delivery is at-least-once, so an event may arrive twice, but a failed read never destroys the events it was carrying.
  • Nodes that never acknowledged a dispatch are written off after a 30-second grace; a node that acknowledged gets the full deadline, so an agent restart mid-run doesn't lose the report.
  • The queue holds only the newest 256 events — treat events as coarse signals and investigate details with command or fs_read on the node that reported.

event_unsubscribe

Surfaces: public · local (observe)

End a subscription from fs_subscribe, command_subscribe, presence_subscribe, or fs_grep_subscribe.

Input Schema

{ "subscription_id": "string (required)" }

Output Schema

{ "removed": "boolean" }

Behavior

This stops the work, not just the delivery: every node tears down the watchers it started for the subscription and kills any command still running under it. Call it as soon as you are done — an abandoned subscription does expire on its own, but only after ~10 idle minutes during which its watchers and commands keep running.

A second call is a no-op. An unknown, expired, or someone else's id returns not_found.


event_publish

Surfaces: public · local (observe)

Publish an event into a subscription yourself.

Purpose

A marker your own reader will see, or a way to hand a subscription id to something else that should report through it. Publishing does not end anything — the subscription closes on what its nodes report.

Input Schema

{
"subscription_id": "string (required)",
"payload": "string (optional, max 8192 bytes)"
}

Output Schema

{ "delivered": "boolean" }

delivered is false when the subscription has expired or been removed.

Behavior

A payload is stored exactly as published and interpreted when it is read: valid JSON embeds verbatim, any other valid UTF-8 becomes a JSON string, and genuinely binary bytes arrive as payload_b64.

A script running on a node under command_subscribe does the same from its shell — its $NOBGP_SUBSCRIPTION is already set — which is the usual way a long command reports its own progress.


event_subscriptions

Surfaces: public · local (observe)

List your live subscriptions in a network.

Purpose

The recovery path when a subscription id has been lost (a new session, a crashed script). A subscription keeps its watchers and commands running on the nodes until it expires, so finding an orphan and unsubscribing it beats waiting out the clock.

Input Schema

{
"network_id": "string (optional)",
"network_name": "string (optional)"
}

Output Schema

{
"subscriptions": [
{
"subscription_id": "string",
"kind": "fs | command | grep | presence",
"dispatched": "number (the dispatches — command and grep — only)"
}
]
}

Behavior

Only subscriptions you created are listed — a subscription id is a capability, and this tool never reveals someone else's.


register_node

Surfaces: public only

Generate install commands to connect an existing machine to a noBGP network.

Purpose

Return ready-to-run shell commands for installing and configuring the noBGP agent on Linux, macOS, or Windows machines.

Input Schema

{
"network_name": "string (optional)",
"node_name": "string (optional)"
}

Parameters:

  • network_name - Network to register the node into. If omitted, it resolves to your sole network — judged inside your personal organization if you belong to several. Name it to register into a shared org's network.
  • node_name - Name for the node. If omitted, the machine's hostname is used.

Output Schema

{
"shell_install_command": "string",
"windows_install_command": "string"
}

Fields:

  • shell_install_command — Shell command for Linux/macOS. Run this with sudo on the target machine to install and register the agent.
  • windows_install_command — Windows command. Pass it on verbatim: the same text runs in Command Prompt, PowerShell 5.1 and PowerShell 7, so never ask the user which shell they are in. The installer self-elevates through UAC, so an Administrator session is not a prerequisite.

Example Usage

Request:

{
"network_name": "production",
"node_name": "web-server-1"
}

Response:

{
"shell_install_command": "curl -fsSL https://downloads.nobgp.com/agent/install.sh | sudo NOBGP_KEY=<YOUR_REGISTRATION_KEY> NOBGP_NAME=web-server-1 sh",
"windows_install_command": "powershell -NoProfile -Command \"Set-Item env:NOBGP_KEY '<YOUR_REGISTRATION_KEY>'; Set-Item env:NOBGP_NAME 'web-server-1'; irm https://downloads.nobgp.com/agent/install.ps1 | iex\""
}

Use Cases

  • Connect an existing server, VM, or Raspberry Pi to noBGP
  • Onboard Windows machines with windows_install_command — one command for every Windows shell
  • Automate agent installation in provisioning scripts

org_update

Surfaces: public only

Rename an organization.

REST/OpenAPI surface

Available at POST /api/v1/tools/org_update. Not registered over MCP — use it from dashboard/management clients.

Purpose

Change an organization's display name. Org administration, gated at Owner/Admin (the same tier as managing members, scope members.manage).

Input Schema

{
"org_id": "string (required)",
"name": "string (required)"
}

Parameters:

  • org_id - Org to update (the active org). Must be a valid UUID.
  • name - New display name for the organization (1–100 characters). Leading/trailing whitespace is trimmed; the trimmed value must not be empty.

Output Schema

{
"org_id": "string",
"name": "string"
}

Fields:

  • org_id - The organization that was updated.
  • name - The organization's name after the update.

Example Usage

Request:

{
"org_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Platform"
}

Response:

{
"org_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Platform"
}

Behavior

  • Requires the Owner or Admin role (members.manage scope); other callers receive forbidden.
  • Personal organizations cannot be renamed here. A personal org's name mirrors its owner's profile name (kept in sync automatically), so renaming it is done by editing your profile name. Calling org_update on a personal org returns failed_precondition.
  • An unknown or non-UUID org_id, or an empty/over-length name, returns invalid_args. A valid-but-missing org returns not_found.

whoami

Surfaces: public · local (observe)

Report the authenticated caller's identity, organizations, and access level.

Purpose

Identity verification and session metadata — and the answer to "may I elevate?" before a call spends itself finding out. Read-only and cheap: call it once at the start of a session when you are unsure which account you are operating as, before your first elevated call, or before acting on another tool's unauthorized / forbidden / not_found error.

Input Schema

{}

No input parameters required.

Output Schema

{
"user_id": "string",
"email": "string (absent in node context)",
"node_context": "string (node uuid; only for calls proxied through a node)",
"can_elevate": "boolean (absent when it depends on the node's organization)",
"orgs": [
{
"id": "string",
"name": "string",
"role": "string (owner | admin | member)",
"can_elevate": "boolean"
}
]
}

Fields:

  • user_id - The authenticated user. For a call proxied through a node's local MCP server, this is the node owner the call runs as.
  • email - Absent in node context: the caller is a node acting under its owner's authority, not the owner's login session.
  • node_context - The node the call was proxied through, present only for those calls. Such a caller is bounded to that node's own network — which is why it may address peers by node_name alone.
  • can_elevate - Whether you may send admin: true. true: elevated calls pass the router's gate (a node whose owner set allow-admin: false still refuses). false: every elevated call returns forbidden, so never send it. Absent means it depends on which organization owns the target node — read orgs[].
  • orgs[] - The organizations you belong to and your role in each, with the same can_elevate answer scoped to that organization. Match id against network_directory's org_id to find the row that governs a given node. Absent in node context, and for a caller who belongs to no organization.

can_elevate is only the router's half of the elevation gate. The node's half is allow-admin, reported per node as info.allow_admin by network_directory — a node whose owner refused elevation still refuses a caller this field says true for.

What it answers, by caller:

Callercan_elevate
A node context (local server)The node's granted tier, exactly: manage yes, observe no — never its owner's organization role, because the local endpoint is reachable by anything holding the node's token
A user whose role is the same across every organization they belong to — a solo account is always this case, since you are the Owner of your own personal organizationThat answer
A user holding different roles in different organizationsAbsent. Elevation is decided per the target node's organization, so a single verdict would be wrong for some of their nodes; read orgs[] and join on org_id
A user belonging to no organization at allfalse, with orgs[] absent. There is nothing for it to depend on — such a caller resolves no node to elevate on

An absent can_elevate is never a "no" — a prediction that disagrees with the gate would be worse than none, so the router stays silent rather than guessing, and a lookup that fails reports absent rather than false.

Example Usage

Request:

{}

Response:

{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"can_elevate": true,
"orgs": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Acme Platform",
"role": "owner",
"can_elevate": true
}
]
}

Response (a Member in one organization, an Admin in another):

{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"orgs": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Acme Platform",
"role": "admin",
"can_elevate": true
},
{
"id": "1f0c2b9e-5d44-4a17-8b6e-3c9a7d2e5f81",
"name": "Contoso",
"role": "member",
"can_elevate": false
}
]
}

can_elevate is absent at the top level here: whether admin: true passes depends on which of the two organizations owns the node you are aiming at.

Response (called through a node's local MCP server):

{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"node_context": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"can_elevate": false
}

A node granted the read-only observe tier: orgs[] is absent because a node acts under its grant, not under an organization role.

Behavior

  • Returns unauthorized if no valid token is supplied.
  • The prose summary states the elevation verdict too, so it is readable without parsing the structured body.

org_sso_setup

Surfaces: public only

Mint a WorkOS Admin Portal link that an organization's Owner opens to connect an SSO identity provider.

Owner only · REST-only

Requires the Owner role in the target organization. Reachable via POST /api/v1/tools/org_sso_setup, not over MCP's tools/call.

Purpose

Set up Single Sign-On for an organization. On first use it creates and maps a WorkOS organization to your noBGP organization, then returns an ephemeral Admin Portal link. The Owner opens that link to connect an identity provider — both OIDC and SAML are handled behind the same WorkOS connection.

Input Schema

{
"org_id": "string (required, uuid)"
}

Parameters:

  • org_id - The organization to configure SSO for (UUID).

Output Schema

{
"portal_url": "string",
"workos_org_id": "string"
}

Fields:

  • portal_url - Ephemeral WorkOS Admin Portal link; open it in a new tab to connect an IdP.
  • workos_org_id - The WorkOS organization id now mapped to this organization.

Example Usage

Request:

{
"org_id": "550e8400-e29b-41d4-a716-446655440000"
}

Response:

{
"portal_url": "https://admin.workos.com/portal/launch?secret=...",
"workos_org_id": "org_01EXAMPLE00000000000000000"
}

org_sso_set_enforced

Surfaces: public only

Turn SSO login enforcement on or off for an organization.

Owner only · REST-only

Requires the Owner role in the target organization. Reachable via POST /api/v1/tools/org_sso_set_enforced, not over MCP's tools/call.

Purpose

Control the per-organization login policy. When enforcement is on, members must authenticate through the organization's connected identity provider; password and social logins are rejected. SSO must be set up (an IdP connected via org_sso_setup) before enforcement can be enabled.

Input Schema

{
"org_id": "string (required, uuid)",
"enforced": "boolean (required)"
}

Parameters:

  • org_id - The organization (UUID).
  • enforced - true = members must log in via the organization's SSO connection; false = password/social login allowed.

Output Schema

{
"org_id": "string",
"enforced": "boolean"
}

Fields:

  • org_id - The organization the policy applies to.
  • enforced - The SSO enforcement policy now in effect.

Example Usage

Request:

{
"org_id": "550e8400-e29b-41d4-a716-446655440000",
"enforced": true
}

Response:

{
"org_id": "550e8400-e29b-41d4-a716-446655440000",
"enforced": true
}
Set up SSO first

Enabling enforcement before an IdP is connected returns failed_precondition — otherwise the organization would lock itself out of its own login. Run org_sso_setup and connect a provider first.


Error Handling

All tools return errors in a consistent format:

{
"error": {
"code": "string",
"message": "string",
"details": {}
}
}

Common Error Codes

Tool errors use stable lowercase codes. Each code maps to a fixed HTTP status on the REST surface.

CodeDescriptionResolution
unauthorizedNot authenticatedSign in via OAuth
forbiddenAuthenticated but not allowedCheck account permissions
permission_deniedThe node refused — a filesystem permission failure, or one of its owner's vetoes: allow-admin, allow-tools, allow-roots, or the agent's own configuration directory. Distinct from forbidden, which is the router's verdict on youRead the message; it names which one. Not fixable by retrying — see Owner vetoes
not_foundResource doesn't existVerify resource name/ID
invalid_argsBad request parameters — including an fs_grep pattern that is not valid RE2Check input schema
target_not_foundNode target can't be resolvedVerify network/node names
target_unreachableNode is offline or unreachableCheck agent connection
already_existsResource already presentUse update or pick a new name
too_largePayload exceeds limits — including a node's own answer overrunning the 64 MiB it can send in one reply (an oversized recursive listing, glob match set or search result)Narrow the request: a smaller root, a tighter pattern, a lower max_count. Paging with limit / offset does not help against a node's own ceiling — the node builds the whole answer before the router pages it
timeoutOperation timed outRetry with a longer timeout
canceledOperation was canceledRetry if desired
rate_limitedRate limit exceededHonor Retry-After; back off
resource_exhaustedA plan resource limit was reached (e.g. creating a network, node, or service beyond the included quota), or the streaming-slot pool is saturatedFor limit hits, upgrade the plan (see details.upgrade_url) or remove existing resources; for streaming-slot saturation, retry shortly
unsupportedOperation not supported on this platformCheck platform requirements
method_not_allowedWrong HTTP verb / tool modeCheck tool documentation
failed_preconditionOperation refused in the current state (e.g. renaming a personal org, enforcing SSO before an IdP is connected, a node whose unelevated identity would be the superuser, or a node that is not itself running privileged enough to become the account it was asked to)Change the state, then retry
precondition_mismatchexpected_sha256 guard failed (HTTP 412); the observed hash is in detailsRebase on current content and retry
not_acceptableStreaming tool called without Accept: text/event-stream (HTTP 406)Add the Accept header
internalServer-side errorRetry; contact support if persistent

Errors include a retryable flag. retryable=true codes (timeout, target_unreachable, rate_limited, internal) are safe to retry with backoff; others — including resource_exhausted from a plan-limit hit — should be surfaced to the user, since retrying without upgrading or freeing resources will fail again.

When the failure came from the node

A failure that happened on the machine — an owner's veto, a permission error, a reply too large to send — carries the code that describes it, whether it surfaced when the call opened or partway through a transfer or a search. Router 0.4.37 is the release that made that true; before it, several of these arrived as internal / 500 / retryable: true, which told a well-behaved client to keep retrying a decision that was never going to change:

The node saysCode you get now
allow-admin: false — elevated execution refusedpermission_denied
the path is outside allow-rootspermission_denied
the domain is not in allow-toolspermission_denied
the path is inside the agent's own configuration directorypermission_denied
the answer is bigger than the node can send in one replytoo_large
the agent is not running privileged enough to become the account asked forfailed_precondition
the identity an unelevated call would run as is the superuserfailed_precondition

The last one is not a veto and is worth reading differently from the rest: it says the node cannot be what was asked for, so permission_denied would be the wrong answer — it would send an org Owner off to widen a role that was never the problem. Router 0.4.42 is the release that classifies it; before it the refusal would have surfaced as internal / 500 / retryable. See Unelevated never means root for the two remedies.

None of these is retryable, and none of them is about your authority — see Owner vetoes for what each one means and who can change it. A caller who was not entitled to ask fails earlier and differently, with forbidden from the router.

The node names its own code from agent 0.4.38. Only the agent knows whether it refused or faulted, so from that release it says which on the wire and the router validates the answer against the codes an agent is allowed to name. Below 0.4.38 the router infers the code from the wording of the node's message, which covers the six above and is why they are classified on any agent — but a refusal phrased in a way that table has no pattern for still arrives as internal. Upgrading a node is what makes its refusals reliably self-describing.

That release covered the decisions a node makes. A permission denial is not one of those — nobody on the node chose it, the filesystem did — so it stayed uncoded and fell back to the router's reading of the message until agent 0.4.47 coded it too. The distinction that matters to a caller is not who decided but whether retrying can help, and on that question a denial is as permanent as any veto.

Ordinary filesystem and argument failures are unchanged: a missing path is still not_found, an unreadable one permission_denied, an old_string that matched nothing invalid_args.

Windows nodes reached that same answer at router 0.4.48. Windows words a permission refusal its own way — Access is denied. rather than permission denied — so until that release an unreadable path on a Windows node came back internal / 500 / retryable: true: the identical unelevated read answered permission_denied on a Linux node and, on a Windows one, told a client to keep retrying an ACL it would never satisfy. Windows phrases a missing path its own way too, and that one has been recognised for longer. That router-side match is on the English wording, so on an agent below 0.4.47 a node whose system language renders the refusal differently still answers internal.

Agent 0.4.47 settles it for every language. From that release the node codes a permission denial from the operating system's error number rather than from the sentence it prints, so an unreadable path answers permission_denied on Linux, on macOS and on Windows, in any system language — and it answers the same whether the denial was met by the node's elevated or its unelevated identity. Nothing about what a node permits changed; what changed is that the failure reliably says it is permanent.

One refusal that reads like a denial is not one, from agent 0.4.48. When the operating system refuses the identity switch at the moment a process is created, the node reports it as failed_precondition rather than permission_denied, because it is a fact about the node — it cannot become the account, whoever asks — and not a verdict on the caller. That matters for the retry: permission_denied on an unelevated call is answered by dropping admin, which is exactly the call that just failed, while failed_precondition is answered by admin: true, which succeeds on such a node because elevated work assumes no account. Two shapes reach it — an agent that is not the superuser, and a root agent that has lost the capability to change identity (a container started with dropped capabilities, or a seccomp/LSM policy) — and the message names whichever remedy applies rather than one that is already true of that node.

A message quotes your own words back. A node's error carries the path or the pattern it was handed — Go's regexp error embeds the expression verbatim — so on an agent below 0.4.38, where the router reads the code out of the message text, a phrase you typed could be read as a decision the node never made. Searching for allow-roots(, or stat-ing a file named allow-roots.yaml, answered permission_denied; a pattern containing does not exist( answered not_found, and too large( answered too_large. Router 0.4.38 fixed both halves of that: the node's refusals are matched on its full wording rather than on a token you could type, and an unparseable pattern is recognised as an argument error before any of them. All of these are invalid_args now.

Example Error

{
"error": {
"code": "forbidden",
"message": "you are not permitted to provision nodes",
"retryable": false
}
}

Errors specific to the local server

These come back only through a node's local server, and the messages say what to do next.

You seeMeaning and next step
tool "X" needs the role.mcp-manage role; this node holds role.mcp-observe (read-only)Tier too low. A network operator can raise it with node_grant role=manage.
tool "X" is not available through a node, at any roleA public-server-only tool — call it from a user session at app.nobgp.com or the public endpoint.
router call failed: … (JSON-RPC -32000)The node could not reach the router for a proxied call — check the node's connectivity with status.
this node has no MCP role yet … (from nobgp mcp install)Grant a role first, in the app or with node_grant, then retry.

A refused or overflow event (rather than a tool error) is a node's owner or a node's throughput talking, not your authorization — see Owner vetoes and the event status table.


Platform-specific behaviour

Windows agents

ToolBehaviour
commandcmd.exe /c, not PowerShell. .exe suffix recommended. Killed processes return exit code 1 (Unix reports 128 + the signal number). Set session.shell: "powershell" for non-trivial PowerShell scripts
command quotingThe command line reaches cmd.exe verbatim, so its own quoting rules apply — dir /b "C:\Program Files\nobgp" works as typed. Agents before 0.4.38 escaped every " on the way, so quoted arguments came back with literal \" and quoted paths failed outright
command with shell: "powershell"Output is plain text. Agents before 0.4.38 interleaved PowerShell's CLIXML serialization (#< CLIXML followed by an <Objs …> blob) into the output of every call; the agent decodes that stream now, so an error's text survives as text
command stderr timingA completed line on stderr is forwarded as soon as it is complete. On agent 0.4.38 the CLIXML decoder — which wraps the stderr leg of every Windows command session, not only PowerShell ones — held finished lines back while the stream sat mid-line, so a command that printed a prompt or a progress line and then worked quietly returned nothing until it wrote another newline or exited. Delayed, never lost
command signalsSIGHUP and SIGQUIT return invalid_args. SIGINT writes Ctrl+C (0x03) to stdin — works only on processes that read stdin. Use SIGKILL for guaranteed termination
file / fs_* pathsDrive-letter form (C:\Users\... or C:/Users/...) is accepted as absolute; bare C:foo (no separator) is drive-relative and rejected
file / fs_delete depth-guardCounts segments after / and \ separators; a drive letter is not a segment, so Windows paths bucket like their Unix analogues. C:\Windows\Temp\cache passes (depth 3); C:\Windows\System32 (depth 2) and C:\ (depth 0) are refused — both tools take force=true to override
file / fs_write / fs_mkdir modeOnly the read-only bit is honoured; full Unix mode bits are ignored
net_routesunsupported / HTTP 501
net_dnsunsupported / HTTP 501

macOS agents

ToolBehaviour
net_routesunsupported / HTTP 501
fs_subscribeA create followed immediately by writes can coalesce into the create alone, so an ops: ["write"] filter may miss a file created and written in one go
Walks rooted at /tmp, /etc, /varAll three are symlinks into /private. fs_list with recursive, fs_glob and fs_grep resolve the root they are handed before walking it on agent 0.4.38 and later. Earlier agents walked no further than the link and returned count: 0 / scanned_files: 0 successfully, so "nothing matched" and "we never looked" were indistinguishable — name /private/tmp explicitly when talking to one
Naming of those resultsEntries come back under the root you asked about — fs_list /tmp reports /tmp/…, not /private/tmp/… — on agent 0.4.39 and later. Agent 0.4.38 named them under the resolved path, so prefix-matching an entry against the root you sent failed; the same applies to fs_stat, which reports the link you named rather than its target

Provisioning targets

providerCPU / memory params honoured?
aws (default)yes

Best Practices

1. Subscribe Instead of Polling

Don't repeatedly call network_directory - cache results and refresh periodically. To follow a fleet's state over time, use presence_subscribe plus event_tail with wait_seconds: the router publishes the transitions, so nothing is polled and the nodes do no work. The same applies to watching files (fs_subscribe) and to long fleet-wide jobs (command_subscribe) — and always event_unsubscribe when finished, since that is what stops the work on the nodes.

2. Sessions Auto-Close

Command sessions close automatically when the command exits — no need to explicitly close them. For interactive shell sessions (where you started bash), send exit\n via input or use signal: "SIGTERM" to end cleanly.

3. Use Descriptive Names

Give networks, nodes, and services meaningful names for easier management.

4. Handle Errors Gracefully

Check for error responses and handle them appropriately.

5. Respect Rate Limits

Batch operations where possible and implement exponential backoff.


Next Steps