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:
| Line | Means |
|---|---|
| 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 only | Not reachable through a node, at any tier. |
| Surfaces: local only | Served 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 inventoryOn 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_directoryreturns them on the public surface;statusandnet_peersreturn 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:
| Key | Default | Governs |
|---|---|---|
allow-tools | fs, command | Capability 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-admin | true | Whether 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 outIt 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.
admin | Runs as |
|---|---|
omitted / false | the 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 configured | the node's ambient identity: root on a bare-root install or a container, LocalSystem on Windows |
true | the 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.
| Hint | Meaning here |
|---|---|
readOnlyHint | The call changes nothing — not on a node, not in the router. |
destructiveHint | The call can overwrite or destroy something: file contents, a service, a network, a running process. |
idempotentHint | Repeating the call with the same arguments has the same effect as making it once. |
openWorldHint | The 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_subscribeis not read-only. Every matching node stands up a real file watcher — that is state, not a read.event_tailis 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_unsubscribeis 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:
- A one-line human-readable summary, as the first
TextContentblock. - For tools whose response is the payload you asked for —
command,file,fs_read,fs_list,fs_stat, thenet_*diagnostics,network_directory,event_tail,event_subscriptions,service_publish,service_share,whoami— a secondTextContentblock 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. 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_secondsand every other typed parameter are callable directly. - Optional means omit it. Optionality is expressed by absence from the schema's
requiredlist. Leave an optional parameter out rather than passing an explicitnull— an explicitnullis rejected. - Unknown argument names are rejected. A misspelled parameter (
nmaesfornode_names) fails withinvalid_argsinstead 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.
| Tool | Public | Local | What it does |
|---|---|---|---|
| This node | |||
| status | — | both tiers | This node's own agent status, answered on the box |
| resolve | — | both tiers | Resolve a peer name in this node's overlay zone |
| Network management | |||
| network_directory | ✓ | — | Discovery and listing |
| network_create | ✓ | — | Create networks |
| network_delete | ✓ | — | Delete networks |
| Organization management | |||
| org_create | ✓ | — | Create an organization |
| org_update | ✓ | — | Rename an organization (REST/OpenAPI only) |
| org_sso_setup | ✓ | — | Mint a WorkOS Admin Portal link (Owner only) |
| org_sso_set_enforced | ✓ | — | Turn SSO login enforcement on or off (Owner only) |
| Node management | |||
| provision_node | ✓ | — | Create compute resources |
| deprovision_node | ✓ | — | Remove compute resources |
| register_node | ✓ | — | Generate install commands for existing machines |
| node_label | ✓ | — | Set, remove, or read a node's labels |
| node_grant | ✓ | — | Start a node's local server and let it reach its peers |
| node_revoke | ✓ | — | Take that reach back |
| Service publishing | |||
| service_publish | ✓ | — | Expose services publicly |
| service_update | ✓ | — | Modify service settings |
| service_delete | ✓ | — | Remove services |
| service_share | ✓ | — | Manage authorized email lists |
| Command execution | |||
| command | ✓ | manage | Run commands and manage interactive shell sessions |
| Filesystem | |||
| file | ✓ | manage | Unified file super-tool (ten ops) |
| fs_read | ✓ | observe | Stream a file from the agent |
| fs_write | ✓ | manage | Stream a file to the agent |
| fs_edit | ✓ | manage | Atomic diff-style edits |
| fs_list | ✓ | observe | Directory listing |
| fs_stat | ✓ | observe | Single-entry metadata |
| fs_delete | ✓ | manage | Remove a file or directory |
| fs_mkdir | ✓ | manage | Create a directory |
| fs_grep | ✓ | observe | RE2 search across one node's files |
| Network diagnostics | |||
| net_peers | ✓ | observe | Agent's peer directory |
| net_interfaces | ✓ | observe | Host network interfaces |
| net_metrics | ✓ | observe | Go runtime metrics |
| net_routes | ✓ | observe | Kernel routing table (Linux) |
| net_dns | ✓ | observe | Resolver configuration (Unix) |
| Event bus | |||
| fs_subscribe | ✓ | observe | Watch files and directories across a cohort |
| command_subscribe | ✓ | manage | Dispatch a command across a cohort and collect reports |
| presence_subscribe | ✓ | observe | Node online / offline / registered transitions |
| fs_grep_subscribe | ✓ | observe | RE2 search across a whole cohort |
| event_tail | ✓ | observe | Read pending events (and keep the subscription alive) |
| event_unsubscribe | ✓ | observe | End a subscription and stop the work |
| event_publish | ✓ | observe | Publish your own event into a subscription |
| event_subscriptions | ✓ | observe | List your live subscriptions |
| Identity | |||
| whoami | ✓ | observe | Caller 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, transport — quic 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, port — port 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.Matchsyntax) matched against each node's name. Malformed patterns returninvalid_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- Whentrue, omit each node'sservicesarray. 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:
| Value | Meaning |
|---|---|
current | Agent matches the channel target — no action. |
behind | Agent is older than the channel target — surface as a nudge to run nobgp upgrade. |
ahead | Agent 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:
| Field | Meaning |
|---|---|
user present, non-empty | The account unelevated work runs as on that node. |
user present, empty | No 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: false | The 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 absent | The 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+briefwithout pulling the full directory - Relocating a node after a
not_foundor offline response — node-to-network mappings can change when devices move between VLANs
not_foundNode-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? Thenot_foundmessage is enriched with(currently in network "..."). Re-issue the call against the network named in the hint, or callnetwork_directoryto inspect the new mapping. - Unknown network or node name. An unrecognized
network_namereturnsnot_foundwith(use network_directory to list your networks); an unrecognizednode_namewithin 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.
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.
Destructive operation. All nodes must be removed before the network can be deleted.
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.
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.
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 asorg_idtonetwork_createto create networks inside it.name- Organization name.role- The caller's role in the new org — alwaysowner.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:
| Role | Capabilities |
|---|---|
owner | Everything, including billing and organization lifecycle |
admin | Manage members and create/delete networks, plus all Member capabilities |
member | View infrastructure, run nodes/commands, and publish/manage services |
provision_node
Surfaces: public only
Provision a new compute instance on a cloud provider.
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 nodenetwork_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
- Request received and validated
- Container instance created on cloud provider
- noBGP agent installed and configured automatically
- Agent registers with your network
- Node appears as "online" in
network_directory
Typical duration: 1-2 minutes
deprovision_node
Surfaces: public only
Terminate and remove a provisioned compute instance.
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 byprovision_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 tonetwork_name+node_name.network_name- Network where the agent residesnode_name- Agent name where the service will be publishedtitle- Human-readable service nameproxy_target_url- Host/URI/port to proxy. Plain URL by default (e.g.http://127.0.0.1:8080). Setproxy_target_encoding: "base64"to pass a base64-encoded value (legacy form). Mutually exclusive withterminal_command.proxy_target_encoding- Encoding ofproxy_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 withproxy_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 rootworkdir- 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). Impliesauth_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 updateauth_required- Change authentication requirementenabled- Enable or disable servicetitle- Update service display nameproxy_target_url- Update proxy target. Plain URL by default (e.g.http://127.0.0.1:8080). Setproxy_target_encoding: "base64"to pass a legacy base64-encoded value. Switches service to proxy mode.proxy_target_encoding- Encoding ofproxy_target_url:"utf8"(default) or"base64". Ignored whenproxy_target_urlis 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 serviceaction- Action to perform:list,add,remove, orrevokeemails- Email addresses (required foraddandremoveactions)
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
| Action | Description | Requires emails |
|---|---|---|
list | Show current authorized emails | No |
add | Grant access to specified emails | Yes |
remove | Revoke access for specified emails | Yes |
revoke | Clear all authorized emails | No |
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 whencommand_idis omitted.-
node_id- Node UUID. Alternative tonetwork_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. fromnetwork_directory). -
network_name- Network where the node resides. Pair withnode_name; omit when usingnode_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 withnetwork_name; omit when usingnode_id. -
command- Command to execute (e.g.,"bash","df -h","python3 script.py") -
shell- Optional interpreter wrapper applied tocommandbefore 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 /con Windows). Set to"powershell"on Windows to wrap aspowershell.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 returninvalid_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_directoryreports each node'suserandallow_adminso 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. Setraw: trueto disable.raw- Send input bytes as-is without resolving escape sequences.signal- Signal to send to the process:SIGINT,SIGTERM,SIGHUP,SIGQUIT, orSIGKILL.idle_timeout- Seconds to wait with no output before returning (default: 5). Use0for non-blocking. Use higher values (e.g.,30or60) 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 outputoutput- 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 withcommand_id);"exited"once the agent reports exit. Prefer branching onstaterather than inferring fromexit_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
| Signal | Linux / macOS | Windows |
|---|---|---|
SIGINT | sent to process group | written as Ctrl+C (0x03) to stdin — only interrupts processes that read stdin |
SIGTERM | sent to process group | mapped to graceful close where possible |
SIGKILL | sent to process group | terminates reliably |
SIGHUP | sent to process group | not supported — returns invalid_args |
SIGQUIT | sent to process group | not 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
op | Purpose |
|---|---|
read | Stream a file from the agent (supports encoding=utf8 or base64, and offset resume) |
write | Stream a file to the agent (atomic via tmp+rename on done=true) |
edit | Single old_string → new_string replacement (atomic) |
multi_edit | Ordered batch of replacements (atomic, all-or-nothing) |
list | Directory listing (recursive=true walks subdirs) |
stat | Single-entry metadata (size, mode, mtime, is_dir) |
delete | Remove a file or directory (recursive=true for dirs) |
mkdir | Create a directory (recursive=true for mkdir -p) |
batch | Run N one-shot ops against the same target in one round-trip (best-effort; not transactional) |
fetch_url | Agent 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
targeton the first call (vianode_idORnetwork_name+node_name). Continuations pass onlyfile_id. - Read/write are streaming: first call returns
file_id; poll with the samefile_iduntildone=true. - Write lands in a sibling
.nobgp-tmpfile 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. adminpicks which of the node's two identities the operation runs as, exactly as it does oncommand. The agent drops privilege before touching the file, so ownership and permissions are the ones that identity would get — a file written withadmin: falseis owned by the node's configured user.allow-admin: falsefails 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 everyop, including withadmin: true— see Owner vetoes. Widening the node'sallow-rootswill not help; that refusal names itself so you can tell it apart from an out-of-roots one. op=listresponses are capped at 200 entries by default (hard max 5000). Page withlimit/offsetand readtotal_entries/truncated/next_offset— same contract asfs_list.op=statwithallow_missing: truereturns success withentry: nullandexists: falsefor a missing path instead ofnot_found, so probes don't need a try/catch path.op=deletewithrecursive: trueis refused on paths shallower than three segments (/,/etc,/home/user,C:\Users\bob). Setforce: truewhen you really do mean it —fs_deletetakes the same field.expected_sha256guards edits against lost updates. A mismatch fails with HTTP 412 (precondition_mismatch) and returns the observed hash indetails.observed_sha256.op=batchruns N one-shot sub-ops against the same target in one round-trip. Each entry inops[]carries its ownop+ per-op fields (no nestedtarget, nofile_id). Sub-ops run serially and are best-effort — a failing sub-op doesn't abort siblings; per-op outcome lands inbatch_results[i]. Allowed sub-ops:mkdir,stat,list,delete,edit,multi_edit,write(implicitdone=true),fetch_url.readand nestedbatchare rejected.op=fetch_urlmakes the agent fetch an HTTP(S) URL directly from its own network position and atomic-write the bytes topath— the router never sees the body. Useful for internal LAN mirrors / authenticated proxies the router can't reach. Optionalexpected_sha256verifies the fetched bytes pre-rename; a mismatch leaves the destination untouched and surfaces asprecondition_mismatchwithdetails.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)
file (not command) for byte movementThe 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 untildone=true. encoding=utf8returnschunkas a plain string; defaultbase64returnschunk_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 withdone=trueto finalize. - Finalize triggers
fsync+chmod+ atomic rename on the agent. Aborted writes leave the destination untouched. chunkandchunk_b64are 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_string → new_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) ORedits[](ordered batch). Mixing them is an error. - When
edits[]has more than one element, the tool dispatches tomulti_editsemantics: later edits see earlier edits' results. - All-or-nothing: if any edit fails (
old_stringnot found, or non-unique withoutreplace_all), no edits land. expected_sha256fails with HTTP 412 (precondition_mismatch) when the agent-observed file hash differs; the observed hash is returned inold_sha256so 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: true — true 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.
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 progressToken | every hit | none |
With a progressToken (public endpoint) | empty, and streamed_as_progress: true | one notifications/progress per hit, as the walk finds it |
| Any call to a node's own server | every hit | none — 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.
count, not matches.lengthA 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.
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.
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_grep | One node, and you want its matches in one response |
fs_grep_subscribe | More 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.
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_argsotherwise). Both may be omitted when you have exactly one network.node_ids- Restrict to these nodes by id — the idsnetwork_directoryreturns.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.archtakes 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 fromnetwork_directoryornet_metricsworks 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:3matches the number3, 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.
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 bothsetandremove— 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_subscribeorpresence_subscribewithout 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 tools —whoami, thenet_*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).manage— 24 tools: everythingobservegrants, pluscommand,command_subscribe,file, and the writingfs_*tools (fs_write,fs_edit,fs_delete,fs_mkdir). That is arbitrary remote execution on peers, so preferobserve.
Both grep tools sit at
observebecause they are searches: they read whatfs_readalready 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/liston 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
managetoobservereally 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.
targetsmust parse to at least onekey=valuepair. Values are typed the same way labels are (trueis a boolean,3is 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 installresume 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:
- Subscribe with fs_subscribe, command_subscribe, presence_subscribe, or fs_grep_subscribe — each returns a
subscription_id. - Read with event_tail, passing
wait_secondsso you block instead of spinning. - 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.
status | Meaning |
|---|---|
completed | The work finished cleanly — a command exited 0, or a node's search walk completed (reason carries the summary) |
failed | The work finished badly — a non-zero exit, or a search the node could not complete |
timeout | Killed at max_seconds |
cancelled | The subscription ended and the run was torn down |
overflow | The node could not keep up and stopped — see reason |
refused | The 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
| Limit | Value |
|---|---|
Idle TTL (refreshed by event_tail) | 10 minutes |
| Pending events held per subscription | newest 256 (drop-oldest) |
Watches per fs_subscribe | 64 |
| Watch path / command length | 4096 characters |
command_subscribe runtime | 300s default, 3600s maximum |
event_publish payload | 8192 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 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:
resources/subscribeonnobgp://events/<subscription_id>.- The server sends
notifications/resources/updatedwhen events land. There is no payload — it only says there is something to read. resources/readon 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 setrecursive.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, wherechmodnoise 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/xreports events under/private/tmp/xon macOS. Don't prefix-match against the path you subscribed. - Changes are batched on roughly a one-second flush, so a
createfollowed immediately by writes can coalesce into the create alone — anops: ["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 thecommandtool gives you. It sees$NOBGP_SUBSCRIPTION, so a script can publish its own progress back into the subscription withnobgp notify; the agent's own binary directory is onPATH, 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 setallow-admin: falsereports arefusedevent rather than running the work unprivileged. Nodes differ: readuserandallow_adminfromnetwork_directorybefore 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 — returnsinvalid_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_foundwhen no online node matched the selector. - It runs once and always ends — there is no repeating mode.
event_tail'sprogressblock tells you where the work stands;outstanding: 0means every node has finished.event_unsubscribekills whatever is still running.- A node whose owner dropped
commandfrom itsallow-tools, or vetoed the requested privilege withallow-admin: false, reportsrefusedwith 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 withfs_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_subscribeandfs_grep_subscribe.acceptedbelowdispatchedmeans some nodes never took the work (offline since, or refusing the source).outstanding: 0means 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
commandorfs_readon 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 withsudoon 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.
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.managescope); other callers receiveforbidden. - 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_updateon a personal org returnsfailed_precondition. - An unknown or non-UUID
org_id, or an empty/over-lengthname, returnsinvalid_args. A valid-but-missing org returnsnot_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 bynode_namealone.
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.
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.
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
}
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.
| Code | Description | Resolution |
|---|---|---|
unauthorized | Not authenticated | Sign in via OAuth |
forbidden | Authenticated but not allowed | Check account permissions |
permission_denied | Authenticated but blocked by policy | Check account permissions |
not_found | Resource doesn't exist | Verify resource name/ID |
invalid_args | Bad request parameters | Check input schema |
target_not_found | Node target can't be resolved | Verify network/node names |
target_unreachable | Node is offline or unreachable | Check agent connection |
already_exists | Resource already present | Use update or pick a new name |
too_large | Payload exceeds limits | Split the request |
timeout | Operation timed out | Retry with a longer timeout |
canceled | Operation was canceled | Retry if desired |
rate_limited | Rate limit exceeded | Honor Retry-After; back off |
resource_exhausted | A plan resource limit was reached (e.g. creating a network, node, or service beyond the included quota), or the streaming-slot pool is saturated | For limit hits, upgrade the plan (see details.upgrade_url) or remove existing resources; for streaming-slot saturation, retry shortly |
unsupported | Operation not supported on this platform | Check platform requirements |
method_not_allowed | Wrong HTTP verb / tool mode | Check tool documentation |
failed_precondition | Operation refused in the current state (e.g. renaming a personal org, enforcing SSO before an IdP is connected) | Change the state, then retry |
precondition_mismatch | expected_sha256 guard failed (HTTP 412); the observed hash is in details | Rebase on current content and retry |
not_acceptable | Streaming tool called without Accept: text/event-stream (HTTP 406) | Add the Accept header |
internal | Server-side error | Retry; 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 see | Meaning 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 role | A 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
| Tool | Behaviour |
|---|---|
command | cmd.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 signals | SIGHUP 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_* paths | Drive-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-guard | Counts 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 mode | Only the read-only bit is honoured; full Unix mode bits are ignored |
net_routes | unsupported / HTTP 501 |
net_dns | unsupported / HTTP 501 |
macOS agents
| Tool | Behaviour |
|---|---|
net_routes | unsupported / HTTP 501 |
fs_subscribe | A 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
provider | CPU / 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
- Public MCP Server - The endpoint noBGP hosts: connecting, auth, transports, rate limits and versioning
- Local MCP Server - Turning a node's own server on, and pointing a client at it
- Node Access Control -
allow-tools,allow-roots,allow-adminfrom the node owner's side - Monitoring & Events - The subscription workflow end to end
- Use Cases & Examples - The tools in action
- CLI Reference - The
nobgp mcpcommand reference