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: 22 proxied tools, of which 15 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.

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.

Roles and tiers

On the public server, authorization is your organization role; the per-tool notes below say which roles may call what.

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.

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.

allow-admin: false can lock you out

It is meant to. On Windows, and on Unix nodes with no user configured, there is no unprivileged identity to fall back to, so refusing the superuser refuses remote execution entirely — including the terminal. Undoing it then needs console, RDP or ssh access to the machine. On a Unix node with user set, 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.

adminRuns as
omitted / falsethe node's configured user — captured from the installing account at registration, so a normal sudo install runs as that person, not root
omitted / false, none configuredthe node's ambient identity: root on a bare-root install or a container, LocalSystem on Windows
truethe node's ambient identity — gated by allow-admin, which refuses rather than downgrades

Read that second row carefully, because it is the common case and it surprises people: unelevated is not the same thing as unprivileged. Where no user is configured there is nothing lesser to fall back to, so admin=false still runs as root — and allow-admin: false therefore refuses everything on such a node rather than narrowing it.

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

user is what admin=false actually resolves to, not what the node's config file says — the two differ exactly when it matters. A configured account that has since been deleted reports as empty, because that node now runs unelevated work as root, and reporting the stale name would give the reassuring answer in the one case that should alarm you. So empty means one thing everywhere: unelevated is root here. Windows always reports empty, since it ignores the setting.

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.

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.

Windows has one identity. The service runs as LocalSystem and there is no user switching, so both values land in the same place and allow-admin: false is an off switch rather than a downgrade there.

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.)

The flag itself lives on file only. The per-op fs_* tools take no admin and always act as the node's configured user — which on a node with none configured is still root, by the rule above. To read or write a path that needs elevation, call file with the matching op and admin: true.

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 and metadata

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, and an optional warning); optional fs mount state and environment block.

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

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

Output Schema

{
"networks": [
{
"id": "string",
"name": "string",
"files_url": "string",
"nodes": [
{
"id": "string",
"name": "string",
"online": "boolean",
"info": {
"hostname": "string",
"agent_version": "string",
"version_status": "string (current | behind | ahead | empty)",
"channel_version": "string",
"platform": "string",
"platform_version": "string",
"kernel_arch": "string",
"user": "string (may be empty; absent on older agents)",
"allow_admin": "boolean (absent on older agents)",
"virtualization": "string"
},
"task_id": "string (provisioning UUID, if applicable)",
"services": [
{
"id": "string",
"title": "string",
"url": "string",
"public_url": "string",
"command": "string"
}
]
}
]
}
]
}

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.

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 and info.allow_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.
user present, emptyNo account is configured, so admin: false resolves to root there — the case worth noticing, since unelevated is not unprivileged. Windows always reports empty.
allow_admin: falseThe node's owner refused elevation, so admin: true fails there. Where no user is configured, that refusal covers everything — there is nothing lesser to fall back to.
Either field absentThe agent predates the field. Absent is not the same claim as empty or false, and must not be rendered as one.

Read these before choosing admin on a command, command_subscribe, or file call — a cohort is rarely uniform.

Example Usage

Request:

{
"network_name": "production"
}

Response:

{
"networks": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production",
"files_url": "https://files.nobgp.com/production/",
"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"
}
]
}
]
}
]
}

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.

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
  • 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:

  • network_name - Name of the network to delete. If omitted and only one network exists, it will be selected automatically.
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, plus all Member capabilities
memberView infrastructure, run nodes/commands, and publish/manage services

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, the default network is used.
  • 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, to the nearest allowed shape — the response reflects the effective allocation.

Output Schema

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

Example Usage

Request:

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

Response:

{
"task_id": "task_provisioning_123",
"provider": "aws",
"resource_summary": "2048 CPU units / 4096 MiB RAM",
"notes": ["Node will appear in network_directory once online."]
}

Provisioning Flow

  1. Request received and validated
  2. Container instance created on cloud provider
  3. noBGP agent installed and configured automatically
  4. Agent registers with your network
  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)

Output Schema

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

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, and note that on a node with no user configured, unelevated is still root
  • 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.
  • 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"
}

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
  • 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)
  • 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, or when the call is proxied through a node's local MCP server — the network then 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 — and on a node with none configured is still root, since there is nothing lesser to fall back to. True runs as the node's ambient identity, which the owner may refuse (allow-admin: false); that fails the call rather than downgrading it. network_directory reports each node's user and allow_admin so you can tell the two 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, -1 = killed by signal on Linux/macOS; on Windows a killed process returns 1). Absence means the process is still running.
  • 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 unelevated therefore means 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

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. One exception worth knowing: only file carries admin, so an elevated file operation goes through this tool.

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)
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"
},
"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.
  • 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.
  • 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. allow-admin: false fails an elevated call rather than downgrading it. 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.
  • 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.

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

Behavior:

  • First call returns a file_id. Pass it back on subsequent calls until done=true.
  • 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)"
}

Behavior:

  • First call opens a session and returns a file_id. Stream chunks and call with done=true to finalize.
  • 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.

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

Behavior:

  • Provide either {old_string, new_string, replace_all} (single edit) OR edits[] (ordered batch). Mixing them is an error.
  • 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)"
}

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

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.

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

Symlinks are not followed — the link's own metadata is returned.

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

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

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

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

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.

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

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).

Skipped automatically: binary files, oversized files, and noise directories (.git, node_modules, .venv, target, dist, …).

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, 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.

Output Schema

{
"matches": [
{"path": "string", "line": "number (1-indexed)", "text": "string", "truncated": "boolean"}
],
"streamed_as_progress": "boolean (only when true)",
"scanned_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.

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.

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.

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.

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.
  • 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.

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

  • 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.
  • The role.* namespace is reserved for grants and is refused here in both set and remove — use node_grant / node_revoke.
  • 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.

    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.
  • 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. 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). 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. It is -1 when the run was killed (timeout / cancelled) on Unix; 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.


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, uses the default 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)

Get information about the currently authenticated user.

Purpose

Identity verification and session metadata.

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

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.

Example Usage

Request:

{}

Response:

{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com"
}

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

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

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_deniedAuthenticated but blocked by policyCheck account permissions
not_foundResource doesn't existVerify resource name/ID
invalid_argsBad request parametersCheck 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 limitsSplit the request
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)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.

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 (not -1). Set session.shell: "powershell" for non-trivial PowerShell scripts
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

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