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 | On both. Reachable through a node whose local MCP is on. |
| Surfaces: public only | Not reachable through a node. |
| Surfaces: local only | Served by the agent itself, never proxied. |
| Surfaces: REST/OpenAPI only | On the account's own endpoint, but not registered over MCP — POST /api/v1/tools/<name>. |
⚠ The tiers are gone from router 0.4.179. A node's local MCP is on or off, so a local line no longer names one; through router 0.4.178 these lines read local (observe) or local (manage), and a node that held the read-only observe tier is now off until someone turns it on.
The local surface is a deliberate subset: 37 proxied tools from router 0.4.201 (it was 34), plus the two local-only tools. What it excludes — provisioning, network and organization management, service publishing, access-list changes, and the labelling, renaming, transferring and switching tools that would let a node widen its own reach or re-address a peer — is listed on the public server page. The three that arrived in 0.4.201 are node_list, network_list and call_list; network_directory, which a node was never given, is now REST-only for everyone.
file left the MCP surface in router 0.4.195, and six narrower tools arrivedfile selected one of thirteen operations from an op parameter, which means one set of annotations had to describe a stat, a delete and a fetch from the internet at once. It is now REST/OpenAPI only, and every operation it carried is a tool of its own on MCP: the seven per-op fs_* tools and fs_copy as before, plus fs_fetch_url, fs_lock and fs_unlock. op=batch has no MCP spelling at all.
command was split the same way: it now starts a session and nothing else, and the three acts that follow are command_read, command_input and command_signal — on REST as well as on MCP. What REST keeps unchanged is file, with all thirteen ops, and the legacy flat body at /actions/execute_shell_command, which still carries command_id and routes itself to the right one of the four.
tools/list is the authoritative inventoryOn both surfaces the advertised list is already filtered to what the caller will actually be allowed to do. Treat this page as the map and tools/list as the territory — and remember that what you may do to a given node (use, root, control) is a second question the list cannot answer: node_list's my_access (with view: "full") does.
Concepts
Addressing a node
Every tool that acts on one existing node — command, the fs_* tools, the net_* diagnostics, node_logs, service_publish — takes one node reference, called node, at the top level of the request. It is either the node's id or its name:
{ "node": "pi5", "path": "/etc/hostname" }
- An id binds exactly and survives a rename and a move between networks, so prefer it when you have one.
node_listreturns ids on the public surface;statusandnet_peersreturn them on the local one. - A name resolves among the nodes of your organizations — in any network, or in none. A name that more than one of your nodes carries is
invalid_argslisting every candidate with its id and its organization, rather than picking one by a rule nobody wrote down. Send the id. From router 0.4.203 each candidate also arrives indetails.candidates[]with its name, network and online state, so a client can show the choice; a client that can ask the person is asked which node instead of being refused. From router 0.4.206 the refusal's own text names the same facts the details carry — each candidate's organization, its network, and whether it isonline,offline since <date>ornever connected— so a client that reads only the message can choose on the same grounds, and the one that is online is usually the one meant. - From a node's local server a name resolves inside that node's own network only. Naming anything outside it is
not_foundrather than a way out of the boundary. fs_copyhas two ends, so it has no singlenode: each end carries its own (fromandto).
One name for each input
Router 0.4.201 gave every input one name across the whole surface, and retired the spellings that said the same thing in several ways — the target and session wrappers, the network_name + node_name pair, and the per-tool variants of "how long to wait" and "where to read from".
An old name is refused as an unknown property, the refusal names the new spelling (node_id is now node., node_ids is now select.nodes.), and nothing is retried. A client that still sends one is holding a tool list from before router 0.4.201 — reconnect it, which is what refetches the list.
| Old input | Now | Where |
|---|---|---|
node_id, network_name + node_name, target, session | node (an id or a name) | every single-node tool |
network_id, network_name | network; share on the file tools | the network tools, provisioning, every selector |
node_ids, node_names, and the selector fields at the top level | select: {nodes, network, labels, os, platform, arch, name, online, owner} | the cohort tools |
source / destination | from / to | fs_copy |
admin | root | every tool that runs something |
command_id, file_id | id | command_read / command_input / command_signal; fs_read / fs_write |
idle_timeout | wait | the command family, event_tail |
output_offset, offset | cursor | command_read, event_tail, fs_read, fs_list |
max, lines, max_bytes, max_count, fetch_max_bytes | limit | event_tail, node_logs, fs_read, fs_grep, fs_fetch_url |
close_input | close | command_input |
subscription_id | subscription | the subscribe tools, event_tail, event_unsubscribe |
keep_seconds | keep | fs_subscribe, presence_subscribe |
max_seconds | timeout | command_subscribe |
expected_sha256 | sha256 | fs_write, fs_edit, fs_copy, fs_fetch_url |
task_id | task | deprovision_node, task_stop, task_deadline_set |
max_hours | hours | provision_node, task_deadline_set |
service_id, grant_id | service, grant | the service_* tools |
proxy_target_url, proxy_target_encoding, terminal_command, auth_required, authorized_emails, all_org_members | url, encoding, command, auth, visitors, org | service_publish / service_update |
org_id, key_id, channel_id, request_id, new_name | org, key, channel, request, name | network_create, org_invite_create; the network_key_* tools; network_update, node_channel_set; node_access_decide; node_rename |
email or user_id | user (an email or a user id) | node_transfer |
Results carry one name each too, by the same rule: a node in a result is node: {id, name} on the envelope, a session handle is id, a command's lifecycle is status, a count of what a result left out is omitted, a failure's text is reason, and a time is *_at in RFC3339 UTC. admin_used is root_used, and allow_admin is allow_root.
REST keeps the names nobgp/app and the nobgp CLI send, so POST /api/v1/tools/<name> and /actions/<name> still accept those; the REST-only file tool keeps its own (file_id, max_bytes, expected_sha256), and the legacy flat body at /actions/execute_shell_command still carries command_id.
When you belong to more than one organization
A node is named without a network from router 0.4.201 — node resolves among the nodes of all your organizations — so this rule is now about the tools that still take a network and let you leave it out: the selector's network, provision_node, register_node. Each resolves an omitted name the same way, and the rule is written to make a shared organization's network something you name rather than something you land in:
- An omitted name means your sole network, judged inside your personal organization when your networks span several orgs. One personal network wins outright, even if you also belong to team orgs holding a dozen more. Only if your personal side is itself ambiguous — or if you have no personal networks at all and several across shared orgs — do you get the
invalid_argsasking which one. If every network you can see lives in one org, personal or shared, nothing changes: a single one is still picked automatically. - A network name is unique inside its organization from router 0.4.204 — it was unique per creator before, so two members of one organization could each create
homeand nothing said so. It is still not unique across an org boundary: two organizations you belong to can each hold ahome. - A name that exists in more than one of your organizations is
invalid_args, never a pick, from router 0.4.204. The refusal lists each candidate's network id and organization, anddetails.candidatescarries them for a client to choose from; send the id. Before, such a name resolved to the personal organization's network first and then to the oldest, with no word to the caller — sonetwork: "home"reached one of two networks silently. - An id binds exactly. A
networkthat is an id is checked for membership by id and never resolved through a name, so passing the id of a shared org'shomereaches that network and not your personal namesake. network_deleteandnetwork_updatenever select a network for you. Each needsnetwork. A call without it isinvalid_args.
Node context is a boundary, not a default. A call proxied through a node can only reach that node's own network — naming another one fails rather than widening. Other networks need the public server.
Addressing storage instead of a node
The file tools also reach noBGP's own file storage — a network's shared drive, and each node's storage area — without going through any agent (router 0.4.56+). Three fields, and which you send decides which of three places you land in:
| What you send | Where it goes |
|---|---|
node | that node's own disk, through its agent — unchanged |
share (a network's id or name), and no node | that network's shared drive |
node and storage: true | that node's storage area |
share together with node or storage is invalid_args: they name two different trees.
⚠ share is its own field from router 0.4.201. The shared drive used to be network_name with no node, which only read as "the share" because the old node reference refused a network name on its own; with one node field there is no such shape left. storage: true now takes the same node every other tool takes — an id or a name; through router 0.4.200 it needed the id, and a name was refused rather than resolved. Reaching a node's storage area still needs control of the node whichever way you name it.
Which of the three to reach for. Addressing the node directly is the default: it needs no route from your own computer — only the node's own agent connection — so a Pi behind CGNAT is written to like anything else, and it works on every platform. The two router-served trees are for bytes that several nodes will collect, or that should be staged before the target is online; each node reads them back under its own mounted drive. ⚠ A node's agent may be refused that mount — node_list reports mount_readable per node (with view: "full") — and where it is, staging bytes on a tree for that node to pick up through noBGP does not work. Deliver to such a node by writing to it directly.
⚠ If the bytes are already on another machine, none of the three is the answer: fs_copy moves the file between two ends the router holds in one call (router 0.4.84+), so it never has to pass through your client at all. Staging it on a tree is for bytes several nodes will collect, not for a one-to-one transfer.
The router serves these bytes itself. No file session is opened, nothing has to be online, and no node's bandwidth is billed for a file it never held. It also means the node's owner vetoes never see the call — allow-roots and allow-tools bound what an agent serves from its own disk, and there is no agent in the path — which is exactly why a node's storage area is gated a step higher than its filesystem.
Who may reach what:
- A network's shared drive — membership of the organization that owns the network, the same access its URL carries. Any member, and this one did not change in 0.4.179: a share carries no per-node step up. The name is resolved among your own networks, so one you are not in is not addressable. A name that does not resolve is
not_found(network "<name>" not found) from router 0.4.184 — the same answer every other tool gives for an unknown network, and word for word the same for a network that does not exist as for one in an organization you are not in, so the answer never tells an outsider that the name is real. Through router 0.4.183 the call fell through to the node path instead and came backinvalid_args: network_name + node_name must both be non-empty, which sent you to fix arguments that were right while a member got a result from the same call.fs_copyanswers the same way for a share end it cannot see. - From a node's local server, only that node's own network's share (router 0.4.72+). Naming another network — even one the node's owner belongs to — comes back
not_foundrather than resolving, which is the boundary every other tool already applied to a node context. Before 0.4.72 this one path was resolved against the owner's organization memberships instead, so a node on the read-onlyobservetier couldread,listandstatany of their shares. - A node's storage area — control of the node: its owner, or an Owner/Admin of the organization that owns it. From router 0.4.179 the node's own owner reaches it at any role; holding
useof the node never does, and a caller who holds only that getsforbiddennaming the requirement, while someone outside the organization getsnot_found, since the node's existence is not theirs to learn. A node you have deleted keeps its area for 30 days, and the people who owned it still reach it in the meantime; after that it is queued for deletion (router 0.4.73). - From a node's local server, its own area whenever the endpoint is on; a peer's only where its owner controls that peer (router 0.4.73, re-based on the owner's rights in 0.4.179). A node reaches
nodes/<its own id>/while its local MCP may act at all. Another node's area is answered by its own network and itstargetsfirst — a peer outside either comes backnot_found— and then by whether the node's owner controls that peer; anything less isforbidden. Before 0.4.73 this path asked the owner's organization role alone, so a node on the read-onlyobservetier couldreadandwritethe storage area of any node in any network its owner's organization held — and nothing on the target node filters a write that arrives that way.
What works on a storage tree, and what does not:
read,write,list,stat,mkdiranddelete— sofs_read,fs_write,fs_list,fs_stat,fs_mkdir,fs_delete, and over RESTfilewith thoseopvalues. Anything else is refused withunsupportednaming what is supported, rather than quietly reading the node's disk instead.copytoo, on either end (router 0.4.84+):fs_copyaddresses each side independently, so a node → share, share → node or node's-storage → node copy is one call. A URL fetch is the one file operation that names a tree and is refused rather than served —fs_fetch_urltakes nostoragefield at all, andop=fetch_urlon REST is refused by name — because the agent performs that fetch and writes the bytes itself, so there is nothing for the router to put on a tree. Before router 0.4.84 it acceptedstorage: trueand wrote to the node's real disk anyway, reporting success.- Locking exists here and nowhere else (router 0.4.62+):
fs_lockandfs_unlockfrom router 0.4.195, andop=lock/op=unlockonfileover REST. A path on a node's own filesystem is refused withunsupported. See Locking a path on a storage tree. filewithop: "batch"works too (router 0.4.59+), on the REST surface — the batch has no MCP spelling. Itsstorageapplies to every sub-op, so a whole batch lands on the tree you asked for. A sub-op the trees do not have —edit,multi_edit,fetch_url— comes back as that entry's ownunsupportedwhile its siblings still run. Before 0.4.59 a batch ignoredstorageand ran every sub-op against the node's real disk, reporting success.pathis relative to that tree's own root, not to any filesystem:/reports/q3.csvis that file in the share and/is the top of it.- One call, never a session. There is no
idto poll: the response carries the bytes. donesays the call finished (router 0.4.90+). Every operation here butreadcompletes inside one response and reportsdone: true;readis the only one that can page, and theredone: falsemeans the object continues past what you asked for. Before 0.4.90list,stat,mkdir,delete,lockandunlockreporteddone: falseon calls that had completed, which is why a listing could disagree with its owntruncatedflag — readfs_list's owntruncatedandnextfor whether a listing has more, on either tree.- A read returns up to 1 MiB per call, and pages past it (router 0.4.59+).
limitis honoured and, from router 0.4.84, defaults to the same 32 KiB a node read does — the two paths no longer disagree, because "one call" was never a delivery for a response a model client refuses. A file larger than what you asked for comes back withdone: false; call again withnextascursoruntildone: true. Before 0.4.59max_byteswas ignored and anything over 1 MiB failed withtoo_largeat every offset. Acursorpast the end of the object isinvalid_argsfrom router 0.4.94, carrying the object's true size indetails.file_size— the answer a node read has always given. Through 0.4.93 it seeked there regardless, read nothing and answereddone: truewithfile_sizeequal to the offset you guessed, so a 10-byte object reported itself as 100 bytes long. An offset exactly at the end is still the legal empty read a paging caller lands on. - A write is capped at 1 MiB and does not page. Send the whole file in one call; past the cap it fails with
too_largeand points at the tree's HTTPS URL, whichPUTs a file of any size and is the right tool for one. A write creates any missing parent directories (router 0.4.59+), as it does on a node's disk. ⚠⚠ Chunking a write here does not fail — it keeps only the last chunk. Each call replaces the file,doneis ignored and noidis issued, so following the chunked recipefs_writeandfiledescribe for a node returns a success for every chunk and leaves the file holding the final one alone, with a digest of that fragment. There is nothing to notice afterwards, which is why the tool descriptions say so outright from router 0.4.146. - These trees stop accepting new bytes at the organization's storage limit (router 0.4.93+).
write,mkdirand the destination end of acopyare refused withresource_exhausted(HTTP 429), and the message names how much the organization is using against its allowance and thatfs_deletestill works.read,list,stat,delete,lock,unlockand copying out of a full tree are unaffected — the test is does this add stored bytes, so the way back under the cap is never blocked. The same cap has refusedPUT,COPYandMKCOLover the tree's HTTPS URL with507 Insufficient Storagesince router 0.4.92; until 0.4.93 these tools reached the same storage without passing it, so a write refused over the URL succeeded here. ⚠ It applies on paid plans too from router 0.4.101 — Free at 10 GB, Pro at 100 GB. Storage stopped being a metered charge in that release, so there is no overage to bill on any plan and every plan is refused at its own limit. - These trees also stop moving bytes while the organization is over its bandwidth allowance (router 0.4.106+, Free only):
readandwriteare refused withresource_exhaustedanddetails.limit_type: "bandwidth", whilelist,stat,mkdir,delete,lockandunlockkeep working. It is a different question from the storage limit above — that one asks does this add stored bytes, this one asks does this move content — which is why areadis refused by one and never by the other. sha256is checked here (router 0.4.91+), as it is on a node — and before anything is opened, so a mismatch leaves an existing file at that path untouched. Through router 0.4.90 these two trees computed the digest and reported it without ever comparing it to the one you sent, so the check silently passed on bytes that had been mangled on the way in. Seefs_write.rootis ignored. There is no second identity to choose when no agent runs the operation, so nothing is elevated and nothing is gated on elevation.recursivemeans the same thing it does on a node (router 0.4.59+):listwalks the subtree, anddeleteneeds it before it will remove a non-empty directory. The depth-guard does not apply here from router 0.4.84 — a tree has no/etcto protect, so a top-level directory needs noforce: true, though sending one is still accepted and means nothing. Deleting the tree's own root is always refused.modeis not applied.- A
deleteanswers as soon as the name is gone (router 0.4.77+). The entry is moved out of its directory in one step and the bytes are reclaimed behind you, so deleting a large directory no longer holds the call open for the object store to work through it — and a whole directory costs the same one step whatever is inside it. The result is the same either way: the moment the call returns, the path is gone for every caller and every router, the name is free to re-create immediately, and metering has stopped counting those bytes..nobgp-trashis a reserved name anywhere in a path on these trees, alongside.nobgp-upload-…; a name of your own using it is refused rather than stored. fs_grepandfs_globtake nostoragefield at all: searching is still a node operation.
Two spellings of one name
On the storage trees — and only there; a node's own disk keeps whatever rules its filesystem has — names are stored verbatim and are unique case-insensitively (router 0.4.71+). Foo.txt and foo.txt are two spellings of one name.
- The first spelling written owns the name and keeps its casing forever. Nothing is lowercased or folded, and a listing gives back exactly the bytes that were written.
- Creating or renaming to a second spelling returns
already_exists, and the message names the stored spelling — the one fact a listing cannot give you, since you were looking for your own. Over the share's URL aPUTanswers 409 rather than the misleading 404 it would otherwise be. - It reaches implied creates too.
fs_writeand recursivefs_mkdircreate missing parents, so a request whose parent directory exists under another spelling is refused at that component rather than creating a second one. - Overwriting the stored spelling is normal, as is renaming a name to another spelling of itself (
foo.txt→Foo.txt), which is how a stored casing gets corrected. - Lookups stay exact.
fs_readon/README.mddoes not find/readme.md; the mounted drive folds on the node where the platform does — a Windowswinfspvolume from agent 0.4.74, where a Linuxfuseornfsmount stays exact like the tools.
The rule is what lets a share be copied onto a Windows or macOS filesystem at all — neither can hold two names differing only in case. It is enforced, not absolute, and not retroactive: a directory that already holds a collision keeps both files, both readable and both overwritable, and only a new spelling is refused. The fold is Unicode simple case folding, which matches neither NTFS's nor APFS's — so K (U+212A) is the same name as k, while İ (U+0130) is not the same name as i. It is not Unicode normalisation: composed and decomposed é remain two names.
Locking a path on a storage tree
Two callers writing the same file on a share had nothing to coordinate with. fs_lock and fs_unlock give them one — on a network's shared drive and on a node's storage area alike. The lease has existed since router 0.4.62, as file's op: "lock" / op: "unlock", which is still how it is reached over REST; the two dedicated tools are the MCP spelling from router 0.4.195 and take the same lease on the same paths:
{ "share": "production", "path": "/reports/q3.csv" }
{ "locked": true, "lock_token": "1786...", "lock_expires_at": "2026-08-09T12:34:56Z" }
- The lock is advisory, with one exception from router 0.4.80. Nothing consults it on a read, and nothing consults it on a write made through these tools, so it protects you mainly from callers that also lock — the same contract
flockhas locally, andlock_token, though it only ever goes up, cannot fence a holder whose write is already in flight. What it does stop from router 0.4.80 is a write that reaches noBGP over WebDAV — every write a node's mounted drive makes, and everyPUT,DELETE,MOVEorCOPYon the share's URL — which is refused with423while another holder has the path. - It is held across routers and across nodes. It is arbitrated where the bytes are served — the router's own storage — which is also why it exists on the storage trees and nowhere else:
lockagainst a node's own disk is refused withunsupported, because the router never sees that write and a lock there would be a promise nothing keeps. - A lock a node holds survives a noBGP deploy, from router 0.4.91. A lock taken through a node — by
fs_lockproxied over its local server, or by its mounted drive — is released early when that node disconnects, which is what stops a machine that died holding a file from holding it for the rest of the two minutes. Until 0.4.91 a routine noBGP release looked like that: every node's connection was re-established on another instance, and the momentary drop released every lock every node held. Now a replica going away is told apart from a node going away, so only the second releases anything. A node that genuinely disconnects still frees its locks at once. - A
fuseornfsmount takes the same lease — a Linuxfusemount from agent 0.4.64, annfsmount from agent 0.4.69. A program that locks a file on such a node's mounted drive contends withfs_lockon the same path, and the other way round. Onwinfspthe mount and this tool remain separate worlds. Onwebdavthe split is finer: your program's ownflocknever leaves the machine, but the WebDAVLOCKthe mount's client sends before it writes takes this same lease from router 0.4.80, so those do contend — and a write through such a mount is refused with423while another holder has the path. - It expires after two minutes unless re-taken.
lock_expires_atis the instant, and re-locking the same path renews it — there is no separate renew call and no unbounded hold, so a caller that walks away is a nuisance rather than an outage. Two minutes is longer than any single call and short enough to forgive a forgotten lock. - A contended path returns
already_exists, marked retryable — the one refusal on this surface that clears on its own, when the holder releases or its lock lapses.detailssays what a contender needs to decide whether to wait:expires_at,held_by_self(truewhen it is you from another client or another tool in the same session), andheld_by_kind—userfor a person's session,nodefor a call proxied through a node, in which caseheld_byalso names the node id. A person's session identifier is never published. - The path is what is locked, not the file. Nothing checks that anything is there, so you can lock a path before creating it. Spelling is normalized first —
/a.txt,a.txtand/x/../a.txtare one path, not three locks. unlockreleasing a lock you do not hold is not an error. It reportslocked: falseand says so in its message, so unwinding after a failure does not have to know how far it got.- A node's locks are released when it disconnects, rather than lingering until they expire. From router 0.4.66 that means the node actually going away: a reconnect — including one that lands on a different router while noBGP is deploying — keeps everything the node holds, where before it released the lot and a second caller could take a path the first still believed was its own. A person's session has no such signal either way: the two-minute expiry is what clears it.
locked, lock_token and lock_expires_at appear on these two tools and no others — their absence on a read or a stat says nothing about whether the path is locked, and there is no call that asks.
A lock taken through fs_lock is coordinated by noBGP. A lock taken by a program on a node's mounted drive — flock, fcntl, an exclusive open — is the operating system's, and on webdav and winfsp it is enforced on that machine at best, does not reach a second node, and does not see this tool at all. The exceptions are a Linux fuse mount on agent 0.4.64+ and an nfs mount on agent 0.4.69+, which forward their locks to noBGP and share this lease. Separately, the WebDAV LOCK a webdav mount's client sends before a write is arbitrated by noBGP from router 0.4.80 and shares this lease too — but that is the client's lock, not the one your program asked for. node_list reports what a given node's mount actually guarantees as locking (with view: "full"), a key into its locking table; read it before putting SQLite or a lockfile-as-mutex on the share.
A node's own mounted drive is not always readable by its agent
The file tools and command reach a node's mounted drive through that node's agent, so what matters is what the agent can read — never what the machine's own user can. On some nodes the agent is refused its own mount, and a path under it then fails in every tool that takes a node-local path: file, all seven per-op fs_* tools, fs_fetch_url, fs_grep, fs_glob, fs_subscribe and fs_grep_subscribe. On macOS the refusal arrives as a bare Operation not permitted.
node_list with view: "full" answers it per node, and this is the field to read (router 0.4.83+, agent 0.4.85+ — the node reports it, so it stays absent on an agent older than that whatever the mount is doing):
mount_readable | Means |
|---|---|
true | the agent read its own mount, and paths under mount work. Do not route around a mount that reports true. |
false | every file and command call on a path under the mount is refused at both identities on that node — root: true fails identically, because this is not a privilege problem. Reach the content through the router instead. |
| absent | the agent never probed, usually because nothing is mounted — fs says off or error there. Absent is not false. |
It is re-probed on the ordinary metadata refresh rather than snapshotted at mount time, so it is the latest answer and not the one from boot.
From router 0.4.87 the refusal itself carries this, so a caller that never read node_list still learns it at the moment it matters. A permission_denied on a path under the node's own mount is answered with what the field says about that node, and the three states give three different sentences:
mount_readable: false— the message names the mount, says the agent cannot read it at either identity soroot: truefails identically, names the owner's remedy (on macOS, the Full Disk Access grant below, which applies to the running daemon with no restart), and points at the two router-served routes that reach the same bytes.mount_readableabsent — it says the node has never reported whether it can read that mount, so this refusal may or may not be the mount, and names the router-served routes as the way to tell. It does not claim the mount is unreadable: absent is notfalse.mount_readable: true— nothing is added. The mount is readable and something else denied this path (mode bits, anallow-rootsconfinement), and naming the mount would send you the wrong way.
A refusal on a path outside the mount is left alone, and so is any code other than permission_denied — a not_found on a mount path has a different remedy.
⚠ Until router 0.4.83 the tool descriptions asserted this as an absolute, and on macOS specifically. Both halves were wrong by then: a Mac whose owner had granted Full Disk Access served reads off its own mount normally, and a Windows node on the webdav backend answers false for an entirely different reason. The cost of the absolute was not a wasted call but a wasted plan — a caller that believes a mount can never be read does not retry, it routes around, which is the one thing that page of instructions tells it not to do.
A false has more than one cause and they share no remedy:
- macOS — the consent layer (TCC). The agent's background daemon is refused access to a network volume; the machine's own user reads the same drive normally through Finder, a login shell or
sudofrom Terminal, because those inherit that login session's consent and a daemon has none. Measured across eleven variations on 2026-08-12: not the account that made the mount, not the path (a mount outside/Volumesis refused the same), not the backend (webdavis refused exactly likenfs), not the mount options. The owner lifts it with a Full Disk Access grant — see below. - Windows on the
webdavbackend — a mapped drive belongs to its logon session. The drive letter was mapped by the interactive user's session and is invisible to the SYSTEM service the agent runs as, so the agent genuinely cannot see it. Full Disk Access means nothing here and there is no equivalent grant; use the router-served routes below, or move that node onto awinfspmount, which is not session-scoped.
Either way the mount itself is healthy — nothing is wrong with the node's drive; what cannot reach it is the agent.
Reach the same bytes through the router instead, which serves them without going near the mount:
| Instead of | Send |
|---|---|
path: "/Volumes/nobgp/networks/production/report.pdf" | share: "production", path: "/report.pdf" |
path: "/Volumes/nobgp/node/backup.tar" | node with storage: true, path: "/backup.tar" |
See Addressing storage instead of a node for what those two trees allow — and note that the node does not have to be online for either, since no agent is in the path.
fs_grep, fs_glob, fs_subscribe and fs_grep_subscribe have no such fallbackThose four run wholly on the node and accept neither storage nor share — sending either is invalid_args, a second dead end. To reach content on the drive of a node whose mount_readable is false, use fs_list and fs_read with the storage addressing above, which do take it.
fs_edit is a third case, and its own description says so from router 0.4.72: it does take storage and share, but the trees serve no edit operation, so following the ordinary remedy traded Operation not permitted for unsupported. Read the file and write it back instead — fs_read then fs_write, either with share or with storage: true.
On macOS: granting Full Disk Access ends it
Granting the nobgp daemon Full Disk Access lifts it for every tool at once, and from router 0.4.76 the tool descriptions name that remedy rather than only the refusal — so an assistant that meets Operation not permitted can tell you how to end it instead of routing around it forever. It is the only fix that makes the node's own paths work; the storage addressing above is a way round them. It is also the reason mount_readable exists as a field rather than a rule: a granted Mac reports true and its paths behave like any other node's.
On the Mac itself: System Settings → Privacy & Security → Full Disk Access → +, then press ⌘⇧G (the file picker hides /usr/local) and enter /usr/local/bin/nobgp — the daemon's own binary, not the app you are reading this in. It applies to the running daemon immediately: no restart, no remount, the next call simply works.
From agent 0.4.144 the Mac asks for it when the node is registered, which is where most of these nodes will now be granted: sudo nobgp register run at a terminal on the Mac has the agent open that pane on the person's own screen, and names the row to switch on. Running it again on a machine that is already enrolled makes the same offer, so that is what to ask the node's owner for on a Mac enrolled before that release. From agent 0.4.145 a registration with a key asks too, provided the command's output is a terminal — the install script run by hand on the Mac therefore arms it — while an enrolment writing to a log or a pipe asks nothing, and neither does anything an MCP client can send.
Nothing an MCP client sends can grant it. TCC consent takes a human at that machine's keyboard (or an MDM profile), so this is addressed to the node's owner, and the tool descriptions say so — a caller that tried it would spend calls on the one path that can never work. Since agent 0.4.72 the grant stays lifted across upgrades: the macOS binaries carry a Developer ID signature, so macOS keys the grant to the signing identity rather than to the exact binary. On 0.4.71 and earlier the grant was voided silently by the next upgrade, which made it a way to unblock one machine by hand rather than a fix to deploy. Linux nodes are unaffected: their agents read their own mount normally. A Windows node has no equivalent grant — where one reports mount_readable: false the cause is the mapped-drive scoping above, not consent.
A script written to a Windows node is parsed as CP1252
fs_write, fs_edit and file write UTF-8 with no byte-order mark, and a Windows script that carries no BOM is not read as UTF-8: Windows PowerShell parses a .ps1 as ANSI/CP1252, and cmd.exe reads a .bat or .cmd in a legacy code page of its own. So a script composed here and run there is decoded with the wrong code page the moment anything in it is outside ASCII. Router 0.4.96 states this in those three tools' descriptions.
⚠ Every integrity check you have passes on this failure. The write returns a SHA-256 that matches the sender's, sha256 matches, and fs_read returns exactly the bytes that were sent — the file on disk is correct. The corruption is in the reading: measured on 2026-08-22, an em dash (—) in a PowerShell script arrived at the parser as â€", whose embedded quote closed a string early, and PowerShell then reported the error against a later line than the damaged one. The reader debugs a line that is fine.
It surprises callers who have been sending such characters through command's script field for months: that body is never left to a guessed code page — noBGP either writes the temporary .ps1 with a byte-order mark or sends the body as -EncodedCommand UTF-16LE, so non-ASCII in it survives either way.
Three ways through, in the order worth trying:
| Keep the script to ASCII | The simplest, and the one that works for every interpreter on the node. Non-ASCII in a script is usually a quotation mark, a dash or a box-drawing character in a message string. |
Send the body through command instead | script with shell: "powershell" is written for you, and its encoding is never guessed: the temporary .ps1 carries a byte-order mark, and the form that writes no file sends the body as UTF-16LE inside -EncodedCommand. Use this where the script is a one-off rather than something that has to live on the node. |
| Write the BOM yourself | For a .ps1, prefix the file's bytes with EF BB BF in base64 mode and Windows PowerShell reads it as UTF-8. noBGP will not add one for you — these tools write the bytes you give them, and a BOM inserted on your behalf would corrupt every non-script file written to a Windows node. |
This is about bytes you compose. fs_copy carries a file that already exists on another machine and does not raise the question.
A file another program on the node holds open
From agent 0.4.182 a file operation that failed because another program on that machine has the file open or locked comes back failed_precondition, and the message names the cause and the remedy: another program on this node has the file open or locked; close that program and retry. Root cannot get past it, so root: true will not help. It is the same classification a read-only file system gets, and for the same reason — it is a fact about the node, which root does not change.
It is Windows where this is met. Reading a file a program holds exclusively — a registry hive, a database file, a log an application has open — fails with a sharing violation, and replacing a file that is open in an editor or a viewer fails too, because Windows cannot rename over an open file. On a node below 0.4.182 both came back as internal (HTTP 500, retryable: true), which tells a client to retry a thing that will never succeed, or — for the rename — as permission_denied, which sends you to audit ACLs that are correct.
- The destination is named. A refused
fs_writeorfs_editsaysC:\path\file.extis open in another program. Where it is the freshly written temporary copy that is held rather than the destination, the message says so and names the virus scanner as the usual holder — the agent writes beside the destination and renames, so an on-access scanner that opens the new file can hold the rename off. - A transient holder is absorbed, not reported. The rename is retried briefly first, which clears the scanner case on its own; only a holder that outlasts that is reported.
- A
permission_deniedhere is still an ACL. The agent probes the file before claiming a holder, so it classifies only a confirmed one; a failure it could not confirm keeps the permission code and its message names both causes. - The diagnosis is no longer overwritten on the way back. A refused write used to arrive as
write failed after 24 bytes: connection not found— the node had already closed the session with the real error, and a later frame replaced it — while the node's own log held the explanation. A node that answers for a session it has finished now repeats how that session ended, in whichever order the frames land.
Who may call what
On the public server, authorization is your organization role and what you hold on the node you are addressing — use, root or control. Membership in the organization lets you see a node; using it is a separate grant, and running as root is a third. The per-tool notes below say which is needed, node_list answers it per node in my_access (with view: "full"), and Roles & Permissions is the full model.
On the local server — a node's own localhost endpoint — there are no tiers from router 0.4.179. The switch is on or off:
node_grantturns it on (omitrole;"manage"is accepted as the old spelling, and"observe"is refused by name — a node that held that read-only tier is off).node_revoketurns it off and stops the server. Both need control of the node: its owner, or an Owner/Admin of its organization, checked per node.targets— label pairs ("role=edge,site=irvine") narrowing which peers the node may act on, evaluated live, so relabelling a peer changes reach with no re-grant. Omitted, it covers every node of the network the owner may use. It only ever narrows.- When on, the endpoint acts for the node's owner with the owner's current rights, re-checked on every call: it reaches the peers the owner may
use, runs as root only where the owner holds root (each target's ownallow-rootstill caps it), reads a peer's agent log withnode_logsonly where the owner controls that peer (router 0.4.183), and stops when the owner leaves the organization or loses a right. A change of the node's owner turns it off. - The whole allowlist — 34 router tools from router 0.4.195 (29 before it), plus the node's own
statusandresolve— is available whenever it is on. ⚠ Whoever can run on the node can call it, so turning it on delegates the owner's reach to everyone who can run on that box.
Neither node_grant nor node_revoke 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, an access-list entry or a sharing setting changed mid-session takes effect on the next call — a command_read on a running session included — 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, logs | Capability domains served (fs = all file tools and fs watches; command = command execution; logs = node_logs, from agent 0.4.153). ⚠ From that release it fails closed: a value that is not a domain is dropped, and with nothing valid left the node serves nothing. |
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-root | true | Whether execution or file access as the superuser is permitted. allow-admin is its old name: the router reports it as allow_root, with allow_admin beside it on the REST surface only from router 0.4.195, and from agent 0.4.153 nobgp config takes --allow-root too. The old key and flag still work, and where both keys are set the stricter value wins — see The veto on root was renamed. |
A veto refuses, never downgrades: an explicit root request against allow-root: false fails — or reports a refused event — rather than silently running unprivileged.
⚠ It is also the cap on what can be granted. From router 0.4.179 a node reporting allow-root: false cannot be given a root access-list entry at all, and an entry written before the veto is kept but inert (blocked_by_node), giving nothing beyond use until the owner lifts it.
More machine-owner settings bound what the node does for the network rather than what a caller may do on it, and none of them is writable through node_config_set. From agent 0.4.128: lan-names, which limits the LAN host names that node resolves for everyone else — net_reach reports a name outside it as resolution.refused: "lan_names" — and the two mDNS keys, which decide whether the node reports the services its host offers (host_services in node_list, with view: "full") and whether it shows peers to its own local applications. From agent 0.4.153: peer-loopback, the owner's veto on a peer reaching a port bound only on that node's loopback — a loopback-only listener it refuses is also left out of host_services — and local-mcp, the owner's off switch for the node's own local MCP server, which a node_grant cannot override.
A vetoed tool call comes back as permission_denied (HTTP 403, not retryable), with a message naming which of the three refused it, so a veto is never mistaken for a transient failure worth retrying. Router 0.4.37 is the release that made that reliable at every point a refusal can land — mid-read, mid-write and mid-search included; before it these arrived as internal and clients retried them forever. See When the failure came from the node.
allow-root 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-root: 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, and /workspace/nobgp on a Grok Bot computer from agent 0.4.180, where /etc/nobgp is a symlink to it and is refused through the link). The fs tools and fs watches never serve it, no allow-roots value opens it, and symlinks are resolved before the check — it holds the node's key and the config these vetoes are read from. command is unaffected, so a session running as an account that can read those files still can.
The tools that walk a tree — fs_list with recursive, fs_glob, fs_grep — ask the same question at every directory they descend into and prune the subtree on a refusal, rather than vetting only the root they were handed (agent 0.4.37+). On earlier agents a recursive walk rooted at a permitted path reported what a single-path call on the same node refuses.
allow-root: false can lock you outIt is meant to. On a Windows node that captured no install account, and on a root-running Unix node with no usable user configured, the two halves close on each other: allow-root: false refuses root: true, and unelevated never means root refuses root: false — so remote execution is off entirely, terminal included. Undoing it then needs console, RDP or ssh access to the machine. On a node with a working user, sessions keep running as that account. The agent warns about this when the setting is applied.
Full treatment, including how to set them, is in Node Access Control.
Execution identity
A node offers exactly two identities, and one field picks between them: root — called admin before router 0.4.179. ⚠ From router 0.4.195 the MCP surface publishes and accepts root only, and the old name is REST/OpenAPI's alone; see the note below. Four rules cover the whole model, and they hold on every tool that carries the field — command (on the call that starts the session), command_subscribe, file and the fs_* tools, fs_fetch_url, fs_grep_subscribe, node_logs, service_publish and service_update:
- Omit
root, and the node picks the identity it offers. - The node decides what unelevated work runs as — not the caller, not us.
- A node can refuse root entirely.
- A node that refuses root must name an account, or it cannot run anything.
Rule 1 does not apply to service_publish and service_update: an omitted root is false on service_publish and keeps the stored identity on service_update. Those two record an identity rather than running at one, so there is nothing for a node to resolve.
⚠ None of it applies to node_logs from agent 0.4.156. The field is still accepted there and changes nothing: the node reads its own log as the agent process itself whatever you send, so there is no identity to pick and allow-root does not bind it either. See The identity field does nothing here.
Roles & Permissions explains each in prose and is the page to send someone who is meeting the model for the first time. What follows is the per-tool contract.
root (old name admin) | Runs as | Who may ask |
|---|---|---|
false | the node's configured user — captured from the installing account at registration, so a normal sudo install runs as that person, not root | anyone who holds use of the node |
false, where that identity would be uid 0 | nothing — the call is refused, rather than run as the superuser | — |
true | the node's ambient identity — gated twice, and both gates refuse rather than downgrade | anyone who holds root on that node: its owner, an org Owner/Admin, or a root entry on its access list. From a node's local MCP: its owner's root there |
| omitted (the default) | the false row where the node offers a non-root identity, the true row where it offers only the ambient one. root_used (old name admin_used) in the result says which one ran | as the row it lands on |
⚠ The authority for true is per node from router 0.4.179, not an organization-wide role: a Member who owns a machine is root on it, and a Member named at root on someone else's machine is root on that one. node_list's my_access (with view: "full") answers it per node before you call, and whoami's can_root answers the organization-wide half.
Where the field goes
One field, two positions. The router's server instructions carry the same rule, stated there for the first time in router 0.4.143 — before that they said nothing about where the field goes, and a caller had to guess. This table is the authority:
| Tool | Where root sits |
|---|---|
command | at the top level, and only on the call that creates the session. command_read, command_input and command_signal carry no identity field at all: the session's identity was settled when it started |
fs_copy, file with op: "copy" | inside each of from and to — one identity per end, because a copy has two subjects |
everything else that carries it — command_subscribe, file's other ops, the per-op fs_* tools, fs_fetch_url, fs_grep, fs_grep_subscribe, node_logs, service_publish / service_update | at the top level of the request |
Until router 0.4.200, command took it inside a session object. That object is retired.
Sending it at the wrong depth is refused by name — invalid_args for the unknown field, never a call quietly run unelevated — so this is friction rather than a hazard. From router 0.4.187, over /mcp, the refusal also says where the field belongs, so the correction takes one call and no guessing; see Argument validation.
root is a boolean
root from router 0.4.179, and it is a boolean from 0.4.201admin is its old name, with the same values and the same meaning.
- Over MCP, from router 0.4.195, only
rootexists. Sendingadminis refused by the schema check as an unknown property. That refusal has nocode; its text tells a client to refetch its tool list. - No result carries an old name, from router 0.4.201, over MCP or REST. Results carry
root_used,from.root_used/to.root_used,allow_rootandcan_root.admin_used,allow_admin,can_elevateandno_unelevated_identityare gone. - Over REST,
service_publishandservice_updatestill acceptadmin, because the noBGP app sends it. Sending both spellings in one call isinvalid_args, even when they agree. rootis a boolean from router 0.4.201. From router 0.4.83 to 0.4.200 it was the string"true"or"false". A string still works: the router changes it to the boolean.
The full table is The root rename. Everything below applies to either spelling. A refusal that an agent below 0.4.153 wrote still names the retry with the old name, for example admin: "true". It is the same call: send root: true.
The field is a boolean, true or false, on every tool that runs at the identity: command, command_subscribe, file, the per-op fs_* tools, fs_fetch_url, fs_grep, fs_grep_subscribe, fs_copy and node_logs — which still takes it and, from agent 0.4.156, ignores it. From router 0.4.83 it was a string on these tools, "true" or "false", with the same meaning.
- A string still works. A client that holds a cached string-typed schema sends
"true"or"false", and the server converts it to the boolean rather than rejecting it. Nothing has to change on your side. A reconnect makes the boolean visible in the schema. - It is a plain boolean, not a union.
"type": ["boolean", "string"]is a shape the primary client cannot represent — it degrades such a field to untyped and stringifies it, and validation then rejects the call — so the field is published as one type and the other spelling is converted on the way in. service_publishandservice_updatetake the same boolean. They record an identity rather than running at one, so there is nothing for the node to resolve.
The field has three states, and no fourth — omitted, true and false:
- Omitted is the default, and it is what a fan-out wants. The call runs unelevated where the node offers an unelevated identity, and elevated where the node offers only its ambient identity. One call therefore covers a fleet whose nodes disagree about which identities they have.
trueforces the ambient identity.falseforces the configured account. Send one of them when the work needs a specific identity. A node with no unelevated identity refusesfalse.- ⚠ The router's gate reads the identity the call resolves to. An omitted
rootthat lands on the ambient identity is an elevation, and it needs the authoritytrueneeds. The same call on a node with an account runs unelevated, and the gate is not asked. A node whose owner setallow-root: falsenever resolves to the ambient identity. - The result says which identity ran, and from router 0.4.206 which account. Every executing tool reports
root_used: truewhere the call ran as the node's ambient identity — including where you omittedroot— anduser, the account it ran as. ⚠ From router 0.4.201root_usedis absent rather thanfalsewhen the call ran as the configured account: absent is the default on this surface, so the field appears only when it has something to say.useris what names the account either way — see The result names the account. Oncommandboth are on the call that created the session, and absent fromcommand_read,command_inputandcommand_signal, whose session had its identity settled at creation; on the router-served storage trees both are absent, because nothing there runs as anybody on a node. - ⚠ The seven per-op
fs_*tools report it only from router 0.4.89.fs_read,fs_write,fs_list,fs_stat,fs_delete,fs_mkdirandfs_editeach accepted the field, and each one's own field description promised the result would name the identity — and none of them returned it. The same operation throughfiledid, sofilewithop: "stat"named the identity on a node wherefs_statanswered nothing at all. On a router below 0.4.89, read the identity throughfilerather than through the per-op spelling. Every other executing tool —command, both cohort tools,fs_grep,fs_copy,file— has reported it since 0.4.83.
The result names the account, from router 0.4.206
root_used says which of the node's two identities, and from router 0.4.206 user beside it says which account — root on a Unix node, SYSTEM on Windows, or the name the node's owner configured. Read it on a mixed fleet: one call that omits root lands on a different account on each node, and until this field the result said only which of the two identities ran, never which account that was.
- It is on every tool that reports
root_used, in the same place and under the same rules:commandon the call that creates the session,file, the seven per-opfs_*tools,fs_fetch_url,fs_grep, and per end onfs_copyasfrom.user/to.user(from_user/to_useronfilewithop: "copy"). - It is the field to read when
root_usedis absent. Since router 0.4.201 an unelevated call reports noroot_usedat all, so an absentroot_usedwith auseris a call that ran as that account, while an absentroot_usedwith nouseris a call that resolved no identity on a node at all.root_used: truebesideuser: "root"says one thing twice, deliberately, so neither reading depends on the other. - Absent means the node did not say, never a guess. A node whose agent is too old to report the account it would run work as carries no
user; so does a call that resolved no identity on a node — a continuation, and a router-served storage tree. - It comes from what the node reports, the same facts the router resolves an omitted
rootfrom — theuserthatnode_listshows withview: "full", as of that node's last registration. An elevated call reports the ambient identity's own name (root, orSYSTEMon Windows) rather than a configured account. A node that reports no account at all reports nouser, which is also what a node whose configured account no longer resolves reports. fs_deleteandfs_mkdirnow carry a payload whenever there is an account to name. They carry one only when there is an identity to report, and from 0.4.206 an unelevated call on a node has one — the account — where before only an elevated call did. A call against a router-served tree still returns the envelope-only shape.- Not on
node_logs, which from agent 0.4.156 reads the log as the agent process whatever you send, and not on the cohort tools, which answer identity asroot, a list of node ids.
Choosing which to send
Choose by what the work needs. Send root: true for anything that requires the superuser — system paths (/etc, /var, /root, the Windows system directories), services, package managers, another account's files. Send root: false for work that must not run as the superuser, such as files that must be owned by the service account. Omit the field for anything else, including a node you know nothing about.
Windows is no exception to that rule, and there is deliberately no platform rule here to learn: a Windows node has a second identity only where its owner configured an account, and where none is configured root: false is refused exactly as on any other node whose only identity is the superuser. An omitted root runs there as LocalSystem, and root_used says so.
Each refusal names one retry, so a wrong guess costs one call and never a loop:
| Error | What it means | The one retry |
|---|---|---|
failed_precondition | The call ran unelevated — root: false, or an omitted root the router could not resolve as elevated — on a node whose unelevated identity would be the superuser — unelevated never means root | Retry with root: true, or have the node's owner configure an account |
forbidden | The call would run as root — root: true, or an omitted root on a node that offers only its ambient identity — and you do not hold root on that node — see gated twice | Retry with root: false where the work does not need the superuser. The same call never succeeds on a retry, and it is never quietly run unprivileged. On a node that offers only the ambient identity there is no retry for you: someone who holds root on it must run it, or its controllers must grant you root |
Both halves of the gate are also answerable before you call, which is worth one lookup on a large fan-out rather than a refusal per node:
whoamireportscan_root(old namecan_elevate) — your organization-wide half.truemeans root passes the router's gate on every node you can see. Absent means it depends on the node, which is the common answer for a Member from router 0.4.179.node_listwithview: "full"reportsmy_accessper node —control,root,useornone— which is the per-node half of your side, and each node'suser,allow_rootanduser_is_admin, which is the node's half.
Unelevated never means root
Read that second row twice, because it is the one that surprises people: unelevated is not the same thing as unprivileged. The node resolves what root: false would run as — the configured account if there is one, its own ambient identity otherwise — and if that comes out uid 0, it refuses the call instead of running it as root. The refusal is failed_precondition (HTTP 403) and is not retryable: the request is not denied, it is incoherent, because "unelevated" would mean the superuser on that box.
Three shapes reach it:
| Node state | Why it refuses |
|---|---|
| No account configured, on a node whose agent runs as root — a service install, a bare-root container, and a Windows node offering only LocalSystem, which reports as uid 0 | there is nothing lesser to fall back to, and the old fallback was root: silent, and in the escalating direction |
| A configured account that no longer resolves — deleted, or a config copied off another machine | same fallback, same answer |
user: root spelled out | the config asked for the superuser; this call did not, and the call decides |
A fourth shape reached the same code on Windows, and it was not about uid 0: on agents 0.4.44 and 0.4.45, a node with a configured account had a second identity it acted as for file operations but could not spawn a process as, so command, terminal sessions and dispatched bus commands refused an unelevated call there rather than quietly running it as LocalSystem. Agent 0.4.46 runs execution as that account too, closing the split. It is a per-node answer that changes as a fleet upgrades rather than a property of the platform, and the code and the single retry are the same on either side of it — root: true.
A Windows node on 0.4.46 or later can still reach this code, for a reason that is about the node rather than the caller: it fails closed, so an account whose token cannot be obtained, whose profile cannot be loaded, or whose environment cannot be built refuses the call instead of running the work as LocalSystem.
Both remedies are named in the message, and either one works:
- Configure an account on the node —
sudo nobgp config --user deploy. Pick one that can actually do the work: naming an account purely to clear the refusal trades one clear error for a run of confusing permission failures. - Retry with
root: true, if the superuser is genuinely what you meant. That path is gated, which is the point — an elevation then appears in the record as an elevation, for whoever reads it later.
An omitted root clears this refusal on the common shapes, and not on all of them. Where the node reports no account, or user: root, the router resolves an omitted root as elevated, so the call runs rather than being refused — and it needs the authority to elevate, exactly as root: true does. Where the node reports some other uid-0 account (user: toor, say), does not report user at all because its agent is too old to, or has allow-root: false set, an omitted root resolves unelevated and gets this refusal. root: true is the retry there, and on a node with allow-root: false there is no retry at all.
Omitting root clears this refusal on some nodes and not others. Where the node reports no account, or user: root, an omitted root runs elevated and needs the authority to elevate. Where the node reports another uid-0 account (user: toor, say), reports no user at all, or sets allow-root: false, an omitted root resolves to the unelevated identity and is refused exactly as root: false is. root: true is the retry that works on the first two of those; on a node with allow-root: false nothing remote runs at all.
An empty user is a warning, not a verdict. A node whose agent is already unprivileged — a rootless container, a nobgp agent started by an ordinary account — serves root: false as itself, account or no account. Refusing there would prevent no elevation, since root: true on such a node resolves to the very same uid. ⚠ The router cannot tell that node from a root-running one, because both report no account, so an omitted root there resolves to elevated: the call needs the authority to elevate and reports root_used: true, although it runs as the same uid. A caller without that authority sends root: false.
This is not a privilege reduction, and it is worth being exact about that: a node with no account moves from everything implicitly root to only root: true, explicitly root. The same privilege is reachable. What changes is that asking for it is now visible.
The rule is the node's, evaluated from its own config at the moment of the operation — the router forwards a true or a false and resolves nothing. An omitted root is the one case the router resolves itself, from the user and allow_root the node last reported, and it elevates only where those say the node has no unelevated identity; moving that choice to the node is still the end state. Agent 0.4.41 is the release that introduced it, replacing a silent fall-through to root.
node_list with view: "full" reports each node's user and allow_root so you can tell which shape you are dealing with before choosing.
A name reported there is not a promise. The node decides at the moment of the operation, so root — or any second superuser account — is reported by name and refused just the same, and an empty value is not a verdict in the other direction either. From agent 0.4.41 the name is the one the node resolved, so an account since deleted reports empty rather than reading as usable. A Windows node reports empty unless it has a configured account, which since agent 0.4.44 is its second identity — a name there is what file operations run as, and from agent 0.4.46 what execution runs as too (on 0.4.44 and 0.4.45 an unelevated execution call was still refused).
Absent — the field missing rather than empty — means only that the agent predates the field. Such a node also predates the identity field itself: it ignores the field and runs the work as its configured user, so root: true there does not elevate. Agent 0.4.34 is the release that honours it; both directions of that skew fail closed, and neither elevates on its own.
The gate is on the resolved uid 0, not a spelling, so any superuser account is covered.
Elevation is gated twice
An elevated call must pass the router and the node — root: true, or an omitted root that lands on the node's ambient identity, because the router gates the identity the call resolves to. Either gate can refuse. A call that runs unelevated passes both trivially and needs no authority at all.
| Gate | Asks | What a refusal looks like |
|---|---|---|
| Router (caller authority) | Does this caller hold root on this node? Its owner, an org Owner or Admin, or a member with a root entry on its access list. On a node's local server: its owner's root on the target. | forbidden, before the request ever leaves the router — "node "x": running as root needs root on this node…", or "execute.root requires a higher role in this organization" |
Node (allow-root) | May anyone be the superuser here? The node owner's setting, on the box. | permission_denied, carrying the node's own wording — or a refused event, on the bus |
A caller with use of a node keeps every ordinary capability: they run commands, read and write files, and read its telemetry, all as the node's configured account. What they cannot do is choose the superuser — or, from router 0.4.183, read the agent's own log, which is control. Handing a caller root on a machine is an administrative act rather than an operational one — which is why root is granted per node, explicitly, with confirm_root, rather than falling out of an organization role.
⚠ The router's half was an organization role until router 0.4.179 (Owner/Admin, or a node holding the retired manage tier). It is now a per-node question, which both narrows and widens: a Member no longer gets root on every machine in the organization by being promoted, and the person who owns a machine has root on it without being promoted at all.
Before the router gate existed at all, the field was checked only at the node. allow-root defaults to true, so on a stock node anyone who could reach it could be root on it.
forbidden on a root call is not worth retryingRetry it with root: false — that runs as the node's configured account, and is what most work wants anyway. Dropping the field is not the same retry: on a node that offers only its root identity an omitted root is refused again. If the operation genuinely needs the superuser (a 0600 file the service account cannot open, a service that must bind a privileged port), the remedy is one of: someone who holds root on that node running it, the node's controllers granting you root with node_access_grant, or the node's owner clearing allow-root: false on the machine. None of them is something a client can fix by trying again.
A published terminal service is a standing grant: it runs at the identity stored on it, and it hands that identity to whatever command you later point it at. So service_update asks what the service will run as after your change, not whether you sent root — updating a service already published with root: true needs the same authority whether or not you send the field. Lowering it to the configured account never needs authority.
Elevation also sheds allow-roots (agent 0.4.37+; earlier agents confine both identities). The node owner's path confinement binds the configured account; the ambient identity is not bound by it, so an elevated call reaches paths an ordinary one is refused. What it never reaches is the agent's own configuration directory — that refusal is unconditional at both identities, because it holds the node's key, its JWT, and the settings these vetoes are read from. Elevating is not a way to read a node's credentials.
The decision follows the root flag you sent, not the uid the work resolves to. That matters on Windows, where a default node's two values both land on LocalSystem: keyed on the uid, every Windows file call would shed allow-roots and the owner's confinement would evaporate on the whole platform.
To run as some third account, use su or runas inside the command itself. There is no way to name one, deliberately: an arbitrary username shipped for a single release and every way it went wrong came from the expressiveness — whether root was a spelling or a uid, and a Windows contract that said one thing while the agent did another.
A Windows node with no configured account has one identity. The service runs as LocalSystem, so both values land in the same place and allow-root: false is an off switch rather than a downgrade there. Since agent 0.4.44 a configured user gives it a second identity: fs_* and file run as that account, and from agent 0.4.46 so do command, terminal sessions and dispatched bus commands (on 0.4.44 and 0.4.45 those refused an unelevated call rather than running as LocalSystem). Which of the two shapes a node is, is the node's answer and not the platform's. See A second identity on Windows.
File operations run as the same identity, and it genuinely changes who acts. The agent drops privilege before opening the file, so permissions and ownership apply as they would to that account — a file written with root=false is owned by the configured user, not by root. (Before this, the file tools always ran as the agent's own account, which is why a path readable via fs_read could be denied via command on the same node. That gap is closed.)
Every file tool takes the flag. file and the per-op fs_* tools all carry root, and it means the same thing on each. Set it on the first call of a read/write session; continuations carry the session's identity. One deliberate exception: fs_glob (REST-only) has no root at all.
fs_grep_subscribe takes it too, from router 0.4.81 — it is the one event source that does, because its walk genuinely reads file contents on every node in the cohort, and pinned to the configured account it returns a short answer that looks complete wherever the interesting files are root-owned. Being a fan-out, it asks root on each node of the cohort from router 0.4.179. With root: true, a node on which you do not hold root fails the whole call with forbidden before anything walks, and the refusal names each such node. With root omitted, a node that would walk as root where you do not hold root reports refused as that node's own event, and the rest of the cohort walks on. A node whose owner set allow-root: false, or whose non-root identity would be root, reports that as its own event too. Before that release the authority was checked once, on the network's organization. fs_subscribe has no root and wants none: a file watcher executes nothing as a user and reports paths, operations and timestamps — never contents.
The per-op fs_* tools gained the field in router 0.4.36, alongside the caller-authority gate. Before that they did not carry it, so every fs_* call was unelevated whatever you sent; and before agent 0.4.34, the file tools ran as the agent itself regardless of either.
One tool was still dropping it much later: file with op: "fetch_url" accepted root and never sent it, so every fetch ran as the node's configured account until router 0.4.88. On a node whose unelevated identity is the superuser, the refusal named root: true as its single retry and answered that retry identically — the one place here where a named retry looped instead of resolving in one call.
Elevation also changes the environment
root: true is not only a different identity — it is a different $PATH. An elevated session gets the superuser's environment, and on macOS root's PATH does not include /usr/local/bin, which is where a Homebrew install and the noBGP agent's own binary live. So the same command line can succeed with root omitted and fail with exit 127 — command not found — under root: true.
Read a 127 on an elevated call as a PATH difference rather than as a missing install, and invoke by absolute path when elevated (/usr/local/bin/nobgp …). The router states this in its server instructions from router 0.4.88, so an assistant meets it once per session; nothing about the identity model changed, only what was being left for the caller to discover the hard way.
⚠ The two command surfaces differ here, and it is worth knowing which one you are on: command_subscribe puts the agent's own binary directory on PATH for a dispatched run, so nobgp notify resolves without an absolute path. Plain command promises nothing of the sort, and elevated on macOS that promise would be false.
From agent 0.4.183 an elevated session on Linux or macOS has a HOME, and starts in it. A service manager starts the agent with almost no environment — systemd and launchd set no HOME — so a session at the node's ambient identity used to run with HOME, USER and LOGNAME empty and to start in the agent's own working directory, while an unelevated one got all three from the account it ran as. The node now sets all three from the account the session runs as, and starts the session in that account's home directory, so ~, git, ssh and anything else that reads HOME behave as they do in a login shell on that machine. ⚠ From agent 0.4.184 the account's own values win over the ones the service manager set: a node whose service manager hands the agent a HOME of its own — procd on OpenWrt sets HOME=/ — now passes the home /etc/passwd names for that account, usually /root, and starts the session there rather than in /. On 0.4.183 the node filled in only what its own environment lacked, so such a node kept the service manager's value. PATH is untouched, so the exit 127 above still happens. Nothing changes on Windows, where a session with no workdir starts in the agent's own directory either way. A dispatched command_subscribe run gets the three variables too, and still starts in the agent's own directory when you send no workdir.
Declaring that a call carries a secret
Router 0.4.85+. command and command_subscribe take an optional redact: an array of short labels, one per secret the call carries.
{ "node": "web-01", "command": "deploy --token=…", "redact": ["deploy token"] }
⚠ It is a declaration, not a transform. Nothing is rewritten, because there is nothing to rewrite it out of: the router stores no command text anywhere — not in a log line, not in a table, not in the audit row. A command's length is recorded and its content never is, which has been true since long before this field existed.
What the declaration is for is the question nobody could answer: how often credentials cross this surface, from which callers, to which nodes. Only two facts are kept — that you declared something, and how many — beside the length already recorded. The labels themselves are read and discarded; put a description in them, never the value.
Neither answer changes anything else: not what runs, not what comes back, not how the call is authorized. Omit it when the call carries no secret. It is not a substitute for keeping a secret out of a command line in the first place — write it to the node with fs_write and read it in the script, or set it through env, both of which keep it out of a process listing on the node.
Tool annotations
Every tool advertises MCP tool annotations on tools/list, so a client that auto-approves on hints can decide without asking you. From router 0.4.195 each one also carries a human-readable title — Run Command on Node, Lock Shared File — for a client that lists tools to a person; the name is still what you call.
| 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.
Router 0.4.195 re-derived every annotation from one rule each, so several moved. destructiveHint is now false only for a tool that is additive only — one that never overwrites and never removes a value — which is the MCP specification's own definition. So node_access_grant is destructive (it replaces a level) and so is node_grant with no targets (it removes a restriction), while the thirteen tools that only ever add something are not: event_publish, feedback_submit, fs_grep_subscribe, fs_lock, fs_mkdir, fs_subscribe, network_create, network_key_create, node_access_request, org_create, presence_subscribe, register_node and service_publish.
Two more tools moved in router 0.4.196, and both are now destructive: org_invite_create, because the invitation email cannot be unsent, and provision_node, because it starts a machine on paid compute and, on a name a stopped node holds, replaces that node's machine. A client that auto-approves on destructiveHint: false will now stop and ask about those two.
Nine tools are open-world, each because the caller names something outside noBGP that it then reaches: command, command_input and command_subscribe (what runs can reach the internet), fs_fetch_url (a URL you supply), net_reach and net_reach_many (a name or address you supply), org_invite_create (mail to an address you supply), and service_publish / service_update (either can make a service reachable from the internet). Everything else operates strictly on nodes in your own networks.
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.command_readis read-only. It reads output the router already holds and sends nothing to the command, which is why the split matters: under the old singlecommandtool, reading output carried the annotations of a tool that can also write to stdin and kill a process.event_tailis read-only but not idempotent. It touches only your own subscription — advancing its cursor, resetting its idle timer — so a bare repeated call returns the next events rather than the same batch. From router 0.4.84 the events themselves survive being read: naming acursorre-delivers a batch you already saw.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.
A tool whose second call repeats its effect says so
From router 0.4.196 the description of a tool whose effect repeats when the same call is sent again ends with a sentence saying so — on both adapters, since the description is the tool's own — so a client that retries a call whose answer it never received reads the consequence first:
| Tool | A second call |
|---|---|
command | runs the command again — unless it sends the same retry_key, which is what the field is for (router 0.4.199) |
command_input | sends the text to the program again |
command_signal | sends the signal again |
command_subscribe | runs the command again on each node, unless it sends the same subscription while the first run is open |
event_publish | adds a second event |
feedback_submit | files a second report and spends a second slot of the quota |
fs_edit | edits again wherever old_string is still in the file — not safe to repeat blindly |
fs_write | appends the same chunk again when it carries the same id |
network_key_create | makes a second key |
org_create | makes a second organization, or is refused by the limit on the organizations one person owns |
provision_node | starts a second machine under a suffixed name, on paid compute, when the first one's machine is running — read node_name in the first result before sending another |
service_publish | publishes a second service with its own URL |
The list is checked in both directions from router 0.4.199: every tool that is neither read-only nor idempotent either carries one of these sentences or is recorded as one whose repeat is refused or changes nothing — so a tool that repeats cannot ship without saying so. A tool whose second call is refused (network_create on a name that exists) or changes nothing (fs_mkdir of a directory that exists) carries no such sentence. It is prose on the tool, not a new field: idempotentHint above is still the machine-readable half.
Each tool declares the sign-in it needs
From router 0.4.196 every MCP tool descriptor carries _meta["securitySchemes"]: one oauth2 entry naming the scopes the protected-resource metadata publishes — openid, profile, email, offline_access. A client that builds a consent screen from the tool list reads it there rather than inferring it.
⚠ It declares and gates nothing. Authorization is the bearer token on every request, plus your organization role and what you hold on the node — exactly as before. These are the sign-in scopes, not the per-tool scope families, which stay advisory and stay on REST and OpenAPI.
The result envelope
Every tool result on both surfaces is the same envelope, with that tool's own payload nested beside it under one named key (router 0.4.83+). It answers the questions asked of any call — which operation, on what, did it finish — plus two channels a tool uses to say something its own fields cannot. The REST response below carries two more, call_id and duration_ms, which MCP has not sent since router 0.4.196:
{
"call_id": "9e4c…",
"op": "fs_read",
"done": true,
"duration_ms": 41,
"node": { "id": "8f2a…", "name": "pi5" },
"path": "/etc/hosts",
"fs_read": { "chunk_b64": "…", "offset": 0, "size": 221, "file_size": 221, "sha256": "…" }
}
| Field | Always present | Means |
|---|---|---|
call_id | REST only from router 0.4.196 | Opaque identifier for this call. Nothing may parse it. Quote it in a bug report or a log search so the reference names the call rather than a tool and a minute. It is on REST failures too, so the call that went wrong is the one you can name. See the note below for what an MCP caller does instead. |
op | yes | The tool that produced the result — always the tool's own name. The multi-operation tools (file) report which operation ran inside their own payload. |
done | yes | true when the operation is complete. false only from a session tool mid-transfer — fs_read before EOF, fs_write before the final chunk — where the call succeeded but the transfer has not finished. ⚠ It is not a process exit: a command call that returns while the process runs is done: true with command.status: "running". |
duration_ms | REST only from router 0.4.196 | Wall-clock duration of this call, measured on the router across everything it did, including any round trip to the node. |
node / path / service | no | The subject the call acted on, and absent where a tool has no single subject (node_list, whoami). service_publish, service_delete and service_check name both their service and the node that runs it, and service_update does too from router 0.4.206. From router 0.4.201 node and service are each {id, name} — the id to send back on the next call, and the name a person reads — where node used to be a bare id and the service's was service_id (REST keeps service_id, which the nobgp CLI reads). From router 0.4.202 a command_read, command_input or command_signal call names the session's node here, which a continuation never used to. path is the resolved form in the target's own separators, not an echo of your spelling, so the same file has one name whichever tool mentions it. Input stays permissive: both separators are accepted everywhere, and joining fs_mount + "/" + child works on Windows too. |
hint | no | Present only when the result's own fields cannot explain it, and then it names the one thing to try. Absent on every ordinary result, including ordinary failures — so a hint is worth reading rather than a field to skip. See command's. |
notes | no | Caveats on a call that succeeded: something the tool could not honour on this platform (a mode sent to a Windows destination), or something you will need later (deprovision_node's re-adoption note). Absent when there is nothing to say — never a placeholder. |
The payload key is the tool's own name — fs_read, net_peers, command, file, network_directory. Three exceptions: the four subscribe tools share one result type and therefore one key, subscription; command_read, command_input and command_signal answer under command, the session's key, so a client reads one shape across all four (op on the envelope names the tool that produced it); and fs_delete and fs_mkdir carry a payload only when there is an identity to name (router 0.4.89+) — path and done are the whole answer otherwise, which is exactly what a call against a router-served storage tree still returns.
call_id and no duration_ms, from router 0.4.196Both are diagnostics about the call rather than part of its answer, and the assistant-platform review guidelines this surface follows ask that a tool response carry no request ids, trace ids or timings unless what was asked for needs them. The router still measures both on every call: REST (POST /api/v1/tools/*, /actions/*) carries them, as the table above describes, the MCP adapter clears them, and the MCP output schemas do not list them. Every JSON example on this page that shows the two fields is a REST response.
Three things follow for an MCP caller:
- An error carries no
call_ideither, and a plan-limit refusal carries nodetails.upgrade_url— the message still names the limit and the ways out. Error handling. feedback_submitno longer needs you to quote one. Name thetoolyour report is about and the router attaches your last call of that tool itself; name no tool and it attaches your last call of any.call_idis a REST-only parameter there now.- The id still exists, on the router's own log line for the call, so a report can be traced back to it.
Each tool's ### Output Schema is the object under that tool's own key. Read it inside the envelope above — op, done and the subject are there on every one of them and are not repeated per tool.
⚠ done was wrong on the router-served storage trees until router 0.4.90. list, stat, mkdir, delete, lock and unlock all answered done: false on a call that had finished — the value the envelope reserves for a session tool mid-transfer — so the same fs_list came back done: true against a node and done: false against a share, and on the share done contradicted the truncated flag beside it, which was right throughout. Only done was affected: the entries, the bytes, truncated and next_offset were correct the whole time. A read on a tree was never affected — it genuinely pages, so its done: false always meant what it says.
⚠ This replaced a flat response, and it is a breaking change for a client that read fields off the top level. Before 0.4.83 each tool's fields sat alongside duration_ms directly, which is how the same surface came to answer fs_delete with {duration_ms, path} and fs_mkdir with {done, duration_ms, op, path} — a caller had to learn each tool's answer shape separately, and the fields that were common looked accidental. A client holding a cached schema should reconnect; structuredContent carries the new shape from the first call after that.
Result content
A successful MCP tools/call returns:
structuredContent, carrying the full typed response. This is the payload to read.- A one-line human-readable summary, as text.
- A compact JSON copy of the same payload, as a second text block behind a
--- result ---marker.
The JSON copy is back from router 0.4.204. It was removed in 0.4.201 on the grounds that a client reading both halves paid twice for one answer — and a client that reads only text then got the sentence and none of the rows. So it is served again, and the copy is now cut in its values rather than at a byte position when it passes 32 KiB: the longest strings are shortened and items are dropped from the end of the longest arrays, so every other field stays whole and the copy is still valid JSON, with a footer naming what was cut. Before, a byte cut kept the fields ahead of one large value and silently lost truncated, next and chunk_sha256 after it. structuredContent always carries the whole payload whatever the copy says.
⚠ A result that grows with your fleet makes itself fit, on the MCP door only. node_list (router 0.4.201), net_routes and net_peers (router 0.4.199), the four subscribe results and event_tail (router 0.4.204) and the REST-only network_directory (router 0.4.198) are each trimmed to about 30 KiB of JSON, keeping the rows most worth reading, with trimmed saying what was left out and omitted counting it. No other tool trims, and REST never does.
⚠ A trimmed event_tail loses nothing. The events it leaves out stay in the queue and next is the first of them, so calling event_tail again — with no cursor, or with next as cursor — reads them. Before router 0.4.204 the shared cursor had already moved past them and they were gone.
When the organization is over its bandwidth allowance
From router 0.4.106, a Free organization past its included bandwidth has its data traffic stopped until the billing period rolls over or it moves to a paid plan. A paid organization is never stopped — with its credit balance empty, or on a lapsed payment, it is slowed instead, and every tool keeps working.
While an organization is stopped, the calls that move content are refused with resource_exhausted and details.limit_type: "bandwidth"; the message names both ways out. The nodes stay online, so node_list, presence, the net_* diagnostics and node_logs all answer normally.
| Call | While stopped |
|---|---|
command — starting a session | refused (a session already running keeps delivering its output) |
fs_read, fs_write, fs_edit, and file read / write / edit / multi_edit | refused, on a node's disk and on a storage tree alike |
fs_copy | refused on either end, naming which — the same bytes cross the router whichever way they go |
fs_list, fs_stat, fs_delete, fs_mkdir, fs_grep, fs_lock / fs_unlock, renaming | work — the way back to a usable state is never blocked |
fs_fetch_url | works — the node performs that fetch itself and the bytes never cross noBGP |
| Everything that is not file or command bytes | unaffected |
Over a shared drive's HTTPS URL the same stop answers 402 Payment Required on GET, PUT and COPY, and leaves PROPFIND, HEAD, DELETE, MOVE, MKCOL, LOCK and UNLOCK alone. It is deliberately not the storage cap's 507: the storage cap asks does this add stored bytes, this asks does this move content, so a read is refused here and permitted there. Full detail on Plans & Billing.
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:select.nodes,select.labels,watches,wait,timeoutand 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 (
ndoefornode) fails withinvalid_argsinstead of being silently dropped, so a typo surfaces immediately rather than as a call that quietly did the wrong thing. - A field sent at the wrong level is told where it belongs, from router 0.4.187, over
/mcp. The refusal names the field and the level that refused it, as before, and now adds one sentence per misplaced field naming the level the same tool does take it at —nodeis not accepted at the top level: send it infromor into. It fires only where that tool genuinely takes the name somewhere else, so it never guesses; where there are several it names the ones nearest the level you used.fs_copy's per-endfrom/toand the cohort tools'selectobject are the shapes this comes up on — see Where the field goes.
Available tools
What a caller is offered depends on the surface — the Public and Local columns below — and, on the public endpoint, on nothing else but one exception: the two tools that make compute start or run longer, provision_node and task_deadline_set, are withheld from an account whose provisioning has been revoked. From router 0.4.110 that is a revocation check rather than an invite list, so in practice every caller sees the whole public list — see Who may provision. deprovision_node and task_stop are offered unconditionally, including to a revoked account, so nothing can be left running and billing with no way to stop it.
| Tool | Public | Local | What it does |
|---|---|---|---|
| This node | |||
| status | — | ✓ | This node's own agent status, answered on the box |
| resolve | — | ✓ | Resolve a peer name in this node's overlay zone |
| Discovery | |||
| node_list | ✓ | ✓ | Every node you can see, in any network or none — call this first |
| network_list | ✓ | ✓ | Your networks, with how many nodes each has |
| call_list | ✓ | ✓ | The calls your own session made: what, where, when, as which identity |
| network_directory | ✓ | — | Networks, their nodes and their services (REST/OpenAPI only) |
| Network management | |||
| network_create | ✓ | — | Create networks |
| network_update | ✓ | — | Rename a network, or change its channel, isolation, access or "members may add nodes" |
| network_delete | ✓ | — | Delete networks |
| network_key_list | ✓ | — | List a network's enrollment keys, without their secrets |
| network_key_create | ✓ | — | Mint a new enrollment key |
| network_key_update | ✓ | — | Rename, disable or enable one key |
| network_key_delete | ✓ | — | Delete one key |
| Organization management | |||
| org_create | ✓ | — | Create an organization |
| org_invite_create | ✓ | — | Invite a person to an organization by email |
| org_update | ✓ | — | Rename an organization (REST/OpenAPI only) |
| org_audit_list | ✓ | — | Read the organization's audit log (REST/OpenAPI only) |
| org_support_access | ✓ | — | Read or change whether noBGP staff may act in the organization (REST/OpenAPI only) |
| org_sso_setup | ✓ | — | Mint a WorkOS Admin Portal link (Owner only, REST/OpenAPI only) |
| org_sso_set_enforced | ✓ | — | Turn SSO login enforcement on or off (Owner only, REST/OpenAPI only) |
| Node management | |||
| provision_node | ✓ | — | Create compute resources |
| deprovision_node | ✓ | — | Remove compute resources |
| task_stop | ✓ | — | Stop a running machine and keep its node |
| task_deadline_set | ✓ | — | Give a running machine more time, or less |
| register_node | ✓ | — | Generate install commands for existing machines |
| node_delete | ✓ | — | Remove a node, deprovisioning it first when it was provisioned |
| node_rename | ✓ | — | Change a node's name and nothing else |
| node_transfer | ✓ | — | Give a node to another member of its organization |
| node_label | ✓ | — | Set, remove, or read a node's labels |
| node_channel_set | ✓ | — | Set a node's release channel |
| node_location_set | ✓ | — | Set where a node is, when its address places it wrong |
| node_location_clear | ✓ | — | Go back to the place derived from its address |
| node_grant | ✓ | — | Turn a node's own local MCP server on |
| node_revoke | ✓ | — | Turn it off |
| node_config_get | ✓ | — | Read a node's own agent settings, over the control channel |
| node_config_set | ✓ | — | Change them, over the control channel |
| Who may use a node | |||
| node_access_list | ✓ | — | Who may use a node: its setting, whether it is shared, its entries, its pending requests |
| node_access_set | ✓ | — | Follow the networks' access, or private |
| node_access_grant | ✓ | — | Name a member on a node's access list, at use or root |
| node_access_revoke | ✓ | — | Remove a member's entry |
| node_access_request | ✓ | — | Ask to use a node, or every node of a network |
| node_access_decide | ✓ | — | Grant or refuse one request, in one step |
| Service publishing | |||
| service_publish | ✓ | — | Expose services publicly |
| service_update | ✓ | — | Modify service settings |
| service_delete | ✓ | — | Remove services |
| service_share | ✓ | — | Manage authorized email lists |
| service_check | ✓ | — | Ask whether a published service's backend actually answers |
| Command execution | |||
| command | ✓ | ✓ | Start a command on a node and wait for its first output |
| command_read | ✓ | ✓ | Read more output of a session, and learn whether it has exited |
| command_input | ✓ | ✓ | Write to a session's stdin, close it, or both |
| command_signal | ✓ | ✓ | Send a signal to a session's process |
| Filesystem | |||
| file | ✓ | — | Unified file super-tool, thirteen ops (REST/OpenAPI only) |
| fs_read | ✓ | ✓ | Stream a file from the agent |
| fs_write | ✓ | ✓ | Stream a file to the agent |
| fs_edit | ✓ | ✓ | Atomic diff-style edits |
| fs_list | ✓ | ✓ | Directory listing |
| fs_stat | ✓ | ✓ | Single-entry metadata |
| fs_delete | ✓ | ✓ | Remove a file or directory |
| fs_mkdir | ✓ | ✓ | Create a directory |
| fs_copy | ✓ | ✓ | Copy a file, or a set of files, from one machine to another in one call |
| fs_fetch_url | ✓ | ✓ | Make a node fetch an HTTP(S) URL itself and store it as a file |
| fs_lock | ✓ | ✓ | Take or renew an exclusive lock on one path of shared storage |
| fs_unlock | ✓ | ✓ | Release the lock you hold on one path of shared storage |
| fs_grep | ✓ | ✓ | RE2 search across one node's files |
| Diagnostics | |||
| net_peers | ✓ | ✓ | Agent's peer directory |
| net_interfaces | ✓ | ✓ | Host network interfaces |
| net_metrics | ✓ | ✓ | Agent Go runtime counters, a host block for the machine, and dropped-packet counters |
| net_routes | ✓ | ✓ | Kernel routing table (Linux) |
| net_dns | ✓ | ✓ | Resolver configuration (Unix) |
| net_reach | ✓ | ✓ | Resolve a name on one node and probe what it resolved to |
| net_reach_many | ✓ | ✓ | The same question asked of every node a selector matches, with a rollup |
| node_logs | ✓ | ✓ | The node's own agent log, by whatever mechanism that node has |
| Event bus | |||
| fs_subscribe | ✓ | ✓ | Watch files and directories across a cohort |
| command_subscribe | ✓ | ✓ | Dispatch a command across a cohort and collect reports |
| presence_subscribe | ✓ | ✓ | Node online / offline / registered transitions |
| fs_grep_subscribe | ✓ | ✓ | RE2 search across a whole cohort |
| event_tail | ✓ | ✓ | Read pending events (and keep the subscription alive) |
| event_unsubscribe | ✓ | ✓ | End a subscription and stop the work |
| event_publish | ✓ | ✓ | Publish your own event into a subscription |
| event_subscriptions | ✓ | ✓ | List your live subscriptions |
| Identity | |||
| whoami | ✓ | ✓ | Caller identity, organizations, whether you may run as root, and the tool list this router offers you |
| Feedback | |||
| feedback_submit | ✓ | ✓ | Tell us where this tool surface fought you |
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, since, offline_for_secs — the last two agent 0.4.96+ — transport — quic or wss — QUIC endpoint and pin details, fallback and reconnect counters); network (local_ip, gateway_ip, tun_device, domain, dns, an optional overlay, ipv6, from agent 0.4.132 either slice6 — this node's IPv6 overlay slice — or overlay6 saying why it has none, 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 — and from agent 0.4.153 local_mcp, the machine owner's own off switch); allow (tools, roots, root, root_source, without_root or without_root_refusal, an optional without_root_is_administrator, an optional tools_invalid, and an optional warning); optional fs mount state, an optional host_services block and an environment block. From agent 0.4.128 network also carries lan_names — the LAN names this node serves the network, [] when none — and mdns_announce, the state of the local-only announcer; from agent 0.4.153 it also carries peer_loopback, the owner's veto on peer reach into loopback. host_services is what this node reports about its own host's file sharing, screen sharing and remote login, and whether its owner turned that reporting off.
⚠ The four allow fields renamed in agent 0.4.153, with the setting behind them: admin → root, unelevated → without_root, unelevated_refusal → without_root_refusal, unelevated_is_admin → without_root_is_administrator. Every old name is still sent, with the same value, so a client reading one keeps working.
The targets[].node_id values are the handles every proxied tool takes.
network.overlay is present only when this node has no overlay at all (agent 0.4.112+), and its presence is the finding — the value says why: no /20 was free in any of the ranges the agent can take a slice from, the machine's routing table could not be read, or — on a Linux node from agent 0.4.142 — the host refused the tunnel adapter, which is a container or pod given no NET_ADMIN capability or no /dev/net/tun. Such a node comes up deliberately without a TUN interface, without overlay DNS and without peer traffic, and this endpoint answers because the control channel is exactly what the mode exists to keep: command, the file tools and this snapshot all work, while nothing reaches a peer. Read it before reading an absent gateway_ip or tun_device as a fault of their own, and before reading dns: unavailable as an ordinary DNS problem; local_ip is the host's own LAN address and is still reported. Through agent 0.4.111 a node in this state exited instead and answered nothing at all; a macOS or Windows node whose tunnel adapter is refused still does, from agent 0.4.143, because that refusal can be temporary there. See nobgp status for the causes in full and their remedies.
router.connected is trustworthy from agent 0.4.96. Before that release it reported whether a connection object existed rather than whether the link was carrying, and nothing ever cleared it — so once a node had connected once it answered true for the rest of the process, including throughout a total outage. From 0.4.96 it is what the control loop last observed, since says when that state began (RFC 3339 UTC, so it can be compared against the same node's offline_at while the link is down, or its online_at while it is up, in node_list (with view: "full")), and offline_for_secs is present only while the link is down. Detection is bounded by the keepalive, so a silent death can take up to about a minute to appear here. ⚠ On wss that bound needs agent 0.4.176: something terminating the WebSocket — a corporate egress gateway, an inspecting proxy — answers the agent's keepalives on its own, so before that release this could read true indefinitely on such a path while the router had long since published the node offline. See A Node Reads Connected While Nothing Reaches It.
allow answers the identity question for this node (agent 0.4.42+), which matters here because it needs no call through the router. From router 0.4.201 node_list with view: "full" also reports user and allow_root on the local surface, for the nodes this node's grant reaches; this one answers for this node itself:
without_rootis what aroot: falseoperation runs as, resolved by the same gate the real call uses — not the string in the config file.without_root_refusalappears in its place when work without root cannot run here, which means every call on this node runs as root: omitroot, or sendtrue— unelevated never means root.without_root_is_administrator(agent 0.4.46+) appears only whentrue, and says that account is itself administrative — a member of the localAdministratorsgroup on Windows — soroot: falsehere drops from the ambient identity to Administrator and no further. It is the on-box half ofnode_list'suser_is_admin. Absent is notfalse: a node that did not check says nothing.allow.root: falseis the other half: root is refused too. With both refused, nothing runs remotely on the node.root_source(agent 0.4.153+) names which key the value was read from, which is what tellsallow-rootfrom the oldallow-adminspelling.tools_invalid(agent 0.4.153+) appears only whentrue: the profile'sallow-toolsnames something that is not a domain. The value is dropped and, with nothing valid left, the node serves nothing.
For a peer, the rule stands in for the lookup: choose by what the work needs and let the refusal name its one retry.
Served by the agent itself, never proxied. Both local tools are pure reads of on-box state and advertise that (readOnlyHint, idempotentHint, closed world), so a client that auto-approves on hints does not stop to ask about them.
resolve
Surfaces: local only
Resolve a peer name in this node's overlay zone.
Parameter: name (string, required — bare or fully-qualified in this node's zone; names under other zones are refused).
Response: name (normalized), node_id, address (a node-local overlay handle — never valid on another machine), fqdn, found. found: false is an authoritative miss; found: true with an empty address means no data yet, and is retryable.
node_list
Surfaces: public · local
List the nodes you can see. Router 0.4.201+, and this is the discovery tool on MCP — it replaces network_directory, which stayed on REST.
Input Schema
{
"select": "object (optional; the cohort selector — nodes, network, labels, os, platform, arch, name, online, owner, lan)",
"view": "string (optional: health | full)"
}
Parameters:
select- Which nodes. Each field narrows and they AND together — the same selector the cohort tools take. Omit it for every node of your organizations, online or not.lan(router 0.4.207+) takes one node and keeps the nodes on that node's LAN segment, that node included.view- Which columns. Omit it for the short row:id,name,online,offline_at,networks,os.healthadds the node's latest stored health sample,stale,version_status,agent_version(router 0.4.206+),closes(connection closes in the last 24 h) and arollup.fulladds the machine, its place, its mount, its identity, its agent version,my_access, its connection and its services.lanis on a row in every view (router 0.4.207+).
Output Schema
{
"node_list": {
"common": { "os": "linux", "org": "…" },
"rollup": { "matched": 14, "online": 12, "stale": 1, "below_floor": 0, "closed": 2, "relayed_lan_pairs": 3, "duplicate_hostnames": [] },
"nodes": [ { "id": "…", "name": "pi5", "online": true, "lan": "192.168.1.0/24", "networks": [ { "id": "…", "name": "default" } ] } ],
"lans": {
"192.168.1.0/24": {
"subnet": "192.168.1.0/24",
"public": "203.0.113.7",
"basis": "gateway | inferred",
"members": 4,
"others": ["node-uuid (on this segment, not listed here)"],
"omitted": 0
}
},
"places": {},
"locking": {},
"trimmed": "string (only when the result was too large for a model client)",
"omitted": "number (rows left out because of the size)"
}
}
Behaviour
- A node needs no network. Nodes are found by their organization (the tenant), so a node in no network is listed like any other — which is why this tool, and not a network listing, is the thing to call first.
- Absent means the default. A field that is empty,
false, zero or the default is left out of a row, and a value every row shares is said once incommonrather than repeated per node. Readcommonbefore concluding a row says nothing aboutosornetworks. view: "health"answers "which machine is in trouble" in one call: readrollupfirst.unavailablesays why a value is absent where that is not obvious —windowsforload1,no_sampleorhost_rebootedfor the whole sample.view: "health"also carriesagent_version, from router 0.4.206, so which nodes are still on the old agent? is answered by the view that fits a whole fleet rather than byview: "full", which a large fleet trims. A version every row shares is said once incommon, like any other shared value.- It reads what the router already stored and runs nothing on a node, so it costs no agent round trip and works for offline nodes.
- A large fleet fits itself on the MCP door: rows that need attention are kept,
trimmedsays what the result leaves out andomittedcounts it. Narrow withselect, or dropview, to read the rest. Nothing is trimmed over REST. orgis noBGP staff only and audited; for everyone else it isforbidden.
Which of your nodes are on one LAN
Router 0.4.207 groups the nodes by LAN segment, so which machine can reach that printer, that NAS, that mDNS speaker? is answered without probing anything. It costs no extra read and is in every view.
- A row carries
lan, a key into thelanstable, when the node shares a segment with another node you can see. It is absent when the node is alone on its segment, or when the router does not have both of its addresses — never read an absentlanas this node is on no LAN. lansnames each segment once:subnet(the subnet of the nodes' own addresses),public(the address they connect from — an IPv6 one as its/64),members(how many nodes you can see are on it, the listed rows included),others(the ids of the ones this answer does not list, at most 20) andomitted(how many moreothersleaves out). To list every member, call again withselect.lanset to any node on the segment — one of the rows that named it, or one of theothers.basissays how sure the grouping is, and read it before acting on it:inferred— they share one public address and their own addresses fall in one/24(or/64). Two homes behind one carrier NAT, or Docker hosts on the default bridge at two sites, can read as one segment this way, so confirm with a probe before relying on it. It is what a segment reads whenever any of its nodes does not report a gateway.gateway— the nodes report one default gateway and one subnet, which is exact. It needs the node's agent to report its default gateway, which agent 0.4.184 does: it sends the prefix length of its own IPv4 address and that gateway's address and MAC, and noBGP keeps a keyed hash of the MAC rather than the MAC itself, so two homes behind one carrier NAT on the same private range stay apart. Expect this value only where every node on the segment has reached that release.- A
gatewaysegment and aninferredone are never merged, so while a fleet upgrades a reporting node and a silent one on the same LAN are listed apart. That is the conservative answer rather than a wrong one.
rollup.relayed_lan_pairs(view: "health") counts the pairs of nodes inlansthat share a segment. Traffic between them goes through the router today, also on one LAN, so it is a count of paths a direct one would shorten — not a fault.- From a node's own local server, only the nodes in the answer are compared, so
membersandothersthere describe the answer rather than the whole fleet.
net_peers answers the same question from one node's point of view.
network_list
Surfaces: public · local
List the networks of your organizations, with how many nodes each one has. Router 0.4.201+.
Input Schema
{}
Output Schema
{
"network_list": {
"common": { "org": "…" },
"networks": [ { "id": "…", "name": "default", "nodes": 14, "online": 12 } ]
}
}
Each row carries id (send it as network), name, access (private when the network is not shared with its organization; absent means the org default), nodes and online. org is noBGP staff only and audited.
call_list
Surfaces: public · local
List the calls that this session made, newest first: what each call did, on which node, when, as which identity, and how it ended. Router 0.4.201+. Use it to show a person what an AI client ran for them, from the router's audit log rather than from the client's own account of it.
A session is your current sign-in; through a node's local MCP, it is that node. No call of another session is listed, even one of yours, and none from before router 0.4.201. The organization's full audit log is org_audit_list (Owner or Admin, REST only).
Input Schema
{
"node": "pi5",
"action": "node.command",
"since": "2026-10-22T09:00:00Z",
"limit": 50,
"cursor": "…"
}
Every field is optional. node (an id or a name) and action narrow the list; action is spelled as the entries spell it (node.command, node.fs_write). since is RFC3339 and defaults to 24 hours ago. limit defaults to 50, at most 200. cursor is the next of an earlier result.
Output Schema
{
"call_list": {
"entries": [
{
"at": "2026-10-22T21:40:12.512Z",
"action": "node.command",
"org": "…",
"node": { "id": "…", "name": "pi5" },
"root_used": true
}
],
"next": "…",
"trimmed": "string (only when the page was too large for a model client)"
}
}
action:command,command_read,command_inputandcommand_signalare allnode.command; eachfs_*tool isnode.<tool name>.nodeis the node the call addressed. Itsnameis absent when you cannot see the node now. A call on something that is not a node hastarget_type(network,service,memberororg) andtarget_idinstead.root_usedis present on a call that ran as root on the node (LocalSystem on Windows). It is absent otherwise, which covers both an unelevated call and a row with no identity in it: a call that ran nothing on a node, one refused before its identity was resolved, acommand_read,command_inputorcommand_signal(thecommandcall has it), and the cohort tools. An entry names no account — the account a call ran as is on the call's own result asuser, and the audit row does not keep it.codeis the error code of a refused or failed call,deletedmarks a target that is deleted now, andprivilegedmarks a call that noBGP staff made outside their own role.trimmedappears over MCP when the page did not fit what a model client accepts: the newest calls are kept,trimmedsays so, andnextpoints at the first one left out — so sending it ascursorreads the rest rather than skipping it. Nothing is trimmed over REST.- ⚠ No command text and no argument is kept, so none is returned.
Listed: command and the command_* tools, every fs_* and net_* tool, node_logs, node_config_get, node_config_set, network_key_list, node_access_list, the subscribe tools, and every change except event_publish and event_unsubscribe. Not listed: whoami, node_list, network_list, service_check, event_tail, event_subscriptions, a service_share list of a terminal that is not root, and a call refused before it named an organization.
A sign-in with no session id is refused with forbidden: sign in again with a client that gets a session.
network_directory
Surfaces: REST/OpenAPI only
List networks, nodes, and published services accessible to the authenticated user.
On MCP the listing tools are node_list and network_list: a node does not need a network, so a listing keyed on networks could not answer "which nodes do I have". network_directory stays at POST /api/v1/tools/network_directory and /actions/network_directory, which is where nobgp/app and the nobgp CLI read it, and it keeps its own input names. ⚠ Its result names changed in router 0.4.201 like every other result: info.memory (was total_memory), info.disk (was total_disk), gpu.memory (was memory_bytes), health.mem_available, health.disk_free and health.home_disk_free (each without its old _bytes), each network's omitted_nodes (was nodes_omitted), and no allow_admin.
Calling it over MCP with an older tool list now tells you what to call instead, from router 0.4.204: unsupported, naming node_list and network_list, saying the call did not run, and telling you to refetch the tool list (in most clients, reconnect). Before, it came back as a bare unknown tool with no next step. Nothing is translated and nothing runs — this is a better refusal, not a compatibility layer. file answers the same way, and where the call carried an op the refusal names the one fs_* tool for it.
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)",
"os": "string (optional: linux | darwin | windows)",
"min_cpu_count": "number (optional)",
"min_total_memory": "number (optional, bytes)",
"gpu_kind": "string (optional)",
"has_gpu": "boolean (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:*,?and[…]character classes. Brace expansion is not part of that syntax —{web,db}*is refused withinvalid_argsnaming the cause rather than matching a node literally named{web,db}…, which is what it used to do, and it read as "none of your nodes are online". List the alternatives, or use a broader pattern and filter the result. Malformed patterns returninvalid_args.hostname_glob- Shell-glob matched against each node's reported hostname.platform_glob- Shell-glob matched against each node'splatformstring (e.g.raspbian,ubuntu,darwin,openwrt,synology), compared case-sensitively. ⚠ That is the free-form name a node calls itself, not its OS family:platform_glob: "linux"matches nothing on most fleets rather than every Linux node, and"windows*"matches nothing at all, because a Windows node's platform string starts withMicrosoft. To select an OS family useosbelow.os- Router 0.4.187+. Keep only nodes whoseinfo.osequals this, compared without regard to case:linux,darwinorwindows. This is the OS family — the filter for "my Windows nodes" — and it takes the same vocabulary as theosaxis of a node selector, so a value that picks a cohort there lists the same nodes here. It is an exact value, not a glob: anything else,macosand a pattern included, isinvalid_argsnaming the three families, rather than an empty result that reads as none of your nodes run that OS. ⚠ A node that has never connected reported no metadata, so it has noosand never matches.min_cpu_count- Keep only nodes whoseinfo.cpu_countis at least this many logical CPUs. Router 0.4.177+. Omit or0for no filter; a negative value isinvalid_args.min_total_memory- Keep only nodes whoseinfo.memoryis at least this many bytes — 16 GiB is17179869184. Router 0.4.177+. Omit or0for no filter.gpu_kind- Keep only nodes whoseinfo.gpu.kindequals this, compared case-insensitively:cuda,rocm,metal,rknpu,none, or any other value a node reports. Router 0.4.177+. The vocabulary is the node's and is open, so a value this router has not seen is matched, not refused.nonematches nodes that probed and found nothing.has_gpu-truekeeps only nodes that reported an accelerator (agpublock whosekindis notnone);falsekeeps only nodes that probed and reportednone. Router 0.4.177+. Sending it alongside a contradictinggpu_kind—truewith"none", orfalsewith any other kind — isinvalid_argsrather than an empty result.brief- Whentrue, omit each node'sservicesarray, itsinfo.host_services(router 0.4.155+ — the other per-node list), and thedetailsentence insideinfo.fs_locking(router 0.4.62+) — that sentence is a constant of the node's backend and platform, so it repeats verbatim across a fleet.fs_locking.scopeandfs_locking.honestsurvive, so the dangerous case is still flagged. Shrinks responses sharply for "is X online?" probes. ⚠ It trims those three things only and is not a general make-it-smaller knob:info.health,info.upgrade,info.fs_mount,info.mount_readable,info.writable_roots, the node'sconnectionblock (router 0.4.199) and the capacity fields (info.cpu_count,info.memory,info.gpu) all survive it, so the compact call is still the one that answers "which node is under pressure" — and, from router 0.4.144, "which node could take this work" — for a whole fleet. From router 0.4.198 the MCP door applies this step itself, and more, when a full result would be larger than a model client accepts; see When the result is larger than a model client accepts.
⚠⚠ A node that did not report the field never matches a capability filter, in either direction (min_cpu_count, min_total_memory, gpu_kind, has_gpu). A node that has never connected reports no cpu_count, and a node whose agent predates the GPU probe reports no gpu block at all — so has_gpu: false does not mean no accelerator, it means probed and found none. Read an empty result as none reported, not none exist: letting an unreported node through a minimum would promise capacity nobody claimed, and letting it through has_gpu: false would call a Mac on an old agent GPU-less. The cost of that rule is that a filtered call can hide a machine that would have qualified — when a fleet is mid-upgrade, call without the filter and read the capacity fields yourself.
⚠ There is deliberately no free-disk filter. Free disk is an info.health level rather than a capability, and health is absent fleet-wide for up to a minute after a noBGP deployment — a disk filter would answer none of your machines for that minute, which is the quiet empty answer these refusals exist to prevent.
A network no node matched is omitted entirely. Once online, os, any glob or any capability filter is set, a network whose nodes all fail the filter drops out of the response rather than appearing with an empty nodes array — a glob aimed at one node used to return every network you belong to, all but one of them empty, and you paid context for each. Two deliberate exceptions:
- A network you named with
network_nameis returned even when the other filters leave it empty. Its presence is the answer that the network exists and simply holds no matching node; a name you don't own is already a distinctnot_found. briefon its own prunes nothing. It narrows what is reported about a node, never which nodes match, so abriefcall still lists every network.
Output Schema
{
"networks": [
{
"id": "string",
"name": "string",
"org_id": "string",
"files_url": "string (WebDAV base URL for this network's shared drive)",
"nodes": [
{
"id": "string",
"name": "string",
"online": "boolean",
"online_at": "string (RFC3339 UTC; when the current connection began — re-stamped on every reconnect except a planned one from router 0.4.200, and on a disconnected node it holds the start of the last session)",
"offline_at": "string (RFC3339 UTC; absent while the node is online, and for one that has never connected)",
"connection": {
"closes_24h": "number (closes the router recorded in the last 24h, up to 1000; absent = the history could not be read)",
"rotations_24h": "number (how many of those closes were planned reconnects — not counted in closes_24h; absent means none)",
"last_close_at": "string (RFC3339 UTC; when the router gave up on that connection)",
"last_close_reason": "string (the router's own words for how it ended; absent when it recorded none)",
"last_seen_at": "string (RFC3339 UTC; when the router last heard from the node on that connection)",
"host_restarted": "boolean (true = the host restarted or lost power across that close; absent = cannot say)",
"node_reason": "string (how the node itself says that connection ended; absent when it has not said)"
},
"info": {
"hostname": "string",
"agent_version": "string",
"version_status": "string (current | behind | ahead | below_floor | empty)",
"channel_version": "string",
"upgrade": {
"offered_version": "string (always the channel's current target — the version this node was offered and has not installed)",
"first_offered_at": "string (RFC3339 UTC)",
"last_offered_at": "string (RFC3339 UTC; absent when the router has no record of it)",
"offers": "number (how many times it has been offered; absent when the router has no record of it — a missing offers is not zero offers)",
"outcome": "string (the node's own code for why it did not install — absent unless the node said)",
"reason": "string (the node's own text; absent with outcome)",
"reported_at": "string (RFC3339 UTC; absent with outcome)"
},
"os": "string (linux | darwin | windows — the OS family; absent for a node that never reported metadata)",
"platform": "string (free-form: a distribution or product name)",
"platform_version": "string",
"kernel_arch": "string",
"build_architecture": "string (router 0.4.200+; the agent build this node runs — arm64, amd64, armv7, armv6, armv5, mips, mipsle, riscv64, 386 — and what a selector's arch matches; absent when the node has not reported it)",
"hardware_model": "string (absent when the node can't name its hardware)",
"city": "string (the city of the node's public address, approximately — or the place a controller set)",
"state": "string (ISO 3166-2 subdivision without the country, e.g. CA)",
"country": "string (ISO 3166-1 alpha-2, e.g. US)",
"latitude": "number (approximate; read it with accuracy_radius)",
"longitude": "number (approximate)",
"accuracy_radius": "number (kilometres — the radius the address is probably inside; 1 for a place a person set)",
"location_source": "string (router 0.4.200+: address | set — where the six place fields above came from; absent when the node has no place at all)",
"asn": "number (autonomous system number of the public address, e.g. 14593)",
"as_org": "string (that AS's organization as the registry records it, e.g. SPACEX-STARLINK)",
"isp": "string (the provider's brand name, e.g. Starlink)",
"connection_type": "string (Cable/DSL | Cellular | Corporate | Satellite)",
"cpu_count": "number (logical CPUs the machine has, SMT threads included; absent when the node hasn't reported it)",
"memory": "number (bytes of RAM the machine has; absent when the node hasn't reported it)",
"disk": "number (usable size in bytes of the filesystem health.disk_free measures — used plus available, which is what df divides by; absent below agent 0.4.144)",
"gpu": {
"kind": "string (none | cuda | rocm | metal | rknpu | a value newer than this page)",
"count": "number (devices of that kind reachable from the agent; absent when the node didn't say)",
"memory": "number (a discrete card's DEDICATED VRAM, PER DEVICE and not a total; absent on a unified-memory machine, and when nothing measured it)",
"unified": "boolean (true = the accelerator draws on the same memory as memory; false = it has its own pool; absent = nobody established it)"
},
"fs_backend": "string (nfs | fuse | winfsp | webdav | off | error; absent when the node hasn't said)",
"fs_mount": "string (where the node mounted it, in a form you can join a child path onto; absent when nothing is mounted)",
"mount_readable": "boolean (whether the AGENT can read that mount; absent = never probed)",
"writable_roots": ["string (the directories under fs_mount you may actually write to)"],
"fs_locking": {
"scope": "string (none | node | cluster)",
"honest": "boolean (false = an unenforceable lock reports success anyway)",
"detail": "string (one sentence; omitted when brief=true)"
},
"health": {
"cpu_percent": "number (0–100 across all cores)",
"mem_available": "number (memory available to start new work; a node below agent 0.4.144 reports free memory, a much smaller number)",
"disk_free": "number (free space where the agent is standing; absent when the node didn't measure it)",
"home_disk_free": "number (router 0.4.176+ with agent 0.4.147+; free space where an unelevated command runs — absent when the node didn't measure it)",
"load1": "number (absent on Windows)",
"at": "string (RFC3339 UTC — the sample is up to 60s old)",
"stale": "boolean (true = the node kept talking and the sample did not refresh; absent = current, or unknown)",
"temp_c": "number (agent 0.4.180+, Linux; the hottest thermal zone, °C)",
"throttled": "number (agent 0.4.180+; the Raspberry Pi firmware's throttling bits)",
"failed_units": "number (agent 0.4.180+; systemd units in the failed state)",
"failed_unit_names": ["string (up to 8 of them)"],
"updates_pending": "number (agent 0.4.180+, dpkg hosts; packages with a newer version in the local index)",
"package_index_at": "string (RFC3339 UTC; when that index was last written)",
"ntp_synced": "boolean (agent 0.4.180+, Linux; the kernel's own clock-sync state)",
"ntp_offset_ms": "number (agent 0.4.180+; the offset the kernel is correcting)",
"probes_at": "string (RFC3339 UTC; when the probes above were measured)"
},
"user": "string (may be empty; absent on older agents)",
"allow_root": "boolean (absent on older agents)",
"user_is_admin": "boolean (absent unless the node checked)",
"virtualization": "string",
"bot": "string (the AI-agent sandbox this machine is; present and empty = it looked and is not one; absent = it did not look)",
"cloud": "string (the cloud this machine is an instance of, read from its own firmware; empty = not one; absent = did not look)",
"tailscale": "string (running | installed | empty for neither; absent = the node did not look)",
"host_services": [
{
"type": "string (DNS-SD service type, e.g. _smb._tcp)",
"port": "number",
"name": "string (the instance name the host announces; may be absent)",
"source": "string (mdns | listener; absent when the node didn't say)"
}
]
},
"my_access": "string (control | root | use | none — what YOU hold on this node; absent on a node's local MCP)",
"access": "string (follow | private — the node's own sharing setting)",
"shared_with_org": "boolean (true = every member of the node's organization may use it)",
"org_id": "string (the organization that owns this NODE — its tenant, which every gate on it asks)",
"owner": "string (user id of the node's owner, who controls it)",
"task_id": "string (provisioning UUID, if applicable)",
"task_deadline_at": "string (RFC3339 UTC — when this node's compute stops on its own)",
"services": [
{
"id": "string",
"title": "string",
"url": "string",
"public_url": "string",
"command": "string"
}
]
}
],
"omitted_nodes": "number (MCP only; nodes of this network left out for size — absent means none)"
}
],
"trimmed": "string (MCP only; present when the full result was larger than a model client accepts — says what it leaves out)",
"omitted_networks": "number (MCP only; networks left out at the end for size — absent means none)"
}
The network's org_id is the organization that owns the network. It is the join key for whoami's orgs[], so a caller who holds different roles in different organizations reads can_root off the orgs[] row whose id matches it.
⚠ Each node carries its own org_id and owner, and the node's is the one every gate asks (router 0.4.179): a node's tenant is the organization that owns the node, never the organization of a network it happens to be attached to, and its owner is the member who controls it. Read the node's pair, not the network's, when you want to know who decides something about that machine.
What you hold on each node: my_access
From router 0.4.179 each node carries my_access — exactly what the router's gates will answer for you on that node, so one directory call replaces a refusal per node:
my_access | You may |
|---|---|
control | Use it, be root on it, and change it: you are its owner, or an Owner/Admin of its organization |
root | Use it, and run as root — a root entry on its access list |
use | Run commands, read and write files, read its telemetry, as its configured account. Its own agent log needs control from router 0.4.183 |
none | See it and nothing more. Ask with node_access_request |
root and control are still capped by the node's own allow-root, which is reported beside them in info. my_access is absent for a call through a node's local MCP, which is bounded by its grant instead.
access and shared_with_org beside it are the node's sharing state — its own setting, and whether it currently resolves to shared once every network it is in is taken into account.
files_url is the network's shared drive as a URL — https://files.nobgp.com/networks/<network-id>/. It is a WebDAV endpoint: PROPFIND to list, GET to download, PUT to upload, MKCOL / MOVE / COPY / DELETE to manage, authenticated with the same bearer token that authenticated this call and carrying the same access. Router 0.4.54 is the release that made it resolve: earlier routers advertised a name-keyed form that had never served anything.
It is keyed on the network id, not its name, and that is not cosmetic — the same network name can appear in more than one of the organizations a caller belongs to, so a name-keyed URL answers differently depending on who opens it. Each node also has a storage area of its own at https://files.nobgp.com/nodes/<node-id>/, separate from every network's share; the directory advertises no URL for it, so build it from the node's id in this same response. Reaching a node's area needs control of the node — its owner, or an Owner/Admin of the organization that owns it — a step above the network share and above the fs_* tools on a node's disk, since a write landing there passes none of the node's own owner vetoes. Holding use of the node is not enough.
The URL is for bytes; the tools are for everything else (router 0.4.56+). Both trees are also addressable from file and the fs_* tools without an HTTP client and without going near a node — share for a share, node + storage: true for a node's area — at the same two authority tiers. See Addressing storage instead of a node; the URL is still what streams a file past the tools' 1 MiB per-call ceiling.
The info.virtualization field is emitted as system/role (e.g. docker/guest, kvm/host) and is omitted on bare-metal nodes. The info block is absent for nodes that have never connected.
info.os is the OS family, and it is the one to branch on — info.platform is not. os is the agent's own build target, so it is a closed set of three values — linux, darwin, windows — and it is what every platform-conditional behaviour on this surface keys off. platform is what the node calls itself: a distribution or a product name, free-form, and measured to differ from the OS family on 13 of 14 nodes of a real fleet — raspbian, openwrt, ubuntu, Microsoft Windows 11 Pro. A predicate comparing platform to "linux" or "windows" therefore matches nothing and reports it as no nodes, which reads like an answer.
Read platform for what os cannot tell you — which of two Linux nodes is a Pi and which is an OpenWrt router, what to quote in a bug report, what to show a person. Both are absent for a node that has never reported metadata.
⚠ The platform_glob filter matches platform, the free-form one, so a glob is the wrong instrument for "every Linux node". From router 0.4.187 this tool takes a real os filter for that — linux, darwin or windows, the same vocabulary as the os axis of a node selector. On an older router, filter on info.os in the response yourself.
online_at is which session you are looking at (router 0.4.108+). online: true is equally true of the connection you just ended and have not yet lost, so the boolean alone cannot tell you a rebooted, upgraded or restarted node has genuinely come back. online_at is RFC3339 in UTC and names the moment the node's current connection began; it is re-stamped on every reconnect, so an online_at later than the moment you acted is the new session rather than the one you were about to lose. No agent release is involved — the router has always recorded it per session, and this only reports it.
⚠ A planned reconnect does not move it, from router 0.4.200 with agent 0.4.178+. A node that replaces its own connection before something in the path cuts it never went away, so online_at keeps the start of the interval the node has genuinely been up for — which is what you want to measure, and what a node rotating several times an hour used to reset each time. connection.rotations_24h is where those reconnects are counted.
⚠ It is not a liveness flag, and it survives going offline. online is "there is a start and no end", so on a disconnected node online_at still holds the start of the last session — paired with offline_at, that gives you how long that session lasted. Read it with online, never on its own: taken for liveness it is right about every node that happens to be up and wrong about exactly the ones you are asking after. It is absent only for a node that has never connected.
⚠ For a node you are about to disrupt, presence_subscribe is still the better instrument — it tells you when the connection changes instead of making you ask again, and there is no replay, so subscribe before you cause the disruption.
offline_at is how long it has been down (router 0.4.50+). online: false is the same two bytes for a node dead six months and one that blipped ten seconds ago, so the question most often asked of the directory — is this thing coming back? — could not be answered from it. offline_at is RFC3339 in UTC and names the moment the node's most recent connection ended; it is cleared on every reconnect, so it is never the first time the node ever dropped. Read it before telling someone a node is down, and before deciding one is worth waiting for.
It is absent whenever there is no such instant — the node is connected right now, it has never connected at all, or its row predates the field. Absence is never an error and never means "long ago". No agent release is involved: the router has always recorded the disconnect, and this only reports it. It survives brief: true, which trims the services arrays, info.host_services and the fs_locking.detail sentence and nothing else — the brief-plus-glob probe is exactly the call that wants it.
A node nobody is serving is now closed out within the hour (router 0.4.97+). online is set when a node's control channel comes up and cleared when that channel is torn down, and there was one way for the teardown never to happen: if the router process serving a node restarted before it had noticed that the node had gone silent, nothing was left to close the record. The node then read online: true for as long as it stayed away — while every call addressed at it failed target_unreachable — and only its own reconnection ever corrected it. The router now closes such a record on a sweep that runs hourly, with a grace period wide enough that a node reconnecting during a router restart is never closed out from under itself.
Two things follow that are worth knowing before you read one of these rows. offline_at on a record closed by that sweep is when the sweep ran, not when the node actually went — for these rows treat it as an upper bound on the disconnection rather than the moment of it. And no offline transition reaches presence_subscribe for one, because there is no live connection left to observe the transition; a client waiting on that event should fall back to the directory. Nothing about routing or billing changes here: a stranded row was never reachable and never accrued anything, so what this repairs is what you are told.
connection is whether this node keeps dropping, from router 0.4.199 — see connection below. online and offline_at describe one moment and the end of one session, so a node that reconnects every few minutes reads as healthy on most samples; this block is the recent history that says otherwise.
suspended_at is gone, from router 0.4.112 — a node has three states and no fourth. The field was published in router 0.4.103 to mark a provisioned node whose compute the platform had stopped, and it invented a fourth state beside online, offline and deprovisioned that nothing else in the model has. A machine whose compute is stopped is an ordinary offline node: online: false, an offline_at naming when it went, everything else exactly as it was. Read online/offline_at for what suspended_at used to say, and see When the compute budget runs out for what a stop keeps and what it costs.
It was never populated in practice — every writer of it sat behind an enforcement switch that has not been turned on — so no node has ever reported it and nothing that read it is losing an answer it was getting.
task_id is the node's stop handle, present only on a node this system provisioned. It is what tells a provisioned node from one somebody installed the agent on by hand — and it is the only handle task_stop, deprovision_node and task_deadline_set accept, with no tool that lists tasks, so a provisioned node whose task_id is missing here is a container with no reachable way to stop it.
It follows the node's identity rather than the name it was provisioned under, from router 0.4.111. The task records the node's name as it stood at provision time and nothing rewrites that copy, so through router 0.4.110 renaming a provisioned node emptied its task_id: the node read as hand-registered from then on, while its container kept running and kept billing. It is now matched on the node_id the container registered as, which does not move.
⚠ One window remains, and it is about a minute wide. A task has no node identity to be matched on until its container connects, and until then it is still found by the name it was provisioned under. So a node re-provisioned and then renamed before that replacement container connects reports its previous task here. Let a replacement come ready before renaming it, and re-read the directory after a rename before acting on a task_id.
task_deadline_at is when that machine stops on its own, from router 0.4.125 — RFC3339 in UTC, and this is the only place a running task's deadline can be read after the provision_node response that set it. Move it with task_deadline_set; do not compute a remaining time from it and cache that, since the instant is what is enforced and it moves.
Absence is three different states, and task_id beside it is what tells them apart: the node is not cloud-provisioned at all (no task_id either), its task has already stopped, or the task genuinely has no deadline and runs until deprovision_node.
Which image a provisioned machine started from is not reported anywhere. A brief router release (0.4.149) reported it here as task_image_name / task_image_version; both were withdrawn in 0.4.152 and nothing on this surface answers it. The machine does not name it either — agent 0.4.124 wrote /etc/nobgp-image and /usr/share/nobgp-image/packages.tsv onto it, and agent 0.4.127 removed both. Every provisioned machine starts from the image of the latest agent release, which Compute Nodes describes; ask the machine itself what it is running — cat /etc/os-release, apt list --installed, nobgp version. ⚠ The image is the one the container started with, not the agent version running now — the agent upgrades itself inside the container, so read info.agent_version for that, and provision the name again to move a node onto a current image.
info.hardware_model is the machine as it names itself — DS918+, Raspberry Pi 5 Model B Rev 1.0, Macmini9,1, NUC11TNHi5 — reported from agent 0.4.45. Nothing else in info answers it: platform and kernel_arch describe the software and the instruction set, so a fleet of arm64 Debian nodes is a row of identical-looking machines whose boards range from a Pi Zero to a NAS. Read it when deciding where a workload belongs.
Absence is a normal answer, not an error. The agent reads it from whatever the machine exposes — the DSM product config on a Synology, the device tree on an ARM board, DMI on x86, the hw.model sysctl on macOS, the cached SMBIOS product name on Windows — and a VM, a container, or a whitebox board whose vendor never filled in its DMI fields genuinely has nothing to say. Placeholder strings that mean nothing (To be filled by O.E.M., Default string, System Product Name) are dropped rather than passed through as though they named the hardware. Whenever there is nothing to report the field is omitted from info entirely, which is also what every node below agent 0.4.45 does — so treat it as a hint, never as a filter that must match. Unlike user, there is no third state here: a missing hardware_model says the hardware is unreported, never that it is unknowable.
Which build a node runs: build_architecture
Router 0.4.200. info.build_architecture is the agent build on that machine — arm64, amd64, armv7, armv6, armv5, mips, mipsle, riscv64 or 386 — and it is the field a node selector's arch matches, which is why it is reported: nothing else on this surface named it, and net_metrics' arch is the Go runtime's, which is arm for every ARM build.
info.kernel_arch beside it is what the machine's kernel reports (aarch64, x86_64, armv7l), and the two differ honestly rather than one of them being wrong:
- A 32-bit agent on a 64-bit kernel reads
armv7here andaarch64there. - An ARMv7 core with no floating-point unit runs the
armv5build, which is what it can execute, while its kernel still saysarmv7l. - MIPS byte order is in the build (
mips/mipsle) and not in the kernel's answer, which saysmipseither way.
It is omitted when the node has not reported it, and a selector then falls back to that node's kernel_arch — so read this field before expecting armv5 or mipsle to select anything. Read kernel_arch for the hardware, and this one for which binary this machine runs, which is the question behind a cohort or an upgrade.
connection — whether this node keeps dropping
Router 0.4.199. online and offline_at describe one moment and the end of one session, so a node that reconnects every few minutes reads as healthy on most samples — which is the shape that breaks a published service while every directory read looks fine. connection is the node's recent history as noBGP saw it, and it answers is this node stable in the call you already make.
It is present only when the router recorded a close in the last 24 hours that was not a planned reconnect, so on a calm fleet almost no node carries it and the block is its own signal. It sits on the node rather than inside info, so it survives brief: true and survives the MCP size fit — "which node keeps dropping" is a fleet question, like "which node is under pressure".
| Field | Meaning |
|---|---|
closes_24h | Closes the router recorded in the last 24 hours, up to 1000. 0 means it recorded none. ⚠ Absent is not zero — the history could not be read, and the result's notes[] says so. |
rotations_24h | How many closes in that window were planned reconnects: the node replaced its connection on purpose, before something in the path cut it. They are not in closes_24h, and the other fields here describe the newest close that is not one. Absent means there were none. |
last_close_at | When the router gave up on that connection — up to about 90 seconds after the node went quiet. Absent when closes_24h is 0 or absent. |
last_close_reason | How the router saw it end, in its own words: websocket: close 1006 (abnormal closure): unexpected EOF, say. Absent when it recorded none. |
last_seen_at | When the router last heard from the node on that connection — within about a minute of the real end, because an idle node sends something at least every minute. This is the honest end of the session: a duration measured to last_close_at or offline_at is too long by however long the router waited before giving up. Absent on a close recorded before noBGP kept this time. |
host_restarted | true: the node's host started after last_seen_at, so the machine rebooted or lost power across the close. false: the host was already up, so what ended was the connection or the agent process, not the machine. ⚠ Absent is neither — the node has not connected again since, or its boot time cannot be used. It compares the node's own clock against noBGP's, so a badly wrong clock on the node makes it wrong. |
node_reason | How the node itself says that connection ended, from its next registration — see below. Needs agent 0.4.178+. Absent when it has not connected since, its agent is older, or it had nothing to say. |
node_reason is the node's account, beside the router's. Read it with last_close_reason and host_restarted, never instead of them: it is a claim, where the other two are observations. The vocabulary is open — treat a value this page does not name as real. Four of them say a new agent process is reporting (clean_stop, upgrade, agent_error, exit_unrecorded); the rest are the same process reconnecting.
node_reason | What the node says ended that connection |
|---|---|
rotated, quic_retry | It replaced the connection on purpose |
path_moved | The host's route to noBGP changed |
host_sleep | The host was asleep or paused |
router_silent | Nothing came back from noBGP |
router_closed | noBGP closed the connection |
link_loss | The link went away |
clean_stop | The agent was stopped |
upgrade | It restarted to install an update |
agent_error | It stopped on an error it logged — read node_logs |
exit_unrecorded | The process ended and wrote nothing: a crash, a kill, a power cut |
⚠ closes_24h is a lower bound. A noBGP instance that stops without finishing its teardown records nothing, a connection held for an upgrade does not count, and neither does one noBGP closed because it was shutting down for a deployment. A connection displaced by the node reconnecting counts as one, with the reason replaced by a new connection of the node.
⚠ A planned reconnect is not a drop, and that is why it has its own counter. A node behind a gateway that cuts connections by age replaces its own connection before the cut, several times an hour, and counting those as closes would make a node that is working perfectly read as the worst flapper in the fleet. rotations_24h keeps them visible without putting them in the number you act on.
⚠ The history starts empty and is kept for 24 hours after the last close, so a node that has been flapping for a week reads as calm until it next drops.
service_check carries the same block as node_connection, beside the backend verdict, when you already have a service in hand.
What kind of machine this is: bot, cloud, tailscale
Router 0.4.199 adds three things the node says about its own environment, beside info.virtualization. All three are reported, never obeyed — nothing in noBGP behaves differently because of them — and all three are open vocabularies: treat a value this page does not name as real rather than as an error.
| Field | What it says |
|---|---|
bot | The AI-agent sandbox this machine is, as the node detects it. grok is a sandbox VM of xAI's Grok Bot. |
cloud | The cloud this machine is an instance of, as the node reads it from its own firmware. aws is an EC2 instance. |
tailscale | running, installed (present on the machine but not running), or empty for neither. Read at the node's last registration, so a Tailscale started since then shows at the next one. |
⚠ Three states each, and the empty one is an answer. Present and empty means the node looked and found none; absent means its agent is too old to look — the node half ships in agent 0.4.178, so every node below it carries all three absent. So an absent cloud is not "not a cloud instance", and an absent tailscale is not "no Tailscale".
⚠ cloud is not derived from the node's public address, deliberately: a machine reaching the internet through a cloud NAT or a VPN is not an instance of that cloud, and reporting it as one is exactly the mistake an address lookup makes. For the provider of the address, read as_org below.
tailscale is worth reading before you diagnose anything about overlay routing or firewall rules on a node: another overlay on the same machine writes its own rules and its own routes. See Coexisting with other VPN clients.
Where a node's public address is
Router 0.4.187 reports where each node's public address is, and which network it reaches the internet through. Ten fields, all in info, all derived by the router from the address it saw — the node reports none of them and cannot assert its own position. From router 0.4.200 the place can also be one a person set, and info.location_source says which it is; the place a controller sets below is that half.
| Fields | What they say |
|---|---|
city, state, country, latitude, longitude, accuracy_radius | The place of that address. state is an ISO 3166-2 subdivision code without the country (CA), country an ISO 3166-1 alpha-2 code (US) |
asn, as_org | The autonomous system of that address and its organization, as the registry records it — 14593, SPACEX-STARLINK. It is which provider the node reaches the internet through, and it changes when the node moves to another one |
isp | That provider's brand name — Starlink, where as_org is the registry's spelling. Read as_org where this is absent |
connection_type | Cable/DSL, Cellular, Corporate or Satellite. It explains a node's latency and loss before you measure them. The vocabulary can grow, so show it rather than branching on it |
⚠ The place is approximate, and it is the address's rather than the machine's. It is where the address is registered, so a VPN, a cloud egress or a mobile carrier reports the place of that egress and not where the hardware sits. Read latitude / longitude with accuracy_radius, which is the radius in kilometres of the circle the address is probably inside — commonly tens of kilometres, and up to 1000 where only the country is known. Two nodes closer together than their radii are not known to be in different places. country is the most reliable of the three place fields: a block with no city often still has a country.
Every field is omitted when there is no answer, and absence is never an error. The two causes are a node with no public address on record — the address is kept only when it is genuinely public, so a node behind a NAT that rewrote nothing has none — and a lookup that found nothing for it. 0 is a real latitude, so the coordinates are omitted rather than zeroed.
They are derived again when the node registers, and for the address on record each time noBGP loads new location data. The address itself changes only when the node registers, so a node that has not reconnected keeps the place of its last address.
⚠ isp and connection_type come from licensed data, and noBGP's own app shows the place alone — none of the four network fields. The provider's licence restricts how the paid data may be displayed and passed on, so read them for your own operations and check that licence before putting isp or connection_type on a page of your own.
The place a controller sets, and location_source
Router 0.4.200. The place above is the address's, and an address is often registered a long way from the machine — a Starlink address sits at its exit point, which can be a hundred kilometres from the machine. A node's controller corrects it with node_location_set, and every reader then shows that place instead:
info.location_source | What the six place fields mean |
|---|---|
set | A person with control of the node set this place. accuracy_radius is 1 — the coordinates are rounded to about a kilometre |
site | Router 0.4.201+. Nobody set this node's place; it followed the place the organization's other nodes at the same site have. The coordinates are those, rounded the same way |
address | Derived from the node's public address, as the table above describes |
| absent | The node has no place at all: no public address on record, or no lookup result for it |
- It replaces all six fields together, so a derived city is never left standing beside set coordinates.
- The derived place is kept in the background and comes back the moment the set place is cleared with
node_location_clear— setting one overrides, it does not overwrite. - Why it is not simply written into the node's metadata: the router re-derives the place from the address at every registration and after every location-data update, so a correction stored there would be gone at the node's next reconnect.
- The node never sets it. A node does not assert its own position; this is a person's statement about a machine they control, and
asn,as_org,ispandconnection_typestill describe the address and are untouched by it. - Every member of the node's organization reads it, in the directory, exactly as they read the derived place.
- It does not need an address. A node with no public address on record — and one that has never connected at all — still reports a set place, where the derived place could never have existed.
whoami's ownlocationfollows the same place: see itssourcefield.
A node that arrives at a site takes the place its neighbours already have (router 0.4.201, location_source: "site"). Correcting one household's place and then adding a machine to it used to leave that one machine alone in the derived place — a new KVM at a Starlink household showed up in San Jose, on its own, as a new location on the map. The router now copies the place:
- It runs only when the node's site changes — a new node, or one that moved — and never on an ordinary reconnect, so a place somebody cleared stays cleared while the machine stays where it is.
- A person's place always wins. The router never changes a place a person set, and it only ever copies a place a person set — never a followed one — so one wrong place cannot spread from node to node.
- Only from a node on the same LAN (router 0.4.202). The site is what the address derives — the same network operator and the same derived coordinates — and that is not one household: two Starlink homes of the same organization can derive the same point. So a node follows a place only when a node of the organization on its own LAN (the same
/24, or the same/64) has one, and they agree. A machine at a second home follows nothing and keeps its derived place. Through router 0.4.201 it followed the first home's. - A node with no LAN address of its own, or whose neighbours disagree, follows nothing. Clearing with
node_location_clearand setting withnode_location_setboth make the place a person's again.
info.fs_backend is which shared drive backend the node actually mounted with — nfs, fuse, webdav, or winfsp on a Windows node that has the driver installed and is running agent 0.4.82+ (through 0.4.81 that backend had to be pinned) — reported from router 0.4.61. It is not predictable from anything else in info: the backend is chosen by probing the host, with the local fs key able to pin it, so two nodes on the same platform running the same agent version land differently when one has a mount helper the other lacks. That makes it the only way to answer the questions a mixed fleet raises — who is still on WebDAV, whether a backend rollout has moved, whether a problem is confined to one backend. It is what nobgp status prints locally as fs.type, reported centrally.
Two values are not backends and are worth reading as answers in their own right. off is the node saying it mounts nothing on purpose — its fs key is set to off — and error is a node that tried and has no mount: a pinned backend the host cannot serve, a failed mount, a backend that declined. nobgp status on the box is where the reason is; fs.error names it.
It is omitted when the node has not said anything at all: an agent too old to report it, or one that has connected and whose first mount attempt has not finished. Absence means unreported, never that the node has no filesystem. Like user and allow_root, it is a fact the node asserts about itself that the router reports and never acts on — the node owns its own mount decision.
It keeps up with the node from agent 0.4.63. Before that the value only travelled with a registration, so a node that mounted after connecting — which is every node, since the mount comes up after the control channel — kept whatever it had said at connect time until it next reconnected, typically hours later. The directory could therefore show error, or nothing at all, for a node whose drive had been mounted and healthy the whole time. The agent now pushes the value as soon as a mount cycle settles it. Against an older agent, read a stale-looking fs_backend as "last reported at connect", not as the node's current state.
And from agent 0.4.146 it only ever reports a mount attempt that has finished — so a healthy node no longer reads error while it is mounting. Agents 0.4.63 through 0.4.145 wrote error before each attempt and overwrote it with the outcome; the mount loop never sent that placeholder, but another part of the agent reports the same set of facts and did, and the true value behind it was then held back by the router's one-refresh-per-node-per-minute limit. Measured across the 0.4.145 upgrade on three nodes that stayed healthy throughout, each published error and then its real backend 90 seconds later. Nothing to do about it on an older agent beyond reading a lone error that clears within a minute or two as a node that was mounting, not a node that failed.
A restarted agent reports its mount at once, from router 0.4.178. The values a node pushes after connecting are rate-limited to one update a minute, and that minute used to be counted per node, so it outlived the connection it was measured on: an agent that restarted inside it — a service restart, an upgrade, a container coming back — sent its first update immediately and had it dropped, and because a node sends each value only when it changes, nothing resent it. The directory then showed no fs_backend, fs_mount, mount_readable or host_services for that node until it next reconnected. The minute is now counted per connection, and a new connection starts with none of it spent. This is the limit the 0.4.146 note above refers to.
⚠ A node remounting keeps its last backend until the new attempt concludes, rather than dropping to error for the seconds a remount takes — while fs_mount below is withdrawn the moment the mount goes. So during a remount fs_backend names the backend last mounted with and fs_mount is absent; read that as "mounting", and nobgp status on the box for what is happening now.
⚠ mount_readable is withdrawn with it, from router 0.4.176. The two travel together — a node on agent 0.4.85 or later sets them together and clears them together — so a node with no mount now shows neither, and the pair can be read as one answer. Through router 0.4.175 only fs_mount was cleared: a field the node stopped reporting kept the value it last sent, so a node whose mount had gone went on showing mount_readable: true beside an absent fs_mount indefinitely. That was the opposite of the false hazard described below, and on an older router fs_mount absent is still the reliable signal. ⚠ Silence from an agent below 0.4.85 is left alone rather than treated as a withdrawal: that agent cannot report the field at all, and nothing on the wire separates too old to say from nothing mounted except the version, so a value a newer agent established survives a downgrade.
info.fs_mount is where that backend is mounted, and info.mount_readable is whether the agent can read it (router 0.4.83+, agent 0.4.85+ — both are reported by the node, so an older agent sends neither and the directory shows neither). Together they are the pair fs_backend was missing: which drive letter or directory the node actually chose, and whether paths under it work through the tools at all.
fs_mountis an addressable form, not the node's most natural spelling.nobgp statuson Windows reports the mount asN:and that exact string is refused by the file tools (invalid_args: path must be absolute), so a Windows value always carries its trailing separator —N:\. Forward slashes are accepted on input on every platform, so composingfs_mount + "/" + childis safe everywhere. The node picks the location, from its own config, a platform default, or the first free drive letter, so it is not derivable fromplatformoragent_version.mount_readableis about the agent, never about you.truemeans the agent read its own mount and paths underfs_mountwork;falsemeans every file andcommandcall on such a path is refused there at both identities. Absent means the agent never probed, which usually means nothing is mounted —fs_backendsaysofforerror. Full treatment, and the two causes with their separate remedies, in A node's own mounted drive.- It is re-probed on every mount cycle, including while a mount is failing (agent 0.4.97+). A mount that fails its health check is re-probed a few times before the node condemns and rebuilds it, so that a passing network blip does not tear down a healthy drive — and through agent 0.4.96 the node published nothing during that stretch, leaving whatever it had reported when the mount came up. A node whose mount had been destroyed under it therefore went on reporting
mount_readable: truefor as long as it kept striking, measured at about 105 seconds. The value now degrades with the mount rather than a minute or two after it. ⚠ It is still re-probed rather than assumed false while striking: the probe reaches the router, so a network hiccup that fails the health check on a perfectly readable mount leaves the field alone instead of flapping it. - Both are withdrawn rather than answered when nothing is mounted: a node with no mount reports neither, instead of claiming a mount point that does not exist or a
falseabout a mount that was never there. ⚠ The directory shows both withdrawn from router 0.4.176; before itmount_readablekept its last value, as above.
info.writable_roots is where you may actually write under that mount. The mount root is a synthesised read-only view, so a write to fs_mount itself fails — and nothing said so before the attempt until this field existed. It lists the node's own area plus one entry per network the node is in, absolute and ready to use. It is derived by the router from fs_mount and the node's network list rather than reported, so it is absent exactly when fs_mount is.
info.fs_locking is what taking a lock on that node's mount actually buys (router 0.4.62+) — the question fs_backend raises and does not answer. Three fields:
| Field | Meaning |
|---|---|
scope | How far a lock is enforced: cluster (between nodes), node (between processes on that machine only), or none (not at all). Read node as "real locally, worth nothing against a second node". Two backends reach cluster, each from the agent release that started forwarding its locks (router 0.4.68+): an nfs mount on agent 0.4.69+, and a Linux fuse mount on agent 0.4.64+. Below those releases the same backend name keeps its measured pre-forwarding row — nfs reads none, fuse reads node. webdav is node or none on every platform, and winfsp is node (router 0.4.71+). |
honest | Whether a lock this backend cannot enforce at least fails instead of falsely succeeding. false is the dangerous case: the caller is told it holds a lock it does not hold. |
detail | One sentence naming what that mount does and does not guarantee, and the workloads to keep off it. |
It is derived from fs_backend, platform and agent_version, not reported by the node — the answer is a property of those three, and the router holds all of them. Every value comes from a measurement on real hardware; a combination nobody has measured says so rather than guessing, which is why Linux davfs2 reads honest: false with a detail saying it is unmeasured. The field is omitted entirely when fs_backend is, because a node that has not said which backend it mounted has said nothing to derive from. Every backend a node can report now has a row: winfsp was the last one without, and got one in router 0.4.71 — until then a node running it was the only node in a directory with no fs_locking key at all, so a caller asking "is this share safe for SQLite" got nothing back to read.
The row is the one for that node's agent, not for your newest one (router 0.4.68+). Forwarding locks arrived in a specific agent release per backend, and a node below it reports the same backend name while doing something different, so the derivation reads agent_version too: nfs reads cluster from 0.4.69 and none below it, fuse reads cluster from 0.4.64 and node below it. That matters because old agents do not leave — a node that is offline is described from what it last reported, and a channel rollback or a late joiner puts a pre-forwarding agent back in the directory long after the rest of the fleet has moved. An agent whose version the router cannot read gets the pre-forwarding row as well: under-promising costs you a lock re-test, while a cluster claim nothing supports is a false all-clear on exactly the question this field exists to answer. A genuinely new capability still lands here only when the router's own derivation learns of it, so a node can read below what its mount does for as long as that takes.
honest is separate from scope because the difference is a bug versus a catastrophe. A lock that cannot be enforced and refuses is recoverable. One that reports success is what macOS over WebDAV does: two processes each take an exclusive flock on one file and both are told they hold it. Windows over WebDAV is the other trap, and it reads node, honest: false from router 0.4.78 — it enforces an exclusive open between local processes and reports that truthfully, which is what scope: node records, and then a held handle's write-back on close overwrites a concurrent writer on another node, leaving a file that is neither version with success reported to both. The field does not ask whether the enforced part is truthful; it asks whether the part the backend cannot enforce at least fails. Across nodes this one does not, which is why the row read honest: true through router 0.4.77 and why that was wrong.
A winfsp volume on that same platform shares the node scope and the unenforced cross-node lock, and is honest: true, because what a losing write costs there is different: its write-backs are conditional on the version the handle read, so the race is refused rather than blended and the rejected bytes are kept. The two Windows rows are therefore told apart by honest — and their detail sentences say which loss you are facing.
brief: true drops detail and keeps scope and honest. The sentence is the same ~350 characters on every node sharing a backend, which is exactly the repetition brief exists to avoid — but a compact response that dropped the warning would be the wrong kind of small, so the dangerous case still shows.
From agent 0.4.79 the node answers the same question on the box, as fs.locking in nobgp status — one sentence about the backend it actually mounted with. The two are independent: this row is derived by the router from what the node reported, so a node that has not reported a backend has no row here at all, while nobgp status still answers on the machine itself and is always current.
See Which backend mounts the drive for the same ground from the node's side, and Locking a path on a storage tree for the lock that is coordinated between nodes.
info.health is the node's most recent host-pressure sample (router 0.4.83+) — cpu_percent, mem_available (agent 0.4.144; mem_free below it), disk_free (router 0.4.144 with agent 0.4.119), home_disk_free (router 0.4.176 with agent 0.4.147), load1, the at it was taken, and from agent 0.4.180 the host probes below. The agent has sampled these every 60 seconds for a long time; until 0.4.83 no tool returned them, and the one whose name promised them (net_metrics) answers about the agent's own Go runtime rather than about the machine. Nothing new runs on the node for this, and no agent release was involved — disk_free is the one exception, being a measurement the node had not been taking.
- Read
atbefore treating the numbers as "now". The sample is up to a minute stale by construction, so a node can look healthy 55 seconds into whatever is killing it. On a node that has gone unreachable,atis simply the last thing it managed to say. - ⚠ The sample belongs to the machine that is running now, and a reboot is what invalidates it (router 0.4.112). These numbers all measure the host, so a sample taken before the machine last booted is not this node's health and is not served — while a bridge reconnect and an agent restart change nothing about the host and no longer suppress anything. The node's reported boot time is the key, and there are two ways it falls back to the older connection-based rule below: a node that has never reported one (an agent that could not read it has that recorded as unknown rather than as 1970, from router 0.4.113), and a boot time that is later than the connection it was read on, which is not a fact about a reboot but about the node's clock and the router's disagreeing.
- ⚠ Through router 0.4.111 the key was the node's current connection, which was a proxy for the wrong event: the router re-stamps
online_aton every reconnect, so a node with a flapping link had its newest sample refused again and again. Measured across an hour: a node reconnecting every 45 seconds reported health at 0% of instants, and one reconnecting every 30 seconds likewise — the node most likely to be under pressure was the one missing from the answer to which node is under pressure. Keying on the boot time removes that completely. - A reboot still withholds
healthbriefly — about 90 seconds, once — because a sample taken in the same minute the machine came up may predate the boot, and the whole of that minute is refused rather than risk serving a pre-reboot reading. This is the case the rule exists for: health is read after a reboot because of the reboot. - Absent
healththerefore means "no sample from this host" — the node has never reported one, or every sample it has predates the current boot. Through agent 0.4.86 the first case also covered the first minute after a node came up, since it sent nothing until its first interval elapsed; from agent 0.4.87 it sends one as it starts. An offline node keeps its last sample, because that is the last thing anyone knew and nothing will refresh it.net_metrics'shostblock applies the identical rule, so the two surfaces cannot disagree about the same node. - ⚠
stale: truemeans the numbers describe the host atat, not now (router 0.4.199). It is set when the sample is more than 3 minutes older than the last time noBGP heard from the node: the node was connected and talking, and its sample did not refresh. A node that reconnects faster than it samples — sampling takes about a minute — is stale by construction, and it is the node most worth reading, which is why the sample is marked and never withheld.staleis absent when the sample is current, and also when noBGP does not know when it last heard from an offline node; absent is never a claim that it is fresh. This is a different question from the reboot rule above, which decides whether the sample describes this host at all. load1is absent on Windows, and that is a platform fact rather than a collection failure: Windows has no load average, and what a collector synthesises there is a number that decays toward zero regardless of load. Absent is the honest answer; a zero would not be. Present on Linux and macOS.disk_freeis free space where the agent is standing (router 0.4.144, measured by agent 0.4.119) — from agent 0.4.144 the filesystem holding the agent's own executable, and its working directory below that release. The two are the same filesystem under every service manager noBGP installs with; naming the executable is what keeps a NAS answering about its system partition rather than about a data volume it would have to spin up. ⚠ It is not the shared drive:writable_rootsis remote share storage, so a node with 2 GB free here can still write gigabytes there. It is omitted when the node could not measure it, and on every node whose agent predates 0.4.119 — never a claim that the disk is full. ⚠ It is a good proxy rather than a promise about a particular call: where a node has a configured account and you leaveworkdirunset, an unelevatedcommandstarts in that account's home directory, which on a machine with/homeon separate storage is a different filesystem from the one measured here.home_disk_freebelow measures that one.- Read it against
info.disk(agent 0.4.144), which is the usable size of that same filesystem, so the two divide into the percentagedfprints on the machine itself. A free-byte count alone cannot be read: 180 MB free is a full disk on a 200 GB volume and a barely-used one on a 184 MB appliance partition, and a fleet sweep on free bytes alone called five healthy nodes low on disk.
- Read it against
home_disk_freeis free space where an unelevatedcommandactually runs (router 0.4.176, measured by agent 0.4.147) — the home directory of the node's configured account, which is where acommandwith noworkdirstarts on Unix. It is the number to read before dispatching work, and the answer to the caveatdisk_freehas carried since it shipped: on a machine with a small root and/homeon its own storage the two differ by orders of magnitude. ⚠ It is absent, never zero, where the node did not measure it — on Synology DSM, where home directories sit on the data volume and asking would wake its drives once a minute; on Windows, where acommandwith noworkdirstarts in the agent's own directory, sodisk_freeis the number there; on a node with no usable account configured; and on any agent below 0.4.147. A zero is a measured full disk. ⚠ It has no denominator:info.disksizes the filesystemdisk_freemeasures, not this one.net_metrics'hostblock carries the same field.mem_availableis memory the machine can hand to new work without swapping (agent 0.4.144) —MemAvailableon Linux, free plus inactive on macOS, available physical memory on Windows. It is the number that answers is this machine under memory pressure, and its denominator isinfo.memory.- ⚠ A node below agent 0.4.144 reports
mem_freeinstead, and the two are not interchangeable. Free memory leaves out reclaimable cache, so it reads near zero on a perfectly healthy Mac and about four times low on Linux (measured on one host: 48 MB free against 198 MB available). Readinfo.agent_versionbefore comparing two nodes, or before reading a small number as pressure.
- ⚠ A node below agent 0.4.144 reports
- What the machine says about its own health rides here too, from agent 0.4.180:
temp_c,throttled,failed_unitswithfailed_unit_names,updates_pendingwithpackage_index_at,ntp_synced,ntp_offset_ms, andprobes_atsaying when they were measured — which for the two taken on their own schedule is not the same instant asat. They are measured on Linux only, and ⚠⚠ absent means not measured, never zero and never false: a host with no source for a probe sends nothing for it, becausefailed_units: 0on a machine that has no systemd reads as a healthy machine.net_metrics'shostblock carries the same fields with the same spelling, and says what each one costs on the node and what it does not promise — the pending-update count in particular, which is read from the index already on the machine and never refreshes it. - Sizes are bytes —
mem_available,disk_free, never megabytes, like every other size on this surface. - ⚠ On a container,
cpu_percentand the memory figure are the host's, not the container's. A 512 MB container on a 128 GB machine reports the machine. Readinfo.virtualizationbefore treating them as this node's own. - These numbers are the load;
cpu_count,memory,diskandgpuare the capacity. Neither answers the other's question, and "which machine could run this" needs both. - It survives
brief: true, deliberately: one brief directory call is the fleet-wide "which node is under pressure" question, and abriefresponse that dropped it could not answer the thing the field was added for.
info.host_services is what that node's host offers on its own LAN (router 0.4.155, reported by agent 0.4.128+), as the node reports it — file sharing (_smb._tcp), screen sharing (_rfb._tcp), remote login (_ssh._tcp) and the like, each with the port it listens on, the instance name the host announces, and source.
- ⚠ It is not the node's
servicesarray besideinfo. Those are noBGP services published through the router and reachable at a public URL. These need no publishing: reach them at the node's own name, on the port given. sourcesays how the node found it.mdnsmeans the host's own responder announces it.listenermeans the node found something listening on the port and the host does not announce it — so the name is a default and the protocol on that port is not confirmed. The vocabulary is the node's and is open, so a node may report a word this page does not name.- An absent
sourcemeans the node did not say, or that what it said was not usable. Asourcethat is not a short word of lower-case letters, digits and hyphens — at most 16 characters — costs that one field from router 0.4.157 and the service is still listed — an earlier router dropped the whole entry, so a reachable service disappeared from the directory over the word the node used to describe how it found it. typeandsourceare read without regard to case and are shown lower-cased (router 0.4.157+), so a host spelling its type_SMB._tcpis reported as_smb._tcprather than dropped. The instancenamekeeps the case the host announces, since that is what a person reads in a file browser.- Reported, never obeyed. noBGP checks the shape, caps the list at 32 entries and passes it on; an entry proves nothing about what is really on the port.
- An absent
host_servicesmeans the node reported none, its agent predates the report (0.4.128 is the release that reports), or its owner turned reporting off withmdns-report-services: false— the cases are not told apart. Dropped bybrief: true, likeservices. - A service listening only on the node's own loopback is reported from agent 0.4.140, because a peer now reaches loopback on that machine; an earlier agent left it out, since no peer could reach it. On Windows, file sharing is reported only when the host really shares a folder, because every Windows machine listens on that port regardless.
- Each node's peers receive this list with its directory entry, which is what lets their agents announce those services to local applications — Finder listing another node's SMB share, for example. The announcing half is agent 0.4.128, and it is off until the machine's owner turns it on (
mdns-announce, macOS and Linux).
See LAN Names and Local Discovery for where the list comes from, and how a macOS or Linux node shows it to its own apps.
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. ⚠ Read it with online and info.upgrade before surfacing it (stated in the field's own description from router 0.4.187): a behind node is often only offline, and one that is offline is offered the update when it connects again, so there is nothing to act on. A node that is behind, online and carries info.upgrade was offered the update and did not install it — that is the one to surface as a nudge to run nobgp upgrade, and info.upgrade.outcome says why when the node reported it. behind and online with no info.upgrade is a node waiting its turn in the rollout: no action. |
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. |
below_floor | Agent is older than the channel's minimum version, so the node is held out of the network until it upgrades (router 0.4.168+). |
"" (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. An offer a node was given and has not installed is info.upgrade below.
info.user, info.allow_root and info.user_is_admin are the node's own answers about what root will mean on it, reported at registration and set only on the node itself (nobgp config --user / --allow-root) — no tool changes them:
| Field | Meaning |
|---|---|
user present, non-empty | The account unelevated work runs as on that node. Still not a promise — the node re-resolves it at the moment of the operation, and root spelled out here is refused like any other superuser. |
user present, empty | No account this node can actually become, so root: false falls through to the node's own identity — and is refused wherever that is the superuser, which is the service-install and bare-root-container shape. A warning, not a verdict: a node whose agent is already unprivileged serves root: false as itself. A Windows node reports empty until it has a configured account — from agent 0.4.44 a name there is its second identity, covering file operations, and from agent 0.4.46 execution as well (0.4.44 and 0.4.45 refused an unelevated execution call). |
allow_root: false | The node's owner refused elevation, so root: true fails there. On a node that also cannot run unelevated, the two refusals cover everything between them. |
user_is_admin: true | The account named in user is administrative on that node — see below. It qualifies user; it never changes what a call is allowed to do. |
| Any field absent | Nobody said. For user and allow_root that means an agent predating the field; for user_is_admin it also covers a node with no second identity to describe. Absent is not the same claim as empty or false, and must not be rendered as one. |
Empty and absent are different answers, and router 0.4.42 is the release that stopped collapsing them: before it, a modern agent reporting "I have no account" was stored the same way as an agent too old to say, so a UI had no way to tell "nothing configured here" from "no idea".
Since agent 0.4.41 a node reports the account it resolved, not the string in its config file. A user that was deleted — or a config copied off another machine — reports empty, which is the answer that matches how the node will behave, rather than a name that reads as "unelevated works here" while nothing on that box can become it. The two differ exactly when it matters.
user_is_admin says how much root: false is worth on that node (router 0.4.47+, from agent 0.4.46+ — the release that answers it). A name alone overstates it: where this is true the account is administrative — a member of the local Administrators group on Windows — so an unelevated call there drops from the node's ambient identity to Administrator and no further. That is still a real reduction and still the right default, but it is not the isolation the word "unelevated" suggests. On Windows it is the common shape rather than an exotic one, because the installer captures the interactive desktop user, who on a personal machine usually is an administrator.
Three states, like its neighbours: true, present-and-false (the node checked and the account is ordinary), and absent (nobody checked — an agent too old to report it, or a node with no second identity to describe). It is never a policy input: nothing refuses on it, and it is never a reason to avoid root: false. It tells you what that choice buys, so a caller weighing a risky operation can see the difference between dropping to an ordinary account and dropping to an administrative one.
Read these before choosing root on a command, command_subscribe, or file call — a cohort is rarely uniform. From router 0.4.152 they are also what the router resolves an omitted root from: it runs elevated only where user is present-and-empty or root and allow_root is not false, and unelevated everywhere else, user absent included. What the router deliberately does not report is a derived "can this node run unelevated" verdict: it would be wrong in both directions (an explicit user: root is a non-empty name that still refuses; an empty one on an already-unprivileged agent is served), and the node is the only party that can answer it.
A node held below its channel's floor
A release channel can carry a minimum version (router 0.4.168+). A node whose agent is older than it connects to noBGP normally and is then held: it gets no peers, no relay sessions and no overlay names, and online reads false for it in this directory while its connection is live — with offline_at stamped at the moment it connected — and info.version_status reads below_floor. It is offered its channel's version at once, outside the rollout it would otherwise wait its turn in, so a node with auto-upgrade on installs it and joins on its next connection — usually within a minute or two.
- The tools still reach it.
command, the file tools (fs_read,fs_writeand the rest),node_logsandnode_config_get/node_config_setall work on a held node, which is what lets one that cannot upgrade itself be fixed from here rather than at the machine. - A node that stays
below_floorfor more than a few minutes is usually one with automatic upgrades turned off. Runsudo nobgp upgradeon it — throughcommandif you have no other way in — and readinfo.upgradebelow for what the node itself said about the offer. - It is never disconnected for being held, and a node already in the network when a floor is raised stays in it until it next reconnects. Only its upgrade offer is immediate.
This is not the minimum supported agent version: below that one an agent is refused at registration and never becomes a node at all. Full treatment in A version floor can hold a node out of the network.
info.upgrade — an offer the node has not installed
info.upgrade is an update the router offered this node and it has not installed (router 0.4.168+): offered_version, first_offered_at, last_offered_at, offers, and — only when the node itself reported why — outcome, reason and reported_at. It survives brief: true, so one compact call answers "which of my nodes did not take their update" for a whole fleet.
- It is only ever an offer that is still open (router 0.4.169+).
offered_versionis always the channel's current target: the router offers a node the current target on its next connection, so a record of an older one — left by a node that went offline before the target moved, or by a target that was rolled back — is history rather than something the node will be offered again, and is not shown. - Absent means there is no open offer of the current target: the node is up to date, or the rollout has not offered it that target yet. Absence during a rollout is normal and is not a problem. The block is carried only while the node is behind its channel target, so it goes as soon as the upgrade lands.
- ⚠
last_offered_atandoffersare absent where the router has no record of them, so a missingoffersis not zero offers.first_offered_atandoffered_versionare always there. outcomeis the node's own code, from an open vocabulary.auto_upgrade_off— the machine's owner turned automatic upgrades off, so the fix issudo nobgp upgradeon the node orauto-upgrade: truein its configuration file;install_failed—reasoncarries the installer's error. Other codes exist, and a node can report one this page does not name: showreasonfor a code you do not recognise.- ⚠ No
outcomeis not a refusal. A node reports one from agent 0.4.140; an older agent never does, and a node may simply not have answered yet. Name a cause only whenoutcomeis present;first_offered_at— andofferswhere it is recorded — says how long this has been going on without claiming why.
What a machine could do, as opposed to what it is doing
info.cpu_count, info.memory and info.gpu are the node's capacity (router 0.4.144) — what the machine has, where info.health above says how busy it is right now. Read them together: a 1-core Pi at 20% and a 32-core server at 20% are not the same offer, and these three are what answer which of my machines could build this, or run this model in the one call you already make first.
| Field | What it says |
|---|---|
cpu_count | How many logical CPUs the machine has — SMT threads included, so a 16-core/32-thread server reports 32. |
memory | Total physical memory, in bytes, like every other size on this surface. It is the denominator of health.mem_available. |
disk | The usable size of the filesystem health.disk_free measures, in bytes — the denominator that makes those free bytes readable. Agent 0.4.144+; absent below it, and on a node that could not measure it. |
gpu | What the node can accelerate work with — see below. |
From router 0.4.177 you can filter on them instead of pulling the fleet and sifting it yourself: min_cpu_count, min_total_memory, gpu_kind and has_gpu compose with the other filters, so which of my machines has a CUDA card and at least 32 GB is one call. Read the rule above the output schema first — a node that did not report a field never matches either way, so an empty result means none reported.
⚠ On a container both cpu_count and memory are the host's, not the container's limit — the same caveat health carries, for the same reason. Read info.virtualization beside them before treating either as this node's own.
⚠ cpu_count is not net_metrics's num_cpu. That one is the CPUs the agent process can actually use, and it honours CPU affinity, so on a pinned container it is the smaller and truer number; this one is what the machine has. They agree everywhere except a pinned container.
cpu_count and memory are reported at registration and have been collected for a long time — this release publishes them rather than starting to gather them, so they are there for essentially every node that has connected. They are omitted for a node that never reported them, which is not a claim the machine has none.
⚠ disk is the one that needs an agent release (0.4.144): unlike the other two it was not being collected, so it is absent across a fleet until its nodes reach that version. It rides registration like them, because a filesystem's size does not change under a running agent — an online resize is picked up at the node's next registration.
It is the size that is usable, not the raw one: used plus available, which is the number df divides by for its own percentage, so disk_free / disk matches what the machine reports about itself. It is below df's Size by the blocks a filesystem holds back for the superuser (about 5% on Linux's ext4).
info.gpu names the programming interface, not a vendor and not a framework, because the interface is what decides whether a job can run. It is a capability and it is static — an accelerator cannot appear under a running agent — so it rides registration rather than the 60-second health sample, and it is on this tool only:
| Field | Meaning |
|---|---|
kind | "none", "cuda", "rocm", "metal", "rknpu", or a value newer than this page — the vocabulary belongs to the node and is open, so treat one you do not recognise as real rather than as an error. |
count | Devices of that kind reachable from the agent's own namespace. On a cuda node they are counted one by one, up to 16 — a machine with more reports 16. On rocm, rknpu and metal the node finds a single compute interface rather than enumerating cards, so 1 there means "there is one", not "exactly one". Absent when the node did not say, which includes a machine reporting kind: "none". |
memory | The accelerator's dedicated pool — a discrete card's VRAM, and nothing else. ⚠ Per device, not a total: a machine with four identical cards reports one card's VRAM beside count: 4, because one card is what a job gets. Absent on a unified-memory machine and wherever nothing measured it. |
unified | true when the accelerator draws on the same physical memory as info.memory. From router 0.4.146 with agent 0.4.121 false is a claim — this accelerator has a pool of its own — and absent is nobody established it. Below either half there was no absent: false was written for a probed discrete card, for a rocm or rknpu node nobody measures and for a probe that failed, alike. |
⚠⚠ The block being absent and kind: "none" are different answers. Absent means the node did not say — an agent that predates the probe, a platform that has no probe at all (Windows — see below), or a probe that could not run. "none" means it looked and found nothing reachable. Reading absence as no GPU tells you the Apple Silicon machines have none, and those are among the ones worth finding.
⚠⚠ A Windows node reports no gpu block, from agent 0.4.121, and its silence is the honest answer. There is no accelerator probe on that platform: every branch of the probe looks for a device file that cannot exist on Windows, so the block is simply absent there. Through agent 0.4.120 such a node answered kind: "none" instead — a probe result it had never obtained — and that was wrong for the whole Windows fleet at once, measured on a machine reporting none while holding a 4 GB Radeon. A Windows node that already reported none takes that answer back as it upgrades, because a node rewrites the whole block on every registration, absence included (router 0.4.146 — see the withdrawal note below). Until somebody decides what a Windows probe should cost, read a Windows node's accelerator as unknown and ask the machine.
⚠⚠ gpu.memory and unified are read together, and neither answers the capacity question alone. Both an RTX 5090 and a DGX Spark report kind: "cuda" — one has 32 GB of its own and the other 128 GB it shares with the CPU, and that second number is what a large model has to fit in. kind cannot separate them:
| The machine | gpu.memory | unified | Where its capacity for a large job is |
|---|---|---|---|
| A discrete card (RTX 5090) | its VRAM in bytes | false — see below | gpu.memory |
| Unified-memory NVIDIA (GB10 / DGX Spark) | absent | true | info.memory |
| Apple Silicon | absent | true | info.memory |
A rocm or rknpu node | absent | absent | unknown — not measured yet |
| A probe that could not run | absent | absent | unknown |
| A Windows node | absent | absent | unknown — the whole block is absent, there is no probe |
The two absent · absent rows above need agent 0.4.121 as well as router 0.4.146: below that agent a rocm or rknpu node, and a machine whose measurement failed, both sent unified: false — the same bare false a genuinely discrete card sends.
⚠ unified is a capability claim, not a warning against double-counting. It says this accelerator can use info.memory, which is what makes a 128 GB unified machine findable for a large model. There is no sum to guard against: such a machine reports memory absent, so there is no number to add.
⚠⚠ Do not infer unified from gpu.memory being absent. Absence has three causes — no separate pool, a probe that could not run, and a kind whose memory nothing measures — and only unified tells the first from the other two. Treating absence as unified reports every AMD and Rockchip node as unified-memory, and a caller then reads a 4 GB board's whole RAM as accelerator capacity.
No GPU field is published as a fabricated answer any more. gpu.memory and count stopped being reported as 0 in router 0.4.145, and unified stopped being reported as false in router 0.4.146 — each because the wire carried no way to tell did not say from said zero, and each of those values is a claim nobody was making. All three are now simply omitted where nothing established them, so absence is the single non-answer.
⚠ That means most unified: false answers disappear on the 0.4.146 router, and that is the point of it. An agent on 0.4.120 sends a bare boolean, so on the wire its false and its unknown are the same thing and all of them now read as absent — including a genuinely-probed discrete card, whose false was a real claim. Those answers come back as nodes upgrade to agent 0.4.121, the half that sends unified only where the probe decided: true for Apple Silicon and for an NVIDIA part the driver says has no separate pool, false for a card whose own VRAM it just measured, and nothing at all for a rocm or rknpu node or a probe that failed. Until a node has upgraded, read absence as unknown, and place large work on memory and memory rather than on unified alone.
A discrete card reports a real gpu.memory from agent 0.4.120, which is also the release that stops answering unified: false for non-Apple unified memory — a GB10 (DGX Spark) read as though it had a pool of its own before it. Below that agent, treat memory as unavailable rather than as zero, and unified as answering for Apple Silicon only.
The figure is the NVIDIA driver's own answer, taken once at registration and only on a node where a CUDA device was already found — so a machine with no accelerator still starts nothing to establish that, which is what keeps the probe below cheap on a fleet of small boards. It is also what separates the two cuda machines: a discrete card has a separate pool and the driver reports its size, while a unified-memory part has no separate pool to size and says so. ⚠ A failure claims nothing in either direction: if that tool is absent, refuses, or does not answer within a couple of seconds, both memory and unified come back absent.
⚠ metal is reported on Apple Silicon only. An Intel Mac reports none even where it has a working discrete GPU, because the node cannot describe that memory honestly and an under-claim was preferred to a wrong one. Read kernel_arch beside gpu on a Mac.
⚠ It answers "can the agent reach an accelerator", not "does this hardware have a display controller". The node probes from where it is standing, so a container without device passthrough reports honestly rather than claiming the host's card. And whether a framework is installed — MLX, PyTorch, llama.cpp — is a different question that changes the moment somebody installs one: an Apple Silicon machine reports metal, and MLX runs wherever that is true.
⚠ gpu needs agent 0.4.119, the release that carries the probe. A node whose agent predates it reports nothing, so the block is absent across most of a fleet until it has upgraded — which is exactly why absence must not be read as "no accelerator".
⚠ A node on 0.4.119 or later can also take the block back (router 0.4.146). Such a node rewrites all four fields every time it registers, absence included, so a machine that stops reporting an accelerator — because a later agent corrected what it used to claim, or because the device is no longer reachable from where it stands — is shown as having said nothing rather than keeping its old answer. Through router 0.4.145 there was no withdrawal path: a block once written stayed until it was replaced by another one, so a stale claim could outlive the agent release that stopped making it. Nodes below 0.4.119 are untouched, which is what keeps their silence readable as too old to say.
⚠ count covers the reported kind only. A machine with two accelerators of different kinds is described by one of them — cuda wins where both a CUDA device and an AMD compute device are reachable — so a node reporting cuda may also have something else the block does not mention.
When the result is larger than a model client accepts
Router 0.4.198. This is the one tool whose answer grows with your fleet, and past about 30 KiB of JSON a model client stops accepting the response at all — outside the protocol, so you get neither the data nor an error. Over MCP the router now makes the result smaller itself, in steps, and says what it left out. Over REST nothing is ever trimmed: the full directory is always returned.
The steps, in order, each taken only if the result still does not fit:
- What
briefremoves — each node'sservices, itsinfo.host_servicesand thefs_locking.detailsentence. - Each node's
infokeeps onlyhostname,agent_version,version_status,upgrade,os,platform,kernel_arch,health,fs_locking,user,allow_root,mount_readable, from router 0.4.199bot,cloudandtailscale, and from router 0.4.200build_architecture— the field a selector'sarchmatches. That keeps every fieldbriefpromises survives it, and drops the location (location_sourcewith it), network-provider, hardware and capacity detail, which is most of the size. From router 0.4.199 each node'sconnectionblock also dropslast_close_reason, the one free-text field in it; the counts and the timestamps stay. - Networks at the end are left out —
omitted_networkscounts them — and then nodes that do not fit, counted on each network by its ownomitted_nodes.
| Field | Meaning |
|---|---|
trimmed | One sentence naming which of the steps above were taken, and how to get the full detail. Absent means nothing was left out — so its presence is the signal, rather than its wording. |
omitted_networks | Networks left out at the end for size. Absent means none. |
networks[].omitted_nodes | Nodes of that network left out. Absent means none — and then an empty nodes array means no node matched, which is what it has always meant. |
⚠ A network is never reported as empty when it is not. That is what omitted_nodes is for: without it, a fleet large enough to trim would answer a filter with networks that look like they hold nothing.
The way to a complete answer is to ask for fewer nodes: network_name, node_name_glob, os and online all narrow the result before any of this applies, and so does brief on its own. A trimmed response says so rather than letting you read part of a fleet as the whole of it.
⚠ The one-line summary is a summary, from this release. Through router 0.4.197 this tool's first text block held the whole directory JSON instead of a sentence, so one MCP result carried the directory three times — that block, the --- result --- block and structuredContent — which is most of what made a 51 KB directory more than a client would take. It now reads N networks, N nodes (N online), as every other tool's summary line already did. That copy was also made before the MCP door drops the old admin names, so it was the last place allow_admin reached an MCP caller; it no longer does.
Example Usage
Request:
{
"network_name": "production"
}
Response:
{
"call_id": "5d0f2b81-…",
"op": "network_directory",
"done": true,
"duration_ms": 63,
"network_directory": {
"networks": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production",
"org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"files_url": "https://files.nobgp.com/networks/550e8400-e29b-41d4-a716-446655440000/",
"nodes": [
{
"id": "8a1f2d5c-7b3a-4e9f-9c1b-2e5a0d8f4c12",
"name": "web-server-1",
"online": true,
"online_at": "2026-08-19T06:41:52Z",
"info": {
"hostname": "web-server-1",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"os": "linux",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64",
"fs_backend": "fuse",
"fs_mount": "/mnt/nobgp",
"mount_readable": true,
"writable_roots": ["/mnt/nobgp/node", "/mnt/nobgp/networks/production"],
"health": {
"cpu_percent": 12.4,
"mem_available": 10087788544,
"disk_free": 41234567168,
"load1": 0.31,
"at": "2026-08-19T09:22:00Z"
}
},
"services": [
{
"id": "svc_xyz789",
"title": "staging-app",
"public_url": "https://abc123.nobgp.link"
}
]
},
{
"id": "3c7b91ae-55d2-4f08-8a6d-1b9c4e70f233",
"name": "web-server-2",
"online": false,
"online_at": "2026-08-04T18:02:55Z",
"offline_at": "2026-08-04T21:14:07Z",
"info": {
"hostname": "web-server-2",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"os": "linux",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64"
}
}
]
}
]
}
}
web-server-1 is connected, so it carries no offline_at and its online_at is when the live connection began. web-server-2 dropped at the timestamp shown and has not been back since — its online_at is the start of that last session, not a claim that it is up, so the pair also says the session lasted about three hours.
Filtered probe ("is any Raspberry Pi online?"):
{
"online": true,
"platform_glob": "raspbian",
"brief": true
}
The response omits the services array on each node so the payload stays small. Flip online to false and each node still carries its offline_at, so the same compact call answers "how long has it been gone?" as well as "is it gone?".
Capability probe ("which machine could run this model?"), router 0.4.177+:
{
"online": true,
"has_gpu": true,
"min_total_memory": 34359738368,
"brief": true
}
Connected machines that reported an accelerator and at least 32 GiB of RAM. Read each match's info.gpu to tell a discrete card's gpu.memory from a unified-memory machine's info.memory, and remember that a machine whose agent has not reported these fields is not in the answer.
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 - Finding the machine with the CPUs, memory or accelerator a job needs —
min_cpu_count,min_total_memory,gpu_kind,has_gpu(router 0.4.177+) - Telling a brief outage from a long one —
offline_atdates the disconnection thatonline: falseonly reports - Telling a node that came back from the session you just ended —
online_atdates the connection thatonline: trueonly reports - 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) name a node with node: its id or its name. A name resolves in any network, so a node that moved between networks is still found. A name that does not resolve returns not_found with (use node_list to list nodes).
Over REST, a call can still name network_name + node_name:
- Node moved networks. If the node isn't in the requested network but you own another network containing a node by that name, the refusal carries
(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(network_list lists your networks); an unrecognizednode_namewithin a valid network returns(use node_list to list nodes).
Passing a 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": "string (optional)",
"access": "string (optional)",
"members_may_add_nodes": "boolean (optional)",
"isolated": "boolean (optional)"
}
Parameters:
name- Name for the network: DNS-compatible, and unique within the organization from router 0.4.204 (it was unique per creator before, so two members could each createhome). A name another member of the organization already used isalready_exists. Case is ignored.org- 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.access-org(the default) orprivate: whether the network's nodes are shared with their organization. Withorg, every member of a node's organization may use the network's nodes — run commands, read and write files, read telemetry — unless the node itself is set to private. Sharing givesuseonly, never root. A node is shared only when it follows its networks and every network it is in isorg.members_may_add_nodes- Default true: any member of the organization may add a node to this network (provision_node,register_node, a signed-in enrollment).falsekeeps that to the network's owner and the organization's Owners and Admins. An enrollment with the network's own key is unaffected — the key is the network's credential. ⚠trueis a reach grant: every member who adds a node gets reach to the network's nodes.isolated- Default false. Client isolation: the network's nodes cannot reach each other — no relay between them, no names or discovery of each other, no event bus. Services published through the router's proxy still work, and each node's own tools are unchanged. Use it for a parking network.
Any member of the organization may create a network, from router 0.4.179, and becomes its owner — it was Owner/Admin before. The owner manages it from then on (network_update) and may delete it, alongside the organization's Owners and Admins. The plan's network limit counts every network in the organization, whoever owns it.
Output Schema
{
"network_id": "string",
"name": "string",
"org_id": "string",
"created_at": "timestamp",
"access": "string",
"members_may_add_nodes": "boolean",
"isolated": "boolean"
}
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.access- The sharing setting the network was created with:orgorprivate.members_may_add_nodes- Whether any member may add a node to it.isolated- Whether client isolation is on.
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) - 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.
It also takes the network's shared drive with it: files_url answers 404 from the moment the network is deleted, and there is no way to restore a network or reach its files afterwards. Copy out anything you want to keep first.
The network's owner — whoever created it — or an Owner or Admin of its organization. Any other Member is refused with forbidden. From router 0.4.179 the network's own owner may delete it at any role; before that it was Owner/Admin only.
Input Schema
{
"network": "string (required)"
}
Parameters:
network- The network to delete: its id or its name. Prefer the id: a name can be the same in two of your organizations. Then the name selects your personal organization's network, else the oldest.
⚠ A delete never selects a network for you (router 0.4.179+). A call with no network is invalid_args, also when you have only one network.
A legacy force field is accepted for backwards compatibility but deprecated and ignored — networks with remaining nodes are always refused. Remove each node first with node_delete, which stops the compute of a provisioned node first.
From router 0.4.179 a network that still holds a live node is refused for every caller, and the refusal names each node and its remedy. Remove the nodes first — node_delete, or deprovision_node for a provisioned one — then delete the network. Detaching a node instead of removing it is not available yet.
deprovision_node removes the node it provisioned from the directory, so a network built entirely with provision_node becomes deletable once every task has been deprovisioned. A node you registered by hand is removed with node_delete or in the app.
Output Schema
{
"network_id": "string",
"name": "string",
"nodes_removed": "number",
"message": "string"
}
Errors
invalid_args— nonetwork.forbidden— you are not the network's owner, and you are not an Owner or Admin of its organization.not_found— the network does not exist, or it is outside your organizations.failed_precondition— the network still has nodes. The message names up to 10 of them, each with itsnode_id, and counts the rest. Remove each node withnode_delete, then callnetwork_deleteagain.
Example Usage
Request:
{
"network": "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)
network_update
Surfaces: public only
Change a network's settings. Router 0.4.179.
A field you do not send does not change. Name the network with network: its id or its name. A rename keeps the network_id, its nodes, its enrollment keys and its services.
The network's owner, or an Owner or Admin of its organization. Anyone else gets forbidden. This is the door the web app writes these settings through.
Input Schema
{
"network": "string (required)",
"name": "string (optional)",
"channel": "string (optional)",
"isolated": "boolean (optional)",
"access": "string (optional)",
"members_may_add_nodes": "boolean (optional)"
}
Parameters:
name- The new name. Same rule asnetwork_create: letters, digits and hyphens, 63 characters or less, lower-cased before it is stored. The owner must not already have a live network with it (already_exists).channel- The release channel for the network's nodes. An empty string clears it, so the nodes follow the default channel. A node's own channel still wins over the network's — seenode_channel_set.isolated- Client isolation.truerefuses new relays between the network's nodes from this call on and hides them from each other's names and directory; a relay already open is not closed and ends when either node reconnects. Use and control of each node do not change, and services published through the proxy still work.access-orgorprivate, the network half of sharing.privatetakes effect on the next call: a member who used a node only through sharing is refused, their running command sessions are closed at the next poll, and their/xfer/URLs stop being redeemable.members_may_add_nodes- Seenetwork_create.falseremoves no node the network already holds, and an enrollment with the network's key still works.
Output Schema
{
"network_id": "string",
"name": "string",
"previous_name": "string (present only on a rename)",
"org_id": "string",
"owner_id": "string",
"channel_id": "string (absent when the network follows the default channel)",
"isolated": "boolean",
"access": "string",
"members_may_add_nodes": "boolean",
"changed": ["string"],
"opened_nodes": [{ "id": "string", "name": "string" }],
"closed_nodes": [{ "id": "string", "name": "string" }],
"kept_private_nodes": [{ "id": "string", "name": "string", "reason": "string" }]
}
changed is empty when every value was already set — nothing was written, and the call still succeeds.
Read the three node lists after a change of access:
| Field | When | What it means |
|---|---|---|
opened_nodes | private → org | Every node of the network now shared with its organization. Every member of each node's organization may now use it |
closed_nodes | org → private | Every node that was shared and is not any more. A member who used one only through sharing is refused on their next call |
kept_private_nodes | private → org | Every node that followed the network but that you do not control. Each was set to private instead, so the change does not share it; its owner can set it back to follow |
That last one is the safety property: a change to org opens only the nodes you control, so a network owner can never open another person's machine to the organization.
network_key_list
Surfaces: public only
List a network's enrollment keys — key_id, name, enabled, digest, created_at, used_at, oldest first. Router 0.4.179.
The secrets are not included. digest is the first 8 hex digits of the key's SHA-256, which identifies a key without showing it; used_at is when a machine last used it, at 5-minute resolution.
The network's owner, or an Owner or Admin of its organization. An enrollment key is the network's reusable credential — whoever holds it can join a machine to the network — so from router 0.4.179 it is no longer readable by every Member. A Member who needs to install an agent asks register_node, which gives them the keyless commands.
Parameters: network, its id or its name (omit it to resolve your sole network).
network_key_create
Surfaces: public only
Mint a new enrollment key for a network. Router 0.4.179.
The router generates the secret — there is no field for one — and the result carries it once. Treat it as a secret: anyone who has it can join a machine to the network.
Parameters: network (its id or its name), and name for the key.
To rotate a key, create a new one and then disable or delete the old one. The secret of an existing key never changes.
The network's owner, or an Owner or Admin of its organization.
network_key_update
Surfaces: public only
Rename, disable or enable one enrollment key. Router 0.4.179.
Parameters: key (the key's key_id from network_key_list), and name and/or enabled.
A disabled key enrolls no machine; the nodes it already enrolled are unaffected and stay in the network. The secret never changes — to rotate, use network_key_create.
The network's owner, or an Owner or Admin of its organization.
network_key_delete
Surfaces: public only
Delete one enrollment key. Router 0.4.179.
Parameters: key (the key's key_id from network_key_list).
The key then enrolls no machine. Nodes it already enrolled stay in the network — a node's identity is its own key, not the enrollment key that introduced it. A network with no enabled key cannot hand out key-bearing install commands until network_key_create makes one.
The network's owner, or an Owner or Admin of its organization.
org_audit_list
Surfaces: public only — REST/OpenAPI, not over MCP
Read an organization's audit log: every tool call and privileged access recorded against it, newest first.
An Owner or Admin. A Member is refused with forbidden.
⚠ Money data is withheld from an Admin (router 0.4.179): an Admin reads every row, with the money figures stripped from the rows that carry them. Which plan and interval the organization bought is not money data — an Admin sees that. Only the Owner reads amounts.
Parameters: org_id (required), action, actor_id, q, limit, offset, include_actions.
actionandqare case-insensitive substring filters, matched literally —%and_are not wildcards.qmatches the actor's name from router 0.4.179, as well as the action, the actor's email, the target id and the target's name. Searching for a person by the name they are known by therefore works; before this release only their email address matched.- A target's name is resolved at read time, so a rename shows the current name — and a target that no longer exists still shows the name stored when the row was written, with
target_deleted: true.
org_support_access
Surfaces: public only — REST/OpenAPI, not over MCP
Read or change whether noBGP staff may act in your organization. Router 0.4.179.
The setting — "Allow noBGP support" — is on by default. With it off, noBGP staff cannot act in the organization at all, and an Owner or Admin can give staff temporary access instead and end it early. Every staff action is audited either way, and its audit row says whether the setting or a temporary grant admitted it.
Input Schema
{
"org_id": "string (required)",
"op": "string (optional)",
"allow_support": "boolean (optional)",
"hours": "number (optional)"
}
Parameters:
op-get(the default): report the setting and any live grant.set: change the setting toallow_support.grant: give staff temporary access forhours(1–720; a new grant replaces the current one).end: end the temporary grant now.
Output Schema
{
"org_id": "string",
"allow_support": "boolean",
"grant_until": "timestamp (absent when no grant is live)",
"granted_by": "string (absent when no grant is live)",
"staff_access": "boolean"
}
staff_access is the answer the router gives at every staff door right now: the setting is on, or a temporary grant is live.
Reading needs membership. Changing needs an Owner or Admin — staff cannot let themselves in, and this tool has no staff path of its own.
What staff can and cannot do is noBGP staff access. A node's allow-root: false refuses staff exactly as it refuses anyone.
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 to network_create.
Input Schema
{
"name": "string (required)"
}
Parameters:
name- Display name for the new organization (1–100 characters)
The new organization starts on Free
From router 0.4.127 the plan is part of the row this call writes: the organization is created on the Free plan, with Free's allowances and Free's hard caps, and it counts towards three free organizations per owner from the moment it exists.
⚠ Through router 0.4.126 it was created on no plan at all. That is not a neutral absence — an organization with no plan was read as the unmetered tier everywhere downstream, so no allowance bounded it and the free-organization limit could not see it. Subscribing has always set a plan, so an organization that went through checkout was never in that state, and the ones that were have been moved to Free.
Three free organizations per owner
From router 0.4.103 an account may own at most three organizations on the Free plan, counting the personal organization created at signup. A fourth is refused with resource_exhausted and details.limit_type: "organizations", quoting how many you own against the limit, and naming the two ways forward: upgrade one to a paid plan, or have someone else own the next one.
Only free organizations you own are counted — being invited into any number of others never is, and paid and Enterprise organizations are not counted at all. The same check applies to org_transfer_ownership, so a free organization cannot be handed to someone the transfer would take past the limit. See Plans & Billing.
Output Schema
{
"org_id": "string",
"name": "string",
"role": "string",
"created_at": "timestamp"
}
Fields:
org_id- Identifier for the newly created organization. Pass this asorgtonetwork_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
Each member of an organization has one role: owner, admin or member.
| Role | Capabilities |
|---|---|
owner | Everything, including billing, SSO and transferring ownership |
admin | Manage members and invites, and control every node and network in the organization |
member | Create networks, and control the nodes and networks they own. Use other nodes that are shared with the organization or that list them on their access list |
What you may do on a node depends on what you hold on it (use, root or control), not on your role alone: see my_access in node_list (with view: "full"). Only making a service public needs an Owner or Admin. Roles & Permissions is the full matrix and the single source of truth. See also Execution identity for what root: true needs and what a refusal looks like.
org_invite_create
Surfaces: public only
Invite a person to an organization by email.
From router 0.4.193 it is on MCP — the one organization-membership tool there. The rest of them (org_members_list, org_member_remove, org_member_set_role, org_invite_accept, org_invite_resend, org_invite_revoke, org_leave, org_transfer_ownership) stay REST-only and are described on Organizations.
Purpose
Add somebody to an organization so they can be given access to its networks and nodes. A node's access list names members of that node's organization and nobody else, so node_access_grant refuses a person who has not joined yet — and names this tool as the way forward, with the org_id the invite needs.
Input Schema
{
"org": "string (required)",
"email": "string (required)",
"role": "member | admin | owner"
}
Parameters:
org- The organization to invite them into, fromwhoami'sorgs[]. ⚠ For access to a node, send the node's ownorg_idasorg—orginnode_list's row withview: "full", not the network's: every gate on a node asks the node's organization.node_access_grant's refusal carries that id, so the invite is one call.email- The address to invite. Only this address can accept — see Only the invited person can accept.role- The role granted on acceptance:member(default),adminorowner. An Admin cannot invite an Owner (forbidden).
Output Schema
{
"invite_id": "string",
"email": "string",
"role": "string",
"invite_url": "string",
"expires_at": "timestamp"
}
Fields:
invite_id- The pending invite.email,role- The address invited and the role it will be granted on acceptance.invite_url- The accept link, carrying a one-time token.expires_at- When the link stops working, RFC3339. Invites last 7 days.
The link is in the result as well as in the mail
noBGP emails the link to the invited address, and a failed send does not fail the call: the invite exists either way and invite_url is in the result, so hand the link to the person if the mail does not arrive.
⚠ They are a member only once they accept, and nothing pushes you that news. Until then node_access_grant still refuses them. There is no members list on this surface — org_members_list is REST-only — so either read the members in the app or try the grant again.
A second invite for an address that already has a pending one is already_exists; resend or revoke that one instead. An invite that has already expired does not block a fresh one.
An Owner or Admin of the organization. A node's local MCP server never has it: a machine must not add members to an organization.
provision_node
Surfaces: public only
Provision a new compute instance on a cloud provider.
Purpose
Create on-demand compute resources with automatic noBGP agent installation.
Who may provision
Every account may provision, on every plan including Free, from router 0.4.110. Through router 0.4.109 the tool was gated on an allowlist written from the caller's email domain, which admitted three internal domains and no customer at all, while every plan sold an amount of included compute — so the one person who ever learned was the customer who had paid for an allowance the tool refused to let them spend.
What bounds a caller is money and nothing else: the included compute, an empty credit balance, and a lapsed subscription. Those refuse with resource_exhausted naming the number, never forbidden. Provisioning stays a Member-tier action.
⚠ The target network has a say, from router 0.4.179. Adding a node to a network needs the network's side as well as yours: its owner, an org Owner/Admin, or any member while the network's members_may_add_nodes setting is on — which is the default, so nothing changes for most callers. A network whose owner turned it off refuses a Member's provision_node with forbidden. See the attach rule.
⚠ A forbidden from this tool now means one thing: provisioning was revoked for that account specifically, which only happens deliberately. Such a caller is not offered provision_node on tools/list at all, nor task_deadline_set, which makes a container run longer and is withheld for the same reason (router 0.4.125) — but is still offered deprovision_node and task_stop, deliberately, so a revocation can never strand running compute that keeps billing with nothing able to stop it.
Input Schema
{
"name": "string (required)",
"network": "string (optional)",
"provider": "string (optional)",
"class": "string (optional)",
"hours": "integer (optional)",
"access": "string (optional)"
}
Parameters:
name- Name for the provisioned node. It is a request, not a guarantee: the response's ownnode_nameis the name actually used, normalized and — when yours is already held or contested — suffixed.network- Target network: its id or its name. An id is the UUIDnetwork_listreports. An id names exactly one network, so use it when two of your organizations have a network with the same name. An id in an organization you are not a member of isnot_found, with the same message as an id that does not exist. ⚠ When two of your organizations have a network with the name you send, the one in your personal organization is used; when neither is in your personal organization, the oldest is used. Send the id for the other.- Omit
networkand the tool uses your sole network — judged inside your personal organization if you belong to several, so a shared org's network is never provisioned into by accident. provider- Cloud provider:"aws"(default)class- Compute size, and the thing that sets the price. Default"small".hours- How many hours this machine may run before its compute stops on its own, from router 0.4.117. Omit it and you get your plan's default, which is 24 hours.0means no deadline and is accepted on every plan but Free. The resolved instant comes back asdeadline_at— read it rather than computing it, and move it later withtask_deadline_set. See Every machine has a deadline.access- The new node's sharing setting, from router 0.4.179.follow(the default) — the node follows its network, so every member of the organization may use it (never as root) while the network's access isorg.private— only you, the organization's Owners and Admins, and the members you add withnode_access_grant. It is written once, onto the node the container creates, and never after the node is shared for a moment; a re-adopted node keeps the setting it already has. Change it later withnode_access_set.
Compute classes
Size and price are one choice, from router 0.4.101. There are four classes, the last two added in router 0.4.103:
class | Shape | Price |
|---|---|---|
small (default) | 0.25 vCPU / 0.5 GiB | $0.02 / hour |
medium | 1 vCPU / 2 GiB | $0.06 / hour |
large | 2 vCPU / 4 GiB | $0.12 / hour |
xlarge | 4 vCPU / 8 GiB | $0.24 / hour |
Billed per minute of running time, from provision until deprovision_node — rounded up to whole minutes, with a one-minute minimum per task, which is why billed minutes rather than elapsed time are what the invoice charges. From medium up the rate is $0.06 per vCPU-hour, and it is the same on every plan: a larger plan includes more hours, not cheaper ones. The included compute is one budget spendable on any class at any concurrency. An hour is an hour of a small machine: a medium hour draws three of them, a large hour six. See Provisioned compute.
All four classes are placeable, from router 0.4.144 — large and xlarge were priced and accepted before the capacity behind them existed, so a request for one could be accepted and then fail to start. That is over; an xlarge node has been provisioned and run. ⚠ An xlarge task fills a whole machine, so several of the big classes at once need capacity to be added and the later ones wait for it rather than starting immediately.
⚠ The memory in a class is a hard limit (router 0.4.144). A process that grows past it is killed by the kernel — an out-of-memory kill inside your container, not a noBGP refusal — so a build that needs more than the class buys wants a bigger class rather than a retry. The vCPU figure is a share, not a ceiling: it decides how much CPU the task wins when the machine is busy, and on an otherwise idle machine a task can use more than it. Size on memory, and read the class you were actually given back out of the response.
⚠ cpu and memory are gone, and sending either is now invalid_args. They let you name a shape nobody had priced — the request was then silently rounded up to a power of two, so the shape you got was often not the shape you asked for and had no rate behind it either way. The default did not move: an omitted class is small, which is exactly the 0.25 vCPU / 0.5 GiB an omitted cpu/memory pair used to produce, so a call that named neither behaves identically. A class name is matched case-insensitively and trimmed, and an unknown one is refused with invalid_args naming every offered class and its shape — the published schema carries no enum, so the refusal is where the vocabulary lives.
Every machine has a deadline
From router 0.4.117 a provisioned machine stops on its own, and by default that is 24 hours after it starts. hours is what sets it, and the resolved instant comes back in deadline_at — the response is the only place that instant exists, so read it rather than recomputing it from the number you sent.
hours | Result |
|---|---|
| omitted | the plan's default: 24 hours, on every plan |
| a number of hours | that many hours from now — up to the plan's maximum, which is 168 (seven days) on Free and none on the paid plans |
0 | no deadline, on every plan but Free; the machine runs, and bills, until deprovision_node |
| negative | invalid_args — -3 is not a duration |
| above the plan's maximum | invalid_args, naming the maximum in hours, the default, and that the paid plans have none |
Omitting the argument is the protection, and 0 is the escape. An omitted hours never means "no deadline" — it means the plan's default. Turning the deadline off is a per-task, deliberate choice on every plan but Free; there is no way to set it for a whole organization and no way to make it the default.
⚠ The maximum is the plan's, from router 0.4.155. Before it every plan shared one bound of 8760 hours — a year — which was too long to bound anything on Free and meant nothing on a plan that can ask for no deadline at all. Free is capped at 168 hours (seven days); Pro, Business and Enterprise have no maximum, because the same caller can send hours: 0 anyway. The refusal names the number in hours, so the retry is not a guess: the free plan allows a deadline of at most 168 hours (7 days).
⚠ Free cannot switch it off. hours: 0 on Free is invalid_args, and the refusal names the default so the retry is obvious: the free plan cannot run a task without a deadline; omit max_hours for the default of 24 hours, or name a longer one. The deadline and the included compute are the only two things bounding a Free machine.
⚠ Enterprise takes the same 24-hour default as Business, from router 0.4.125. It used to have no deadline at all — an unmetered organization had no default applied and max_hours was not read. Being unmetered is a statement about the invoice, not about the machine: an Enterprise container nobody remembers still occupies capacity, still holds its name, and still destroys its own disk whenever it eventually stops. It turns the deadline off per machine with hours: 0, like Pro and Business.
Which plan decides is the plan of the organization that will be billed — the same organization the money gates are checked against, which for a node being replaced is the one that owns the node rather than the one that owns the network.
What happens at the deadline
The task's compute stops, and what is left is an ordinary offline node — exactly the state a machine stopped for the compute allowance is left in:
| Kept | Gone |
|---|---|
| The node's identity, name, labels and role grants | The container, and everything written to its own disk |
| Its node storage area | Whatever was in memory — processes start fresh next time |
Starting it again is provision_node on the same name, which re-adopts the identity and hands out a fresh deadline.
⚠⚠ To KEEP a machine that is still working, move its deadline with task_deadline_set — do not re-provision it. Re-provisioning renews the node, not the machine, and while the first machine is still running it does not even hand back the name: an open task holds it, so the new container is provisioned under the next free name as <name>-1. You end with two machines billing, the second one empty, while the one you meant to keep still stops at its deadline and takes its disk with it. This paragraph used to end "a running task's deadline cannot be extended in place; there is no tool for it", and from router 0.4.125 there is.
⚠ A machine may run a few minutes past its deadline. Deadlines are checked on a sweep rather than by a per-task timer, so the stop lands at or shortly after the instant in deadline_at, never before it.
⚠ It is not an enforcement action — it is the bound you asked for at provision time — so it applies whatever your organization's usage state, and no allowance, cap or payment status is consulted to honour it.
⚠ Tasks provisioned before router 0.4.117 have no deadline and are unaffected. Nothing was applied retroactively: a container whose owner was told it runs until they deprovision it is not stopped by a rule written after it started.
⚠ Read the notes[] on the envelope. Every provision carries a sentence naming the instant, or saying there is no deadline. In the rare case where noBGP started the container but could not record the task, the notes say so explicitly — that container has no deadline, nothing will stop it, and there is no task_id to deprovision it with, so it has to be found by node name.
The money gates
Every provision is checked, from router 0.4.103, against the organization that will be billed for it — which for a node being replaced is the organization that owns the node, not necessarily the one that owns the network:
| Check | Applies to | Refusal |
|---|---|---|
| Compute allowance | Free — blocked, never billed for overage | resource_exhausted, limit_type: "compute", quoting used and included in hours |
| Empty credit balance | Paid plans, where usage past the allowance is drawn from credit — router 0.4.127+ | resource_exhausted, limit_type: "credit_empty" |
| Lapsed subscription | Paid plans | resource_exhausted, limit_type: "lapsed" |
Every refusal carries details with limit_type, used, included and hard_cap, so a client can branch on the kind rather than on the message text. An upgrade_url is there too over REST; from router 0.4.196 the MCP surface sends no link to the billing page, and the message names the limit and the ways out.
⚠ The money gate on a paid plan is the balance, not the cap, from router 0.4.127. It refuses only when an included allowance is used up and the credit balance behind it is empty, and it names both ways out — buy credit, or wait for the period to roll over. Through router 0.4.126 it compared your period-to-date overage against the spend cap, which has bounded automatic top-ups only since router 0.4.122: an Owner could hold a large balance and the default $0 cap at once, be refused every provision, and find that buying credit did not clear it. ⚠ limit_type: "spend_cap" is a refusal the router no longer produces at all, from router 0.4.132. It survived only where a deployment invoiced usage past the allowance instead of drawing it from a balance, and that mechanism is retired; the spend cap now bounds automatic credit refills and refuses nothing. A client branching on it can drop that branch. See an empty balance is what refuses creation.
⚠ There is no node-count gate any more, from router 0.4.106. No plan caps how many devices an organization may have — Free's cap of 25 was the last one — so limit_type: "nodes" is a refusal provision_node no longer produces. What still refuses a provision is money: the compute allowance, an empty credit balance, or a lapsed subscription. See Plan limits.
Output Schema
{
"task_id": "string",
"provider": "string",
"network_id": "string",
"node_name": "string",
"class": "string",
"resource_summary": "string",
"deadline_at": "string (absent when the machine has no deadline)",
"readopted_node_id": "string (only when this replaces an existing node)"
}
This is the payload under provision_node. Any caveat about the outcome arrives as notes[] on the envelope around it, from router 0.4.83 — it used to sit in the payload.
task_id- The deprovision handle. Keep it: the node runs, and bills, untiltask_stopordeprovision_nodeis called with it — or untildeadline_at, whichever comes first.network_id- The id of the resolved network the node will register into, from router 0.4.149. Subscribe to readiness with it (presence_subscribewithselect: {network: <network_id>}): networks in two of your organizations can share a name, and an id names exactly one.network_name- REST only from router 0.4.201: the resolved network's name, whichnobgp/appreads. Over MCP usenetwork_id.node_name- The name actually used, which is not always the one you asked for. It is normalized (case, whitespace), and from router 0.4.112 it is suffixed when the name you asked for was held by a live node — or, from router 0.4.113, when it is contested. See A name already in use is suffixed. Address the node by this value everywhere afterwards; never assume it echoes your input back.class- The class actually provisioned, and the one that bills. Present on every provision, including one that named no class and got the default.resource_summary- The class and the shape it allocated, as a person reads it —"small (0.25 vCPU / 0.5 GiB)". It is derived from the shape the provider actually gave you rather than echoing the class you asked for, so read it back rather than assuming.deadline_at- RFC3339 UTC instant at which this machine's compute stops on its own, from router 0.4.117. It is the resolved value — from yourhoursor from the plan's default — so read it rather than recomputing it. Absent means no deadline: the machine runs, and bills, untildeprovision_node. It can be moved later withtask_deadline_set, and re-read at any time fromnode_list'sdeadline_at(withview: "full"). See Every machine has a deadline.readopted_node_id- Present only when this call replaces an existing node rather than creating one: thenode_idthe container will register as, keeping that node's labels, role grants and node storage. Absent means a new node will be minted.notes(on the envelope) - Present only when there is something to report about the outcome.
There is deliberately no node_id in this response — unless readopted_node_id is set, which is the one case where the id is known in advance. Otherwise the node row is minted when the container's agent self-registers, which happens after this call returns — typically within a minute. A node_list call issued immediately will not find it, and that is not a failure.
Re-provisioning replaces the node it names
Provisioning a name that a node you previously deprovisioned held brings that same node back: the same node_id, and with it the node's labels, its role grants and its own storage area. The response says so in readopted_node_id. The container's own disk is new — only the identity and what hangs off it survive.
The storage area comes back only if the node has been gone for less than 30 days (router 0.4.73). Past that window its area has been queued for deletion and the node returns with an empty one; the identity, labels and grants return either way. Re-provisioning inside the window cancels that deletion outright, so a node parked for a week is unaffected.
⚠ A rename changes what this re-adopts, because re-adoption is by name: after a node is renamed, provisioning its old name creates a new node and provisioning its new name brings that node back.
Re-provisioning is also how a node whose compute was stopped comes back — there is deliberately no resume tool. A node the platform stopped for the compute allowance, an empty credit balance, a lapsed payment or its own deadline — or one you stopped yourself with task_stop — is left as an ordinary offline node with its identity intact, so bringing it back is exactly the re-adoption above: provision the same name into the same network once the cause is cleared. What does not come back is the container's memory or local disk — the replacement starts its processes fresh.
⚠ From router 0.4.112 there is nothing extra to look for. Through router 0.4.111 such a node carried a suspended_at in the directory and the provision response said it had been resumed; both are gone, because a stopped machine is an offline node and there was never a fourth state to report. See node_list.
A name already in use is suffixed
From router 0.4.112, provisioning a name that a live node holds is no longer refused — the container is provisioned under the next free name in the family instead (<name>-1, then -2, and so on, up to eight steps before the call gives up and returns already_exists). The walk starts after any numeric suffix the name you asked for already carries, so web-3 is offered web-4 and never web-3-2.
Three things move a name, and none of them is about who created the node:
- the node holding the name is online, or
- an open provisioning task still holds it — a container that was started and never deprovisioned, which is what the online flag cannot see when a node's connection dropped while its container kept running, or
- the name is contested (router 0.4.113): another node that still exists was renamed off this name and is not answering, so noBGP cannot tell which machine the name belongs to. See A contested name costs the name, not the connection.
⚠ Read node_name off the response. The suffix is reported there and nowhere else, and it is the only thing standing between the caller and addressing the wrong machine: every later command, fs_* or node_list call keyed on the name you typed reaches the node that already held it. A notes[] entry and the result message say the same thing for a person — from router 0.4.113 they name both names ("Named web-1, not web: the name you asked for is already in use. Address this machine as web-1.") and no longer state a cause, because the three collisions above have three different ones and only two of them involve a running machine.
readopted_node_id beside it separates the outcomes:
node_name | readopted_node_id | What happened |
|---|---|---|
| the name you asked for | absent | a new node under your name |
| the name you asked for | present | an existing identity re-adopted |
| suffixed | absent | a new node, because your name was taken or contested |
| suffixed | present | a node already holding that suffixed name was re-adopted — its identity, not the one behind the name you asked for |
The whole of what a name already in use does — the last three rows are the already_exists refusals that remain, and each names the case that fired:
| The node holding the name | Result |
|---|---|
| Is offline or deprovisioned — whoever created it | Replaced, keeping its node_id, labels, grants and node storage |
| Is online, or has an open provisioning task | Suffixed — provisioned under the next free name |
| Was renamed off this name by a node that still exists and is not answering | Suffixed (router 0.4.113) — the identity behind the name you asked for is not re-adopted, because noBGP cannot confirm which machine it belongs to; read readopted_node_id to see whether anything was re-adopted under the name you got |
| Is revoked | Refused — a revocation is about the identity, not the name, so moving you to a free name would quietly hand you a working node instead |
| Holds a key that a different live node has since registered | Refused — the name is free but the identity cannot be restored; the message names the node holding the key |
| Would suffix past a valid node name, or past eight attempts | Refused — deprovision one of them, or ask for a shorter or different name |
Who created the node is deliberately not one of the conditions. Provisioning is a registration, so a name an offline hand-registered node holds is replaced too, exactly as pointing a second register_node install at that name already would — what is new is that a container claims the identity immediately. If that is not what you want, deprovision or rename deliberately rather than relying on a refusal.
From router 0.4.111 the open-task check finds the container behind a node that has since been renamed. It matched the name each task recorded at provision time, which nothing updates — so one rename made a still-running container invisible to the guard whose whole job is to see it, and the name was accepted. Provisioning onto it then put two containers under one identity, evicting each other's connection, while the first task went on billing. The lookup is now keyed on the node itself.
Waiting for the node
Subscribe before the node exists, then read the bus:
presence_subscribewithselect: {network: <network_id>}, thenetwork_idfrom the response, and noselect.nodes— anodesselector resolves against nodes that already exist, so it cannot name one that does not.event_tailuntil a presence event carries the returnednode_namewith transitionready— router 0.4.84+, and notonline. A provisioning node's bridge comes up, drops and comes back (measured: five transitions in 1.5 seconds), so the firstonlinenames a session that is about to die, whilereadymeans one has held for 5 seconds. On an older router, or against a client that does not know the token,onlineis what you have — expect to see the flap.
Polling also works, and is the only option on an isolated network, which has no event bus — repeat this node_list call until the node appears:
{
"select": { "network": "production", "name": "api-server-2", "online": true }
}
Once the node is ready, node_list with view: "full" reports its user and allow_root — read those before the first command or file call to pick the root bool. A provisioned container now arrives with an unprivileged account already configured, so it serves root: false from its first boot; before that it came up with root as its only identity and every unelevated call was refused until someone created an account by hand. Read the field rather than assuming either shape — a node you installed the agent on yourself reports whatever that machine has.
Example Usage
Request:
{
"name": "api-server-2",
"network": "production",
"class": "medium",
"hours": 72
}
Response:
{
"task_id": "3f8c1a92-5d47-4b0e-9c21-7a6e0d5f3b18",
"provider": "aws",
"network_id": "8b2e4f10-6c3a-4d7e-9f15-2a0b7c9d1e44",
"node_name": "api-server-2",
"class": "medium",
"resource_summary": "medium (1 vCPU / 2 GiB)",
"deadline_at": "2026-09-05T18:00:00Z"
}
Provisioning Flow
- Request received and validated
- Container instance created on cloud provider — this call returns here
- noBGP agent installed and configured automatically
- Agent registers with your network — the node row is created,
registeredpresence event - Node appears as "online" in
node_list
Typical duration: 1-2 minutes
deprovision_node
Surfaces: public only
Terminate and remove a provisioned compute instance.
Destructive operation. All data on the container's own disk will be permanently lost.
Purpose
Remove provisioned infrastructure and stop billing.
Control of the node — its owner, or an Owner or Admin of the node's organization — from router 0.4.179; before that any Member of the organization could deprovision any machine in it. It is offered even to an account whose provisioning has been revoked, so nothing can be left running with no way to stop it.
Input Schema
{
"task": "string (required)",
"delete_node": "boolean (optional, default true)"
}
Parameters:
task- Identifier of the provisioning task to deprovision (thetask_idUUID returned byprovision_node)delete_node- Router 0.4.179+. Omitted ortrue: remove the node too, as described below.false: keep the node, offline, with its id, labels and storage — the statetask_stopleaves — soprovision_nodeon its name starts it again. Either way the container stops and its billing ends.
With delete_node omitted or true, this stops the container and its billing, and removes the node from the directory — so the network can then be deleted with network_delete, and teardown round-trips without an app-side step.
The node's identity is retained, not destroyed. The row is kept precisely so that provisioning the same name again brings back the same node_id with its labels, role grants and node storage intact; the notes on the response say which of the two you will get. Deleting outright would free the name and lose all three.
Its storage area is retained too, but for 30 days (router 0.4.73), after which it is queued for deletion and the bytes go about a week later. The identity is kept indefinitely; only the files have a clock on them.
Only the node this task created is removed, and only while this task is still the newest one for that node. A node that was registered by hand onto the same name is left alone, and re-running a deprovision that a later provision_node has already superseded leaves the node alone rather than removing the one the new container is running as.
That guard asks about the node, not about the name, from router 0.4.111. It used to compare the name each task recorded at provision time — a copy nothing updates — so on a node that had been renamed and then re-provisioned, the newer task carried the new name, the older one looked unsuperseded, and re-running its deprovision removed the node the new container was running as. Both halves are now keyed on the node_id the container registered as.
A task the provider no longer runs — stopped out of band, or aged out of the provider's task store — succeeds with a note saying so, rather than failing. Already-stopped is the desired state, reached earlier; returning an error there described a failed teardown of something that was already not billing, and invited retries that could never succeed. The call still does the bookkeeping the out-of-band stop skipped, so it is safe to issue when you are unsure.
It waits for the node to go offline
From router 0.4.120 this call waits, up to 35 seconds, for the node to stop reading online before it returns — so a script may chain deprovision_node straight into provision_node on the same name.
Stopping the container is asynchronous: the provider is asked to stop it and answers at once, while the machine then takes its shutdown signal, its agent's connection closes, and only then is the node marked offline. Through router 0.4.119 the call returned at the first step, so a caller that immediately re-provisioned the name arrived while the node still read online, was taken for a live collision, and got a suffixed name with a new node_id and an empty storage area — success-shaped, and visible only by comparing the name returned against the name asked for.
node_offline on the response reports the outcome, and it is the field to read before re-provisioning:
node_offline | What it means | What to do next |
|---|---|---|
true | The node's connection closed. The name is re-adoptable now | Provision the same name; it comes back as the same node_id |
false | The wait ended without seeing that. The deprovision still succeeded | Poll node_list until the node is gone, then provision — provisioning now can suffix |
| absent | No wait ran: either this task had no node, or a newer task has since re-provisioned this node and owns it now | Read notes[], which says which |
⚠ It is the node's connection that is observed, not the container. A node is marked offline when its connection to the router drops, and an agent whose connection dropped while its container kept retrying reads offline and is still alive.
⚠ A false is an answer, not a failure. The teardown happened either way; what did not happen is the confirmation that the name is reusable. The result message and notes[] say so too, for a person reading only the sentence.
⚠ A superseded task waits for nothing. Re-running a deprovision that a later provision_node has already replaced returns immediately with node_offline absent — the node it names belongs to a newer container and is supposed to be online.
⚠ A deprovision whose bookkeeping fails now fails the call (internal), rather than succeeding with a warning. The container is stopped either way, but until the task is recorded as closed the name cannot be re-provisioned — one lost write used to make that name suffix on every later provision, permanently, with nothing to close it. Retry deprovision_node.
Output Schema
{
"task_id": "string",
"reference": "string",
"provider": "string",
"node_offline": "boolean (absent when no wait ran)"
}
This is the payload under deprovision_node; notes[] sits on the envelope beside it, from router 0.4.83 — it used to be part of the payload. It carries the human-readable detail: the node the task was associated with, whether the provider reported it already stopped, how long the offline wait took, whether provisioning the name again will re-adopt the same node_id (a revoked node will not be), and the task's total lifetime.
node_offline- Whether the node reached offline before this call returned (router 0.4.120+). See It waits for the node to go offline — absent,trueandfalseare three different answers.
The wait means this call can take up to 35 seconds where it used to return in about one. A client whose per-call timeout is shorter than that cancels mid-wait; the teardown is unaffected and completes, but you lose the answer node_offline was about to give.
Example Usage
Request:
{
"task": "task_provisioning_123"
}
Response:
{
"op": "deprovision_node",
"done": true,
"notes": [
"Node dev-server-1 read offline 892ms after the stop; the name is reusable now.",
"Task ran for 2h 15m before deprovisioning."
],
"deprovision_node": {
"task_id": "task_provisioning_123",
"reference": "arn:aws:ecs:...",
"provider": "aws",
"node_offline": true
}
}
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
url(plain URL by default, e.g.http://127.0.0.1:8080). - Terminal mode: Provide
command(command to run in the terminal) - Shell mode: Omit both to create a terminal running the default user shell
Input Schema
{
"node": "string (required)",
"title": "string (optional)",
"url": "string (optional, plain URL by default)",
"encoding": "utf8|base64 (optional, default: utf8)",
"command": "string (optional)",
"root": "boolean (optional)",
"admin": "boolean (REST only; the old name of root)",
"confirm_root": "boolean (optional)",
"workdir": "string (optional)",
"enabled": "boolean (optional, default: true)",
"auth": "boolean (optional, default: true)",
"visitors": ["string (optional)"],
"org": "boolean (optional, default: false)"
}
The node's controllers — its owner, or an Owner or Admin of the node's organization — from router 0.4.179. A service belongs to the node it runs on, so holding use of the node is not enough, whoever published the services already there. Before that release any Member of the organization could publish on any node in it.
Parameters:
node- The node where the service will be published: its id or its nametitle- Human-readable service nameurl- Host/URI/port to proxy. Plain URL by default (e.g.http://127.0.0.1:8080). Setencoding: "base64"to pass a base64-encoded value (legacy form). Mutually exclusive withcommand. ⚠ A node refuses to dial its own agent ports from agent 0.4.144 — see A target that is one of the agent's own ports below.encoding- Encoding ofurl:"utf8"(default — value is a plain URL) or"base64"(legacy — value is decoded before use). Omit unless you have a specific reason to encode.command- Command to expose via a web terminal. Mutually exclusive withurl.root- Run the terminal as root (terminal/shell services only).adminis its old name, REST-only from router 0.4.195; over REST, sending both isinvalid_args. Omit it to run as the node's configured user; see Execution identity. A node whose non-root identity would be the superuser refuses a non-root terminal rather than publishing a root one — publish it withroot: trueif that is what you mean, or configure an account on the node. Publishing one as root needs root on the node and control of it: arootaccess-list entry gives root, never the right to pass it on. A published terminal is a standing grant that outlives this call, so authority is checked when it is created andallow-rootis re-checked by the node at every session open. ⚠ Here it stays a plain boolean, and omitting it meansfalse— the executing tools spell it as a string whose third state is omission, and a standing grant has no identity for a node to resolve, so the type differs across the surface deliberatelyconfirm_root- Required when this call names a visitor on a root terminal who is not one of the node's controllers. A named visitor there gets a root shell with norootentry on the node, an address outside the organization included, so the name is itself the root grant. Without it the call is refused with a warning that names every outside address, and changes nothing — from router 0.4.192 that refusal also carriesdetailsa client can branch on. See A root terminal is a root grant.workdir- Working directory for command execution (terminal/shell services only)enabled- Whether the service starts enabled (default: true)auth- Whether sign-in is required (default: true). Only set to false if the user explicitly asks for unauthenticated access. Setting it tofalseneeds org Owner/Admin — anyone else is refused withforbidden("service.unauthenticated requires a higher role in this organization"), because it publishes something anyone with the URL can reach. Publishing with authentication on needs no special role, and passingvisitorsforces it back on. ⚠ A terminal can never be public:falsetogether withcommand, or with nourl(the default shell), isinvalid_argsfor every role. See Roles & Permissions.visitors- Email addresses or patterns to authorize (e.g.user@example.com,*@company.com). Impliesauth: true. On a root terminal every entry must be an exact address — a wildcard isinvalid_args— and each name is itself the root grant.org- Open this sign-in service to every member of the node's organization, on top ofvisitors. Defaultfalse. ⚠ Router 0.4.179: without it a sign-in service opens only for the node's controllers and the addresses you name — organization membership alone opens nothing. Set it only when the whole organization is meant to have access. It isinvalid_argson a root terminal, and it changes nothing on a public proxy. See Who may visit a service.
Output Schema
{
"public_url": "string",
"mode": "proxy|terminal",
"auth": "boolean (true: a visitor must sign in)",
"org": "boolean (true: every member of the node's organization may visit; absent otherwise)"
}
The service's id and name are on the envelope as service: {id, name} — over REST the envelope also carries service_id.
auth and org echo who may visit, so the default — the node's controllers and the addresses you named, and nobody else — is visible in the response rather than assumed.
public_url is https://<name>.nobgp.link (router 0.4.172), where <name> is the service's own name, lower-cased. Read it from the response rather than building it: through router 0.4.171 the domain was nobgp.com, and that spelling now serves no service — from router 0.4.175 it redirects permanently to the nobgp.link URL, which is a migration aid rather than the service's address. The same URL appears as public_url on service_update, service_delete and each service in node_list's services (with view: "full"). ⚠ Requests under /.nobgp/ on that host are the router's and never reach the backend; see Where a service is served.
Verifying your work
A successful publish proves the route was recorded — not that the backend is alive. url is fetched from the node, so 127.0.0.1 there means the node itself, and nothing in this call reaches it.
Fetching public_url cannot confirm the backend either. With authentication on (the default) the proxy answers 307 to the login flow whether the backend responds or not, so the status code you get back says nothing about what is behind it.
service_check is the answer (router 0.4.84+). It probes through the same channel a real visitor's request rides and reports reachable / unreachable / not_applicable, with the backend's own status code or the node's own error text:
{ "service": "svc_abc123" }
Two older ways still work and are worth knowing:
-
Run the target fetch on the node, via
command:curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080 -
Once a browser has traversed the proxy, the backend's own log shows the request.
A target that is one of the agent's own ports
From agent 0.4.144 a node will not dial its own control surfaces on a published service's behalf. Those are the node's local MCP server, the shared-drive proxy, the NFS server, the overlay DNS server and the Windows agent API — plus port 111, the rpcbind the installer brings in for the drive.
- Publishing still succeeds, because this call only records the route and never reaches the node. The first request fails with
permission_deniednaming the socket that was refused, andservice_checkanswersunreachablecarrying the same sentence — which is how to find out without waiting for a visitor. - What it prevents is a target like
http://127.0.0.1:<an agent port>handing a visitor the node's own control surface: withauth: falsethat is anyone with the URL, and the shared-drive proxy in particular stamps the node's own credentials onto every request it forwards. - ⚠ It is about the node itself. Where the target names another machine on the node's LAN, only port
111is refused — a listener this agent holds says nothing about the same port on a different host. - It is the same check that keeps another node off those ports, applied to the second route into them. A node below 0.4.144 dials such a target as it always did.
Example Usage
Proxy Service:
Request:
{
"node": "web-server-1",
"url": "http://localhost:8080",
"title": "staging-app",
"auth": true
}
Response:
{
"public_url": "https://a1b2c3d4.nobgp.link",
"mode": "proxy",
"auth": true
}
Terminal Service:
Request:
{
"node": "raspberry-pi",
"command": "bash",
"root": false,
"auth": true
}
Response:
{
"public_url": "https://x9y8z7w6.nobgp.link",
"mode": "terminal",
"auth": true
}
Default Shell Service:
Request:
{
"node": "my-server",
"auth": true
}
Response:
{
"public_url": "https://p1q2r3s4.nobgp.link",
"mode": "terminal",
"auth": true
}
service_update
Surfaces: public only
Modify an existing service's configuration.
Purpose
Change service settings without deleting and recreating.
Input Schema
{
"service": "string (required)",
"auth": "boolean (optional)",
"enabled": "boolean (optional)",
"title": "string (optional)",
"url": "string (optional, plain URL by default)",
"encoding": "utf8|base64 (optional, default: utf8)",
"command": "string (optional)",
"root": "boolean (optional)",
"admin": "boolean (REST only; the old name of root)",
"confirm_root": "boolean (optional)",
"workdir": "string (optional)",
"org": "boolean (optional)"
}
The node's controllers — its owner, or an Owner or Admin of the node's organization — checked at this call, whoever published the service. On a root terminal, a change that could widen it (a new command or workdir, re-enabling it, turning sign-in off) also needs root on the node; a new title, disabling it and turning sign-in on do not.
Parameters:
service- ID of the service to updateauth- Change authentication requirement. The gate is on the transition: turning it from true tofalseon a proxy needs org Owner/Admin, because it turns an endpoint only its named visitors can reach into one anyone with the URL can reach — and so does an update that changes what an already-public proxy exposes (url, orenabledfalse → true). Turning it back on is never gated, and neither is a title change on a public proxy. Omitting the field leaves the current setting alone; it never means false. ⚠falseisinvalid_argswhen the service is a terminal after the update, for every role — and a stored terminal that somehow has sign-in off must be givenauth: truein the same call. See Roles & Permissions.enabled- Enable or disable servicetitle- Update service display nameurl- Update proxy target. Plain URL by default (e.g.http://127.0.0.1:8080). Setencoding: "base64"to pass a legacy base64-encoded value. Switches service to proxy mode.encoding- Encoding ofurl:"utf8"(default) or"base64". Ignored whenurlis omitted.command- Update terminal command. Switches service to terminal mode.root- Update whether the terminal runs as root (terminal/shell services only).adminis its old name, REST-only from router 0.4.195; over REST, sending both isinvalid_args. The gate is on the identity the service will run as after this update, not on whether you sent the field: changing a service already stored as a root terminal — its command, workdir, or whether it requires sign-in — needs root on the node just as raising it does, because the stored identity carries over to whatever you put in it. Lowering to the configured account, or switching the service to a proxy target, needs no root. Converting a proxy into a terminal starts it non-root unless you sendroot: truewith the change. ⚠ It is three-state here and omission does not lower: omitting it keeps the stored identity, which is why aforbiddenon this tool is answered withroot: falserather than by dropping the field. It stays a plain boolean — seerootis a string on the tools that run something.confirm_root- Required when this call names a visitor on a root terminal who is not one of the node's controllers — seeservice_publish, anddetailson the refusal.workdir- Update working directory (terminal/shell services only)org-trueopens this sign-in service to every member of the node's organization, on top of its named addresses;falsecloses it to them again, leaving the node's controllers and the addresses on its share list. Omitting it leaves the setting alone.trueisinvalid_argson a root terminal — to make an all-members terminal into a root terminal, sendorg: falsein the same call. Router 0.4.179.
Output Schema
{
"public_url": "string",
"mode": "proxy|terminal",
"title": "string",
"enabled": "boolean",
"auth": "boolean",
"org": "boolean (absent unless true)"
}
The service's id and name are on the envelope as service: {id, name} — over REST the envelope also carries service_id. From router 0.4.206 the envelope also names the node the service runs on, as node: {id, name}, which service_publish and service_delete have always done: an update names its service by id, so without it the result did not say which machine had changed.
Example Usage
Request:
{
"service": "svc_abc123",
"auth": false,
"enabled": false
}
Response:
{
"public_url": "https://a1b2c3d4.nobgp.link",
"mode": "proxy",
"title": "staging-app",
"enabled": false,
"auth": false
}
service_delete
Surfaces: public only
Delete a published service.
Purpose
Remove a service and free up its public URL.
Input Schema
{
"service": "string (required)"
}
Parameters:
service- ID of the service to delete
The node's controllers — its owner, or an Owner or Admin of the node's organization — from router 0.4.179, whoever published the service.
Output Schema
{
"mode": "string",
"public_url": "string"
}
The service's id and name are on the envelope as service: {id, name} — over REST the envelope also carries service_id.
Example Usage
Request:
{
"service": "svc_abc123"
}
Response:
{
"mode": "proxy",
"public_url": "https://a1b2c3d4.nobgp.link"
}
service_share
Surfaces: public only
Manage the authorized email list for a published service.
Purpose
Add, remove, list, or revoke the addresses that may reach a service, and answer an access request — without recreating the service.
The node's controllers — its owner, or an Owner or Admin of the node's organization — for every action, list included, from router 0.4.179. Who may reach a service is not something to hand out more freely than the writes beside it. Anyone else is refused.
Input Schema
{
"service": "string (required)",
"action": "string (required)",
"emails": ["string (conditional)"],
"grant": "string (required for approve and reject)",
"confirm_root": "boolean (optional)"
}
Parameters:
service- Unique identifier for the serviceaction-list,add,remove,revoke,approveorrejectemails- Email addresses (required foraddandremove)grant- The access request to answer, forapprove/reject.approveputs the requester's address on the list and marks the request granted in one step (router 0.4.179 — it used to take two);rejectmarks it refused, and the requester cannot ask again until the refusal lapses.confirm_root-addandapproveon a root terminal only. A named address there opens a root shell on the node, so the name is itself the root grant: entries must be exact addresses (never a wildcard), naming anyone who is not one of the node's controllers needsconfirm_root: true, and the warning names every address outside the organization. From router 0.4.192 the refusal carriesdetailsa client can branch on rather than only that sentence. See A root terminal is a root grant.
revoke clears the address list and does not change org — org on service_update sets that.
Output Schema
{
"visitors": [
{ "email": "string", "name": "string", "member": "boolean" }
],
"org": "boolean (absent unless true)",
"grant": { "grant_id": "string", "requester_email": "string", "status": "granted | rejected" }
}
The service's id is on the envelope as service: {id, name}. Over REST the payload also carries authorized_emails (the bare addresses) and all_org_members (the old name of org), which nobgp/app reads.
visitorsis each address that may visit, in list order, with each entry named:membersays whether the address is a member of the node's organization, andnameis their profile name when they have one — so an outsider, a pattern and a colleague are distinguishable without a second lookup. It isnullonly when the members could not be read; a note then names the addresses on the list.orgis the other half of who may visit — see Who may visit a service.grantis present only onapprove/reject.
Example Usage
List authorized emails:
Request:
{
"service": "svc_abc123",
"action": "list"
}
Response:
{
"visitors": [
{ "email": "alice@example.com", "name": "Alice Ng", "member": true },
{ "email": "*@company.com", "member": false }
]
}
Add emails:
Request:
{
"service": "svc_abc123",
"action": "add",
"emails": ["bob@example.com", "*@partner.com"]
}
Revoke all access:
Request:
{
"service": "svc_abc123",
"action": "revoke"
}
Response:
{
"visitors": []
}
Actions
| Action | Description | Requires |
|---|---|---|
list | Show who may reach the service | — |
add | Let the given addresses reach it | emails |
remove | Stop the given addresses reaching it | emails |
revoke | Clear every address (leaves org alone) | — |
approve | Put one requester on the list and mark their request granted | grant |
reject | Mark one request refused | grant |
service_check
Surfaces: public only
Ask whether a published service's backend actually answers. Router 0.4.84+. It needs use of the node the service runs on — the same access command needs, since it exercises reach you already have — and no more.
Purpose
A successful service_publish proves the route was recorded, not that anything is listening behind it — and until this tool there was no way to ask. With authentication on, which is the default, fetching public_url answers 307 to the login flow whether the backend is alive or dead, so the status code you get says nothing. The old advice was to run a curl on the node with command; that was a recipe, not an answer.
service_check probes through the same channel a real visitor's request rides — a mesh dial addressed by service id, which the node answers by connecting to the service's url — so it verifies the path people actually use rather than some other one.
Input Schema
{
"service": "string (required)"
}
Deliberately nothing else: the service already knows its node, its target and its mode, so anything you could add here would be a chance to contradict it.
Output Schema
{
"backend": "reachable | unreachable | not_applicable",
"check": "http | tcp",
"status_code": "number (http checks that got a response)",
"latency_ms": "number",
"reason": "string (present when unreachable)",
"target": "string (the backend URL that was probed)",
"node_connection": {
"online": "boolean (a connection of the node to the router may be up now)",
"online_at": "string (RFC3339 UTC; when that connection began — on an offline node, when its last one began)",
"offline_at": "string (RFC3339 UTC; absent while the node is connected)",
"closes_24h": "number (closes of the node's connection the router recorded in the last 24h, up to 1000)",
"rotations_24h": "number (how many of those were planned reconnects — not counted in closes_24h; absent means none)",
"last_close_at": "string (RFC3339 UTC; absent when closes_24h is 0 or absent)",
"last_close_reason": "string (why the router saw that close end; absent when it recorded no reason)",
"last_seen_at": "string (RFC3339 UTC; when the router last heard from the node on that connection)",
"host_restarted": "boolean (true = the host restarted or lost power across that close; absent = cannot say)",
"node_reason": "string (how the node itself says that connection ended; absent when it has not said)",
"history_error": "string (why closes_24h is absent; present only then)"
}
}
This is the payload under service_check; done and the service are on the envelope.
Fields:
backend- The verdict, and a three-value string rather than a boolean:reachable— the node connected, and on an HTTP check the backend answered. Readstatus_codefor what it said.unreachable— it did not.reasoncarries the upstream failure verbatim from the node that tried, e.g.dial tcp 127.0.0.1:8080: connect: connection refused.not_applicable— this service has no backend to reach. A terminal service runs a command, so there is nothing to probe and nothing is wrong: that answer is a success, not a fault.
check- Which probe ran.httpfor anhttp/httpstarget — a real request, redirects not followed, since a301is itself an answer.tcpfor any other scheme (ssh,redis,postgres), where a successful connect is the whole verdict and no HTTP meaning should be read into it. Absent when no probe ran.latency_ms- How long the probe took end to end — the node's own connect (and request) plus the router↔node hop, so it is an upper bound on the backend's own latency rather than a measurement of it.node_connection- The node's own connection to the router, from router 0.4.198 — see below. Absent when the service has no node.
The node's connection, beside the verdict
Router 0.4.198. backend is one moment, and a node whose connection keeps dropping answers reachable on one call and unreachable on the next — an intermittent outage that no single call could show. node_connection is the other half of the answer: whether a connection of the node is up now, and how often it closed in the last 24 hours.
| Field | Meaning |
|---|---|
online | A connection of the node to the router may be up now. ⚠ Not the same question node_list's online answers: a node held below its channel's floor is connected and listed offline there, and true here. |
online_at | When the node's current connection began — on an offline node, when its last one began. Absent for a node that has never connected. |
offline_at | When the node's last connection ended. Absent while it is connected. |
closes_24h | How many closes of the node's connection the router recorded in the last 24 hours, up to 1000. |
last_close_at, last_close_reason | The newest of those closes, and the router's own words for how it ended — websocket: close 1006 (abnormal closure): unexpected EOF, say. The reason is absent when the router recorded none. |
history_error | Why closes_24h is absent. Present only then. |
Four more fields from router 0.4.199, identical in meaning to the ones node_list reports per node with view: "full" — read connection for the full treatment:
| Field | Meaning |
|---|---|
rotations_24h | How many closes in the window were planned reconnects. They are not in closes_24h, and the fields above describe the newest close that is not one. Absent means there were none. |
last_seen_at | When the router last heard from the node on that connection — the honest end of the session, where last_close_at is up to 90 seconds later. |
host_restarted | true: the machine rebooted or lost power across the close. false: the host stayed up and only the connection or the agent process ended. Absent is neither. |
node_reason | The node's own account of how that connection ended — clean_stop, upgrade, host_sleep, exit_unrecorded, and others. A claim, to read beside last_close_reason. |
Read it beside backend: a service whose backend is reachable on a node that closed its connection 26 times today is a service that answers for most of the day and fails for the rest of it, and the second fact is the one to act on. The one-line summary names the count too — and, from router 0.4.199, the planned reconnects separately — so a reader who only sees the text still learns the node flaps.
⚠ An absent closes_24h is not zero. 0 means the router recorded no close; absent means the history could not be read, and history_error says why. The count is also a lower bound: a noBGP instance that stops without finishing its teardown records nothing, and a connection held for an upgrade is not counted. A connection displaced by the node reconnecting counts as one with the reason replaced by a new connection of the node.
⚠ A noBGP deployment no longer counts, from router 0.4.199. Through 0.4.198 shutting an instance down for a release recorded one close on every node it was serving, so for 24 hours after each release the whole fleet carried a close it had nothing to do with. Those are noBGP's event rather than the node's and are not recorded at all now. The node still reports upgrade or clean_stop in node_reason for a close of its own making.
⚠ The history starts empty. It is kept for 24 hours after the last close, so a node that has been flapping for a week reads as calm until it next drops.
What "reachable" does and does not say
⚠ It is not a health check. Any status counts as reachable, 500 included: the backend answered, which is a different fact from a refused connection, and status_code carries the rest. A caller that needs "healthy" reads the code itself.
A disabled service answers unreachable naming that — service is disabled; the router does not proxy to it — rather than the dial layer's misleading "service not found" about a service that plainly exists.
A target that is one of the node's own agent ports answers unreachable too, from agent 0.4.144, with reason naming the socket the node refused to dial rather than a connection failure — see A target that is one of the agent's own ports. It is the node's decision and a retry does not change it.
A self-signed certificate on the backend is not a failure: the node already terminated the hop to its own backend, and the reverse proxy that serves real visitors makes the same choice.
The call never fails for a verdict. Only the arguments — an unknown service, a caller who cannot reach it — are errors.
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.
Which sessions are forwarded: command sessions (the command's id, whether the follow-up arrives as command_read, command_input, command_signal or — on REST — as command itself), and from router 0.4.81 file / fs_read / fs_write transfers (the transfer's id) — a continuation that lands on another replica now returns the bytes instead of reporting the session missing mid-transfer. The one-shot file operations that can also hand back a file_id on a slow node — list, stat, delete, mkdir, glob, grep, fetch_url — are not forwarded: a follow-up on one still comes back naming the replica that owns it, so retry the call rather than the poll.
A dead session returns not_found. A forwarding failure returns target_unreachable (retryable).
Everything only the owning replica can observe crosses the hop too (router 0.4.84): a command call's output cursor and its signal verdict come back with the same values a local call would give, and an out-of-window cursor is the same invalid_args with the same window numbers. From router 0.4.147 an output overrun crosses it too, as the same resource_exhausted with the same message, rather than as the retryable internal an unrecognised error becomes on the way across. During a rolling upgrade a session owned by a not-yet-upgraded replica simply answers without those fields, and notes[] says so — never a false or a zero standing in for an answer nobody made.
command
Surfaces: public · local
Start a command on a remote node and wait for its first output.
Purpose
Run a one-shot command, or open a stateful interactive session. The response carries the session's id, and over MCP the three acts that follow are separate tools: command_read for more output, command_input for stdin, command_signal for a signal.
command starts a session and nothing else, from router 0.4.195Until then one tool did all four acts and chose between them by which fields were present — a command_id with input was a write, with signal a kill, with neither a read. That cannot carry honest annotations: reading output had to advertise itself as destructive, because the same tool could also kill a process. So command_id, signal and output_offset are no longer fields of command on MCP: sending one is refused by the schema check as an unknown property. That refusal has no code; its text carries the sentence that tells a client to refetch its tool list — which is where it meets the three tools that do take them.
The four tools are on REST too — POST /api/v1/tools/command_read and the rest — and /api/v1/tools/command is start-only there as well. The one surface that still takes the whole one-tool shape is the legacy flat body at /actions/execute_shell_command: it accepts command_id, signal and output_offset as before, and the router sends each call to the tool that performs it. From router 0.4.195 a body that carries both input (or close_input) and signal is refused with invalid_args, and so is output_offset together with either: they are separate tools. Before 0.4.195 one call did the write and then sent the signal.
Input Schema
{
"node": "string (required; the node's id or its name)",
"command": "string (one command line — this or script, never both)",
"script": "string (a script body — this or command, never both)",
"shell": "string (optional)",
"stdin": "\"open\" | \"closed\" (optional; default \"closed\" — stdin is at end of file)",
"root": "boolean (optional)",
"workdir": "string (optional)",
"env": "object (optional)",
"input": "string (optional; also opens stdin)",
"close": "boolean (optional; with input — write it, then put stdin at end of file)",
"detach": "boolean (optional; start the command outside this session so it outlives the call)",
"retry_key": "string (optional; a key you make for this one command, so a retry does not run it twice)",
"raw": "boolean (optional)",
"redact": ["string (optional; one short label per secret this call carries)"],
"wait": "number (optional; default 5, clamped to 100)",
"until": "string (optional: \"done\" — wait for the exit, at most 100 s)"
}
The session wrapper is gone, and so are node_id, network_name + node_name, idle_timeout, close_input and admin — see One name for each input. Over MCP the retired names are refused as unknown properties; the legacy flat body at /actions/execute_shell_command still takes the old ones.
Parameters:
-
node- Required. The node's id, or its name. A call with neithercommandnorscriptisinvalid_args. See Addressing a node. -
command- One command line to execute (e.g.,"bash","df -h","python3 script.py"). Provide this orscript, never both — sending both, or neither, isinvalid_args. Backslashes are literal here; no C-style escapes are resolved (unlikeinput). -
script- A script body, from router 0.4.81 — send it the way you would write the file: real newlines, no escaping, no shell quoting, no trailing-backslash continuations. Use it instead ofcommandfor anything multi-line, anything with an embedded quote, and anything you would otherwise hand a person to paste. The router lowers it for the target platform, so nothing between you and the interpreter can re-quote it — see Sending a script body. -
stdin- Router 0.4.155+. What this session's stdin is, for the life of the session. Omit for the default,"closed": stdin is at end of file, so a tool that reads stdin when stdin is not a terminal finishes instead of waiting for ever. Send"open"to keep it an open pipe with nothing written to it — the REPL shape. Sendinginputon the start call also opens stdin, so a caller that already has its first line needs nostdinat all;"closed"together withinputisinvalid_args, because the two ask for opposite things. See stdin is at end of file unless you ask for a pipe. -
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). It applies toscriptthe same way — set it whenever the body is PowerShell. Unknown values returninvalid_args. -
root- which identity the session runs as.adminis its old name, retired from router 0.4.201. A JSON boolean from router 0.4.201 —trueorfalse, published as a boolean; the string spellings"true"and"false"that router 0.4.83 to 0.4.200 published are still decoded. Omitted (the default) runs as the node's configured user where the node has one, and elevated where it offers only its ambient identity — so one call works on a mixed fleet, and the response'sroot_usedsays which identity ran.falseforces the configured user, which is not uniformly root.trueruns as the node's ambient identity, and is gated twice: the router refuses a caller who does not hold root on that node withforbidden, and the node's owner may refuse everyone withallow-root: false. Both fail the call rather than downgrading it, so aforbiddenhere is answered withroot: false, not by retrying. ⚠ The router gates the identity the call resolves to, so an omittedrootthat lands on the ambient identity needs the same authoritytruedoes. The refusal also runs the other way: a node whose unelevated identity would be uid 0 — no account configured on a root-running agent, an account that no longer resolves, oruser: root— returnsfailed_preconditionfor an unelevated call rather than running it as root, so retry that node withroot: trueor configure an account on it. Any other value isinvalid_args.node_list(withview: "full") reports each node'suserandallow_rootso you can tell the shapes apart. See Execution identity. ⚠ Elevating also changes the session's environment, not only its identity: on macOS anexit 127underroot: truethat works without it is root'sPATHlacking/usr/local/bin, not a missing install — see Elevation also changes the environment. -
workdir- Working directory for the session, defaulting to the home directory of the identity it runs as. On Linux and macOS that includes a session at the node's ambient identity —root: true, or an omittedrooton a node that offers only that identity — from agent 0.4.183: it starts in that account's home, usually/rootor/var/root. ⚠ On OpenWrt, where the service manager gives the agentHOME=/, agent 0.4.183 kept that value and started such a session in/; from agent 0.4.184 the account's own home wins, so it starts in/root. Below 0.4.183 such a session started in the agent's own working directory, which is still where it starts on Windows and on any node whose ambient account has no home directory. A path that is missing, or that is not a directory, is ignored — the session starts in that default anyway rather than failing, so checkpwdif a relative path behaves oddly. ⚠command_subscribeanswers the same mistake the other way, refusing the node's run, because nobody is watching a fan-out. -
env- Additional environment variables (e.g.,{"DEBUG": "1"}), merged over the identity's own environment: your value wins where the name already exists, and an empty value sets that variable empty rather than leaving the inherited one. Names that would inject code into the session are refused by the node —LD_PRELOAD,LD_LIBRARY_PATH, theDYLD_*trio,BASH_ENV,ENV,ZDOTDIR,PYTHONSTARTUP,PERL5OPT,RUBYOPT,NODE_OPTIONS,CLASSPATH— as are names containing=or a null byte; set what you need inside the command instead. -
input- Text to send to stdin, on the start call. C-style escape sequences (\n,\t,\xHH) are resolved by default. Setraw: trueto disable. It also opens stdin, so a caller that already has its first line needs nostdin: "open"; later text iscommand_input. See stdin is at end of file unless you ask for a pipe. -
close- Router 0.4.155+, andclose_inputuntil 0.4.200. Withinput: write the text, then put stdin at end of file, in this one call — which is what makessort,wcand anything else that consumes all of stdin usable in a single call. The response carriesstdin_closed: truewhen the node confirmed it. Withoutinputand withoutstdin: "open"stdin is already at end of file, andcloseis theninvalid_args. -
detach- Router 0.4.199+, honoured by a node from agent 0.4.178+. Start the command outside this session, so that it keeps running after the call and across a stop, restart or upgrade of the node's agent. The call returns at once withdetached_pidandstatus: "completed", and stdin, stdout and stderr are the null device — redirect inside the command, with absolute paths, to keep its output. Not withinputorstdin: "open": a detached command cannot be fed, so sending either isinvalid_args. See Starting something that outlives the call. -
retry_key- Router 0.4.199+. A key you make for this one command — a fresh UUID is the intended shape. Send the same call with the same key after a failure that returned no result, and a command that already started is not run again: you get itsidand its output from the first byte, ascommand_readgives. See Retrying a start without running it twice. -
raw- Send input bytes as-is without resolving escape sequences. It applies toinput, never to the command. -
redact- Router 0.4.85+. Declare that this call carries secrets, as one short label per secret (["deploy token", "db password"]) — never the secret itself. It changes nothing about what runs or what comes back. See Declaring that a call carries a secret. -
until- Router 0.4.201+."done"is the only value: wait until the command exits, bounded at 100 seconds like every other wait — the call returns at the exit, or after 100 s withstatus: "running"and a note saying how to keep waiting. Not withwait, and not withdetach: sending either beside it isinvalid_args, because a caller that sent both meant one of them.command_readtakes it too, which is how a longer job is waited out one call at a time. -
wait- Seconds to wait with no output before returning (default: 5), andidle_timeoutuntil router 0.4.200. Use0for non-blocking. The command keeps running regardless. ⚠ Clamped to 100 from router 0.4.84, and the ceiling is the client's patience rather than the router's: a 107-second wait was measured returning normally with its output, while a 400-second one was abandoned by the MCP client with its own timeout string — outside the protocol, where the router can neither see it nor report it. Asking for more is silently treated as 100 rather than refused. For anything longer, read again withcommand_read(the output waits for you — see Re-reading output) or dispatch it withcommand_subscribeand read it from the event bus. See A poll always comes back for the guarantee the number now carries.
Output Schema
{
"id": "string",
"output": "string (omitted if empty)",
"stderr": "string (the subset of output written to stderr; omitted if none)",
"exit_code": "number (absent if still running)",
"signal": "string (name of the signal that ended the process, if one did)",
"signal_delivered": "boolean (command_signal only; absent = the node did not confirm)",
"signal_mechanism": "string (command_signal only; which mechanism carried it)",
"signal_target": "string (command_signal only; what it was delivered to)",
"stdin_closed": "boolean (true when the node confirmed close; absent = it did not)",
"detached_pid": "number (detach only; the pid on the node — absent = the node did not confirm the detach)",
"root_used": "boolean (present on the call that created the session, and only when it ran as root)",
"user": "string (router 0.4.206+: the account the session runs as, as the node reports it)",
"status": "running | completed | failed",
"next": "number (present on every response)",
"dropped": "number (bytes that had aged out before the cursor asked for; absent = none)"
}
This is the payload under the command key; op, done, the node it ran on ({id, name}) and the optional hint are on the envelope around it. command_read, command_input and command_signal return this same payload under this same key, so a client reads one shape whichever of the four it called; op on the envelope names which tool produced it. From router 0.4.202 those three also carry the envelope's node, read off the session — before that a continuation named no node, so a client holding several sessions could not tell from a result which machine had answered.
Fields:
-
id- The handle for this session, andcommand_iduntil router 0.4.200. Pass it asidtocommand_read,command_inputorcommand_signal -
output- Everything the command has said starting atcursor— or, as usual, where the previous call stopped: stdout and stderr interleaved in the order they were written, the stream a terminal would have shown. Omitted if no output was produced. -
stderr- Router 0.4.84+, and agent 0.4.86+ on the node. The subset ofoutputthe command wrote to stderr, in order. The bytes are repeated rather than removed fromoutput, so an error message can be told apart from program output without losing where it fell in the stream. Absent when the command wrote nothing to stderr — and also on a node whose agent does not tag its streams, which is every agent before 0.4.86, wherenotes[]on the envelope says so rather than leaving the absence to read as "nothing went to stderr". On such a nodeoutputis still complete and still ordered. ⚠ From router 0.4.147 the same note also appears on the poll carrying the last bytes of a session cut short by an overrun, whatever the node's agent does: the router stops vouching for the labels at the point the loss began. -
exit_code- Present only when the command has exited.0= success, non-zero = failure. A process killed by a signal reports 128 + the signal number on Linux/macOS —130forSIGINT,143forSIGTERM,137forSIGKILL— the encoding every shell already uses, so the number names the signal; on Windows a killed process returns1. Absence means the process is still running. Agents before 0.4.38 reported-1for a signalled process with nothing else to distinguish it, which a caller could not tell apart from a program that genuinely returned -1. ⚠ From router 0.4.84 you no longer have to read the number to learn whether a signal ended it:signalcarries the name, and the code cannot answer that question anyway, since a program may legitimately return any number a kill would produce. ⚠ From router 0.4.147 it is also withheld from a session whose output was cut short by an overrun — reporting one there would announce a clean finish over incomplete output — andnotes[]says so. -
signal- Router 0.4.84+, and agent 0.4.86+ on the node — the name comes from the node's own wait status, so an older agent sends nothing and you are back to readingexit_code. Name of the signal that ended this process —"SIGTERM","SIGKILL"— spelled the way you send it in the request. Present only on a process the node reported as signalled; never inferred fromexit_code. Absent means it exited on its own, or that this node does not report the signal: Windows has none to report, since a process there ends by returning or by being terminated, and only a termination the agent itself performed is named. ⚠ On Windows through agent 0.4.93 a signal arriving in the instant a process was exiting on its own was attributed the kill anyway, so a natural exit came back namingSIGTERM; from agent 0.4.94 a process whose exit the node has already recorded is refused —signal_delivered: false, process already exited — which is what the Linux and macOS path has always said of a reaped child. -
signal_delivered,signal_mechanism,signal_target- Router 0.4.84+ with agent 0.4.86+, and only on acommand_signalcall. See What a signal did. -
stdin_closed- Router 0.4.155+ with agent 0.4.128+, and only on a call that sentclose— here, or oncommand_input.trueis the node confirming that stdin reached end of file. There is nofalse: a node that answers at all answerstrue, so an absent field means the node did not confirm — most often an older agent, where the program is still on an open pipe and nothing you send will end it.notes[]on the envelope says so, and the way out of such a session iscommand_signalwithSIGKILL. -
detached_pid- Router 0.4.199+ with agent 0.4.178+, and only on a call that sentdetach. The process id of the detached command on the node, as the node reported it. Absent means the node did not confirm the detach — most often an agent below 0.4.178, which ran the command attached to the session — andnotes[]on the envelope says so. See Starting something that outlives the call. -
next,dropped- Router 0.4.84+, andnext_output_offset/output_dropped_bytesuntil 0.4.200. See Re-reading output. -
root_used- Old nameadmin_used, in no result from router 0.4.201. Which of the node's two identities this session actually runs as. The request says what you asked for, this says what ran, and it istruealso where you omittedrootand the node offered only its ambient identity. ⚠ From router 0.4.201 it is present only when the answer istrue— an unelevated session reports noroot_usedat all, because absent is the default. Present on the call that created the session; absent fromcommand_read,command_inputandcommand_signal, whose identity was settled at creation. Router 0.4.83+. -
user- Router 0.4.206+. The account the session runs as —root,SYSTEMon an elevated Windows session, or the name the node's owner configured — as the node reports it. It sits besideroot_usedand is the field to read when that one is absent. Absent when the node reported no account, and on a continuation. See The result names the account. -
status- Always present, andstateuntil router 0.4.200, which had two values where there are three questions."running"while the command is still executing (read again withcommand_read);"completed"once it exited with code0;"failed"once it exited with any other code or was ended by a signal, withexit_codeandsignalsaying which. Prefer branching onstatusrather than inferring fromexit_code's nil-ness. ⚠ It is not the same question as the envelope'sdone, which says whether the call finished: a call that returns while the process runs isdone: truewithstatus: "running". ⚠ Two endings never reach a terminal status, and on both the next call refuses rather than reporting a finish nobody can vouch for: a session cut short by an overrun, and one whose node connection closed before the exit code arrived. On either,notes[]on that response says which. -
hint(on the envelope) - Router 0.4.81+, and usually absent. It appears only on a result whose own fields cannot explain it, and then it names the one thing to try. Ordinary results — including ordinary failures that printed something of their own — carry nohint, so one that is there is worth reading rather than a field to skip. It fires on two causes.The shell cause: a Windows node, no
shellset, noscriptsent, a non-zero exit, andcmd.exehaving either mangled an embedded PowerShell line's quoting or not recognised the command at all. The hint points at the two ways through: send the body asscript(the router writes it out for the interpreter, socmd.exenever sees it), or setshell: "powershell"and send the script as-is. Only session-creating calls can carry it, becauseshellis a session parameter.The stdin cause (router 0.4.155): this call opened or fed a session whose stdin is an open pipe, it waited, and the process produced no output and no exit code. The remedy it names is
close— on Windows, where a console reader can be neither fed nor closed through stdin, it namesSIGKILLas well. ⚠ It can only fire on a session you asked to keep open: with stdin at end of file by default, a quiet default session is just a slow one, and the hint says nothing about it.⚠ Router 0.4.83 widened what it catches, because the original signature never fired. It required
exit_code: 255and completely empty output, andcmd.exeis loud: its usual answer to a mangled PowerShell line is to echo'Get-ChildItem' is not recognized as an internal or external command,on stderr, which makes the empty-output test false — and one of the two real mistakes measured exited1, not255. It fires on exit9009(cmd.exe's own command-not-found code), on a silent failure of a command line that is PowerShell, or on cmd.exe's own refusal wording. The first two are locale-independent and are the gate; the wording match is an English-only refinement on top, so a node running a non-English Windows still gets the hint from the first two.⚠ A silent failure alone is not evidence, from router 0.4.178. Through 0.4.177 any non-zero exit with no output earned the hint — and that is how a correct batch check signals:
exit 7,findstrwith no match,fc,if not exist … exit 1. Such a caller was told to rewrite a call that was right. The silent arm now also needs the line you sent to look like PowerShell (a$var, a verb-noun cmdlet, a script block,-Command/-EncodedCommand), which is the case that arm exists for: a PowerShell linecmd.exeparsed into something that failed quietly. A non-zero exit with no output from an ordinary batch line now carries no hint, which is the correct answer for it.
A poll always comes back
One command call returns within 100 seconds whatever the command does (router 0.4.146). The wait clamp on its own never delivered that: the idle timer resets on every chunk, so a command that keeps printing — a heartbeat loop, a progress line every 30 seconds — never idled out, and the call simply ran until the MCP client gave up. One of the two reports of this came from a caller printing a heartbeat in order to keep the session alive — the surface's own advice, taken, producing the failure.
What that cost was the handle. A client that abandons a call abandons it outside the protocol, so nothing comes back: no envelope, no call_id and — decisively — no session id. The command kept running on the node with nothing able to poll it, read it or kill it, and command is built so a long job is recoverable (the session id plus cursor) — so the recovery was defeated exactly when it was needed, because the id only ever arrived in the response that never came.
A separate wall-clock timer is now armed once per poll and never reset. Hitting it is an ordinary result, not an error and not an exit: the call returns whatever has arrived with status: "running", byte-identical to an idle expiry, and you read again with command_read, passing the session id and the next you were given. So wait bounds the silent gaps and this bounds the call, and the two can no longer disagree. From router 0.4.201 until: "done" is the way to say wait for the exit without choosing a number: it waits out the whole ceiling and the result's notes[] tells you to call again when the command is still running.
A forwarded poll behaves identically, because the forwarding replica's own deadline is sized off the same ceiling rather than off wait. Those two halves shipped together and had to: a replica that gave up at wait while the owning replica held the poll for longer would hand you a transport error in place of the output it was about to return.
Re-reading output
Reading output no longer destroys it (router 0.4.84). Until then a poll consumed the bytes it returned, so a response that never reached you took the output with it: echo LINE-ONE; sleep 60; echo LINE-TWO polled at wait: 3 returned LINE-ONE, the next poll returned a result with no output, and LINE-ONE was gone — indistinguishable from a command that had said nothing.
cursor is a parameter of command_read from router 0.4.195 (it was command's), and it is what output_offset was called until 0.4.200. Three rules, and each matters:
- Omitting
cursorresumes where the last delivery stopped, byte for byte what a poll did before. No existing caller changes anything. nextrides every response, including one that carried no output. Send it back ascursorand the same bytes are delivered again — so a lost response costs a retry rather than the output. You can only ever re-request a position you were told about.- The newest 1 MiB is retained. A
cursorolder than that is served from the oldest byte still held, withdroppedsaying how much was missed — never silence at a different offset. Absentdroppedmeans nothing was lost. Poll more often, or run the work throughcommand_subscribe, to stop it recurring.
An offset past the end is invalid_args, not a truncation report: nothing was dropped, and that position belongs to a different session.
The cursor crosses a replica hop unchanged, so a forwarded call is the same shape with the same numbers. During a rolling router upgrade a session whose owning replica has not been upgraded yet answers without next, and notes[] says so; the field returns once the deploy finishes.
A command that outruns the router now fails the poll
Router 0.4.147. A command's output is streamed from the node as fast as the command produces it, and nothing paces it. If it outruns what the router can take, the frames that do not fit are dropped and the session is ended — and until this release that loss was reported as the command finishing: the last poll returned the bytes that had survived, stamped the process's exit code onto them and said state: "exited". So a command that had in fact succeeded reported exit_code: 0 over output that was missing an unknown amount, with nothing anywhere saying so — and for most of what this tool is used for the output is the answer.
This is the same defect the read path had, on the other session type, and it is worse here: on a read the bytes are a file you can re-read, while here they are the only record that the command ran at all.
An overrun session never reports an exit code, however cleanly the process finished:
- The poll that carries the last surviving bytes returns them as an ordinary result, with
status: "running", noexit_code, and a note on the envelope saying the output was cut short and why the exit code is withheld. It is said on that response rather than only on the next one, so a caller that takes those bytes and never polls again is still told. - The next poll refuses with
resource_exhausted, naming the session. No further output is coming, and the session cannot be continued. - Stream labelling stops where the loss began. The dropped frames took the bookkeeping with them, so
stderris reported for the bytes accounted before anything was lost and not for the ones delivered after — a wrong stream is worse than none. On that last pollnotes[]reports the absence in the words it uses for a node whose agent does not label its streams, so read it as not labelled here rather than as a statement about the node.outputitself is complete and in order for the bytes you were given, and output already delivered keeps the labels it had. - What you were given is correct; what is missing is unknown. The dropped frames were never counted, so there is no figure for them and no offset to resume at —
droppedstays absent, because that field reports what aged out of the retained 1 MiB, which is a different thing. Re-reading an earliercursorstill works while the retained bytes last. command_inputagainst such a session is refused the same way, with the same code and message, rather than reporting a clean exit — an interactive session is the shape that reached this through the other door.- A forwarded poll answers identically — the same code and the same message, rather than the retryable
internalan unrecognised error becomes on the way across, which would have made one truncation two different answers depending on which replica you reached. ⚠ The explanatory note is the one part that does not cross that hop: the reply between replicas carries the bytes and the withheld exit code but has no field to carry a note on, so on such a session that poll arrives with output,status: "running"and nothing saying why. The refusal on the next poll is what tells you — which is why a response that carried output and noexit_codeis worth one more poll rather than being left as "still running, ask later".
The remedy is to produce less output through the session, not to retry harder. Redirect the command's output to a file on the node and read it back with fs_read; filter at the source (grep, tail -n); poll more often with a shorter wait; or run the work through command_subscribe, which reports a node that out-talks it as an overflow event instead. Re-running the identical command with the identical polling is likely to lose the output again.
A session ends with the node's connection that carried it
Router 0.4.187. A command session rides the node's connection to noBGP, and the agent stops the processes of a session when that connection closes. Until this release nothing on the router ended the session: it went on answering state: "running" with whatever output had arrived and no exit_code, indistinguishable from a slow command, until the hour-long idle reap. So an agent restart, an upgrade, a service restart or a config change that bounces the agent left a poll that could never resolve, and a caller waiting on it waited for nothing.
- The poll that carries the last surviving bytes is an ordinary result —
status: "running", noexit_code— with a note on the envelope saying when the connection closed, that no exit code arrived, that this is the output that arrived before it, and that the next call refuses. It is said on that response rather than only on the next one, because a caller may take those bytes and never poll again. - The next call on that
idistarget_unreachable, and it is not retryable — theretryableflag is absent, as it is on every permanent failure — because the same call can only get the same answer. The message names the node and the instant, anddetailscarriesid,node,lost_at,nextand aremediation.command_inputandcommand_signalare refused the same way. - The command is not running. The agent stops a session's processes when its channel closes, so there is nothing left to signal — unless the work detached itself, which survives by design.
- The output stays readable for five minutes. The session is kept that long so you can re-read what arrived with
cursor; a poll does not extend it. For a while after that a call still gets this answer rather than a barenot_found, so a late caller is told what happened instead of being left to wonder whether theidever existed. - An exit code that arrives wins, including one that lands after the close — a frame can be in flight — so a session that genuinely finished reports its exit rather than this.
- The remedy is a new session. Check the node with
node_list(online, andonline_atwithview: "full"— anonline_atlater thanlost_atis a connection that has come back), then start the command again. For work that must survive the node reconnecting, dispatch it withcommand_subscribe, which reports the outcome on the event bus instead of holding a session open.
⚠ A forwarded call answers the same way — the code, the message and details all cross the hop between instances. Two things do not: the explanatory note on the poll that carried the last bytes, and the memory of a session that is already gone, which belongs to the instance that held it — so a call that arrives after that window and lands elsewhere is the plain not_found.
⚠ One limit: the session and the node's connection must be on the same noBGP instance. When the node's connection is on another instance, nothing ends the session. A poll then answers status: "running" with no output and no exit code until the one-hour idle reap, as before router 0.4.187. If a poll stays silent after you stopped the agent, read node_list and do not wait on the call.
What a signal did
Router 0.4.84 answers what happened to a signal you sent, rather than only that the call succeeded. Sending one also adds a short wait for that verdict, even with wait: 0. The verdict is the node's, so it needs agent 0.4.86+ as well; an older agent delivers the signal exactly as before and simply reports nothing about it.
| Field | What it says |
|---|---|
signal_delivered | Whether this node's mechanism accepted the signal. true with status: "running" means delivered and ignored — escalate to SIGKILL. false means the mechanism refused it (already exited, no such process group, unsupported here) and notes[] carries the reason. Absent is neither: the node did not confirm — an agent older than 0.4.86, or a reply that did not arrive in time — so it may or may not have landed. From agent 0.4.94 a verdict for a signal that landed while the command was exiting is sent out ahead of the exit status rather than being lost to the teardown, so the common case for an absent field is now an old agent. |
signal_mechanism | Which mechanism the node used, in the node's own words: kill (a real signal, to the process group), stdin_ctrl_c (Windows SIGINT — 0x03 written to stdin), terminate_job / terminate_process (Windows SIGTERM and SIGKILL, which are the same act). The list is open — a node may report one this page does not name. |
signal_target | What it was delivered to: a process group (pgid 4242), a pid, or a handle. |
⚠ Read signal_mechanism before treating signal_delivered: true as "the process was signalled". On Windows a delivered SIGINT means 0x03 reached stdin, which only interrupts a process that actively reads stdin — the mechanism is what stops the verdict from overstating itself. Where a platform has no mechanism for that signal name at all (SIGHUP, SIGQUIT on Windows) the field is absent, and that is a permanent refusal rather than a failed attempt.
Sending a script body
command is one command line. script (router 0.4.81+) is a script body — real newlines, no escaping, no shell quoting, no trailing-backslash continuations. Provide exactly one of the two.
Reach for script whenever the work is more than one line, contains a quote, or is something you would otherwise ask a person to paste. The failure it removes is not cosmetic: a pasted $f -Raw arriving as $f-Raw made PowerShell look for a file that did not exist, so the replacement silently did nothing — and the restart on the next line still ran, leaving the machine in the wrong state while the call reported success.
The router lowers the body for the target, so nothing between you and the interpreter re-quotes it:
| Target | What the body becomes |
|---|---|
| Linux / macOS | Run verbatim under the session user's login shell. Write POSIX sh unless you know the node. |
Windows, shell omitted | A batch script: written to a temp .cmd with CRLF line endings, run through %ComSpec% /d /c, its exit code returned and the file deleted. cmd.exe's 8191-character line limit and its quoting layer never apply. |
Windows, shell: "powershell" | A PowerShell script. From router 0.4.204 it is written to a temporary .ps1 (UTF-8 with a BOM) and run with -File; a body too large for that form travels as -EncodedCommand, which is what every body did before. -ExecutionPolicy Bypass throughout (router 0.4.155+). Set this whenever the body is PowerShell — it works on command too. |
⚠ The temporary .ps1 is why PowerShell errors read properly from router 0.4.204. Under -EncodedCommand every error record began with the whole script text — growing with the body — and all of stdout arrived before all of stderr whatever order the script wrote them in. With -File a record names the file and the line, and the two streams interleave as written. The cost is one extra powershell.exe per call, about 0.7–1.2 s. The exit code is unchanged: -File exits 0 when the last statement fails, so noBGP appends the line that puts the -EncodedCommand code back.
⚠ A killed command leaves no body in %TEMP% from router 0.4.204. The temporary file holds your script, and possibly a secret in it: before, ending a command — command_signal, a closed session, the idle timeout, an agent restart — killed the whole process tree and left the file behind. Now the .ps1 removes itself on its first line, and each run first removes the .ps1 and .cmd files of its own pattern whose writing process is gone. Only the first second or so of a run is open, and the next Windows command on that node closes it. ⚠ The cleanup costs room on the command line, so it is left out of a body that is close to the length limit: such a body runs in the form it had before 0.4.204, and a kill can still leave its file behind. Keep a body with a secret in it well under the limit, or write it with fs_write and remove it yourself.
Three refusals, all invalid_args and all naming the way through:
- A node that has never reported its OS. There is nothing to lower the body for, so it is refused rather than guessed at — send
commandwith a single line instead, or setshell: "powershell"if the body is PowerShell. shell: "powershell"on a node that reports a non-Windows OS, from router 0.4.204. The lowering is apowershell.execommand line and only Windows haspowershell.exe, so it is refused here with the real reason instead of failing on the node. The refusal names what the node reported: omitshellto use the node's own shell, or — where PowerShell 7 (pwsh) is installed there — write the body to a.ps1withfs_writeand runpwsh -File <path>withcommand. A node that has never reported an OS still gets the lowering: nothing is guessed for it.- A lowered form over the platform's command-line limit. On Windows that limit is
cmd.exe's own 8,191 characters from router 0.4.204 (it was taken as 32,000 before, whichcmd.exerefuses on the node with The command line is too long. and exit 1, so none of the body ran) — in practice about 1.8 KiB of batch and 2.8 KiB of PowerShell, or more where the body compresses, because the encodings inflate: base64 of UTF-16LE is about 2.67×, and the batch path encodes twice. On Linux and macOS it is about 117 KiB. The check is on the lowered string, not on what you sent. Past that, write the body to the node withfs_writeand run its path withcommand.
-ExecutionPolicy Bypass is what makes a .ps1 the body calls run too, from router 0.4.155. The encoded body itself was never subject to the policy, so a script that only ran inline worked — and the moment it dot-sourced or invoked a .ps1 file on the node, that file met the machine's default policy and was refused. The bypass is process-scoped: it applies to the PowerShell this call starts and to nothing else on the machine, and a policy set by Group Policy still wins over it.
input behaves the same for a script as for a command: stdin is at end of file unless you ask for a pipe.
command_subscribe takes script too, from router 0.4.83 — with the same three lowerings, applied per node rather than once for the cohort, which is what lets one dispatch span Linux, macOS and Windows. See Sending a script body to a cohort.
stdin is at end of file unless you ask for a pipe
From router 0.4.155 a command's stdin starts at end of file, for command and script alike. A tool that reads stdin when stdin is not a terminal therefore finishes instead of waiting for bytes you never meant to send — ausearch, aureport, cat with no file argument, mysql, python3 with no script, ssh whose remote command reads stdin. None of them looks interactive, and every one of them used to hang: the call reported state: "running" with no output at all, forever, and a script stopped at that line with nothing after it run.
The contract is four rules:
| You send | This session's stdin |
|---|---|
| nothing | end of file — the default |
stdin: "open" | an open pipe with nothing written to it — the REPL shape |
input on the start call | an open pipe, with those bytes written |
close: true on command_input | end of file from that point, optionally after a final input on the same call |
- A REPL still works, and needs one field. Start it with
stdin: "open"— or with the first line you mean to send, which opens stdin by itself — then sendcommand_inputon later calls and read its prompts out ofoutput. closeis what makessort,wcand anything else that consumes all of stdin usable. Send the text withinput, thenclose: true(the two may ride the same call, oncommandor oncommand_input), and the program produces its output.stdin: "closed"together withinputisinvalid_args— the two ask for opposite things. So isinputorcloseagainst a session whose stdin is already at end of file, which is what a session started with neither opener is.- Two calls racing on one session get a decision, not a silent loss (router 0.4.158). Where one call sends
inputwhile another closes the same session's stdin, either the bytes are written before the close or the losing call is refused withinvalid_args; a secondclosethat loses the same race is refused the same way. A single call carrying bothinputandclosewas always ordered — write, then close.
⚠ On a node whose agent predates this, stdin is still the open pipe it always was. The node is what puts stdin at end of file, so an agent that does not know the field ignores it and behaves exactly as it did before — the new default changes nothing there, and neither does close. Agent 0.4.128 is the release that honours both, so a fleet gains this one node at a time.
⚠ The response's stdin_closed is a verdict, and its absence is an answer. true means the node confirmed the close; absent means it did not — most often an agent older than 0.4.128, where the program is still on an open pipe and nothing you send will end it. notes[] says so, and command_signal with SIGKILL is the way out of such a session.
⚠ On Windows only a program that reads redirected stdin sees input at all. The process has a stdin pipe and no console, so a console reader — Read-Host, pause, choice, a credential prompt — never receives it, and close does not help it either. Give such a program its value as a command-line argument, in env, or in a file written with fs_write. When a Windows session receives input and then says nothing for the whole idle window, the result's hint says so.
⚠ command_subscribe has always had an empty stdin and is unchanged by this: a cohort run cannot be fed.
⚠ What it costs a caller who relied on the old default: a REPL started with no input and fed later no longer works unless the session named stdin: "open". Redirecting stdin on the line itself (</dev/null, <NUL) is still accepted and is now redundant.
Starting something that outlives the call
Send detach: true (router 0.4.199+, agent 0.4.178+) for a command that must keep running after this call — a server, a watcher, a script that restarts the agent itself. The node starts it outside the session, so it survives the session ending and a stop, restart or upgrade of the agent. The call returns at once with detached_pid and status: "completed": that is the detach succeeding, not the command finishing.
- Its output does not come back. stdin, stdout and stderr are the null device, so redirect inside the command itself, with absolute paths, if you want to keep what it prints:
/usr/local/bin/worker >/var/log/worker.log 2>&1. - It cannot be fed.
detachtogether withinputorstdin: "open"isinvalid_args— a detached command has no stdin to write to. For the same reasoncommand_inputandcommand_signalhave nothing to act on afterwards: there is no session holding the process. - Check on it with a separate short call —
pgrep,ps -p <pid>,Get-Processon Windows — or read the log you redirected to. - ⚠
detached_pidabsent means the node did not detach it. Most often its agent is below 0.4.178 and ran the command attached to the session, in which case it dies with the session exactly as before;notes[]says so. The flag is accepted by the router from 0.4.199 and honoured by a node from agent 0.4.178, so readdetached_pidrather than assuming.
The identity, workdir and env are resolved exactly as for an attached command, and the same two gates apply: the node owner's allow-tools must still permit command, and root: true still needs root on that node. A detach is not a way around either.
How the node takes the process out of the session, and the limits worth knowing:
| Platform | What the node does |
|---|---|
| Linux, agent in a systemd unit | Moves the process into a transient scope of its own, so stopping or restarting the agent's unit does not stop it. setsid alone would not be enough: the process would stay in the unit's control group. |
| Other Linux, macOS | A new session of its own (setsid), which neither the agent's teardown nor a service manager's process-group kill reaches. |
| Windows | A process with no console and a process group of its own, outside the kill-on-close Job Object the session's own children are in. |
- ⚠ One case keeps a limit, and the node says so in
notes[]: an agent that runs in a systemd unit without root cannot ask systemd for a scope, so the command survives the session but is still stopped when the agent's unit stops, restarts or upgrades. The same note appears when the move into a scope fails. Readnotes[]on the envelope before relying on a detached command surviving an upgrade on such a node. exit_codeis not always0, and that is how a command that could not start is reported. The node waits a quarter of a second before answering: a process that ended inside it reports its own exit code —127for a command that does not exist — with a note saying it ended and that a child it left in the background is still running. A process still going by then reports0, which means started, not finished.- Nothing noBGP does afterwards stops it. A revoked grant, the compute deadline, the idle reap and
task_stophave no process to act on, because the session ended when the process started. That is the same rule as for a program that detaches itself.
On a node whose agent is below 0.4.178, a helper that must keep running has to do two things itself, and the second differs per platform. First, stop writing to this session's stdout and stderr: a backgrounded child that inherits them holds the reader open for up to 2 seconds after the command itself exits (measured on Linux: 4005 ms to EOF unredirected, 1 ms redirected), and on a chatty child that is the difference between a prompt result and a poll that looks stuck.
| Platform | What works |
|---|---|
| Linux | setsid ./script >/dev/null 2>&1 & |
| macOS | launchctl submit -l NAME -o /tmp/NAME.out -e /tmp/NAME.err -- /absolute/path/to/script |
| Windows | Invoke-CimMethod Win32_Process -MethodName Create -Arguments @{CommandLine='C:\absolute\path\prog.exe args'} — with shell: "powershell" |
⚠ What teardown kills differs by platform. On Unix a session closed while its command is still running — a revoked grant, the compute deadline, the idle reap, task_stop — takes the command's whole process group with it (agent 0.4.152+); a session whose command had already exited leaves its children alone, and a child that calls setsid or setpgid itself leaves the group. ⚠ Under systemd that is not enough: stopping the agent stops every process of its unit, setsid ones included, which is exactly the case detach exists for. On Windows every child of the session is in a kill-on-close Job Object, so start /b and Start-Process (with -PassThru too, measured) die with the session. Win32_Process Create survives, measured: the WMI service creates that process, so it is outside the Job Object. That process has no link to this session, so its output does not reach you — use absolute paths and redirect on the command line (cmd /c "C:\path\prog.exe > C:\path\log.txt 2>&1"). ReturnValue 0 and a ProcessId in the result mean it started.
⚠ The macOS recipe needs an absolute path. launchd does not use the calling shell's working directory, so a relative ./script returns 0, starts nothing and leaves both log files empty — which reads as started, nothing printed yet. Reusing -l NAME is a silent no-op returning 0 as well, because the label outlives the job: use a fresh name, or run launchctl remove NAME first. macOS has no setsid, and nohup returns cleanly while leaving the child in the launcher's process group, which is why neither is the recipe here.
Then verify with a separate short call — pgrep on Unix, Get-Process on Windows — or read the log you redirected to, rather than waiting on the original one.
⚠ Restarting the agent ends the session carrying the restart — as does anything else that stops the agent: an upgrade, a service restart, a config change that bounces it. From router 0.4.187 the session ends with that connection and says so: the poll carrying the last bytes notes it, and the next call on that id is target_unreachable rather than another status: "running", except in one case. See A session ends with the node's connection. Either way the outcome of a restart is read elsewhere — node_list for the node coming back, node_logs afterwards — rather than by waiting on the call. ⚠ Through router 0.4.186 such a poll was indistinguishable from a slow command: it answered state: "running" with partial output and no exit code until the hour-long idle reap, and never resolved. A command the node detached is the exception and survives it, which is what makes detach the way to restart an agent from a command run through it.
Retrying a start without running it twice
Router 0.4.199. A call can fail with no result either because it never reached noBGP or because the answer was lost on the way back — and from the outside those look the same, so a caller cannot tell whether the command ran. Retrying blindly runs a script with side effects a second time; not retrying abandons work that may never have started.
retry_key settles it. Make a key for this one command — a fresh UUID is the intended shape, up to 128 bytes — and send it on the start call. Send the same call with the same key again after a failure and the command is not run a second time: the retry returns the first call's id and its output from the first byte, exactly as command_read would, with a note on the envelope saying an earlier call started it.
- A retry must be the same call. The key covers the node (however you spelled it), what runs, the identity it runs as, and what goes to stdin. The same key with a different command, node or input is
invalid_argsnaming the key — to run something else, make a new key.waitandredactonly change the answer, so a retry may change those. - It is yours alone, and it lasts one hour. The key is scoped to you (and, through a node's local MCP, to that node), so no caller can see or claim another's. After the hour, the same key starts a fresh command.
- ⚠ One answer is a refusal on purpose. If the first call asked the node to start the command and got no answer at all, the command may or may not have run, and noBGP will not guess: the same key returns
failed_preconditionsaying so for the rest of the hour. Look on the node before running it again, and use a new key when you do. - While the first call is still starting the command, a retry waits for it — up to 30 seconds — and then returns that command. If the first call still has not reported an
idby then, the retry isfailed_preconditiontelling you to send the same call again later, which will return the command once it starts. - If nothing ran, nothing is remembered. A start the node refused, or one that ended before it reached the node, releases the key, so the next retry genuinely starts the command.
- If noBGP cannot reach the store that holds the keys, the command does not run and the call says so (
internal, retryable): send it again later, or omitretry_keyto run it without the guard. Refusing is the safe direction — starting the command with no record of the key is the double-run this field exists to prevent. - ⚠ Omitting
retry_keykeeps today's behaviour: nothing is deduplicated, and a retry runs the command again. The tool's own description says so — see A tool whose second call repeats its effect.
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.
A session that is still running is reaped after one hour idle, and from router 0.4.146 polling counts as activity: every call with the session's id resets that hour, whether or not it returns any output. Until then the clock ran from the last byte of output, so a long silent job — a large build, a restore, a sleep-then-report script — was killed at the one-hour mark however diligently its caller polled. That was the one thing the surface tells you to do, and the 100-second poll ceiling means anything quieter than that must be polled.
⚠ A poll after the command has exited does not extend anything. The 30-second grace above is deliberate: polling for the exit code is the normal end of a command session, so refreshing there would hold every finished session — and up to 1 MiB of retained output each — for another hour. Read the exit code within the grace, as before.
A session also ends when the node's connection carrying it closes (router 0.4.187), rather than running down that idle hour: the next call on it is target_unreachable and the output stays readable for five minutes. See A session ends with the node's connection.
A poll that arrives too late says so, from router 0.4.178. It is still not_found, but the message now names how long ago the command exited, with what exit code and signal, and says the output and exit code were kept for 30 seconds after the exit — so a late poll is no longer indistinguishable from an id that never existed. Both the 30-second grace and the remedy are stated in the tool's own id description as well: poll within the grace, or dispatch the work with command_subscribe, which delivers the output and the exit on the event bus and needs no polling at all. A session closed for any other reason — the hour-long idle reap, an overrun — is the plain not_found it always was. A session whose node connection closed is target_unreachable instead. The exit is remembered for an hour, and only by the instance that held the session, so a poll a different one answers is the plain not_found too.
Usage Patterns
One-Shot Commands
Start a command and read output in a single call:
Request:
{
"node": "web-server-1",
"command": "df -h"
}
Response:
{
"op": "command",
"done": true,
"node": {"id": "8a1f2d5c-…", "name": "web-server-1"},
"command": {
"id": "cmd_abc123",
"output": "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 12G 36G 25% /\n",
"exit_code": 0,
"user": "deploy",
"status": "completed"
}
}
Multi-line scripts
Send the body as script rather than stringing lines together with && or ; (router 0.4.81+):
{
"node": "web-server-1",
"script": "set -e\ncd /srv/app\ngit pull --ff-only\nsystemctl restart app\n",
"wait": 30
}
The same on a Windows node, in PowerShell — no pre-escaping, whatever the body contains:
{
"node": "9f1c…",
"shell": "powershell",
"script": "$f = 'C:\\ProgramData\\app\\default.yml'\n(Get-Content $f -Raw) -replace 'debug: true','debug: false' | Set-Content $f\nRestart-Service app\n"
}
Omit shell on Windows and the body is run as a batch script instead.
Interactive Shell Sessions
Start bash as the command to get an interactive shell, then send commands via input. ⚠ A shell needs stdin: "open" from router 0.4.155 — stdin is at end of file by default, and a shell whose stdin is already at end of file exits at once:
Start shell:
{
"node": "web-server-1",
"command": "bash",
"stdin": "open"
}
Response:
{
"op": "command",
"done": true,
"node": {"id": "8a1f2d5c-…", "name": "web-server-1"},
"command": {
"id": "cmd_xyz789",
"output": "user@web-server-1:~$ ",
"user": "deploy",
"status": "running"
}
}
Send a command — command_input:
{
"id": "cmd_xyz789",
"input": "cd /var/log && ls -lt | head -5\n"
}
Response:
{
"op": "command_input",
"done": true,
"node": {"id": "8a1f2d5c-…", "name": "web-server-1"},
"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$ ",
"status": "running"
}
}
Close the shell — command_input again:
{
"id": "cmd_xyz789",
"input": "exit\n"
}
Polling Long-Running Commands
For commands that take a while, read with command_read until exit_code appears. From router 0.4.147 there is a second way such a loop ends: a command that produces output faster than it can be read loses the frames that do not fit, so exit_code never arrives and the poll refuses with resource_exhausted — see A command that outruns the router for what to do instead.
Start:
{
"node": "build-server",
"command": "make build",
"wait": 30
}
Poll — command_read:
{
"id": "cmd_build123",
"wait": 30
}
Sending Signals
Interrupt a running process with command_signal:
{
"id": "cmd_xyz789",
"signal": "SIGINT"
}
The response carries the node's verdict on it (router 0.4.84+) — here, delivered to the process group and ignored, which is the case that tells you to escalate:
{
"command": {
"id": "cmd_xyz789",
"signal_delivered": true,
"signal_mechanism": "kill",
"signal_target": "pgid 4242",
"status": "running"
}
}
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: with root omitted, the node's configured user (the user key in its profile config), which is not uniformly root — it may be the owner's login on one node, a service account on another, and nothing at all on a container or bare-root install, where the session runs elevated instead and root_used reports true. On such a node root: false is refused rather than run as root. Set root: true to force elevation, gated by allow-root. 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 root 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 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.
For anything longer than one line — batch or PowerShell — send it as script instead of command (router 0.4.81+). A multi-line batch body is written out to a temp .cmd and run from there, so cmd.exe's line-length limit and quoting layer never apply; a PowerShell body still wants shell: "powershell". When a Windows node answers a non-zero exit that cmd.exe itself produced — no output at all, exit 9009, or cmd.exe's own "is not recognized" wording — that is cmd.exe having mangled a PowerShell command line, and the response's hint field says so.
⚠ Exit 9009 is not always cmd.exe's, and from router 0.4.89 the hint stops assuming it is. Windows ships zero-byte App Execution Alias stubs on the PATH for python, python3, winget and others; run one on a machine where the program is not installed and the stub prints …was not found; run without arguments to install from the Microsoft Store and exits 9009 — the same code cmd.exe uses for a name it could not resolve. Through 0.4.88 that got the quoting hint, whose two remedies provably do not apply: sending the same body as script exits 9009 with a byte-identical message, and shell: "powershell" exits 1 with the same message again. The hint now names the real cause instead — install the program on that node, or turn the alias off in Settings › Apps › Advanced app settings › App execution aliases — and says outright that this is not a quoting problem.
Two edges of that, both deliberate:
- A line that failed both ways gets both answers.
commandtakes one line, sopython3 --version & Get-ChildItemmakes the stub print its message and cmd.exe print its own refusal beside it. There the hint gives the alias diagnosis and the quoting hint for the rest of the line, rather than denying a quoting problem the output on your screen contradicts. - An exit
9009whose output is neither of those now hedges rather than asserting. A program may return9009for reasons of its own, so the hint ends by telling you to prefer the output's own account of itself if it names one. Where the diagnosis is earned — no output at all, or cmd.exe's own wording — it is stated flatly, as before.
The alias signature is matched in en-US only, the same limit the cmd.exe wording carries: a node in another display language still gets the quoting hint, which is the pre-0.4.89 behaviour rather than a regression.
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 |
On Linux and macOS a process that dies from one of these reports exit_code as 128 + the signal number (130, 143, 137 for SIGINT, SIGTERM, SIGKILL), so a signalled command is distinguishable from one that exited on its own. Windows reports 1. From router 0.4.84 you do not have to read the number at all: signal carries the name of whatever ended the process, and signal_delivered / signal_mechanism / signal_target say what the node's mechanism did with the one you sent.
Cross-replica behaviour
command_read, command_input and command_signal 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.
command_read
Surfaces: public · local
Read more output of a command command started, and learn whether it has exited. Router 0.4.195+.
Input Schema
{
"id": "string (required)",
"wait": "number (optional; default 5, clamped to 100)",
"until": "string (optional: \"done\" — wait for the exit, at most 100 s)",
"cursor": "number (optional; byte position in this session's output to read from)"
}
Parameters:
id- The idcommandreturned. Required, andcommand_iduntil router 0.4.200.wait- Seconds to wait with no new output before returning (default 5, clamped to 100;0does not wait), andidle_timeoutuntil router 0.4.200. The timer resets on every chunk, and the whole call is bounded whatever the command does — see A poll always comes back.until- Router 0.4.201+."done"waits until the command exits, bounded at the same 100 seconds: the call returns at the exit, or withstatus: "running"and a note saying to call again with the sameidanduntil: "done". No output is lost across such a chain — the next read starts atnext. Not withwait.cursor- Where to read from, andoutput_offsetuntil router 0.4.200. Omit for the normal case: reading resumes where the last call stopped. See Re-reading output.
Output Schema
The same payload command returns, under the same command key — output, stderr, status, exit_code when it has exited, next on every response. root_used is absent: the session's identity was settled when it started. From router 0.4.202 the envelope carries the session's own node, so a client holding several sessions can tell from the result which machine answered.
Behaviour
- It sends nothing to the command and does not stop it. This is the one read-only tool of the four, which is the point of the split: under the old single
commandtool a read of output had to advertise the annotations of a tool that could also write to stdin and kill a process. - Call it again until
statusis no longer"running". A poll counts as activity, so a command that prints nothing for hours stays alive as long as you keep reading it — see Session Lifecycle. - ⚠ Once the command has exited, the session is kept for 30 seconds only, and a read does not extend that. Read within 30 seconds of the exit to get the last output and the exit code; a later call gets
not_foundnaming when the command exited. For work you will not poll closely, usecommand_subscribe, which delivers the output and the exit on the event bus. - If the node's connection that carried the session closed, the first read returns the output that arrived before that and the next one is
target_unreachable, not retryable — the command did not survive, so start it again. See A session ends with the node's connection.
command_input
Surfaces: public · local
Write text to the stdin of a command command started, close its stdin, or both in one call. Router 0.4.195+.
Input Schema
{
"id": "string (required)",
"input": "string (optional; text to write to stdin)",
"close": "boolean (optional; put stdin at end of file)",
"raw": "boolean (optional; do not resolve escape sequences in input)",
"wait": "number (optional; default 5, clamped to 100)",
"redact": ["string (optional; one short label per secret this call carries)"]
}
Parameters:
id- The idcommandreturned. Required, andcommand_iduntil router 0.4.200.input- Text to append to an open stdin. C-style escapes (\n,\t,\xHH,\\) are resolved by default;raw: truedisables that.close- Put stdin at end of file, so a program that reads to end of file finishes, andclose_inputuntil router 0.4.200. It may ride the same call as a finalinput— the bytes are written first, then stdin closes.wait- After the write, wait this long with no output before returning (default 5, clamped to 100;0does not wait), andidle_timeoutuntil router 0.4.200. The command keeps running.redact- As oncommand. See Declaring that a call carries a secret.
A call must send input, close, or both. Neither is invalid_args naming command_read, which is the tool for reading output without sending anything.
Output Schema
The same payload command returns, under the same command key, including whatever output arrived during wait and stdin_closed: true when the node confirmed a close. From router 0.4.202 the envelope names the session's node.
Behaviour
- It works only on a session whose stdin is open — one started with
input, or withstdin: "open". A session started with neither has stdin at end of file, andinputto it isinvalid_args. See stdin is at end of file unless you ask for a pipe. - Two concurrent calls on one session are serialized, so their bytes cannot interleave on the process's stdin, and a write racing a close either lands before it or is refused — never reported as sent to a program that had already reached end of file.
- ⚠ On Windows only a program that reads redirected stdin sees this at all. A console reader (
Read-Host,pause,choice, a credential prompt) never receives it andclosedoes not help it; the way out of such a session iscommand_signalwithSIGKILL. When a Windows session receives input and then says nothing for the whole idle window, the result'shintsays so. - It is open-world, like
commanditself: the program that reads the text can reach the internet.
command_signal
Surfaces: public · local
Send a signal to a command command started — to interrupt it or to stop it. Router 0.4.195+.
Input Schema
{
"id": "string (required)",
"signal": "SIGINT | SIGTERM | SIGHUP | SIGQUIT | SIGKILL (required)",
"wait": "number (optional; default 5, clamped to 100)"
}
Parameters:
id- The idcommandreturned. Required, andcommand_iduntil router 0.4.200.signal- Required. One ofSIGINT,SIGTERM,SIGHUP,SIGQUIT,SIGKILL. Anything else isinvalid_args. Over REST an omittedsignalisinvalid_argstoo; over MCP the schema check refuses it. Per-platform behaviour is in Signals by platform.wait- After the signal, wait this long with no output before returning (default 5, clamped to 100;0does not wait), andidle_timeoutuntil router 0.4.200. ⚠ Sending a signal adds a short wait for the node's verdict even withwait: 0.
Output Schema
The same payload command returns, under the same command key, plus the three verdict fields: signal_delivered, signal_mechanism and signal_target. exit_code and signal appear when the command ended. From router 0.4.202 the envelope names the session's node.
Behaviour
- The result says what the node did with the signal, not merely that the call succeeded — read What a signal did before treating
signal_delivered: trueas "the process was signalled". An absentsignal_deliveredmeans the node did not confirm, which is not the same asfalse. - A delivered signal can be ignored. Read
status: if it is still"running", escalate toSIGKILL. - ⚠ On Windows
SIGINTis Ctrl+C written to stdin and reaches only a program that actively reads stdin;SIGHUPandSIGQUIThave no Windows mechanism and are refused there;SIGKILLstops any process.
file
Surfaces: REST/OpenAPI only
Unified super-tool for reading, writing, editing, and managing files on a remote agent's local filesystem.
file left the MCP surface in router 0.4.195It is still served at POST /api/v1/tools/file and /actions/file, with all thirteen operations and both identity spellings, and nothing about it changed there. It is simply not in tools/list on either MCP surface any more, and a tools/call naming it is refused.
The reason is that one tool selecting one of thirteen operations from a parameter cannot carry honest annotations: one set had to describe a stat, a delete and a fetch from the internet at once. Every operation has its own tool on MCP, and they run the same code paths this one does:
file op | On MCP |
|---|---|
read / write / edit / multi_edit / list / stat / delete / mkdir | fs_read, fs_write, fs_edit (both edit ops), fs_list, fs_stat, fs_delete, fs_mkdir |
copy | fs_copy |
fetch_url | fs_fetch_url |
lock / unlock | fs_lock / fs_unlock |
batch | nothing — send the sub-ops as separate calls, or use REST |
Everything below describes the REST shape, and remains the authority for each operation's behaviour: the per-op tools are the same handler with a narrower schema.
From router 0.4.204 that refusal names the tool to call instead. An MCP tools/call of file answers unsupported, says it did not run, and tells you to refetch the tool list — and where the call carried an op, it names the one fs_* tool for that op rather than listing all eleven. Nothing is translated and nothing runs. Before, it came back as a bare unknown tool, which reads like a bug rather than a rename.
Purpose
A single tool that dispatches to thirteen operations via the op field, over REST. On MCP, use the narrower per-op tools above — their argument schemas evolve independently, and they are what a tool list advertises. Both carry root and it means the same thing on each. What only file has is op=batch.
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; missing parent directories are created) |
copy | Copy one file — or, with paths / recursive, a set of files — between two machines the router can reach. The only op with two subjects, so it takes source / destination instead of target + path. Same operation as fs_copy |
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, and from router 0.4.178 — agent 0.4.152 on a machine's own disk — the entry's type plus a symlink's target) |
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 |
lock | Storage trees only. Take a two-minute advisory lock on one path, held across routers and nodes |
unlock | Storage trees only. Release a lock you hold; releasing one you don't is not an error |
Input Schema
{
"file_id": "string (optional, continues a read/write session)",
"target": {
"node_id": "string (optional)",
"network_name": "string",
"node_name": "string"
},
"storage": "boolean (address the node's own storage area instead of its disk; needs target.node_id)",
"op": "read|write|copy|edit|multi_edit|list|stat|delete|mkdir|batch|fetch_url|lock|unlock",
"path": "string (absolute path on the agent)",
"source": {"target": {}, "path": "string", "storage": "boolean", "root": "string", "admin": "string (REST only; the old name of root)"},
"destination": {"target": {}, "path": "string", "storage": "boolean", "root": "string", "admin": "string (REST only; the old name of root)"},
"paths": "array (copy only; a copy set — paths relative to source.path)",
"encoding": "base64|utf8 (read/write only; base64 default)",
"offset": "number (read: resume byte offset; list: entries to skip)",
"max_bytes": "number (read only; default 32768, max 1048576)",
"transfer": "\"url\" (read/write only; return a URL for the bytes instead of the bytes)",
"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; on copy, a copy set of every file under source.path)",
"root": "\"true\" | \"false\" (optional; omit to run at the identity the node offers — a JSON boolean is also accepted)",
"admin": "string (REST only; the old name of root)",
"allow_missing": "boolean (stat only; return exists=false instead of not_found)",
"force": "boolean (delete only; bypass the recursive depth-guard — not needed inside /tmp and the other scratch roots)",
"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 (write/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.
⚠ A path that begins with two separators is refused (router 0.4.186), in any mix of / and \ — //server/share, \\server\share, //?/C:/x. On a Windows node that is a UNC or device path, which can name any file on any reachable host without looking like it; the agent refuses one too, and the router now says so before the call leaves. Use one leading /, or a drive letter. The rule covers every path-taking file tool: path here and on the seven fs_* ops, both ends of a copy, fs_fetch_url's destination, fs_glob's pattern and the paths of fs_grep and fs_grep_subscribe.
Key behaviors:
- Provide
targeton the first call (vianode_idORnetwork_name+node_name). Continuations pass onlyfile_id. From router 0.4.92 the same three fields are also accepted at the top level, besidepath, instead of insidetarget— the one flat spelling every node-addressed tool takes. The two are equivalent everywheretargetis read, storage trees included; naming the node both ways isinvalid_args, andop=copytakes neither, since it names each end for itself. op=writeagainst a node is how bytes reach a machine your own computer cannot — it needs no inbound route to the target, only the node's own agent connection.op=fetch_urlis the same move from the other side: the node does theGETfrom its network position, so a mirror only it can see works and the bytes never cross the conversation. Router 0.4.72 puts that in the tool description and the server handshake, so it is picked before a plan forms around a second machine, a copy by hand, or publishing something private. See Which of the three to reach for.targetalso selects noBGP's own storage (router 0.4.56+):network_namealone reaches that network's shared drive, andnode_idwithstorage: truereaches that node's storage area — see Addressing storage instead of a node. On those two trees the ops areread,write,list,stat,mkdir,delete,lockandunlock;edit,multi_editandfetch_urlare node operations and say so withunsupported.op=batchis served there too from router 0.4.59 — the outerstorageapplies to every sub-op, and a sub-op the trees do not have fails on its own without taking its siblings with it. On 0.4.56–0.4.58 a batch droppedstorageand ran every sub-op against the node's real disk.- ⚠⚠
op=writeon those two trees is a single whole-file call, and chunking it keeps only the last chunk (said in the tool description from router 0.4.146). Nofile_idis issued,doneis ignored and each call replaces the file, so the streaming recipe two bullets down applies to a node target only. Past the 1 MiB inline cap the call is refused withtoo_large. Seefs_writefor what to do instead. op=copymoves one file between two machines in a single call (router 0.4.84+). It is the one op with two subjects, so it takessourceanddestinationobjects instead of the top-leveltarget/path— and the top-leveltarget,path,storageandrootare refused on it rather than quietly ignored. The router streams the bytes between the two ends, so neither needs a route to the other and nothing passes through the conversation. Full contract onfs_copy; acopycannot be a sub-op ofop=batch, which inherits one target, and says so by name.paths/recursiveonop=copycopy a set of files in one call (router 0.4.170+), exactly as onfs_copy— bothpaths become directories, the result lists every file infiles[], andcompleteistrueonly when all of them were copied. See Several files in one call.pathsbelongs tocopyalone: on any otheropit is refused by name rather than ignored, because ignored on a delete it would read as delete these.transfer: "url"returns a URL for the bytes instead of the bytes onreadandwrite(router 0.4.84+) — for handing to something that can fetch. See Moving bytes without reading them.- Read/write are streaming: first call returns
file_id; poll with the samefile_iduntildone=true. A read returns 32 KiB by default from router 0.4.84 (it was 256 KiB, which no model client would accept); the 1 MiB hard cap is unchanged. From agent 0.4.152 anop=readof a folder, a pipe, a socket or a block device fails withinvalid_argsnaming the type instead of blocking, while a character device is served and bounded bymax_bytes. Seefs_read. - Write lands in a sibling
.nobgp-tmpfile and is atomically renamed on finalize — aborted writes leave the destination untouched. From router 0.4.77 the finalize carries the whole-file SHA-256 and an agent on 0.4.79+ refuses the rename unless the finished file hashes to it, so a corrupted transfer fails rather than committing. Agent 0.4.81 extends that check to the router's older commit signal, which a write falls back to when the ordinary commit is lost on the way to the node — seefs_write. op=writeandop=fetch_urlcreate a missing parent chain forpath(agent 0.4.45+), so neither needs anop=mkdirahead of it. It saves a round-trip and grants nothing new: the same recursivemkdirwas already available to the caller, the destination is still vetted against its deepest existing ancestor before anything is created, and the new directories are owned by the identityrootselected. An agent below 0.4.45 fails these calls with the parent'snot_foundinstead.- Router-computed SHA-256 on a write is cumulative and returned on every response, over the bytes the router accepted; from router 0.4.77 the final one also travels to the node with the commit and is verified there (agent 0.4.79+). Because it is a hash of what the router accepted, it certifies the router→node leg and cannot speak for your bytes reaching the router — send
expected_sha256on thedone: truecall to cover that half (router 0.4.79+). ⚠ On a read it is no longer cumulative (router 0.4.84):sha256is the whole file and appears only on thedone: trueresponse of a read that started at offset0, whilechunk_sha256verifies the bytes in one response. Either way a read's digest is one-sided — compare it againstsha256sumon the node. A read also reportstruncated/next_offsetnow, asfs_readdescribes. op=writereturnsnot_found— session ended before write completed — for a chunk it could not deliver (router 0.4.77+), instead of the success it used to report for bytes that never reached the node. Open a fresh session and resend; the path is not the thing that is missing.- 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. rootpicks which of the node's two identities the operation runs as, exactly as it does oncommand. Omit it and the node's own shape decides: the configured account where the node has one, the ambient identity where it offers only that. The agent drops privilege before touching the file, so ownership and permissions are the ones that identity would get — a file written withroot: "false"is owned by the node's configured user. Asking for"true"needs root on that node and the node'sallow-root; either gate fails the call rather than downgrading it, and an omittedrootthat resolves to the ambient identity is gated the same way. Asking for"false"can fail too: a node whose unelevated identity would be uid 0 returnsfailed_preconditionrather than acting as root. An elevated call is also not bound byallow-roots(agent 0.4.37+) — but never reaches the agent's own configuration directory, at either identity. See Execution identity. Set it on the first call of a read/write session; continuations carry the session's identity. From router 0.4.83 it is a string —"true"or"false", with a JSON boolean still accepted — and the response reportsroot_used, the boolean identity the operation actually ran as.root_usedis absent on the router-served storage trees, which run as nobody on a node — and from router 0.4.88 arootsent for one of those trees is reported rather than quietly accepted: the result carries anotes[]entry saying the field applied to nothing and quoting what you sent. It is not refused, because sending it is harmless; what the note prevents is a caller concluding an elevated read happened.fs_copyhas said the same per side since 0.4.84.- Paths inside the agent's own configuration directory (
/etc/nobgp,/usr/local/etc/nobgp,C:\ProgramData\nobgp, or/workspace/nobgpon a Grok Bot computer) are refused on everyop, including withroot: 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. - ⚠ A script this tool writes to a Windows node is parsed as CP1252 unless it is ASCII (router 0.4.96).
write,editandmulti_editland UTF-8 with no BOM, and a BOM-less.ps1/.bat/.cmdis parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so the write, its digest and a read-back are all correct, and only the parse is wrong, against a later line than the damaged one. See A script written to a Windows node. - A path under the agent's own mount point may be refused to the agent on every
op, at both identities —root: truefails identically, because it is not a privilege problem.node_listanswers it per node asmount_readable(withview: "full"); where it isfalse, reach that content through the router withnetwork_namealone orstorage: trueinstead. See A node's own mounted drive. op=listresponses are capped at 200 entries by default (hard max 5000). Page withlimit/offsetand readtotal_entries/truncated/next_offset— same contract asfs_list. Withrecursive: truethe walk re-checksallow-rootsat every directory it enters (agent 0.4.37+), naming a refused directory and then pruning it — and from agent 0.4.151 apaththat is missing, unreadable or not a directory fails the call instead of coming back as an empty listing, exactly as onfs_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 a node's own filesystem for paths shallower than three segments (/,/etc,/home/user,C:\Users\bob). Setforce: truewhen you really do mean it —fs_deletetakes the same field. On the router-served storage trees that floor is gone from router 0.4.84; only the tree root is refused there.expected_sha256is one field over three different sets of bytes, and theopsays which: onedit/multi_editit is the file's hash before the change (a guard against lost updates), onfetch_urlthe hash of the fetched bytes, and from router 0.4.79 onwritethe hash of the whole file you meant to send — passed on thedone: truecall and compared against what the router assembled before it commits anything. A mismatch fails with HTTP 412 (precondition_mismatch) and returns the observed hash indetails.observed_sha256. Onwritenothing is written and no temporary file is left on the node. Awritesub-op insideop=batchtakes it too. Onedit/multi_edityou may not need the guard to learn the hash: anold_stringthat matched nothing is itself aprecondition_mismatch, and from agent 0.4.85 its message ends with the file's currentsha256, ready to pass back asexpected_sha256on the rebased retry — seefs_edit.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=lock/op=unlockexist on the storage trees only (router 0.4.62+) and are refused withunsupportedagainst a node's own disk.locktakes a two-minute advisory lock onpath, held across routers and nodes, and answerslocked: truewith alock_tokenand an RFC3339lock_expires; re-locking renews it. A path someone else holds returnsalready_exists, retryable, withdetails.expires_at,details.held_by_selfanddetails.held_by_kind.unlockanswerslocked: false, whether or not you held it. Those three response fields are populated on these two ops and no others.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. Apathwhose parent directories don't exist yet gets them created, same asop=write— and from agent 0.4.89, a fetch that does not land (a bad digest, a refusing server, a stream over the size cap) removes the directories it created on the way in, so a failed fetch leaves nothing behind at all.- ⚠
op=fetch_urlhonoursrootfrom router 0.4.88; before that it silently ignored it. The field was accepted, passed the schema, and never reached the node — so every fetch ran as the node's configured account whatever you sent, and the result reported no identity. Two consequences on older routers: a fetch to a path only the superuser can write failed withpermission_deniedwhile the sameroot: "true"onfs_writesucceeded, and on a node whose unelevated identity is the superuser the refusal namedroot: trueas the retry and then answered that retry with the identical refusal — the one place on this surface where a named retry looped. From 0.4.88 the fetch runs at the identity you pick, the result reportsroot_used, and asking to elevate is gated like every other elevated call. See Execution identity.
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.
op: "write" (and fs_write) sends content the caller has already produced, so a file on your own disk passes through your context to get here — and a large one cannot. If you have a shell, ask for transfer: "url" with no chunk and curl -sS -T the file to the URL instead: the bytes never enter the conversation. Bytes already on another machine are fs_copy; bytes the node can fetch for itself are fs_fetch_url. See fs_write.
fs_read
Surfaces: public · local
Stream a file from a remote agent to the client.
Input Schema
{
"id": "string (optional)",
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute)",
"cursor": "number (optional, resume)",
"encoding": "base64|utf8 (default: base64)",
"limit": "number (default: 32768, max: 1048576)",
"transfer": "\"url\" (optional; return a URL for the bytes instead of the bytes)",
"storage": "boolean (read the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Behavior:
- First call returns an
id. Pass it back asidon subsequent calls untildone=true. - The node is
node, at the top level besidepath— its id or its name. The same holds forfs_write,fs_edit,fs_list,fs_stat,fs_delete,fs_mkdirandfs_grep. See One name for each input. - ⚠
limitdefaults to 32 KiB from router 0.4.84 — it was 256 KiB, and that was a bug rather than a preference. Measured: a 256 KiB chunk comes back as roughly 349,700 characters and a model client refuses it outside the protocol — the harness writes the response to a local file — so the caller got neither its bytes nor a tool error, while 32 KiB was delivered. The 1 MiB hard cap is unchanged: raiselimitfor a client that consumes bytes rather than reading them (REST, a script). A file over the limit pages bydone/cursoras it always has; to keep the bytes out of the conversation entirely, usefs_copy,op=fetch_url, ortransfer: "url". sharereads the network's shared drive, andstorage: truereads the node's storage area (router 0.4.56+). Either way the router answers from its own storage, with noidand no session.limitis honoured there from router 0.4.59, and from router 0.4.84 it defaults to the same 32 KiB a node read does — an over-default object pages bydone/next, where before it arrived in one 1 MiB response.sha256is present only when the whole file came back in that one response — there is no session to carry a running hash — so verify against the tree's HTTPS URL, which is also what moves a large file in one go.encoding=utf8may be paged here, unlike a node read: a chunk that would split a character is trimmed back to the last whole one, andoffset + chunk_sizestill resumes exactly. From router 0.4.94 acursorpast the end of the object isinvalid_argswithdetails.file_size, matching what a node read answers — before that it came back as an emptydone: trueread whosefile_sizewas the offset you sent rather than the object's size.encoding=utf8returnschunkas a plain string; defaultbase64returnschunk_b64.- UTF-8 mode is single-shot on a node read: it does not support offset-based resume (partial-codepoint state is per-session), and sending
cursorwithencoding: "utf8"isinvalid_argsrather than a silent restart. If the file may not fit in one response, start withbase64rather than discovering the limit on the second call. The storage trees are the exception — they pageutf8fine, trimming each chunk back to the last whole character. - Small files fit in a single response with
done=true. - A source that is not a regular file is refused, from agent 0.4.152. A directory, a FIFO, a socket or a block device comes back
invalid_argsnaming the type the node found, rather than the call blocking on a pipe nobody is writing to or streaming a whole disk. A character device is served —/dev/urandom, a sensor, a serial port — bounded bylimitand reportedtruncatedlike any long file, so an endless one such as/dev/zeroreturns a chunk instead of never ending. A symlink to a regular file is read as before. From agent 0.4.182 the refusal of a directory namesfs_listas the call to make instead, and it is a refusal on Windows too: there, reading a directory used to fail asinternalwith the operating system's Incorrect function. ⚠ On a node whose agent predates 0.4.152 the read is attempted: a FIFO with no writer spends the whole call blocked, which is how arecursivefs_copyover a tree holding one used to skip every file after it.
Output Schema
{
"id": "string",
"chunk_b64": "string (base64 mode)",
"chunk": "string (utf8 mode)",
"offset": "number",
"chunk_size": "number (bytes in this response)",
"truncated": "boolean (bytes remain past this chunk)",
"next": "number (where the next chunk begins; set while truncated)",
"size": "number (the whole file; present on the done=true response)",
"chunk_sha256": "string (this response's bytes)",
"sha256": "string (the whole file; only on the done=true response of a read that started at 0)",
"transfer": "object (present instead of the bytes when transfer:\"url\" was asked for)",
"root_used": "boolean (present when the read ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the read ran as)"
}
This is the payload under fs_read; the path read and done are on the envelope.
root_used (router 0.4.89+) is the identity the read actually ran as — true for the node's ambient identity, and absent rather than false for the account its owner configured, which user names from router 0.4.206. It is an outcome and not a request: an omitted root that landed on the ambient identity reports true. It is set on the call that opens the session; a continuation carrying an id omits it, because that session's identity was settled when it opened. It is also absent on the router-served storage trees, which run as nobody on any node. Read it beside a short or empty read: unelevated, the read sees only what that account can, which is how one comes back as permission_denied on a path you can list. See the seven fs_* tools for what an older router returns.
offset is where this chunk starts and chunk_size is what this call delivered, so offset + chunk_size is where the next call resumes. From router 0.4.84 a read reports that like a listing does: truncated says bytes remain past this chunk — read it rather than deriving it from done — and next is where they start. Continuing with id is still cheaper, since the session tracks the position itself; next is what you pass as cursor to open a new session when the old one was lost.
⚠ Two digests, and they answer different questions (router 0.4.84). chunk_sha256 covers the bytes in this response, so it verifies a chunk on arrival. sha256 covers the whole file — the same promise fs_write's sha256 makes, so the two tools agree on what the word means — and it is present only on the done: true response of a read that started at offset 0, where it is comparable against sha256sum on the node. A read resumed at an offset carries none, because the router hashed only the tail it forwarded. Before this, sha256 was cumulative and a resumed read published a hash of a fragment under the name of the file's.
A node that outruns the router now fails the read
Router 0.4.146. An agent streams a read as fast as it can and nothing paces it. If it outruns what the router can take, the mesh drops the frames that do not fit — and until this release that loss was reported as the end of the file: done: true, a file_size measured over the part that survived, and no error anywhere. A caller reading a 5 MiB file was told it had all of it at 1 MiB.
Such a read now fails with resource_exhausted, and the message carries the two things you need:
- What you already read is correct, and the file is not finished. The loss is at the tail, so nothing you were given is wrong — but how much was lost is unknown, so
offset + chunk_sizeis not where to resume. - Where to resume, and that this
idis dead. The error names the first byte that was not delivered; open a new read with that byte ascursor. Continuing the old session is not possible. Withencoding: "utf8"— which cannot take acursorat all, because the codepoint boundary died with the session — the message says so and points atencoding: "base64"for the resumption.
The same loss reaches the node's other one-shot answers as resource_exhausted rather than the internal they used to report: fs_list, fs_stat, fs_delete, fs_mkdir, fs_edit, fs_grep, fs_glob, op=fetch_url, node_logs and the net_* diagnostics. There it is a whole answer that did not fit, so the remedy is to retry or to narrow the request — a smaller root, a tighter pattern, a lower limit — rather than to resume. fs_copy answers it in its own way, since the destination is what has to be protected there, and a command session's output answers it in its own way from router 0.4.147.
Moving bytes without reading them
Router 0.4.84 adds transfer: "url" to reads and writes — on fs_read, fs_write and file. Instead of the bytes, the response carries a descriptor for a URL you can hand to something that can fetch:
transfer: "url" did not work on 0.4.84 through 0.4.86: every mint, download and upload alike, answered internal: could not record the transfer authorization. Two separate defects sat on that one path, and the first fix — shipped in 0.4.86 and announced there as the fix — closed only the first of them. Both are closed from 0.4.87. No URL was ever issued in that window, so there is nothing to re-check or revoke; reissue the call. whoami reports router_build if you need to know which router is serving you.
{
"transfer": {
"uri": "https://router.nobgp.com/xfer/…",
"name": "build.tar.gz",
"size": 5242880,
"method": "GET",
"auth": "none",
"expires_at": "2026-08-19T10:45:00Z"
}
}
⚠ Decide who is going to dereference it before you ask for one, because a URL you cannot fetch wastes the call:
| What you are doing | What to use |
|---|---|
| Reading bytes yourself | The ordinary session protocol with a small limit — an assistant has no fetch. 32 KiB is the size that reliably survives a model client's per-result budget. |
| Moving bytes node to node | fs_copy. They never need to reach you at all: one call, no URL, nothing to stand up or clean up. |
Handing bytes to something that can fetch — a person's browser, a CI job, a script, your own shell if you have one, a node's own fs_fetch_url | transfer: "url". This is what the field is for. |
| Uploading a file from your own disk, when you have a shell | transfer: "url" on a write, then curl -sS -T <file> '<uri>'. The bytes never enter the conversation. |
- A download URL comes from a read. It is single-file, single-direction and short-lived, and it carries its own authority — so treat it as a secret, and read
expires_atfor when it stops working. The response'sX-Nobgp-Sha256trailer carries the digest, which is why the descriptor has none: knowing it at mint time would mean reading the whole file first, the cost this route exists to avoid. - An upload URL comes from a write with
transfer: "url"and no chunk.PUTthe body to it —curl -sS -T <file> '<uri>'is the whole of it from a shell — and the commit it performs, digest check and atomic rename, is the one the session protocol already does. OnePUTcarries up to 8 GiB, and the response body reports the committed size andsha256. SendX-Nobgp-Expected-Sha256: <hex>to have the router compare what it assembled against what you meant to send, before it commits anything: it is the only check covering your bytes reaching the router, and it is the header form of thesha256a chunked write sends.Content-Rangeresumes an interrupted upload against the same router replica. ⚠ AContent-Rangemust name the total —bytes 0-7/8, neverbytes 0-7/*. An unknown total is refused up front with400andkind: "bad_content_range"from router 0.4.94, because the total is the only thing telling the router a part from the whole: through 0.4.93 a*parsed as no total, so the first part of a multi-part upload was committed as the finished file —200, digest and all — and the second part then failed against a destination already holding a truncated one. If you do not know the size, send the file in a single request with noContent-Rangeat all. - Read
authrather than assuming."none"means the URL carries its own authority and you send noAuthorizationheader."bearer"— which is what the two router-served storage trees return, since those are ordinaryfiles.nobgp.compaths rather than capabilities — means send the same bearer token you called the tool with, and stripping it is a 401. ⚠ A shell you hand the URL to usually does not hold that token, socurlagainst a tree URL is not the upload path a node URL is: write to the node itself, or send the bytes inline. A tree URL also carries noexpires_at— it is a permanent path rather than a capability, so what bounds it is your token's own lifetime. - A download URL serves a regular file only (router 0.4.178+). A URL has no
limitand serves the whole file, so a source whose type is anything butfileis refused withinvalid_argsat the mint rather than issuing a URL that would stream a device node until the transfer's own limits cut it. A symlink is followed, and the descriptor'ssizeis the target's — the link's own size is the length of its text, and the download servesContent-LengthandRangefrom that number, so it would otherwise cut the file short. A dangling link isnot_found, and a directory is refused pointing atfs_listand one URL per file. On a node whose agent predates 0.4.152 there are no entry types to judge and the mint behaves as before. - A capability is re-checked when it is redeemed, not frozen when it is minted: a URL minted before a membership is revoked stops working with the membership. Every failure on one says whether to mint another or to fall back to the session protocol.
- Handing one to a node's own
op=fetch_urlreports the capability's verdict, not the status line (router 0.4.92+). The recipe thetransferfield recommends — mint a download URL, have the destination node fetch it — used to lose its diagnosis on the way back: the node reported the HTTP status it saw and the router classified that like any foreign origin, so a spent capability came back asnot_found— the origin has no such object, check the URL and the artifact name — about a URL that was perfectly correct. The router now recognises its own transfer route by host and answers with the refusal's own kind, code and next step (token_spent→failed_precondition, mint another withtransfer: "url"), carryingdetails.xfer_kindbesidedetails.http_status. A URL on any other origin is classified from its status line exactly as before, and a noBGP router reached under a different hostname falls back to that too — a miss, never a wrong answer.
⚠ total is gone, and file_size is what replaced it (router 0.4.83). total meant "bytes delivered so far", which is offset + size and therefore derivable, and it sat beside size reading like a second name for the same number — while the thing a reader actually wanted from it, how much file is left, it never answered. file_size is the size of the whole file, and it is size from router 0.4.201. It appears on the done: true response and not before: the agent streams to EOF without announcing a length, so an earlier response says nothing rather than guessing. To size a file up front, use fs_stat. On a write, the bytes accepted so far are offset.
fs_write
Surfaces: public · local
Stream a file from the client to a remote agent.
Input Schema
{
"id": "string (optional)",
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"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)",
"sha256": "string (optional; whole-file hash, sent on the done=true call)",
"transfer": "\"url\" (optional, with no chunk; return an upload URL instead)",
"storage": "boolean (write to the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Behavior:
- This is how bytes get onto a machine your own computer cannot reach — code, configs, build artifacts. The node needs no inbound route and nothing installed on your side, only its own agent connection, so a target behind NAT, CGNAT or a firewall is addressed like any other. Router 0.4.72 says so in the tool descriptions and the server handshake, so an assistant reaches for it instead of proposing a second machine on the target's LAN, a manual copy, or making a private repository public. (
fs_fetch_urlis the other half: the node fetches a URL from its network position, so a mirror only it can see works and the bytes never cross the conversation.) - ⚠ A file on your own disk gets here one of two ways, and which one depends on whether you have a shell (router 0.4.170 states both in the tool description; through 0.4.169 it said the upload
PUTcould not be made at all, which is false for a coding agent with a shell tool and sent callers to base64 instead).- If you have a shell — a coding agent with a Bash tool — call with
path,transfer: "url"and no chunk, then upload the file yourself:curl -sS -T <file> '<uri>'. The bytes never enter the conversation. For a node target the URL needs noAuthorizationheader; start thePUTbeforeexpires_at; onePUTcarries up to 8 GiB. Add-H 'X-Nobgp-Expected-Sha256: <hex>'to have the router check your bytes before it commits them. The response body carries the committed size andsha256. ⚠ That is the recipe for a node, and only for a node: a URL minted for the shared drive or a node's storage area is an ordinaryfiles.nobgp.compath rather than a capability, so itsauthreads"bearer"and thePUThas to carry the same token you called the tool with — which a shell usually does not hold. Write to the node itself, or send the bytes aschunk. Router 0.4.171 says which destination the recipe works for in the tool description itself. - If you have no shell, send the bytes as
chunk/chunk_b64and every byte passes through your context. Base64 costs roughly one token per two bytes: 10 KiB is about 5,000 tokens, 1 MiB about 480,000. Above a few hundred KiB this is not viable — put the file at a URL the node can reach and usefs_fetch_url. - Two cases need none of this: bytes moving node to node are
fs_copy, and a file the node can fetch for itself isfs_fetch_url.
- If you have a shell — a coding agent with a Bash tool — call with
- First call opens a session and returns an
id. Stream chunks and call withdone=trueto finalize. transfer: "url"with no chunk returns an upload URL instead of opening a chunked session (router 0.4.84+) —PUTthe body to it from something that can make an HTTP request, and it commits through the same digest check and atomic rename. To move a file between two machines, preferfs_copy: no URL, one call. See Moving bytes without reading them.sharewrites to the network's shared drive, andstorage: trueto the node's storage area (router 0.4.56+). Both are one call, not a session: send the whole file inchunk/chunk_b64and it lands, up to 1 MiB. A larger file goes through the tree's HTTPS URL with aPUT. Missing parent directories are created there too, from router 0.4.59 — before it, the same call that works on a node's disk answerednot_foundon a storage tree. Writing to a node's storage area needs Owner or Admin in the organization that owns the node — see Addressing storage instead of a node. A write to either tree is refused withresource_exhaustedonce the organization is at its storage limit (router 0.4.93+, and on paid plans too from 0.4.101); a write to a node's own disk is not counted or capped.- ⚠⚠ Do not chunk a write to either of those two trees — it silently keeps only the last chunk (spelled out in the tool descriptions from router 0.4.146). There is no session there: no
idis issued,doneis ignored, and every call replaces the whole file. So the chunked recipe above — the one this tool andfiledescribe, and which both offer these destinations — answers every chunk with a success and leaves a file holding the last one alone, carrying asha256of that fragment, with no error anywhere. It is the one place on this surface where following the documented pattern destroys the payload. Past the 1 MiB inline cap the call is refused withtoo_largerather than truncated; for anything bigger write to the node's own disk, which does have the session, orPUTthe tree's HTTPS URL.transfer: "url"also lifts the cap but mints a URL something else has to upload to. - Finalize triggers
fsync+chmod+ atomic rename on the agent. Aborted writes leave the destination untouched. - The
done: truecall waits for the node to confirm that commit, and from router 0.4.187 the wait follows the file's size. The node's commit is not a fixed cost — itfsyncs, reads the whole temporary file back to hash it, then renames — so the allowance is 10 seconds plus one per 16 MiB, bounded at 60 seconds here and at 10 minutes for the uploads that are not one tool call (atransfer: "url"PUT, andfs_copy). The call returns as soon as the node answers, so a generous allowance costs nothing on a node that does answer. Through router 0.4.186 it was a flat 10 seconds everywhere, and a large write therefore reported a failure for a commit that had completed.- ⚠ A timeout here is
timeout, and it does not mean the write failed: giving up does not cancel the commit, so the file may still land. Check withfs_statbefore sending it again — the message says so. A file too large for the 60-second bound (above roughly 800 MiB) belongs ontransfer: "url", where the wait is not bounded by a tool call.
- ⚠ A timeout here is
- The finalize carries the whole-file SHA-256, and the node checks it (router 0.4.77+ with agent 0.4.79+). The agent hashes the finished temporary file and refuses the rename unless it matches, so a transfer corrupted on the way to the node fails the call instead of landing a file of the right length with the wrong contents. Until this, nothing anywhere compared what the node received against what the router sent: the
sha256on the response was computed by the router over the bytes it accepted, so a caller checking "did it all arrive?" was reading the sender's own account of itself. Against an agent below 0.4.79 the digest is sent and simply not verified — behaviour is exactly as before, and hashing the file on the node is still the way to be sure. From router 0.4.79 the digest rides both of the router's finalize signals, and from agent 0.4.81 the node checks the second one as well — so a commit lost on the way to the node can no longer downgrade the write to an unchecked one: the fallback finalize carries the same hash, and since the lost commit took the trailing bytes with it the node hashes a short file and the call fails rather than committing a truncation. On agents 0.4.79 and 0.4.80 that fallback was still the one path with nothing to check against, so a write that lost its commit committed unverified even though the router had sent the hash. Agent 0.4.81 fixes the other half of that path: a finalize that failed there was reported back as a clean exit, so a refused commit could answerdone: truewith nothing written. Agent 0.4.94 closes the last of it: every refused commit — the digest check, thefsync, the mode change, the rename into place — records a failing exit before the session closes, so the failure the node reports is no longer followed by a synthesised clean exit whose arrival order decided which of the two you were told. Through agent 0.4.93 that was a race, and it could still answerdone: truefor a write that never landed. sha256is your half of that check (router 0.4.79+). The digest above is taken over the bytes the router accepted, so it certifies the router→node leg and structurally cannot say whether those were your bytes — mangle the chunks on the way in and every check downstream passes on the wrong file. Send the whole file's hash assha256on thedone: truecall: the router compares it with what it assembled before it commits anything, so a mismatch isprecondition_mismatch(HTTP 412) with nothing written and no temporary file left on the node, and the assembled hash and byte count come back indetails.observed_sha256/details.observed_size. It is optional: omit it and the first leg stays unverified, send it and adone: trueresponse becomes an end-to-end claim about the bytes on disk rather than a claim about the second leg alone.- Send it on a chunked write; a single-call write does not need it (router 0.4.144 states which, having recommended it for every write before). A multi-call write is the one your side assembles, so it is the one a dropped or reordered chunk can shorten — and the check refuses that before the file is renamed into place. A single-call write's bytes came straight from the conversation with nothing but TLS between them and the router, so the guard adds nothing there, and a hash you cannot genuinely compute over them turns a correct write into
precondition_mismatch. - It covers the storage trees too, from router 0.4.91. A write there is a single call served by the router, and the digest is compared before anything is opened — so a mismatch is the same
precondition_mismatch, with the samedetails.observed_sha256/details.observed_size, and an existing file at that path is left byte-for-byte intact. Through router 0.4.90 those two trees computed the digest and reported it without ever comparing it to what you sent: a write of mangled bytes committed and answered like a success, carrying the hash of what actually landed rather than of what you meant to send. The field was documented without qualifying by tree throughout, so the contract you were reading was the one the node path implemented and this one did not.fs_copywas never affected — it has always compared. - A write that could not be delivered is now reported as one (router 0.4.77+). A chunk sent into a session that ended while the call was in flight comes back
not_found— session ended before write completed — where it previously returned success, along with asha256and atotalfor bytes the node never received. Retry by opening a fresh session; it does not mean the path is missing. Two concurrent calls carrying the sameidare also serialized now, so they can no longer interleave their bytes inside one file. chunkandchunk_b64are mutually exclusive — pick one encoding per session.- A missing parent directory is created (agent 0.4.45+). Writing to
/opt/app/config/settings.jsonon a node where/opt/app/configdoes not exist creates the chain and proceeds, instead of failing on the first call and requiring anfs_mkdirwithrecursive: truefirst. No authority is added — thatfs_mkdirwas always available to the same caller, and the destination is still vetted against its deepest existing ancestor — so a path outside the node'sallow-rootsis refused exactly as before. The directories are created by the same identityrootselects, so they are owned by whoever will own the file. On an agent below 0.4.45 the call still fails with the parent'snot_found; issue thefs_mkdiryourself. - ⚠ A script written to a Windows node is parsed as CP1252 unless it is ASCII (stated in the tool description from router 0.4.96). These bytes go out as UTF-8 with no BOM, and a BOM-less
.ps1/.bat/.cmdis parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so a non-ASCII character survives the write, the digest and a read-back intact and fails only at parse time, blaming a later line than the damaged one. See A script written to a Windows node. - A chunked write the router stops talking to is given up on by the node itself (agent 0.4.121+). A write session is normally ended by the router, so a session that lost its other end used to sit on the node for the life of the agent with its temporary file on disk. The node now gives up after two hours with no chunk and no finalize: the temporary file is discarded, nothing is written, the destination is untouched, and the write is reported as failed rather than left pending. A finalize that arrives after that is refused rather than renaming a file that is about to be deleted — the error says the write was abandoned and to retry it, which opens a fresh session and sends the bytes again. ⚠ The two hours are deliberately longer than the hour the router allows a session to sit idle, so a write you are making across several calls with long gaps between them is never the thing that gets reaped: the clock is silence from the router, not slowness on your side.
- A write that never lands takes those directories back with it (agent 0.4.89+). A session abandoned before
done: true, a finalize the digest check refused, or a transfer that failed part way now removes exactly the directories that write created, innermost first — so a failed write no longer leaves an empty tree behind that reads like a half-finished install. Directories that already existed are never touched, and the unwind stops at the first one that is not empty, so a directory another write has since put a file into is left alone. Through agent 0.4.88 the chain simply stayed, including when thefs_mkdiritself failed part way down. If you want the directories, issuefs_mkdiryourself — one made that way is yours and is never unwound.
Output Schema
{
"id": "string",
"offset": "number",
"size": "number (the whole file; on the done=true response)",
"sha256": "string",
"root_used": "boolean (present when the write ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the write ran as, and therefore who owns the file)"
}
This is the payload under fs_write; the path written and done are on the envelope.
root_used (router 0.4.89+) is the identity the write actually ran as, and therefore who owns the file that lands — true for the node's ambient identity, and absent rather than false for the account its owner configured, which user names from router 0.4.206. It is an outcome and not a request: an omitted root that landed on the ambient identity reports true. It is set on the call that opens the session and omitted on a continuation carrying an id, whose identity was settled at open, and it is absent on the router-served storage trees, which run as nobody on any node.
sha256 is the router's hash over the bytes it accepted for the node, cumulative across the session. On the done: true response it is the whole-file hash — and, against an agent on 0.4.79 or later (0.4.81 or later to cover a write that finalized through the router's older commit signal), the node has checked the finished file against it before committing, so that response means the bytes on disk are these bytes. What it does not cover is your bytes reaching the router; pass sha256 on that same call to close it.
fs_edit
Surfaces: public · local
Apply atomic old_string → new_string replacements to a file on a remote agent.
Input Schema
{
"node": "string (the node's id or its name)",
"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", "sha256": "string"}
],
"sha256": "string (pre-edit guard)",
"share": "string (accepted for schema uniformity; the storage trees have no edit operation)",
"storage": "boolean (accepted for schema uniformity; the storage trees have no edit operation)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Behavior:
- Provide either
{old_string, new_string, replace_all}(single edit) ORedits[](ordered batch). Mixing them is an error. - There is no edit on noBGP's own storage trees.
storage: true, orshare, is refused withunsupportednaming the operations those trees do have. Read the file, change it, write it back. - 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. 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.- An
old_stringthat matched nothing names the file's current hash, from agent 0.4.85. That failure means the file is not what you read, so it is aprecondition_mismatchwhose remedy is to re-read and rebase — and the message ends… is sha256 <64 hex> — re-read it and rebase the edit, so the retry can send that value assha256without spending a second read to learn it. Anold_stringthat matched more than once is a different failure:invalid_args, because the file was read correctly and the request was ambiguous — passreplace_all: trueor makeold_stringmore specific. Re-reading changes nothing there. - ⚠ Editing a script on a Windows node can introduce a character it will not parse (stated in the tool description from router 0.4.96). The file is written back as UTF-8 with no BOM, and a BOM-less
.ps1/.bat/.cmdis parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so anew_stringcontaining a curly quote or a dash lands correctly, reads back correctly, and breaks the script at parse time. Keep the replacement ASCII. See A script written to a Windows node. - On a Windows node this needs agent 0.4.75. Through agent 0.4.74 every edit on a Windows node failed with
Access is denied, single and batch alike: the edit is written to a temporary and renamed over the original, Windows refuses to replace a file anybody still holds open, and the agent was itself holding the one it had just read. Nothing was changed on disk when it failed, and Linux and macOS were never affected — renaming over an open file is allowed there. On an older Windows agent,fs_readthe file andfs_writeit back instead.
Output Schema
{
"edits_applied": "number",
"size": "number (file size after)",
"sha256": "string (after)",
"old_size": "number (before)",
"old_sha256": "string (before)",
"root_used": "boolean (present when the edit ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the edit ran as)"
}
This is the payload under fs_edit; the path edited and done are on the envelope. ⚠ The size after the edit is size from router 0.4.201, and was file_size from router 0.4.83; it was total before, which meant something different on a write.
root_used (router 0.4.89+) is the identity the edit actually ran as — true for the node's ambient identity — including where root was omitted and the node offered only its ambient identity — and absent rather than false for the account its owner configured, which user names from router 0.4.206. There is no edit on the router-served storage trees, so on this tool the identity is always resolved on a node: root_used is there on an elevated edit, and user wherever the node reported the account it ran as.
fs_list
Surfaces: public · local
List a directory on a remote agent.
Input Schema
{
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute directory)",
"recursive": "boolean (walk subtrees)",
"limit": "number (default 200, hard max 5000)",
"cursor": "number (entries to skip)",
"storage": "boolean (list the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Unreadable subentries surface as entries with the reason field set rather than failing the whole call.
The directory you named is not one of those subentries, and from agent 0.4.151 a recursive walk of a node's own filesystem says so. A path that does not exist, one the walk's identity cannot open, and one that is a regular file or a dangling symlink each fail the call — not_found, permission_denied, or an error naming the path as not a directory — exactly as the same call without recursive already did. Through agent 0.4.150 a recursive walk answered entries: [], count: 0 and done: true for all three, so a mistyped path, a directory that is not on that node, and one the unelevated identity may not enter were each indistinguishable from an empty directory. ⚠ Code that reads a zero-entry recursive listing as this directory is empty now has an error to handle instead.
share lists the network's shared drive, and storage: true the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. Entries come back sorted by name, and limit / cursor / truncated / next work exactly as below. recursive walks the whole subtree from router 0.4.59, each entry carrying its path and the listing sorted by it; a directory the walk cannot read is reported rather than costing you the rest of the listing. From agent 0.4.158 it is reported once — the directory's own entry, with reason set on it. Earlier agents named it twice, as that entry and then again as a second entry with the same path carrying the error, so a client that keys entries by path kept only one of the two. On 0.4.56–0.4.58 recursive was accepted and ignored — you got the top level, with truncated unset and a total_entries matching it, which is indistinguishable from an empty subtree.
A recursive walk is bounded by the node's allow-roots at every directory it enters, not just at path: a subtree the node will not serve — including the agent's own configuration directory — is named and then pruned, so you see the directory entry but nothing beneath it. Agents before 0.4.37 checked only the root and listed the contents.
A symlinked path. The walk descends the directory the link points at — it has to, since a walk never follows a symlink, including the one it is handed — but every entry is named under the path you sent. fs_list /tmp on macOS reports /tmp/…, not /private/tmp/…, and the same holds for fs_glob, fs_grep and fs_stat. Agent 0.4.39+; 0.4.38 descended correctly but named the results under the resolved path, and agents before that did not descend at all.
Pagination. A single response is capped at 200 entries by default (hard max 5000 — larger limit values are silently clamped). Page through a large walk by re-calling with cursor advanced by the number of entries you received; read truncated and next 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 takes limit and offset with identical semantics.
limit and cursor page a listing the node has already assembled in full — there is no entry cap on the agent side of a recursive walk. A tree big enough to produce more than 64 MiB of listing is therefore refused outright with too_large, and paging does not get you past it: every page asks for the same whole listing. Narrow path, or walk the subtrees one at a time instead of setting recursive at the top.
Paging therefore bounds the response, not the work, and the tool descriptions say so from router 0.4.84: limit and cursor cut bytes and not time, and paging through a listing re-walks the tree once per page. Measured: /usr/lib with recursive: true and limit: 3 took 5,523 ms to return 3 entries of 23,968. Page to bound a response you already know is large; to make a listing cheap, narrow path, or use fs_glob / fs_grep, which filter on the node.
That ceiling is agent 0.4.40+, where it replaced one set so high (256 MiB) it could never fire. It binds root: true and root: false identically — it is a property of the machine, sized so a 512 MB single-board node survives its own answer, not of the account the node runs work as.
Output Schema
{
"entries": [
{
"name": "string",
"path": "string (absolute, recursive only)",
"size": "number",
"mode": "string (octal)",
"modified_at": "string (RFC3339 UTC)",
"is_dir": "boolean",
"type": "string (file | dir | symlink | fifo | socket | device | other; absent = the node did not say)",
"reason": "string (walk error, if any)"
}
],
"total": "number (entries walked, before limit/cursor)",
"truncated": "boolean (more entries exist past this window)",
"next": "number (cursor for the next page; set when truncated)",
"root_used": "boolean (present when the walk ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the walk ran as)"
}
This is the payload under fs_list; the directory path and done are on the envelope.
type says what kind of entry it is (router 0.4.178+, and agent 0.4.152+ on a node's own disk) — one of file, dir, symlink, fifo, socket, device or other. mode carries only the permission bits and is_dir only answers one of the seven, so without it a FIFO, a socket, a device node and a symlink all read as a regular file of size 0 — and code that walks a tree and opens what it finds blocks on the first FIFO. It describes the entry itself: a symlink is symlink whatever it points at, because the listing does not follow links. See Entries that are not regular files.
root_used (router 0.4.89+) is the identity the walk actually ran as — true for the node's ambient identity — including where root was omitted and the node offered only its ambient identity — and absent rather than false for the account its owner configured, which user names from router 0.4.206. Read it beside a short listing: unelevated, a walk sees only what that account can read, which is how a directory comes back looking emptier than it is. It is absent on the router-served storage trees, which run as nobody on any node.
Entries that are not regular files
⚠ An absent type means unknown, never file. It is populated on the router-served storage trees from router 0.4.178, and on a node's own disk from agent 0.4.152 — so while a fleet is mid-upgrade the same call answers with the field on some nodes and without it on others. Read the field if it is there; if it is not, this node cannot say what its entries are.
What the seven words mean for the tools that open a file:
fs_readserves a regular file or a character device from agent 0.4.152, and refuses adir,fifo,socketor a block device withinvalid_argsnaming what it found. ⚠deviceis one word for both kinds, so the field alone does not say whether a device node is readable — the refusal is what says block device.fs_copymoves regular files only, and refuses any other present type rather than opening it.- A transfer URL (
transfer: "url") serves a regular file only, for the same reason. - A
symlinkis followed by a single copy, by apathsmember and by a transfer URL — ascatandcpdo — and is not followed by arecursivecopy, which judges the listing it walked. - Nothing else changes for a
dir: a directory has always been refused by the tools that read bytes, andis_diralready said so.
fs_stat
Surfaces: public · local
Return metadata for a single path on a remote agent.
Input Schema
{
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute)",
"allow_missing": "boolean (optional)",
"storage": "boolean (stat inside the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
share stats inside the network's shared drive, and storage: true inside the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. allow_missing behaves identically there.
Symlinks are not followed — the link's own metadata is returned, and the response's path is the one you asked about rather than whatever it resolves to. Agent 0.4.38 reported the resolved path here, so fs_stat /tmp/x on macOS came back as /private/tmp/x. From router 0.4.178 the entry does describe what a link resolves to, in a separate entry.target object, without the stat itself following it.
allow_missing: true returns success with no entry for a missing path instead of not_found, so an existence probe tests for entry rather than for an error. Through router 0.4.200 the result also carried exists. Default false keeps the error-on-missing behaviour. Same field, same semantics, as file with op: "stat".
Output Schema
{
"entry": {
"name": "string",
"size": "number",
"mode": "string (octal)",
"modified_at": "string (RFC3339 UTC)",
"is_dir": "boolean",
"type": "string (file | dir | symlink | fifo | socket | device | other; absent = the node did not say)",
"target": {
"type": "string (what the link resolves to; never symlink)",
"size": "number (the size a copy or a transfer URL of the link serves)"
}
},
"root_used": "boolean (present when the stat ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the stat ran as)"
}
This is the payload under fs_stat; the path you asked about and done are on the envelope.
With allow_missing: true, a missing path gives a result with no entry. Without it, a missing path is not_found.
entry.type says what kind of entry it is, and entry.target what a symlink points at (router 0.4.178+, and agent 0.4.152+ on a node's own disk). type is the same seven-word vocabulary fs_list uses and describes the entry itself, so a symlink is symlink. target appears on a symlink only and carries the type and size of the file the link resolves to — which is what a copy or a transfer URL of that link would actually move, since the link's own size is the length of its text. It is absent on anything that is not a link, on a dangling link (nothing resolved, so a read of it is not_found), and on every fs_list entry: a listing vets nothing about where its links point. ⚠ An absent type means unknown, not file — see Entries that are not regular files.
root_used (router 0.4.89+) is the identity the stat actually ran as — true for the node's ambient identity — including where root was omitted and the node offered only its ambient identity — and absent rather than false for the account its owner configured, which user names from router 0.4.206. It is absent on the router-served storage trees, which run as nobody on any node. It is what tells you which identity a permission_denied here was refused for, and — on a call that omitted root — which of a mixed fleet's two shapes this node turned out to be.
fs_delete
Surfaces: public · local
Remove a file or directory on a remote agent.
Input Schema
{
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute)",
"recursive": "boolean (required for non-empty directories)",
"force": "boolean (optional; bypass the depth-guard — not needed inside /tmp and the other scratch roots)",
"storage": "boolean (delete inside the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Deleting an already-missing path returns not_found. Callers wanting ensure-absent semantics should treat that as success.
There is no allow_missing here, unlike fs_stat — treating the not-found as success is the whole of it, so the field would add nothing. Sending it anyway is invalid_args naming the offending property, not a silent no-op. Router 0.4.88 says so in the tool's own description: it had been naming the exact need allow_missing serves without mentioning that the field is absent on this tool.
share deletes from the network's shared drive, and storage: true from the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. From router 0.4.59 a non-empty directory needs recursive: true there exactly as it does on a node, and deleting the tree root itself is always refused. A missing path is not_found, as on a node.
⚠ The depth floor no longer applies on these trees (router 0.4.84). It is calibrated for a machine with an /etc on it, and a router-served tree has no system to destroy — its top-level directory is the natural unit of work, so the guard fired on the ordinary case (path: /reports, recursive: true → refused at depth 1) and trained force: true as a reflex. A guard that is routinely overridden protects nothing. force is still accepted here and means nothing, so a caller that sets it defensively — or replays a call that worked against a node — is unaffected. Only the tree root is refused.
On 0.4.56–0.4.58 neither guard existed here: a delete with no recursive removed a whole subtree and reported success. Check with fs_list before deleting on these trees either way — nothing on them passes an agent, so no node-side veto stands between the call and the bytes.
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.
Anything strictly inside a scratch root is exempt from the depth rule entirely (router 0.4.87+), so /tmp/build needs no force even though it is depth 2. The roots are /tmp, /var/tmp, their macOS /private spellings (/private/tmp, /private/var/tmp) and C:\Windows\Temp. /tmp is the most common working directory on this surface — the recipes in these pages use it — so every routine cleanup was being refused, which taught force: true as a reflex, and a caller that sends force for scratch work sends it at /etc too.
- The scratch root itself is not exempt.
/tmpstill needsforce: true; deleting the contents of a scratch directory is routine, deleting the scratch directory is not. - A
..segment forfeits the exemption and takes the ordinary guard, so/tmp/../etcis refused. From router 0.4.94 the guard counts the path after resolving.and..lexically, which is what makes that refusal real:/tmp/../etcis/etcat depth 1. Through 0.4.93 the exemption correctly declined to exempt it and the guard then counted the raw spelling at depth 3, so the one path the two halves agreed was suspicious went through withoutforce. The exemption itself still reads the spelling you sent and still refuses to exempt anything containing... - Case is folded only behind a drive letter.
C:\WINDOWS\TEMPis the same directory;/TMPon Linux is a different one and keeps the guard. - The per-user Windows temp (
C:\Users\<name>\AppData\Local\Temp) is not on the list and does not need to be — it is already depth 5.
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.
From router 0.4.203 the refusal also carries details — force_required, the path as you sent it, its depth, and clean_path — so a client can put its own confirmation in front of the retry; and a client that can ask the person is asked delete this and everything under it? rather than refused.
Output Schema
The envelope carries the answer — path plus done is what a delete has to say — and from router 0.4.89 an fs_delete key appears beside it on a delete that ran on a node, carrying the one thing path and done cannot: which identity did the deleting.
{
"call_id": "string (REST only)",
"op": "fs_delete",
"done": true,
"duration_ms": "number (REST only)",
"node": {"id": "string", "name": "string"},
"path": "string",
"fs_delete": {
"root_used": "boolean (present when the delete ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the delete ran as)"
}
}
⚠ The fs_delete key itself is absent on the router-served storage trees, which run as nobody on any node — so a delete there returns exactly the envelope-only shape it always did, rather than a payload with nothing in it. On a node it carries whatever there is to say about the identity: root_used: true on an elevated delete, and from router 0.4.206 user, the account — which is why an unelevated delete on a node now carries the key at all, where through 0.4.205 only an elevated one did.
fs_mkdir
Surfaces: public · local
Create a directory on a remote agent.
Input Schema
{
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute)",
"mode": "string (octal, default: \"0755\")",
"recursive": "boolean (mkdir -p semantics)",
"storage": "boolean (create inside the node's own storage area instead of its disk; with node)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Without recursive, an existing path returns already_exists. With recursive, the call is idempotent.
share creates in the network's shared drive, and storage: true in the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. recursive works the same way there; mode is not applied. A mkdir on either tree is refused with resource_exhausted once the organization is at its storage limit (router 0.4.93+, and on paid plans too from 0.4.101) — creating a folder is a stored resource, where fs_delete never is.
Output Schema
path and done on the envelope are the answer, and — as on fs_delete — from router 0.4.89 an fs_mkdir key appears beside them on a call that ran on a node, naming the identity that created the directory and therefore owns it.
{
"call_id": "string (REST only)",
"op": "fs_mkdir",
"done": true,
"duration_ms": "number (REST only)",
"node": {"id": "string", "name": "string"},
"path": "string",
"fs_mkdir": {
"root_used": "boolean (present when the mkdir ran as root; router 0.4.89+)",
"user": "string (router 0.4.206+: the account the mkdir ran as)"
}
}
⚠ The fs_mkdir key itself is absent on the router-served storage trees, which run as nobody on any node, so a call there returns the envelope-only shape it always did.
fs_copy
Surfaces: public · local
Copy one file, or a set of files, from one machine to another in a single call. Router 0.4.84+, copy sets from router 0.4.170; the same operation is file with op: "copy".
Purpose
This is the answer to get this build artifact off pi5 and onto cm4. The router streams the bytes between the two agents, so:
- the two ends need no route to each other — each needs only its own agent connection, the same precondition every other tool here has;
- nothing is installed or stood up anywhere, and there is nothing to clean up afterwards;
- the bytes never pass through the conversation, so size is bounded by the transfer rather than by a client's per-result budget.
Measured before it existed, the same 5 MiB pi5 → cm4 transfer took four calls: stand up an HTTP server on the source, know how the source resolves in the overlay, op=fetch_url from the destination, kill the server. fs_copy is one.
Input Schema
{
"from": {
"node": "string (the node's id or its name)",
"share": "string (optional; a network's id or name — its shared drive, instead of a node)",
"path": "string (absolute)",
"storage": "boolean (optional)",
"root": "boolean (optional)"
},
"to": { "…the same shape…" },
"sha256": "string (optional; a precondition on the SOURCE's content)",
"mode": "string (optional octal for the destination, default \"0644\")",
"paths": ["string (optional; a copy set — paths relative to from.path)"],
"recursive": "boolean (optional; a copy set — every file under from.path)"
}
Both ends take the same shape, so you learn it once. Each end may be a node (node, its id or its name), the network's shared drive (share), or a node's storage area (node with storage: true) — the addressing every file tool uses, described under Addressing storage instead of a node.
Parameters:
from/to- Where the bytes come from and go to. An existing destination is overwritten, exactly asfs_writeoverwrites one, and missing parent directories are created.root- Per side, and authorized on that end's own node: the source may need the superuser where the destination does not. From router 0.4.187 the field's own description says which node answers — root on one end needs root on that node (its owner, an Owner or Admin of its organization, or arootentry on its access list), and holding root on the other end gives nothing here. Omitted, each end runs at the identity its own node offers, andfrom.root_used/to.root_usedreport which one that was — withfrom.user/to.usernaming the account from router 0.4.206, which is the field to read for the end that ran unelevated and therefore reports noroot_used. Every refusal leads withfrom:orto:, because a two-ended call that says only forbidden is one you cannot act on. It applies to a node end only: sent for a router-served tree it selects no identity anywhere, and the result says so innotes[]rather than leaving you to assume it took.sha256- A precondition on the source's content, the same meaning it has onop=fetch_url. The router hashes the bytes as they pass through and compares before publishing anything, so a mismatch isprecondition_mismatch(HTTP 412) with the destination untouched anddetails.observed_sha256/details.observed_sizecarrying what was actually there.mode- Unix mode for the destination. A Windows destination synthesises its mode bits from no ACL, so the value applies to nothing there and the result says so innotes[].paths/recursive- Router 0.4.170+. Either one turns bothpaths into directories and makes the call a copy set. Send one or the other, never both, and neither withsha256.
Output Schema
{
"size": "number (bytes copied; on a set, the total of the files that were copied)",
"sha256": "string (one digest, over the bytes the router moved; absent on a set)",
"from": {
"node": {"id": "string", "name": "string (router 0.4.206+; a node end only)"},
"path": "string (in the source's own separator form)",
"root_used": "boolean (present when that end ran as root)",
"user": "string (router 0.4.206+: the account that end ran as)",
"share": "string (a router-served end: the network whose shared drive it is)",
"storage": "boolean (a router-served end: a node's storage area)"
},
"to": {
"node": {"id": "string", "name": "string (router 0.4.206+; a node end only)"},
"path": "string (in the destination's own separator form)",
"root_used": "boolean (present when that end ran as root)",
"user": "string (router 0.4.206+: the account that end ran as)",
"share": "string (a router-served end: the network whose shared drive it is)",
"storage": "boolean (a router-served end: a node's storage area)"
},
"complete": "boolean (copy set only; true when every file was copied)",
"files": [
{
"from": {"path": "string"},
"to": {"path": "string"},
"status": "string (copied | failed | skipped)",
"size": "number (copied only)",
"sha256": "string (copied only)",
"code": "string (failed or skipped; an error code, or \"unreadable\" / \"not_regular_file\")",
"reason": "string (why this file was not copied)"
}
]
}
This is the payload under fs_copy. The envelope's path and node name the destination — the end this call changed — and from.path in the payload is the other half. Both paths come back resolved, so on a copy between two platforms they differ in more than the directory.
Each end names its own node, from router 0.4.206. from.node and to.node are each {id, name}, so a copy between two machines says which two rather than leaving the source to be inferred from what you sent — and a router-served end carries no node at all, naming share (the network whose shared drive it is) or storage: true instead. The identity fields are per end on the same footing: root_used where that end ran as root, and user — router 0.4.206+ — the account it ran as. Both are absent on a router-served end, which runs as nobody on any node.
One digest, end to end. sha256 is computed by the router once as the bytes pass through, so the same number covers both legs: it is the hash of what was read from the source, and it is the digest handed to the destination's commit, where an agent on 0.4.79+ re-hashes the finished file and refuses the rename unless it matches. Compare it against sha256sum at either end. On a copy set there is no single file to hash, so the top-level sha256 is absent and each row in files[] carries its own.
Behaviour and limits
- Nothing is published until the last byte has crossed. A copy that fails at any point — a deadline, a dropped node, a digest mismatch — leaves the destination exactly as it was, so the retry is simply the same call again. There is no resume position and no
copy_id. - ⚠⚠ A copy that loses bytes on the way in now fails instead of committing (router 0.4.146). This is the one failure the digests above structurally cannot see: both are taken over the same stream, so a stream cut short agrees with itself, and only the router knows. The fix has two halves. The router now drains the source from the instant that end opens, rather than reading and writing in turn — through router 0.4.145 nothing was reading the source while the destination session was being opened, which is a round trip, so a file barely over 1 MiB lost its tail and committed, digest-verified, reported as a success. Measured on 2026-09-11: 1 048 576 bytes copied correctly, 1 048 577 did not, and the digest returned for the larger file was the digest of the first 1 048 576 bytes of it.
- A source that still outruns the copy is now resumed, not refused (router 0.4.198). The router reopens the source at the next byte and carries on, so a large file onto a slow destination completes instead of coming back
resource_exhausted. Nothing about the call changes: no new field, nocopy_id, no offset to pass, and the onesha256still covers the whole file. It resumes only while the source is unchanged — its size and modification time must be what they were when the copy began, because a file replaced by a rename mid-copy would otherwise splice two versions under a digest that agrees with itself — the last read must have moved at least 64 KiB, and one file resumes at most 8 times plus one per MiB of its size. Outside any of those it still refuses with the destination untouched:failed_preconditionwhen the source changed under it, otherwiseresource_exhaustednaming which end and how far it got. Through router 0.4.197 every overrun was that refusal; if you meet one, retry, or fetch the file withfs_fetch_urlfrom the destination, which goes node to node without passing through the router. - A copy onto a storage tree is invisible while it runs (router 0.4.94+). The in-flight file now carries the same reserved
.nobgp-upload-…prefix every other upload to those trees uses, so it is hidden fromfs_listand from a mounted drive's listings, and it is not counted toward stored bytes until it is published. Through 0.4.93 it was written as an ordinary.nobgp-copy-….tmp, which appeared in listings mid-copy and was metered. - A destination on a storage tree may not be a reserved name (router 0.4.177): a path any segment of which is
.nobgp-trashor begins.nobgp-upload-is refused withinvalid_args, naming which end and the two names. Those are the names the router uses for its own in-flight work, so a file published under one would be hidden from every listing, not counted toward stored bytes, and impossible to delete. Every other door onto these trees has refused them since router 0.4.77; this one accepted them. On a copy set the name can come from a source entry as well as fromto.path, so arecursivecopy of a directory holding one is refused for that file rather than storing it. A node destination is unaffected: those names mean nothing on a machine's own disk. - One file unless you ask for a set. Without
pathsorrecursivethe call copies exactly one file, and the twopaths name files. See Several files in one call. - A copy moves regular files only (router 0.4.178+). A single copy — and each member of a
pathsset — stats its source first, and a source whose type is anything butfileis refused withinvalid_argsbefore a byte moves, naming the type: a copy reads to EOF and/dev/zerohas no end, so a device node would stream into the destination until the deadline, and a FIFO or socket has nothing to serve. Read a character device withfs_readinstead, which stops atlimit. A directory source is refused as it always was, namingrecursive: trueandpathsas the two ways to copy one. In apathsset the refusal fails that one row and the rest of the set still runs; arecursiveset needs no extra stat, because its listing already carries the types. - A symlink source is followed, and the target is what is judged (router 0.4.178+) — as
catandcpdo on the machine itself, and it grants no reach the copy did not already have, since the read follows the link either way. A link to a file copies; a link to a device is refused exactly as the device is, with the refusal naming the link; a dangling link — one whose target does not exist, or that the session identity cannot reach through — isnot_found. A link to a directory isinvalid_argspointing at the directory it resolves to, because arecursivecopy does not follow links and so cannot be aimed at the link itself. ⚠ A node whose agent predates 0.4.152 reports no entry types, so there is no type to judge and such a source is copied as before. - Every failure names which end it came from, and on which node (router 0.4.88+). A copy is the one file operation with two subjects, so
not_found: /tmp/x: no such file or directorynames the path you already knew and withholds the only fact that would correct you. Errors the node itself reports — a missing file, a permission refusal, a directory where a file was expected — now lead withsource (pi5):ordestination (cm4):. The node's own wording and the error code are unchanged, so the retry a client derives does not change; what changed is that you can tell which side to look at. - A copy into a full storage tree is refused (router 0.4.93+, and on paid plans too from 0.4.101): the storage limit is checked against the destination only, with
resource_exhausted, because a copy out of a full tree is a read and stays allowed. A node destination is unaffected — a node's own disk is neither counted nor capped. - A copy with either end in an organization that is over its bandwidth allowance is refused (router 0.4.106+, Free only), naming which end. Unlike the storage limit above, this one is checked on both ends: a storage cap bounds what is held, so copying out of a full tree adds nothing, while a bandwidth stop bounds what moves, and the same bytes cross the router in either direction.
- Cross-organization copies are refused, naming which end sits where, even where you hold both ends. It is a guardrail rather than a boundary — the same caller can still
fs_readandfs_write— so what it stops is one call spanning two tenants by accident. - A copy cannot be a sub-op of
op=batch, which inherits a single target; that is refused by name rather than as an unknown op.
Several files in one call
Router 0.4.170+. Make from.path and to.path directories and add either:
recursive: true— every file underfrom.path;paths: ["syslog", "sub/kern.log"]— only those, each named relative tofrom.path.
Each file lands at the same relative path under to.path. Sending both, or either with sha256, is invalid_args — a digest binds one file and a set has many, so compare each row's own sha256 instead.
{
"from": {"node": "pi5", "path": "/var/log"},
"to": {"node": "cm4", "path": "/tmp/bundle"},
"recursive": true
}
It is sequential single-file copies, not a new kind of transfer. The two ends are resolved and authorized once, on the two directories, and each file then moves exactly as a single copy does: its own digest, its own atomic commit. Missing parent directories are created per file; empty directories are not recreated.
⚠⚠ A set is not atomic, and the result says so. A source that dies between file 3 and file 4 leaves a partial bundle — so every file gets a row in files[] with its own outcome, and complete is true only when every file was copied. Read it; the one-line summary leads with the word INCOMPLETE when it is not, and names how many failed and how many were skipped.
status | What it means |
|---|---|
copied | The file is at the destination and its sha256 covers it |
failed | This file was tried and was not copied; code and reason say why, and its destination path keeps whatever it held before |
skipped | This file was not tried — an earlier failure or the shared deadline ended the set; reason names which |
- Some failures end the set rather than only their own file: the caller went away, the node went away, the organization is blocked or rate-limited, the clock ran out (
canceled,timeout,unauthorized,forbidden,target_not_found,target_unreachable,resource_exhausted,rate_limited). Trying the next file would fail the same way, one round trip at a time, so the rest come backskippedcarrying that code. Anything else — one missing file, one permission refusal — fails that file and the set carries on. - A set that copied nothing is an error, with the first failure's code, so a caller that checks only for an error is never told success about nothing. A set that copied some files succeeds and reports the rest in
files[]; an error there would hide the rows you need. - Bounds. At most 256 files either way, and a
recursiveset moves at most 1 GiB, measured from the listing before any byte moves. The whole set shares one 15-minute deadline; files it did not reach areskippedwithtimeout, and copying the remainder is another call. Apathsset does not know its sizes up front, so the deadline is its only size bound. recursiveneeds a directory. Afrom.paththat does not exist, or that is a file, is an error rather than an empty,complete: trueanswer. A directory holding more entries than one listing can return is refused too — copy a narrower directory, or name the files withpaths.- Two
codevalues infiles[]are not error codes.unreadablemeans the source listing could not read that entry, and the row carries why.not_regular_file(router 0.4.178+) means the entry is a symlink, FIFO, socket or device node, which a copy does not open — see below. - An entry that is not a regular file is not opened (router 0.4.178+). Under
recursivethe listing already knows each entry's type, so such an entry is afailedrow with codenot_regular_fileand a symlink is not followed. Apathsmember names a file nobody listed, so it is stat'd like a single copy and its row carriesinvalid_argsinstead — and there a symlink is followed. Either way the rest of the set runs. ⚠ A node whose agent predates 0.4.152 reports no entry types and cannot be checked this way, and there the older behaviour stands: a symlink under arecursivesource is followed and its target's size is not counted toward the 1 GiB, and a FIFO or a device node blocks its read until the set's deadline. Until 0.4.152 reaches a node, do not point arecursivecopy at a tree holding one. - On a Windows end a relative path containing
:is refused for that file: there it names an alternate data stream rather than a file in the directory. - Every
pathsentry is relative, and the whole call is refused when one is not. An entry beginning with/or\, or with a drive letter and a separator (C:\,C:/), isinvalid_argsnaming its index rather than being joined ontofrom.path— as is one that climbs out of the directory with.., and one that names the same file as an earlier entry. Separators may be written either way;sub\kern.logandsub/kern.logare the same entry. - Everything above holds for
filewithop: "copy"and on the router-served storage trees: one runner serves both front doors, so they answer identically.
fs_copy or fs_fetch_url?
Choose by where the source is.
| Source | Reach for |
|---|---|
| A node, the network's shared drive, or a node's storage area | fs_copy |
| A URL only the destination node can reach — an internal mirror, an authenticated artifact store | fs_fetch_url, which the agent performs from its own network position |
fs_copy is not fs_fetch_url with a nicer URL: the fetch route needs the destination to reach the source, a server running on the source, and a cleanup call, and this needs none of the three.
fs_fetch_url
Surfaces: public · local
Make a node fetch an HTTP(S) URL itself and store the result as a file on that node. Router 0.4.195+ under this name; the same operation is also file's op: "fetch_url", which is older, and still is over REST.
Purpose
The bytes go from the URL to the node: they do not pass through noBGP and they do not pass through this conversation, so the size is bounded by the node's own limits rather than by what a response can carry. And the node makes the request from its own network position, so a source only that node can reach — a mirror on its LAN, a service on its localhost, an authenticated artifact store behind its proxy — is a valid source.
Input Schema
{
"node": "string (the node's id or its name)",
"path": "string (required; absolute path on the node)",
"url": "string (required; http or https)",
"headers": "object (optional; HTTP headers — Host / Content-Length are filtered)",
"mode": "string (optional; octal, e.g. \"0755\"; default 0644)",
"sha256": "string (optional; sha-256 of the bytes you expect)",
"limit": "number (optional; default 256 MiB, hard agent ceiling 1 GiB)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
Name the node with node — see One name for each input. There is no storage field: this tool stores on a node's own filesystem only — the agent performs the fetch and writes the bytes itself, so there is nothing for the router to put on a storage tree.
Output Schema
{
"size": "number (bytes stored)",
"sha256": "string (of the stored file, computed on the node)",
"root_used": "boolean (present when the fetch ran as root)",
"user": "string (router 0.4.206+: the account the fetch ran as, which owns the stored file)"
}
Behaviour
- One call. It returns when the file is stored, with its size and digest — there is no
idand nothing to poll. - The destination is replaced only when the whole fetch succeeded. A
sha256that does not match the fetched bytes fails withprecondition_mismatch, carriesdetails.observed_sha256, and leaves what was atpathuntouched. - Missing parent directories are created (agent 0.4.45+) — and from agent 0.4.89 a fetch that does not land removes the directories it created on the way in, so a failure leaves nothing behind.
- A failure is coded by its cause, so a client can tell "retry" from "never": the origin's HTTP status becomes
permission_denied(401 / 403 / 407),not_found(404 / 410),timeout(408),too_large(413),rate_limited(429) ortarget_unreachable(5xx, or no answer at all), withdetails.http_statusanddetails.url. ⚠ On thetarget_unreachablearm the unreachable thing is the origin, not the node. - It is open-world and destructive — the one file tool that reaches outside noBGP, and it replaces a file.
rootpicks the identity, which is also the owner of the stored file. The same two gates as everywhere else apply; see Execution identity. ⚠ Through router 0.4.87 this operation accepted the field and silently ignored it.- To move a file from one node to another, use
fs_copy— it needs no URL and no route between the two.
fs_lock
Surfaces: public · local
Take an exclusive lock on one path of shared storage, or renew the one you hold. Router 0.4.195+ under this name; the lease itself, and file's op: "lock", date from router 0.4.62.
Input Schema
{
"node": "string (with storage: true — that node's storage area)",
"share": "string (a network's id or name — that network's shared drive)",
"storage": "boolean (address a node's storage area; requires node)",
"path": "string (required; absolute path inside the tree)"
}
The tree is addressed exactly as it is for every other file tool — see Addressing storage instead of a node. There is no identity field: the router holds these bytes, so no identity on a node is involved.
Output Schema
{
"locked": "boolean (whether you hold the lock after this call)",
"lock_token": "string (monotonic, advisory only — it cannot fence a stale holder)",
"lock_expires_at": "string (RFC3339 UTC; re-locking renews it)"
}
Behaviour
- Shared storage only. A path on a node's own filesystem is refused with
unsupported: the router never sees that write, so a lock there would be a promise nothing keeps. - Held across every node and every caller. A node's mount and another caller contend for the same path.
- A path another caller holds is
already_exists, marked retryable — the one refusal on this surface that clears on its own.detailscarriesexpires_at,held_by_selfandheld_by_kind. - It expires by itself after two minutes unless re-taken; calling again renews it. There is no separate renew call.
- Advisory, with one exception. Nothing consults the lock on a read or on a write made through these tools; a write arriving over WebDAV is refused with
423while another holder has the path.
The whole contract — what contends with what per mount backend, what details says, how a disconnect releases a node's locks — is Locking a path on a storage tree.
fs_unlock
Surfaces: public · local
Release the lock you hold on one path of shared storage. Router 0.4.195+ under this name.
Input Schema
Identical to fs_lock: the tree, storage, and path.
Output Schema
{
"locked": false
}
Behaviour
- It releases only your own lock. A path another caller holds is not changed.
- A path that is not locked is success, reporting
locked: false— so unwinding after a failure does not have to know how far it got. - Shared storage only, as
fs_lock.
fs_grep
Surfaces: public · local
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.
On earlier agents the search is refused before it starts, with path must be absolute: "". A grep carries its roots in paths, and the agent's path check did not know that — a bug that stayed invisible for as long as the tool was REST-only and in no client's list. Router 0.4.35 listed it; agent 0.4.37 is what makes it run.
Input Schema
{
"node": "string (the node's id or its name)",
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"limit": "number (optional)",
"root": "boolean (optional; omit to run at the identity the node offers)"
}
pattern is Go RE2 — no backreferences and no lookaround, which RE2 does not have. ignore_case is equivalent to prefixing the pattern with (?i). limit defaults to 1000 (hard cap 100 000).
This tool searches a node and only a node. It takes no storage field, and node here always means an agent — the storage trees the other file tools can address have no search operation, so list them with fs_list and read the files you want.
A pattern that does not compile comes back as invalid_args from router 0.4.38; before it, an unbalanced bracket was internal / 500 / retryable: true, so a well-behaved client retried a typo forever. The message quotes the expression back to you — see When the failure came from the node for why that matters.
Skipped automatically: binary files, noise directories (.git, node_modules, .venv, target, dist, …), and any file over the agent's 16 MiB per-file cap. The first two are skipped by design and are not counted anywhere; the size skip is counted in skipped_files, because it is the one that can hide a match. A path the walk could not read at all — a typo, a directory that is not on this node, a tree its identity cannot enter — is counted separately in unreadable_paths and named in unreadable[] (router 0.4.96+ with agent 0.4.96+).
Every entry in paths must be absolute and inside the node's allow-roots — one outside them refuses the whole call, naming allow-roots, and a permitted root alongside a forbidden one does not smuggle it in. The walk then re-asks at each directory it descends into, so the agent's own configuration directory is pruned even when it sits under a root you were given. Agents before 0.4.37 applied neither check to a search — but nothing was reachable through the gap, because the same release fixed the guard that refused every grep before it ran. No search that returned matches starts being refused on upgrade; searches start working.
How results arrive
The response always carries the matches. matches[] holds every hit, on every path. Sending a progressToken changes when you can have them, never whether: each match is also pushed as a notifications/progress message as the walk finds it, and streamed_as_progress: true on the response says that happened.
matches[] | Notifications | |
|---|---|---|
No progressToken | every hit | none |
With a progressToken (public endpoint) | every hit, 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 |
streamed_as_progress means you may have seen these already, never these were withheld. Ignore it unless you consume notifications and want to skip what you have already rendered. The doubling it costs is bounded by the same limit that already bounds a non-streaming caller, so a streaming call is no more expensive than an ordinary one.
progressToken emptied matches[]Until router 0.4.86, sending a progressToken was read as a declaration that the caller consumes notification payloads, and the response dropped matches[] on that basis. A token says no such thing — a client may attach one to drive its own progress display without ever surfacing the payloads — so such a caller got count: 1 with matches: null: a successful-looking call carrying none of the data it asked for, with no error raised and no way to opt out.
Against a router below 0.4.86, read count rather than matches.length and be ready for an empty array. From 0.4.86 the two agree.
total is the authoritative total on every path — it is what answers "did this find anything", and it stays correct however the matches reached you. An empty result is a successful call with an empty matches[], not an error.
A zero is only a zero when skipped_files and unreadable_paths are both 0
An empty result is a successful call, so the two fields that say whether the search happened are the ones to read before believing it. They describe different failures and neither implies the other.
skipped_files — files that exist and were too big. The agent does not read a file over its 16 MiB per-file cap; it skips it and the walk carries on. Point a search at a directory holding a rotated log or a database dump and the result comes back total: 0, scanned_files: 0, no error: identical in shape to "nothing matched", and the file whose contents you were asking about was never opened. Measured on a 30 MB corpus: 0 matches; the same bytes split into three 10 MB files: 15 000. When it is nonzero, search those files individually, split them, or narrow paths. It counts the size skip only — binary files and noise directories are skipped by design and are not counted. Reported from agent 0.4.40.
unreadable_paths — places the walk never looked, and the more common wrong zero (router 0.4.96+ with agent 0.4.96+). A path that is not on this node, or one the identity the walk ran as cannot enter, used to be stepped over in silence: fs_grep over a wholly mistyped path answered count: 0, scanned_files: 0, skipped_files: 0 — a success shaped exactly like a completed search of an empty tree, with the check above passing and confirming it. Two wrong paths out of three answered scanned_files: 1, which reads as a small legitimate search. The count covers both the roots you named and directories underneath them the walk could not descend into.
unreadable[] names which of your paths failed and why, so the answer is actionable without a second call:
reason | Means | What to do |
|---|---|---|
not_found | No such path on this node | Fix the path — or accept that this node does not have it, which across a cohort is an ordinary per-node answer rather than an error |
permission_denied | The path is there and the identity this walk ran as cannot read it | Retry with root: true, then read root_used to see what you got |
unreadable | Anything else the walk could not open | — |
Only the roots from your paths[] appear in it. A directory the walk could not descend inside one of them raises unreadable_paths without adding a row — that list has no bound, while this one is the size of your own request.
A path that is absolute only on another platform is one of those not_found rows, from agent 0.4.141 — /etc/os-release sent to a Windows node, or C:\Users\Public to a Linux one. It names nothing on that node, so that is what it reports, and the rest of the search runs. Through agent 0.4.140 such a path failed the whole node's search, and as an internal error: on a cohort that mixes platforms, the same path other nodes answered not_found for took its node out of the walk entirely. A path that is absolute on no platform is still invalid_args — that one is a mistake on every node.
Both counts also reach the human-readable message, so the caller least likely to open the structured body still sees them: 0 match(es) across 0 file(s), 1 file(s) too large to search, 1 path(s) NOT SEARCHED: /tmp/typo (not_found). A clean walk says nothing at all about either, rather than reassuring you with two zeroes.
Both fields are always present in the response, so a zero is a real count and not a silence — but an agent older than the release that reports each one sends nothing and the router renders 0. Below agent 0.4.40 a skipped_files: 0, and below agent 0.4.96 an unreadable_paths: 0, therefore means nothing was skipped, or this node cannot say; check the node's agent_version in node_list (with view: "full") before trusting a zero from an old node.
Output Schema
{
"matches": [
{"path": "string", "line": "number (1-indexed)", "text": "string", "truncated": "boolean"}
],
"streamed_as_progress": "boolean (only when true; the matches were ALSO pushed as notifications)",
"scanned_files": "number",
"skipped_files": "number",
"unreadable_paths": "number (paths that were never searched; router 0.4.96+)",
"unreadable": [
{"path": "string (as you sent it)", "reason": "not_found | permission_denied | unreadable"}
],
"truncated": "boolean",
"total": "number",
"root_used": "boolean (present when the walk ran as root)",
"user": "string (router 0.4.206+: the account the walk ran as)"
}
This is the payload under fs_grep; the node searched and done are on the envelope.
root_used (router 0.4.83+) is the identity the walk actually ran as — true for the node's ambient identity, and absent rather than false for the account its owner configured, which user names from router 0.4.206. Read it beside a small total: unelevated, a walk sees only what that account can read, which is how a search comes back short and looks complete.
text is capped at 2 KiB per line. Top-level truncated means the walk hit limit before exhausting the inputs — raise it for more. scanned_files counts the files the walker read; skipped_files counts the ones it did not, because they were over the size cap; unreadable_paths counts the places it never looked at all, with unreadable[] naming those of your own roots that failed. unreadable[] is absent on a clean walk — see A zero is only a zero.
Timing
Expect silence on a big tree and then everything at once, unless you asked for the matches as they land — a progressToken on the public endpoint, or fs_grep_subscribe — in which case each one is pushed as the walk finds it.
From agent 0.4.89 that holds at either identity. When the search runs as the node's configured user — the usual case — the agent performs it inside its privilege-dropped file worker, and through agent 0.4.88 that worker answered one reply per request: the same search streamed its matches with root: "true" and arrived in one lump without it, so the identity a node runs as was visible in when its results appeared. The match set was always identical and no result was ever lost; only the arrival timing differed.
Narrow paths and set limit rather than searching / on a busy node. The walk has a 120 s deadline.
The whole match set is one payload, and a node will not send more than 64 MiB of it in one answer (agent 0.4.40+; the same ceiling at either identity). A search that reaches it is refused with too_large naming what to narrow, rather than the node attempting an allocation it may not survive. In practice limit binds first — 100 000 matches of ordinary source lines is around 15 MB — so lower limit or tighten pattern and this is not a limit you meet.
On those agents the size ceiling on a reply applied to the worker path only, so the same search over the same files returned its results with root: true and failed with an over-cap refusal without it — the identity a node runs as was visible in what its file tools could return. Worse, the failure did not end with that call: the oversized reply was left in the worker's socket, and every later unelevated fs_* call on that node — fs_stat, fs_list, fs_read, fs_write, each sub-op of a file batch — read fragments of it as its own answer and failed, until the agent was restarted. Agent 0.4.38 splits a large reply across frames, applies one ceiling to both identities, and retires the worker on any framing failure so a single bad reply cannot outlive the call that produced it.
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. The matches are all in the response, as they are on the public endpoint; what is unavailable here is only their earlier arrival, so streamed_as_progress is never set.
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
The async half of fs_grep: the same RE2 walk, aimed at a cohort, delivered through the event bus instead of one big response.
On earlier agents the search is refused before it starts, with path must be absolute: "". A grep carries its roots in paths, and the agent's path check did not know that — a bug that stayed invisible for as long as the tool was REST-only and in no client's list. Router 0.4.35 listed it; agent 0.4.37 is what makes it run.
Returns immediately with the subscription's id. Read the matches with event_tail; end it with event_unsubscribe, which stops any walk still running.
Input Schema
{
"select": {
"network": "string (a network's id or name)",
"nodes": ["string"],
"platform": "string", "os": "string", "arch": "string",
"labels": {"key": "value"}
},
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"limit": "number (optional)",
"root": "boolean (optional; omit to walk at the identity each node offers)",
"subscription": "string (uuid, optional)"
}
The selector is the same select object every subscribe tool takes — nodes, network, platform, os, arch, labels and the others, all ANDed. paths are walked on every node in the cohort, and limit is per node (default 1000, hard cap 100 000).
Supplying your own subscription 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. The echo compares root alongside the pattern, so the same id at the other identity is not answered with "already walking".
Which identity the walk runs as
root works exactly as it does on fs_grep, from router 0.4.81. It was a string from router 0.4.83, and it is a boolean from router 0.4.201. Omitted, each node walks at the identity it offers: its own configured account where it has one, its ambient identity where that is all it has — so one call covers a cohort that mixes both shapes. false pins every node to its configured account, and true walks as each node's ambient identity. The identity matters more here than on a single node — a cohort search pinned to the configured account comes back short and looks complete on every node where the interesting files are root-owned. The response's root list names the nodes that walk as root; dry_run: true shows the whole answer, with each node's owner, before any walk starts.
The two refusals land in different places, because this is a fan-out:
- Your authority to elevate is asked on each node of the cohort, from router 0.4.179: you hold root on a node as its owner, as an Owner or Admin of its organization, or through a
rootentry on its access list. Withroot: true, a node on which you do not hold root fails the whole call withforbiddenbefore anything walks, and the refusal names each such node. So a cohort is never partly elevated — retry withroot: false. Withrootomitted, only the nodes that would walk elevated are refused, each as that node'srefusedevent, and the rest of the cohort walks unelevated. The call fails withforbiddenonly when every node is refused. - A node's own veto is that node's event. A machine whose owner set
allow-root: false, or whose unelevated identity would be root, reports its refusal and settles; every other node in the cohort keeps walking.
⚠ Below agent 0.4.89, an unelevated walk delivers its matches in one batch at the end. Whichever identity is chosen, the match set and the terminal summary are the same — but a node walking as its configured account performed the search inside a privilege-dropped worker that could only answer once, so its matches all landed together when it finished rather than arriving as it found them. From 0.4.89 they stream at either identity. On a mixed fleet expect both timings in one subscription; outstanding is still what tells you when the cohort is done.
See Execution identity for the whole contract.
Output Schema
{
"id": "string (uuid)",
"matched_nodes": "number",
"nodes": [
{ "id": "node-uuid", "name": "string", "status": "running | refused", "code": "string", "reason": "string" }
],
"root": ["node-uuid (the nodes that walk as root)"],
"owner_filter": "string",
"not_mine_skipped": "number",
"dry_run": { "would_run": "number", "refused": "number" },
"trimmed": "string (MCP only)",
"omitted": "number (MCP only)",
"ttl": "number of seconds"
}
All four subscribe tools return this one payload, under the shared subscription key inside the envelope — they differ in what they watch, not in what creating a subscription tells you.
nodes is the cohort as resolved at subscription time — see below. ttl is always the 600-second default: a walk is bound to the work rather than to idleness, so this tool takes no keep and sending one is invalid_args.
Reading the matches
Each match arrives as an event:
{"node": {"id": "node-uuid", "name": "edge-01"}, "at": "timestamp", "payload": {"path": "string", "line": 42, "text": "string"}}
node is the node the match is on, so a fleet-wide search is attributable without a second call. It is {id, name} from router 0.4.207 and was the id as a bare string before it — see Event shape.
Pass wait 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 limit.
Each node closes with a completed event whose reason summarises its own walk — 12 match(es) across 340 file(s) in 1180ms. It names files skipped for size when there were any (, 2 file(s) too large to search, agent 0.4.40+), and from router 0.4.96 the paths that were not searched at all, naming each one and why (, 1 path(s) NOT SEARCHED: /opt/app (not_found)). Both are there for the reason fs_grep returns them: a node that reports no matches and skipped a 2 GB log did not answer your question, and neither did one that never found the directory.
⚠ The second one matters more on a cohort than on a single node. A path that exists on some machines and not others is the normal case across a fleet — different distributions, different install prefixes — and such a node used to report completed with no matches, which is indistinguishable from having searched. A fleet-wide search could be mostly not performed and read as authoritative. A cohort search that comes back empty is evidence of nothing only on the nodes where both numbers are zero; a node whose agent is older than 0.4.96 cannot report the second one, so its silence is still ambiguous.
⚠ A POSIX path sent to a Windows node, or a drive path to a Unix one, is a not_found for that path from agent 0.4.141 — and through 0.4.140 it failed that node's whole walk with an error instead, while every other node in the cohort answered not_found for the same path. Mixed-platform cohorts are exactly where this arises; see fs_grep.
From router 0.4.207 both totals are fields on the completion event, not only words in reason — skipped_files, unreadable_paths and unreadable[], under the same names and with the same meanings fs_grep returns them:
{
"node": {"id": "node-uuid", "name": "edge-01"},
"status": "completed",
"reason": "0 match(es) across 0 file(s), 1 path(s) NOT SEARCHED: /opt/app (not_found)",
"skipped_files": 0,
"unreadable_paths": 1,
"unreadable": [{"path": "/opt/app", "reason": "not_found"}],
"at": "timestamp"
}
- The two counts are on every completion, zero included. A zero is the statement this walk read everything it was pointed at, which is the whole reason to read them — so a node that matched nothing can be trusted without a second
fs_grep. Only acompletedevent carries them; arefusedorfailedone does not. unreadable[]names only the roots you sent, each withnot_found,permission_deniedorunreadable. A directory the walk could not descend into inside one of them raisesunreadable_pathswithout adding a row.event_tail'suntil: "done"copies all three onto that node's row in thenodestable, so one call answers the question for the whole cohort.- A node whose agent predates the release that measures a count sends nothing and the field reads
0— below agent 0.4.40 forskipped_files, below 0.4.96 forunreadable_paths. Check the node'sagent_versioninnode_listbefore trusting a zero from an old node.
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
Read the directory of peers the agent currently knows about.
Input Schema
{
"node": "string (the node's id or its name)"
}
All net_* tools share the same input shape: just node, the node to read from. {"node": "pi5"} is the whole call. See One spelling everywhere.
Output Schema
{
"peers": [
{
"name": "string",
"internal_ip": "string",
"internal_ip6": "string (router 0.4.204+ with agent 0.4.182+: the entry's IPv6 handle, when it has one)",
"dst_ip": "string (local NAT alias)",
"id": "string (node UUID)",
"local": "boolean",
"stale": "boolean",
"agent_version": "string (the agent version this node knows for the peer)",
"ipv6_wire": "boolean (whether this node sends that peer IPv6 as IPv6)",
"path": "relayed | direct_lan | direct_p2p (router 0.4.207+; absent on a local entry)",
"same_lan": "boolean (router 0.4.207+; the peer is on this node's LAN segment)",
"data_channel_state": "live | connecting | failed | none (the session that carries this peer's traffic)",
"transport": "relay/quic | relay/wss (live sessions only)",
"session_age": "number of seconds (live sessions only)",
"consecutive_connect_failures": "number (0 is healthy; absent = not reported)",
"last_connect_error": "string (the newest failure, if there was one)",
"last_connect_attempt": "string (RFC3339 UTC, with last_connect_error)"
}
],
"trimmed": "string (MCP only; present when the full list was larger than a model client accepts)",
"omitted": "number (MCP only; peers left out for size — absent means none)"
}
This is the payload under net_peers; the node it came from and done are on the envelope.
⚠ A peer directory large enough to break a model client is trimmed over MCP (router 0.4.199), the same way net_routes is: the peers that are not stale are kept first, then the stale ones, each in name order while they fit, and omitted counts the rest (omitted_peers until router 0.4.200). The entries in peers plus omitted are the peers the node knows, trimmed says so in a sentence, and net_reach with one peer's name is the way to ask about a peer that was left out. Over REST nothing is trimmed.
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.
⚠ An entry can have two handles, and router 0.4.204 with agent 0.4.182 reports both. internal_ip is the IPv4 one — what the name answers to A on this node — and internal_ip6 is the IPv6 one beside it, what the name answers to AAAA and what a glibc application there will use. internal_ip6 is absent when the entry has only one handle, and an entry whose single handle is IPv6 carries it in internal_ip, so never read an absent internal_ip6 as "no IPv6". The node has to send it, and agent 0.4.182 is where it starts — the router shipped the field first, because it decodes this payload strictly — so a node on 0.4.181 or earlier answers net_peers exactly as before, with no internal_ip6 on any entry.
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. The entry's id — the
node behind it — is the identifier that is valid everywhere.
⚠ data_channel_state, transport, session_age, consecutive_connect_failures, last_connect_error and last_connect_attempt arrive from agent 0.4.178 — see How this node's session to each peer is doing — while rtt_ms, which the schema also publishes, is reported by no agent: a round-trip time would have to be measured by probing the peer on every call, which a diagnostic read must not do. Absent is not a value — an absent consecutive_connect_failures is nobody counted, not zero — and in particular do not read a missing transport as a direct path: traffic to any peer that is not local goes through the router, including between two nodes on one LAN. Only a name a node serves itself (local: true) stays on that node's own network.
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.
⚠ The marker only reaches you from agent 0.4.89. The node has computed it
since 0.4.83 and the router has understood the field since 0.4.85, but the
agent's copy of a peer into this reply dropped it — so through agent 0.4.88
stale was absent from every peer of every node, and a leftover entry read as
directory corruption with nothing to say it was a known and expected one.
Against an older agent, treat an entry the directory does not list as
unmarked, not as fresh.
From router 0.4.81 there are fewer of them to explain: when a directory entry is deleted — a node deprovisioned, or an expired entry swept — the router now tells the network's nodes to drop it, where before a node's directory only ever grew and a leftover survived restarts until something overwrote that name.
Acting on that message needs agent 0.4.83, which is where the other half landed: an agent on 0.4.82 or earlier ignores the withdrawal and keeps the leftover exactly as it did before, so a network drops a deleted name only as fast as its slowest node is upgraded. On an agent that does act, the entry stops being served and stops being listed the moment the message arrives — a deleted name answers "no such host" instead of resolving to an address whose traffic goes nowhere — while the address it held stays reserved rather than being handed straight to another name, so a peer still holding the old answer cannot be pointed at the wrong machine.
How this node's session to each peer is doing
Agent 0.4.178 reports, per peer, the state of the relay session that carries that peer's traffic — what to read when a peer resolves but nothing gets through. The router has understood these fields since 0.4.91, so they appear as soon as a node is upgraded; a node below 0.4.178 carries none of them.
| Field | What it says |
|---|---|
data_channel_state | live — a data channel is up; connecting — one is being established; failed — the last attempts failed; none — nothing has been tried |
transport | relay/quic (the channel is a stream on the node's QUIC control connection) or relay/wss (its own WebSocket socket). Only while live. Both go through the router: there is no direct path between two agents |
session_age | How long the live channel has been up, 0 on one younger than a second. Only while live |
consecutive_connect_failures | Failed attempts in a row on that session, reset by a success. 0 is the healthy answer and is reported |
last_connect_error, last_connect_attempt | The newest failure in the node's own words, and when it was tried (RFC3339, UTC). Absent when nothing has failed |
- ⚠
noneis the ordinary answer for a peer nobody is talking to, not a reachability problem: a session is made when traffic needs one and recycled when it goes idle. Read it besideconsecutive_connect_failures—nonewith failures is a session that has been trying and failing — and usenet_reachto ask whether a peer actually answers. - Only a peer this node reaches over the overlay has them. A name the node serves itself (
local: true) has no session, so none of these fields appear on it. - Either direction counts. Where this node is the gateway for a LAN name or the peer opened the session, the node reports that session rather than reading
nonefor a peer whose traffic is flowing. - They describe this node's view. The same pair of nodes can report different states at the same moment, exactly as the addresses here are node-local.
How this node treats each peer's IPv6 traffic
Router 0.4.170 adds two fields that say, per entry, which peer version this node is working against and what it does with that peer's IPv6 handle. Together they answer why is traffic to this peer still IPv4?
agent_version- The agent version the router stated for the node behind this entry, as this node learned it. For a LAN name served by a gateway, it is the gateway's version, not the device's. ⚠ Absent is not an old version — it means this node knows no version for that node: the peer is in another organization, the entry is local, the router is older than the one that states versions, or the node you asked runs an agent below 0.4.142.ipv6_wire-true: this node sends that peer's IPv6 traffic as IPv6.false: it translates it to IPv4, because the peer is older than the first agent that accepts IPv6 from the overlay, or its version is unknown — readagent_versionbeside it. Present on every entry this node reaches over the overlay; absent on a local entry, where no overlay decision applies, and on an agent below 0.4.142. Absent is neverfalse.
⚠ The node has to send them, and agent 0.4.142 is where it starts. The router understands both from 0.4.170 and shipped first, so a node on 0.4.141 or earlier answers exactly as before, with neither field on any peer. node_list reports each node's agent_version (with view: "full"). See An IPv6 address beside the IPv4 one.
local is unchanged in meaning — it has always been this entry has a dst_ip — and from router 0.4.170 the router derives it from that field rather than taking the node's word for it. The value you read does not move.
Which peers are on this node's LAN, and how traffic reaches them
Router 0.4.207 adds two fields that belong together: the path a peer's traffic takes, and whether the two machines are on one LAN segment. Either alone is half an answer — same LAN, relayed is a slow path, and two machines on one flat network are one blast radius.
path—relayed(through the router),direct_lanordirect_p2p. Today every entry that is notlocalisrelayed, including between two nodes on one LAN: there is no direct path yet. Absent on alocalentry, which is a name the node serves itself and never leaves it.same_lan—true: the node behindidshares this node's LAN segment, so either of them can do a LAN task there (reaching a LAN-only host, mDNS, SSDP).false: compared, and different. Absent: not compared — an address the router does not have, alocalentry, or a peer in another organization. Absent is neverfalse.- The comparison is the router's, from what each node reports — the same segments
node_listgroups by. For a pair of nodes on agent 0.4.184+ that is their reported default gateway and subnet, which is exact; where either node is older it is theinferredgrouping: one shared public address and own addresses in one/24or/64.net_peerscarries nobasis, so readnode_list'slanswhen you need to know how exact the answer is. A node's own value for either field is replaced. - The one-line summary says how many peers share the LAN, so a reader that never opens the body still sees it.
Use node_list's lans to ask the question for a whole fleet in one call, and this to ask it from the machine that would do the work.
net_interfaces
Surfaces: public · local
Read the agent host's network interfaces (equivalent to ip -j link / ifconfig) — and, from router 0.4.81, the node's own overlay identity.
Output Schema
{
"interfaces": [
{
"name": "string",
"index": "number",
"mtu": "number",
"hardware_addr": "string",
"flags": ["string"],
"addrs": ["string (CIDR)"],
"overlay": "boolean (true: this interface carries the overlay)"
}
],
"overlay": {
"interface": "string",
"slice": "string (CIDR)",
"slice6": "string (CIDR; router 0.4.204+ with agent 0.4.182+)",
"self_ip": "string"
}
}
This is the payload under net_interfaces; the node it came from and done are on the envelope. The same holds for the three net_* schemas below.
What is my overlay IP
The top-level overlay block is the answer, and it is reported by the node rather than derived:
interface- the entry it was read off: the node's overlay TUN.slice- the IPv4/20this node self-assigned, out of100.64.0.0/10on nearly every machine and from agent 0.4.161 out of172.16.0.0/12or10.0.0.0/8where that range was claimed. Every IPv4 handle it mints — for itself and for every peer it names — comes out of it.slice6- router 0.4.204+ with agent 0.4.182+: the IPv6/64beside it, out offd00::/8. Every IPv6 handle the node mints comes out of it. Absent on a node with IPv4 handles only, and on an agent below 0.4.182 — the router shipped the field first, because it decodes this block strictly.self_ip- the address this node's own overlay name resolves to here.
Each interface entry carries a plain overlay: true when it carries the overlay — the TUN, or an interface holding an address in the node's slice — and the identity itself is named once, in the top-level block.
addrs is not the identityThe TUN is configured with the slice base, and on macOS a point-to-point utun reports that base with a /8. So addrs on that entry is the kernel's honest answer to "is my TUN configured the way I think" and a confidently wrong answer to "what is my overlay IP". Both are reported and neither corrects the other.
self_ip is node-local exactly like every net_peers address: it names this node on this node and something else, or nothing, anywhere else. The entry's id — the node's id, as node_list reports it — is the identifier that is valid everywhere.
overlay is absent when the agent reported none. The node has to send it, and agent 0.4.83 is where it starts — router 0.4.81 shipped first so that no agent could answer a router that would refuse the new field, so a node on 0.4.82 or earlier answers net_interfaces exactly as before, with no overlay anywhere in the response. It is also absent on a node that has one but has nothing to say: no overlay TUN in the interface list, or neither a slice nor a self-handle yet.
net_metrics
Surfaces: public · local
Read the agent's Go runtime counters — and, from router 0.4.84 with agent 0.4.86, a host block for the machine it runs on, and from router 0.4.165 with agent 0.4.133, a datapath_drops block counting the overlay packets it refused. Triggers a brief stop-the-world pause on the agent — fine for occasional diagnostics, not a replacement for a metrics pipeline.
Output Schema
{
"uptime": "number (seconds)",
"goroutines": "number",
"heap_alloc": "number (bytes)",
"heap_sys": "number (bytes)",
"num_gc": "number",
"gc_pause_total_ms": "number",
"num_cpu": "number (logical CPUs usable by the agent process, after CPU affinity)",
"gomaxprocs": "number",
"version": "string (Go version)",
"agent_version": "string (router 0.4.206+: the agent version the node registered with)",
"os": "string (GOOS)",
"arch": "string (GOARCH)",
"virtualization": "string (system/role, e.g. docker/guest; omitted when nothing was reported)",
"datapath_drops": {
"forward": "number",
"reverse_expired": "number",
"reverse_foreign": "number",
"reverse": "number",
"no_return_channel": "number",
"panics": "number",
"return_full": "number (absent on an agent below 0.4.142)",
"queue_full": "number (absent on an agent below 0.4.142)",
"protocol": "number (absent on an agent below 0.4.146)",
"icmp_type": "number (absent on an agent below 0.4.146)",
"malformed": "number (absent on an agent below 0.4.146)",
"no_delivery": "number (absent on an agent below 0.4.146)",
"deliver_failed": "number (absent on an agent below 0.4.146)"
},
"host": {
"cpu_percent": "number",
"mem_available": "number (bytes; agent 0.4.144+; below it the key is mem_free and the number is free memory)",
"disk_free": "number (bytes; free space where the agent is standing; absent when the node didn't measure it)",
"home_disk_free": "number (bytes; router 0.4.176+ with agent 0.4.147+; free space where an unelevated command runs — absent when the node didn't measure it)",
"load1": "number (absent on Windows)",
"at": "string (RFC3339 UTC)",
"stale": "boolean (router 0.4.199+; a stored sample that stopped refreshing — absent when it is current)",
"temp_c": "number (agent 0.4.180+, Linux; the hottest thermal zone, °C)",
"throttled": "number (agent 0.4.180+; the Raspberry Pi firmware's throttling bits)",
"failed_units": "number (agent 0.4.180+; systemd units in the failed state)",
"failed_unit_names": ["string (up to 8 of them)"],
"updates_pending": "number (agent 0.4.180+, dpkg hosts; packages with a newer version in the local index)",
"package_index_at": "string (RFC3339 UTC; when that index was last written)",
"ntp_synced": "boolean (agent 0.4.180+, Linux; the kernel's own clock-sync state)",
"ntp_offset_ms": "number (agent 0.4.180+; the offset the kernel is correcting)",
"probes_at": "string (RFC3339 UTC; when the probes above were measured)"
}
}
⚠ version is the Go runtime's, not the agent's — agent_version beside it, from router 0.4.206, is the agent version the node registered with, which is what a rollout is checked against. The router fills it in from the node's own registration rather than asking the node, so it is absent only where the node has reported no version at all. Before 0.4.206 this tool named no agent version, and version was read as one.
⚠ Everything outside host and datapath_drops is the agent process's own Go runtime, not the machine's — the name has always promised more than the tool answered, which is why the host block was added rather than the tool renamed (the name sits in every client's cached tool list).
⚠ There is no accelerator block here. gpu is on node_list (with view: "full") only: an accelerator is static, so it rides registration rather than the 60-second sample this tool reports.
⚠ num_cpu is the process's, not the machine's, and it is not node_list's cpus. This one is what the agent can actually use and honours CPU affinity, so on a container pinned to two CPUs it reads 2 while the machine has 32; cpus is what the machine has. They agree everywhere except a pinned container — read this one for "what would a process here get", that one for "how big is this machine".
datapath_drops counts the overlay packets this node refused, by class, from agent 0.4.133 with router 0.4.165. It is the one place a node says so: a drop is logged at debug level — deliberately, because the sender decides how often it happens and a warning each would be a flood the node's owner cannot stop — so without these counters a node dropping thousands of packets a minute reads exactly like a healthy one.
- The block is always present on an agent that has it, and a zero is an answer: nothing of this class has been dropped since the agent started. It is absent entirely on an agent older than 0.4.133;
node_listreports each node'sagent_version(withview: "full"). - Counts are since the agent process started and per profile. An agent restart, including an auto-upgrade, puts them back to zero — the same claim
uptimebeside them makes. reverse_expiredis routine on a busy node: a reply arriving for a flow that had already gone idle, which TCP recovers from. Read the other classes as the interesting ones.forwardis a packet refused on its way to a peer or to a target;reverseis any other refused reply;no_return_channelis a reply accepted with nowhere left to send it;reverse_foreignis a packet naming a flow it is not part of — something is sending into someone else's flow.return_fullandqueue_fullname a slow path to one peer (router 0.4.170+).return_fullis a reply dropped because the return path to the peer was full — the peer's channel drains slower than this node fills it; most are lost TCP segments, which TCP recovers from, so read a count that moves fast during a transfer as a slow path to that peer.queue_fullis a packet dropped because this node's queue to that peer was full, which is the same story in the sending direction: TCP reads the loss as congestion and slows down, so movement during a bulk transfer is normal on a slow link. ⚠ Both are absent rather than zero on an agent below 0.4.142, and the two absences mean different things: a node that does not sendreturn_fullcounts those drops insideno_return_channel, while one that does not sendqueue_fulldid not count them at all. Absent is never a claim that there were none. The router understands both from 0.4.170 and shipped first, so they appear as the fleet's agents reach 0.4.142. ⚠ From agent 0.4.162return_fullalso holds the UDP replies the machine's own kernel dropped at the socket a relayed flow reads from — the stage before the return path, and a different cause from the one above: whatever is answering (a service on this machine, or the LAN host behind a LAN name it serves) produces replies faster than the node reads them off that socket, so the machine short of CPU is this one and not the peer. Readhost.cpu_percentin the same answer before going looking for a slow link. That half is Linux only (it is the socket's own drop counter, which needs kernel 4.12 or later); elsewhere only the return-path drops are counted. It also moves for the few datagrams a kernel refuses for another reason, a bad checksum among them. ⚠ The node reads this count at intervals rather than per packet, so it can lag the drops by up to about ten seconds — take two readings a little apart before concluding a burst has stopped.- Five classes name a packet another node sent to this one that this node did not deliver (agent 0.4.146 with router 0.4.174). Since agent 0.4.145 a node carries TCP, UDP and ping to its own services and drops the rest; these are the tally of what it dropped, and until 0.4.146 they existed only in the node's own log.
protocol— an IP protocol other than TCP, UDP and ICMP. Nothing can carry it to the machine's own services, and it never had a return path.icmp_type— an ICMP message that is not a ping. A fragmented IPv6 ping is not one of these: from agent 0.4.145 the machine reassembles it and answers it.malformed— a packet the node could not read. This one is about the packet, so it says something about whatever sent it.no_delivery— every packet a peer sends, because socket delivery did not start on this node at all.nobgp statusreportsnetwork.peer_access: nonethere and the node's log says why; it is a whole-node fault, not one service's.deliver_failed— this node holds the name the packet was addressed to and could not carry the packet to it. The cause is on this node, never in the packet: the machine's own stack refused it, the node was shutting down, or the LAN name it was for is one this node no longer serves — a name the owner took out oflan-names, for instance, which from agent 0.4.146 counts here rather than being dropped uncounted. From agent 0.4.162 it also counts a UDP datagram that this node accepted and then dropped because the receiving buffer of that flow was full, which says the sender — usually a peer — is sending faster than this node delivers it; the usual cause is this machine being short of CPU, so readhost.cpu_percentin the same answer. On a small node that is the common one. ⚠ Likereturn_full's UDP half, this count is read at intervals rather than per packet and can lag the drops by up to about ten seconds. Measured on a Pi Zero W offered 8 Mbit/s of UDP and carrying 3 Mbit/s: several thousand datagrams a run, every class reading0before this release.
- ⚠ On a Mac,
reversecounted the machine's own broadcasts through agent 0.4.137. A macOS overlay interface is point-to-point, so a LAN broadcast from a local application — Dropbox's LAN sync sends one every 30 seconds — arrives back from and to the machine's own overlay address, which agents 0.4.132 to 0.4.137 read as a refused reply and warned about in the node's log every minute. From agent 0.4.138 such a packet is dropped without counting, so a non-zeroreverseon a Mac on that release or later is a real one. - ⚠ On Windows,
reversecounted the machine's own NetBIOS broadcasts through agent 0.4.138. Windows sends name registrations and browser announcements to the broadcast address of the machine's own overlay range, and agents 0.4.132 to 0.4.138 read each one as a refused reply and wrote a warning about it in the node's log every minute. From agent 0.4.139 such a packet is dropped without counting, exactly as the Mac case above. panicsis a packet whose handling failed and was recovered. It is a bug: the packet is dropped and the node carries on, and this counter is how anyone learns how often it happens. Report a non-zero value.- A minute in which anything but
reverse_expiredmoved also writes one log line naming the classes and counts, readable withnode_logs— the magnitude the single rate-limited warning cannot give.
⚠ Both versions are required, because a router older than 0.4.165 refuses the whole answer rather than dropping the block: noBGP will not accept a field from a node it does not recognise, so on a router below 0.4.165 this tool fails outright for any node running agent 0.4.133 or later. A self-hosted deployment therefore upgrades its router before its nodes reach 0.4.133; on noBGP's own routers the two were released in that order.
⚠ Every class added since raises that floor the same way, and it is always the router first: return_full and queue_full need router 0.4.170 for a node on agent 0.4.142, and the five delivery classes need router 0.4.174 for a node on agent 0.4.146. A self-hosted router below the floor does not lose the new counts — it fails this tool outright for those nodes.
host carries the same fields node_list reports per node as health with view: "health", so the two surfaces cannot drift into two spellings of one fact. Read node_list for a whole fleet in one call; read this when you already have a node in hand.
⚠ The sample belongs to the machine that is running now, and a reboot is what invalidates it (router 0.4.112) — the same rule node_list applies to health, from the same one place, so the two tools cannot disagree about the same node. Where the block is served from the node's stored sample rather than sent live by the agent, a sample taken before the machine last booted is withheld rather than published beside fields that are all current. So host is absent for about 90 seconds after a reboot, until the node samples again.
⚠ Through router 0.4.111 the key was the node's current connection instead, which suppressed the block on every reconnect as well — so a node with a flapping link reported no host block at all, measured at 0% of instants for one reconnecting every 45 seconds. A reconnect and an agent restart no longer withhold anything: neither of them is a new machine.
Before router 0.4.110 the two tools disagreed about the same node at the same instant: the directory withheld a stale sample and this tool went on serving it, which on a node being looked at because it had just restarted is the reading most likely to mislead. The rule now lives in one place and both read it.
atis not decoration: a node samples its host every 60 seconds, so the numbers beside it are up to a minute old by construction. A node can read healthy 55 seconds into the thing that is killing it.load1is absent on Windows, which keeps no load average — the number a collector synthesises there is not one, and a zero would be a load a real machine can have.disk_free(router 0.4.144, measured by agent 0.4.119) is free space on the filesystem holding the agent's own executable from agent 0.4.144, and its working directory below that — the space a dispatched build has, not the shared drive, whosewritable_rootsare remote share storage and answer a different question. Absent when the node could not measure it, and on every node whose agent predates 0.4.119. Read it as free space where this agent is standing: an unelevatedcommandwith noworkdirstarts in the configured account's home directory, which need not be the same filesystem — from agent 0.4.147home_disk_freebelow measures that one directly. Its size isnode_list'sdisk(withview: "full"), which is not repeated here — a capacity does not belong in a 60-second sample.home_disk_free(router 0.4.176, measured by agent 0.4.147) is free space where an unelevatedcommandactually runs, and it is the number to read before dispatching work.node_list'shealth(withview: "health") carries it too, so one call ranks a whole fleet by it.disk_freeabove measures the filesystem holding the agent's own program, which on a machine with/homeon separate storage is a different disk entirely — the caveat that field has carried since it shipped, now answered. It is the home directory of the node's configured account, which is where acommandwith noworkdirstarts. ⚠ It is absent, never zero, when the node did not measure it: on Windows, where a command with noworkdirstarts in the agent's own directory anddisk_freealready describes it; on Synology DSM, where home directories sit on the data volume and asking would wake its drives once a minute; on a node with no usable account configured, where unelevated work is refused anyway; and where the directory could not be read. A zero is a measured full disk and means what it says. ⚠ A home directory on a network mount is measured with a two-second bound, so a node whose file server is gone reports nothing here rather than holding up its own health reporting.mem_available(agent 0.4.144) is memory the machine can give to new work without swapping. ⚠ An agent below 0.4.144 sendsmem_freehere instead, a different and much smaller number — free memory excludes reclaimable cache — so read the key you got rather than assuming one.node_list'shealthcarries the same field and the same caveat.stale(router 0.4.199) istruewhen this block came from noBGP's stored sample and that sample is more than 3 minutes old: the numbers describe the host atat, not now. It is only ever set on the stored sample — a sample the agent answered with live is current by definition and is never marked. Absent means current.node_list'sstale(withview: "health") is the same field, by the same rule.- On a containerised node
cpu_percentand the memory figure are the host's, not the container's: a 512 MB container on a large box reports the box.
From agent 0.4.180 the block also carries what the machine says about its own health — its temperature, Raspberry Pi throttling, failed services, pending package updates and its clock. They ride the same 60-second sample as the numbers above, and they are read on Linux only: macOS and Windows keep each of them behind an interface the agent cannot read cheaply, unprivileged, or without starting a process per sample. ⚠ The temperature, the throttling bits and the clock are taken with the sample, so they are as recent as at; the two that cost a round trip or a file walk — failed units and pending updates — are taken on their own schedule below and the sample carries the last value each of them produced, so they can be older.
⚠ From agent 0.4.182 a sample never waits on a probe for more than two seconds. The three taken with the sample share that one bound, so a stuck sensor cannot hold up a node's health reporting: a probe that has not answered is simply absent from that sample, and one that answers just after the sample stopped waiting is carried by the next one — a value one sample (about a minute) old rather than a field that disappears for good. Only one run of each probe is ever in flight, so a sensor that never answers parks nothing further. On a host where every probe answers in milliseconds, which is every one measured, none of this is visible.
⚠⚠ Absent means not measured — never zero and never false. A host with no source for a probe sends nothing for it, deliberately: failed_units: 0 on a machine that has no systemd reads as a healthy machine, which is worse than saying nothing. ⚠ Both versions are required, as for every field added to this block: a router that does not know one of them refuses the whole answer rather than dropping it, so the router ships first.
temp_c— the hottest of the host's thermal zones, in °C. Absent where the kernel exposes none, which is most VMs and most containers on a VM host. A zone reading outside −40 to 150 °C is a broken sensor or an error code and is ignored rather than reported.throttled— the Raspberry Pi firmware's throttling bits, the valuevcgencmd get_throttledanswers with, read through oneioctlon/dev/vciorather than by starting a process. Only on a Raspberry Pi, and absent on one whose agent cannot open that device.failed_unitsandfailed_unit_names— the unitssystemctl --failedlists, asked of systemd over the system bus at most every five minutes, with no process started and in a routine of its own, so a bus that does not answer never holds up the node's reporting — the sample carries the answer the node already had. The count is all of them; the names are the first eight in sorted order, which is all noBGP keeps. Absent where systemd is not the init — OpenWrt, Buildroot, Alpine, most containers — and where the bus did not answer.updates_pendingandpackage_index_at— installed packages that have a newer version in the package index already on the machine, counted from/var/lib/dpkg/statusagainst/var/lib/apt/lists, with the time the newest of those index files was written beside it. ⚠⚠ The node never runsaptand never refreshes the index.apttook 17 to 20 seconds on the 246 MB appliance that asked for this number, so the count reads the same files once, as a stream, and only when one of them has changed — at most hourly, at the lowest priority the host has, and a check that finds the index unchanged costs onestatper file. Readpackage_index_atwith the count: on a machine whose index is a month old, so is the answer. ⚠ It can be higher thanapt list --upgradable, which applies pin preferences (/etc/apt/preferences.d) and Ubuntu's phased updates that this count does not; a source marked not-automatic is honoured. Absent on a host with no dpkg index at all, and on one whose index is in a compression the node cannot read — a count over part of an index is a wrong number that looks like a right one.ntp_syncedandntp_offset_ms— the kernel's own answer about its clock, read without privilege and without starting anything. ⚠ From agent 0.4.182ntp_syncedis exactly whattimedatectlshows as System clock synchronized, read the way systemd itself reads it: the kernel's maximum error is under 16 seconds. On agent 0.4.180–0.4.181 a host runningchronyreportedntp_synced: falsewhiletimedatectlon the same host said yes —chronyleaves the kernel's unsynchronized flag set, so that the kernel does not write the hardware clock, on a clock it is keeping in step. Upgrade such a node before reading this field across a fleet. ⚠ The offset is present only where the kernel's own loop is disciplining the clock (systemd-timesyncd,ntpd):chronysteers the clock itself and leaves that offset at zero, which is not a measurement, so the field is absent there rather than reported as0.probes_at— when the probes above were measured, present only when at least one of them was. It is the same instant asat, and it is what keeps the probes attributable where this block is read from a stored sample.
virtualization makes that last caveat readable in the result itself (router 0.4.85+). It is the same system/role string node_list publishes as virtualization (with view: "full") — "docker/guest", "kvm/host" — derived by the router from what the node already reported, so no agent release is involved and the two surfaces cannot spell one fact two ways. Read it before the host block: when it names a container, the numbers there belong to the machine underneath. Until this release the caveat lived only in a field description pointing at another tool, so a caller reading net_metrics alone got a box's free memory with nothing in the response saying so.
⚠ It is omitted both on bare metal and on a node that never reported the underlying fields, and the two are indistinguishable here — absence means not said, never definitely not virtualized.
- The whole block is absent when there is no usable sample to report: an agent older than 0.4.86, which does not carry one at all; on 0.4.86 an agent that has been up for less than one 60-second collection; and, from router 0.4.112, a node whose only stored sample predates the machine's current boot. From agent 0.4.87 that startup window is gone — the node takes its first sample as it comes up rather than waiting out the first interval, so the block is there from the moment the node is answering, which is exactly the window someone asks in on a node that just started or just came back. The node samples for
node_listeither way, so itshealth(withview: "health") answers for a node this tool cannot.
net_routes
Surfaces: public · local
Read the agent host's kernel routing table (IPv4 + IPv6). Currently Linux-only; macOS/Windows return HTTP 501 unsupported.
The unsupported refusal names the node and what to do instead (router 0.4.87+). Through 0.4.86 it came back as the agent's own unsupported netinfo kind: ROUTES not implemented on this platform — neither the node nor an alternative, on a fan-out where knowing which node answered is the point. It now adds the node's name and says to use net_interfaces for addressing rather than routes (it also carries the node's overlay identity), and command for anything the host itself can print. Only unsupported is enriched: a timeout's remedy is to retry, not to switch tools.
Input Schema
{
"node": "string (the node's id or its name)",
"destination": "string (optional; an address or a CIDR — return only the routes that overlap it)"
}
The node reference is the usual one.
destination asks the question a routing table is usually read for (router 0.4.199+): how would this machine reach that address?
- An address (
192.0.2.10,2001:db8::5) returns the routes that cover it, the default route included — so the answer is the candidate set the kernel chooses from, and the most specific of them is the route that wins. - A prefix (
10.0.0.0/8) returns the routes inside it and the routes that cover it, so you also see which route carries that range today. 0.0.0.0/0or::/0returns one address family — the compact way to ask "just the IPv4 table" on a host whose IPv6 routes dominate it.- An address and its
/32(or/128) give the same answer. A value that is neither an address nor a CIDR isinvalid_argsnaming examples; omitting it returns the whole table as before.
Output Schema
{
"routes": [
{
"family": "ip4|ip6",
"destination": "string (CIDR or \"default\")",
"gateway": "string",
"source": "string",
"interface": "string",
"metric": "number",
"scope": "string",
"protocol": "string"
}
],
"trimmed": "string (MCP only; present when the full table was larger than a model client accepts)",
"omitted": "number (MCP only; routes left out for size — absent means none)"
}
When the table is larger than a model client accepts
Router 0.4.199. A routing table grows with the host, and past about 30 KiB of JSON a model client stops accepting the response at all — outside the protocol, so you get neither the routes nor an error. Measured on one node: 1,540 routes, 225 KB, of which 1,514 were IPv6 /128 routes a firmware installs on a LAN bridge.
Over MCP the router now keeps what fits and says what it left out. It keeps every route that is not a host route (/32 or /128) first, then host routes in table order, so the routes that describe the host's reachability survive and the per-device clutter is what goes. Table order is preserved either way, omitted counts what is missing (omitted_routes until router 0.4.200), and the entries in routes plus omitted are the size of the table (or of the destination match). Over REST nothing is ever trimmed.
⚠ trimmed present is the signal, not its wording — it names which of the two cases applied and points at destination as the way to a complete answer. Prefer narrowing with destination: a filtered result is complete for what you asked, where a trimmed one is part of what you asked for. The node still sends its whole table either way — the filter and the fit are both applied after it arrives — so neither saves the node any work; what they save is the part of the answer you cannot read.
net_peers fits itself the same way, keeping the peers that are not stale first and counting the rest in omitted.
net_dns
Surfaces: public · local
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.
The unsupported refusal names the node and what to do instead (router 0.4.87+), as it does on net_routes: the node keeps its resolver configuration somewhere other than /etc/resolv.conf, so read it with command — on Windows, ipconfig /all, or Get-DnsClientServerAddress with shell: "powershell".
Output Schema
{
"nameservers": ["string"],
"search": ["string"],
"options": ["string"],
"source": "string (e.g. /etc/resolv.conf)",
"stub": "boolean",
"note": "string (hint when snapshot is incomplete)"
}
net_reach
Surfaces: public · local
Router 0.4.155+. Ask one node a single question: does this name resolve here, to what, and does the answer respond? The node resolves the name the way an application on it would — through the host resolver, which reaches the node's own overlay DNS server for overlay names — asks its overlay DNS server directly as well, then probes the address it got. The whole call is bounded at 5 seconds on the node.
It replaces the command recipe everyone was writing by hand (ping -c 3 NAME, nc -zv NAME 22), and answers the part that recipe cannot: which resolver answered, and which node delivers an overlay name.
Input Schema
{
"node": "string (the node's id or its name — the node to probe FROM)",
"name": "string (required — the name to resolve there)",
"port": "number (1–65535; TCP connect when set)",
"count": "number (1–5 ICMP echo requests, default 3; refused together with port)"
}
nameis whatever an application there would use: a bare name (glkvm), a name in the node's overlay zone, a LAN name (nas.lan), or a public name. An IP address is probed directly, with no resolution.- Prefer
port. Many hosts and firewalls drop ICMP, sono_replyis not proof a host is down — and a port proves the service, not only the host.portandcounttogether areinvalid_args:countbelongs to the ICMP probe. - The node reference is the ordinary
node.
Output Schema
{
"net_reach": {
"name": "string",
"resolution": {
"source": "string (overlay | system | literal | none)",
"resolver": "string (router 0.4.204+ with agent 0.4.182+: tailscale | system — which resolver gave a system answer)",
"address": "string (absent when nothing resolved)",
"class": "string (overlay | cgnat | loopback | lan | public | none)",
"address6": "string (agent 0.4.182+: the IPv6 answer beside the IPv4 one, when the name has both)",
"class6": "string (the class of address6, with class's values)",
"system": { "addresses": ["string"], "not_found": "boolean", "reason": "string" },
"overlay": { "queried": "boolean", "rcode": "string", "addresses": ["string"], "reason": "string" },
"refused": "string (lan_names — the node was not allowed to look this name up)",
"lan_name": "string (agent 0.4.147+; the LAN host's own name when the name asked for is the label this node serves it under)",
"note": "string (present when the result needs explaining)",
"elapsed_ms": "number"
},
"target": {
"name": "string",
"node": { "id": "string (the node that delivers the name — for a LAN name, the gateway)", "name": "string (that node's name)" },
"target_id": "string (the router's target id for the entry — the session key)",
"hosted": "boolean (true when the probing node delivers the name itself)",
"dst_ip": "string (for a hosted entry, the address it forwards to)",
"stale": "boolean", "retracted": "boolean", "undeliverable": "boolean",
"lease_until": "string (RFC3339 UTC)"
},
"probe": {
"method": "string (tcp | icmp | none)",
"port": "number", "outcome": "string",
"sent": "number", "received": "number",
"rtt_ms": "number", "rtt_min_ms": "number", "rtt_max_ms": "number",
"socket": "string (raw | dgram | iphlpapi)",
"interface": "string (router 0.4.206+ with agent 0.4.183+: the interface the probe left by)",
"reason": "string", "elapsed_ms": "number"
},
"probe6": "object (agent 0.4.182+: the probe of resolution.address6, with probe's own fields)",
"elapsed_ms": "number"
}
}
Every outcome is a result, never an error
A name that does not resolve is resolution.source: "none", and a closed port is probe.outcome: "refused" — both are successful calls. Only a malformed argument, an offline node, or a node that cannot serve the tool is an error.
⚠ source: "none" does not always mean the name does not exist — it can mean "not resolved yet" (router 0.4.178+). The first lookup of a name another node serves waits on noBGP and on that node, so a cold lookup can outlast the budget the probing node gives it: its own overlay DNS server either did not answer in time, or answered NOERROR with no address, which is what it answers while the lookup is still in flight. The summary line now says the name is not resolved YET and to retry in a few seconds, instead of did not resolve on this node — which is a positive claim that the host is not there. Read it yourself off resolution.overlay: queried: true, no addresses, and either no rcode or rcode: "NOERROR", is a lookup in progress. NXDOMAIN is a real answer and stays did not resolve, and a refused name is neither. The two signs are the same on every agent that serves this tool, so a cold lookup reads correctly on an older one too.
Agent 0.4.152 resolves most of those on the first call, and explains the rest in resolution.note. The node asks the host resolver and its own DNS server together, each with its own slice of the call's 5 seconds, and where the host resolver returns nothing — including a name its owner's lan-names kept it from looking up at all — the node's own server is the only answer left and now gets 3.5 seconds rather than 2. A cold overlay name asks noBGP and then the node that serves it, which is what used to outlast the shorter budget: the same name came back as none and then answered in milliseconds on a retry. What is still in flight past 3.5 seconds carries a resolution.note saying the name is not resolved yet and to retry in a few seconds; a host resolver that timed out while the overlay server answered later says that too, rather than pointing at the zone not being routed on that node. The probe keeps what is left of the budget, so the cold case that resolves is still probed in the same call.
Read resolution.source and resolution.class first:
source | What it means |
|---|---|
overlay | The node's own noBGP DNS server answered — the address is an overlay handle, and target names the node that delivers it |
system | The host resolver returned a non-overlay address: LAN DNS, the hosts file, or public DNS |
literal | The name was an IP address |
none | Nothing resolved |
resolution.resolver says which resolver gave a system answer, from router 0.4.204 with agent 0.4.182: tailscale where the address is in Tailscale's ranges on a node where Tailscale is running — that is MagicDNS answering — and system for the host's own DNS, its hosts file or LAN DNS. It is absent when noBGP answered or nothing resolved. The node infers it from the address, because the host resolver does not say which of its upstreams replied, so read it as an attribution rather than a measurement. It is the field to check when a name resolves on a node to an address you did not expect: another overlay product on that machine is the usual reason, see Coexisting with other VPN clients.
⚠ source: "overlay" does not always mean an overlay address. From agent 0.4.139 a node's own DNS server answers a name it reaches on its own LAN with that device's LAN address, so such a result reads source: "overlay" with the class of that address — usually class: "lan", but "cgnat" for a name another overlay product answers and "public" for a device with a public address — and carries no target. The node reaches the device itself, over one hop, with nothing relayed. Read class to tell the two apart: only class: "overlay" is an overlay address. Through agent 0.4.138 the same name came back as source: "system", because that node's DNS server declined it.
probe.outcome is the other half — for TCP: open (connected), refused (the host answered and nothing listens — the path works), timeout, unreachable, error; for ICMP: replied, no_reply, permission_denied (this node may not send ICMP — probe a port instead), unsupported, error; and skipped when nothing resolved.
probe.interface says which path the probe took, from router 0.4.206 with agent 0.4.183: the interface the packet left the node by — nobgp0 is the noBGP overlay, tailscale0 (or Tailscale on Windows) another overlay product, eth0 or wlan0 the node's own LAN. ⚠ On macOS both noBGP and Tailscale use a utun name: compare it with the overlay entry of net_interfaces on the same node before you read a utun as noBGP's. class says what kind of address answered; this says which leg of the machine carried it, which is what settles did this test noBGP at all on a node running more than one overlay. probe6 carries its own. It is absent where the node did not report one — every agent below 0.4.183, and a probe that sent nothing. ⚠ Through router 0.4.205 the router did not know the field, so an agent that sends it needs a router on 0.4.206 or later; the two halves ship in that order.
An answer that is not an overlay address says so in the summary line, from router 0.4.206. Where class — or class6 — is anything but overlay or none, the one-line message adds that the name resolved outside the noBGP overlay and that the probe tested that path rather than noBGP's. It is said per family, so an IPv4 LAN answer beside an IPv6 overlay one names which of the two it is about. Read it before concluding anything about noBGP from the outcome: a refused there is a host on some other path answering, and says nothing about whether the overlay would have reached the name.
resolution.lan_name names the device behind a label (agent 0.4.147). Some devices lease a DHCP name that is not a host name — WiiM Mini-9D84, with a space — and from that release the node running the LAN's DHCP server serves them under the label that name maps to, WiiM-Mini-9D84. Ask that node about the label and this field comes back holding the device's own name, so the answer says which host it actually reached. It is present only when the address probed belongs to that host, and only on the node that serves the name: a node that reaches the same name through a peer gets no lan_name, because only the gateway knows. A name that resolved any other way, and a refused one, carry none.
Both families are probed, from agent 0.4.182
A name on an overlay with IPv6 answers A and AAAA, and a glibc application on that node uses the AAAA answer — so a call that only proved the IPv4 handle proved the wrong half. From agent 0.4.182 the node reports the IPv6 answer beside the IPv4 one and probes both:
resolution.address6is the IPv6 answer andresolution.class6its class, read exactly likeclass— onlyoverlayis an overlay handle. Both are absent when the name has one family only, and absent for an IP literal, which is probed as given. A name that resolves to IPv6 alone carries that address inaddressand nothing inaddress6, so never read an absentaddress6as "no IPv6".resolution.addressis still the IPv4 answer, andresolution.classstill its class, so nothing a caller already reads changes.probe6is the probe ofaddress6, with the same fields asprobeand the samemethodandport. The two probes run at the same time, so the IPv6 one does not spend the IPv4 one's share of the 5-second budget, andprobe6is absent wheneveraddress6is.
⚠ Read both before concluding a name is reachable. probe.outcome: "open" with probe6.outcome: "timeout" is the shape of a host that answers on IPv4 while the IPv6 path is broken — which is the failure an application there actually meets. On a node below 0.4.182 there is no probe6 and no address6: the answer is about the IPv4 handle only.
⚠ An ICMP probe of an IPv6 address from a Windows node needs agent 0.4.141. Through 0.4.140 the interface the agent used there sent IPv4 echoes only, so an IPv6 address came back unsupported — send a port instead on such a node. Nothing changes for an IPv4 address.
The failure it exists to find
source: "overlay" with an empty resolution.system.addresses is the one result that looks healthy and is not. The node's overlay DNS server knows the name and the probe of it succeeds — while every application on that machine fails to resolve it, because the overlay zone is not wired into the host resolver. The summary line says so outright, and resolution.note explains it. It is the named macOS hazard: the agent answers the zone from a server on loopback and needs an /etc/resolver entry to route the zone to it — see DNS & Hostname Resolution.
What it will not tell you
- ⚠ An address with
class: "overlay"is NODE-LOCAL. Every node mints its own handles, so the same name has a different overlay address on each of them. Run the tool from each node you care about; never reuse an address one node reported on another. - ⚠
resolution.refused: "lan_names"withholds the address, not the existence. The node's owner limits which LAN names that node will resolve for the network withlan-names, and this name is not on the list — so the node did not look it up for you and probed nothing, and you never get its address. ⚠ Do not read the refusal as "no such host": the node's own lookups are not filtered, and one still runs on the miss path, so the shape of the refusal can distinguish a refused name that exists on that LAN from one that does not (agent#1167). An overlay answer is never refused this way. - ⚠ A first probe to a cold overlay peer also sets up the session, so its
rtt_msis slower than the path really is. Probe twice. - ⚠ A node whose agent predates the tool answers
unsupported, naming the node. Agent 0.4.128 is the release that answers it. Upgrade that agent, or fall back tocommand.
net_reach_many
Surfaces: public · local
Router 0.4.155+. net_reach asked of every node a selector matches, in one synchronous call, with a rollup in front of the rows — so can every node in this network reach glkvm by name? is one question and one answer.
Input Schema
{
"name": "string (required)",
"port": "number (1–65535)",
"count": "number (1–5, default 3)",
"detail": "boolean (include each node's full net_reach result under reach)",
"select": {
"network": "string",
"nodes": ["string"],
"platform | os | arch": "string",
"labels": { "key": "value" }
}
}
The selector is the shared cohort selector, ANDed. Omitting every node-narrowing field covers the whole network.
Bounds
- At most 64 nodes. Above that the call is
invalid_argsasking you to narrow the selector. - Eight nodes are probed at a time, and the call returns within 60 seconds. A node that has not answered by then is counted under
deadlineandrollup.completeisfalse— which means every other count is partial. - Each node runs exactly what
net_reachruns, with the same authorization and the same 5-second bound on the node.
Output Schema
{
"net_reach_many": {
"name": "string",
"rollup": {
"matched": "number", "resolved": "number",
"answered": "number", "port_refused": "number", "no_answer": "number",
"not_resolved": "number", "pending": "number", "lan_names_refused": "number",
"unsupported": "number", "offline": "number", "error": "number",
"deadline": "number", "complete": "boolean"
},
"nodes": [
{
"id": "string", "name": "string",
"status": "string (the rollup key this node is counted under)",
"source": "string", "address": "string", "class": "string",
"resolver": "string (for a system answer, which resolver gave it: tailscale or system)",
"address6": "string (the name's IPv6 answer on that node, when it has one)",
"via": "string (for an overlay answer, the node that delivers the name)",
"probe": "string", "probe6": "string (the outcome of the IPv6 probe; status counts probe only)",
"interface": "string (router 0.4.206+ with agent 0.4.183+: the interface the probe left by)",
"rtt_ms": "number",
"code": "string", "reason": "string",
"reach": "object (the full net_reach result; only with detail: true)"
}
]
}
}
Read rollup first. Every matched node lands in exactly one status count, so they add up to matched. resolved is answered + port_refused + no_answer. Rows are sorted by node name, and via is what lets a failure be followed to the gateway that was supposed to deliver the name.
A row says how its node answered as well as whether it did. class is the one to read beside status: from router 0.4.206 the summary line counts how many of the rows resolved outside the noBGP overlay — cgnat, lan, loopback or public — because on those nodes the outcome tested some other path and counting their port_refused as the path works overstates what noBGP reached. resolver attributes a system answer, address6 and probe6 carry the IPv6 half (the status count is the IPv4 probe's), and interface — router 0.4.206+ with agent 0.4.183+ — names the interface the probe left by, which is the same field net_reach reports.
unsupported is mostly a fleet-age reading: it counts the nodes whose agent predates 0.4.128 and cannot answer the question at all. lan_names_refused counts the nodes whose owner has limited which LAN names they resolve and excluded this one — those nodes looked at nothing, so they are neither a yes nor a no about the host.
pending is a node whose lookup was still in flight (router 0.4.178+) — the name is not resolved yet there, which is not a verdict about the host, so it is counted apart and is in neither resolved nor not_resolved. It is the ordinary shape of a cold name: the first lookup of one another node serves waits on noBGP and on that node. Re-run the call a few seconds later and expect those nodes to move. Through router 0.4.177 they were counted under not_resolved, which read as these nodes cannot reach it.
From a node's local server, the grant decides the cohort
A node calling this through its own local server reaches only the peers its grant's targets allow. The rest are outside the answer entirely — not probed, not named, and not counted: matched is the number of nodes this call probed, never a count of the network. There is no "not permitted" count, deliberately, because one would let a caller measure how many peers carry a given label, platform or architecture. A node named explicitly in select.nodes but outside the grant is not_found, exactly as it is for net_reach.
From the public server nothing is excluded, so matched is the whole cohort.
node_logs
Surfaces: public · local
Read a node's own agent log — the first thing to ask of a node that is misbehaving. Router 0.4.84+, and the node's own agent must be 0.4.86 or newer: the switch over the mechanisms lives on the node, so this is one of the few tools an upgrade of the fleet, not the router, turns on.
The node's controllers — its owner, or an Owner or Admin of the node's organization — from router 0.4.183. The agent's log records every session, config change, refusal and registration on that machine, other people's activity included, so it is control rather than use: a use or root entry on the node's access list does not give it, and neither does the node being shared with the organization. Either way the refusal is forbidden naming the requirement; a caller outside the organization gets not_found. Before that release anyone who could use the node could read its log.
A member who cannot read it can read the log at the machine with nobgp service logs. noBGP support staff read it as an Admin would, audited, while your organization allows support access — one of the two things otherwise reserved to a node's controllers that staff reach, the other being the node's remote configuration. From a node's local server the node's owner must control the target, not merely use it.
Purpose
The node picks whatever mechanism it actually has and tells you which one it used, so you do not have to know the platform and the same call works everywhere. Before this, reading an agent's log meant shelling out with a guess: the log lives in at least five shapes across an ordinary fleet — journalctl -u nobgp with no file at all, logread on OpenWrt, a file on macOS whose path is named only inside a LaunchDaemon plist, a file under C:\ProgramData, and a container whose output is held by a runtime on another machine — and a guess that misses returns nothing at all, silently, with exit 0.
The bare call is the right call:
{ "node": "pi5" }
That returns the newest ~200 lines at every level, oldest-first within the page.
⚠ From agent 0.4.156 the bare call is the whole of it: the identity field does nothing on this tool. The node reads its own log as the agent process itself, whatever root (old name admin) the call sends, so there is no second call to make and no field that makes an unreadable log readable. See The identity field does nothing here.
On an agent 0.4.86–0.4.155 the read ran at the identity the call resolved to, so a log belonging to the superuser came back refused and wanted one retry with root: "true" — from agent 0.4.88 that refusal is failed_precondition naming exactly that retry, so the flow was at worst two calls. On those agents an omitted root already elevated where the node reported its account as empty or as root, which is where most of a fleet's refusals came from.
Input Schema
{
"node": "string (the node's id or its name)",
"limit": "number (optional; max 2000)",
"since": "string (optional; \"2h\" or an RFC3339 instant)",
"min_level": "error|warning|info|debug|trace (optional)",
"pattern": "string (optional RE2 regex)",
"cursor": "string (optional; a previous response's next)",
"root": "boolean (optional)"
}
The node reference is node, at the top level, as on every node-addressed tool.
Parameters:
limit- How many of the newest matching lines to return. Omit for the node's default (~200). Maximum 2000, and a larger value is refused withinvalid_argsrather than quietly clamped — so a short answer is always distinguishable from a capped one.since- A lower time bound, in either of two forms: a duration back from now ("2h","90m","1h30m") or an RFC3339 instant ("2026-08-19T04:00:00Z"). Days are not a duration unit — write"48h", not"2d". ⚠ A line the node could not stamp is excluded by this filter; it cannot place it in time. ⚠⚠ And a line the node mis-stamped is excluded just as silently — see below. ⚠ The response does say how much went, from router 0.4.113: see How much your filters removed.min_level- A severity floor, in the CLI's own--log-levelvocabulary. It means that level and above, so"warning"returns warnings and errors — which is why it is not calledlevel. ⚠ A line the node could not tag is excluded rather than kept, so a filtered read can be shorter than the unfiltered one by more than the levels you excluded; those lines are present in a call that omits the field, where they arrive with nolevel. ⚠ How much shorter is answerable from router 0.4.113 — see How much your filters removed.pattern- An RE2 regex matched against each line — the same spelling and engine asfs_grep. Case-insensitive is(?i)at the front; no backreferences and no lookaround.cursor- Continue a previous read, backward in time. Take it from a response'snextand nowhere else.root- The usual execution identity field, still accepted — ⚠⚠ and from agent 0.4.156 it changes nothing on this tool. The node reads its own log as the agent process itself whatever you send, so omit it. On an agent 0.4.86–0.4.155 it picked the identity the read ran as, and a log was the case where elevation was usually needed — a journal unit, a root-owned mirror and aProgramDatafile are not uniformly readable by the account a node's owner configured. There, an omittedrootwas the cheaper first call across a mixed fleet: it elevated on a node reporting its configured account as empty, or asroot, and on a node reporting any other account it resolved to that account, which was the identity being refused. ⚠ A node that reported no account at all was not the empty case — silence is this agent did not say — so an omittedrootleft such a node unelevated and its refusal stood, and"true"was what reached the log.node_listreports each node'sagent_version(withview: "full") if you need to know which of the two a node is on.
Every filter narrows on the node. limit, since, min_level and pattern compose with AND and are applied before a byte crosses the wire, because the logs on an ordinary fleet run to megabytes — "return the log and filter it afterwards" is a call that could not be made.
Output Schema
{
"source": "string (journald | logread | syslog | file | …)",
"path": "string (absolute; absent on a command-backed source)",
"lines": [
{ "text": "string", "at": "string (RFC3339 UTC)", "level": "string" }
],
"scanned": "number (raw lines this page examined)",
"truncated": true,
"unreachable": "string (what this page does not show you, and why — five causes, see below)",
"next": "string (present when truncated)",
"root_used": "boolean"
}
This is the payload under node_logs; done and the node are on the envelope.
Fields:
source- The mechanism this node actually read, in the node's own words — todayjournald,logread,syslogorfile, and whatever a future packaging change names. It is an open vocabulary: reported so you know what you are looking at, never validated and never chosen by you. It is also how you tell a quiet node from a differently-plumbed one. ⚠ The same machine can answer with a different mechanism after an agent upgrade: a Synology that answeredjournaldanswerssyslogfrom agent 0.4.159, with apathbeside it — see On a Synology the journal is not the log.path- The absolute path of the file the node read — the answer to where is this node's log, which nothing else on this surface reports. Hand it straight tofs_read,fs_greporfs_stat. Absent on a command-backed source (journald,logread), where no file is read; absent means exactly that and is never a placeholder. ⚠⚠ That file can contain NUL bytes, and both follow-ups this field invites are blind to it — see Searching the file this names.lines- The newest matching lines, oldest-first within this page. Never null.atandlevelare present where the node could parse them and absent where it could not — a mechanism that logs everything at one severity, or a line with no stamp.scanned- How many raw lines this page examined — everything the filters were applied to, matched or not, and never the lines a cursor skipped over to get here. It is what makes an emptylines[]mean something: 0 matches out of 50 000 scanned is this node did not say that, recently, while 0 matches out of 0 scanned is nothing was examined at all, and nothing else on the payload separates them. Sum it across a paged walk to say how much of the log a search actually covered. ⚠⚠ Subtract the length oflines[]from it: the difference is what this page examined and did not return, which withsince,min_levelorpatternset is what your filters removed — and from router 0.4.113 the one-line summary does that subtraction for you, see How much your filters removed. New in agent 0.4.95, and ⚠ absent — not zero — on a node whose agent predates it: such a node still answers the query, it just cannot say how much it looked at, so on such a node the subtraction cannot be made at all.truncated- A further page follows. Always present, becausefalseis a real answer. It is conservative: exact when the node could look one match further back, true-when-unsure if its scan budget ran out first, and provablyfalsewhen you suppliedsinceand the node reached that bound. From agent 0.4.87 it also accounts for a rotated generation of the same file, so reaching the top of the live log is not the end when the node kept the previous one beside it — see below — and from agent 0.4.94 for ajournaldrender that came back full, which used to read as the end of the journal on the busiest units. See A cursor always moves. ⚠falsemeans no further page follows, which is not always you have the whole log: it is also how a walk ends at the limit of what the node's mechanism can render, at thesincebound you supplied, or at the beginning of a bounded ring buffer that holds nothing older.unreachableis what tells them apart —falsewith nothing there is the beginning of a log the agent is actually writing to.unreachable- What this page does not show you, and where to get it. A page carrying it is a success, never an error. ⚠ It carries five causes with five different remedies, and more than one can apply at once, so read the sentences rather than the presence of the field — each one names its own cause. See Whatunreachableis telling you. New in agent 0.4.95, when it carried the first cause alone — before it, that one arrived as anunsupportederror. The next two are agent 0.4.103, the fourth — another mechanism on this node refused the identity you ran as — is agent 0.4.105, and the fifth — this log is a bounded ring buffer and you reached its beginning — is understood by the router from 0.4.170.root_used- Old nameadmin_used, in no result from router 0.4.201. The identity the query ran as. ⚠⚠ From agent 0.4.156 there is only one identity to report: the read runs as the agent process itself, so this field no longer separates a short answer caused by the identity from a quiet node — a short page there is either a quiet node or a mechanism the agent itself may not read, andunreachableis what says which. On an agent 0.4.86–0.4.155 it is the field to read beside a short or emptylines[].
Read source and path before you believe the lines
A log is not evidence about the running agent until you know they are the same process. source says which mechanism answered and path says which file it read, and those two are what make the lines attributable — nothing else in the response does, and nothing in a stale answer looks stale.
Measured on an appliance-class node: the newest line in the file was the shutdown of a previous agent, while the agent actually running had been started by hand from a terminal and was writing to a pty and into no file at all. The lines were real, recent-looking, and about a process that had already exited.
Where path is present, that is the check: hand it to fs_stat and compare its mtime against how long the node has been up, or read /proc/<pid>/fd/1 with command. Where it is absent the source is command-backed (journald, logread) and follows the service manager's own view of the unit, which is the case that does not have this problem.
Where the node can tell, it says so itself, from agent 0.4.103: that is the third cause unreachable carries — the running agent is not writing this file. Because it is a fact about the whole read rather than about one page, it is set on every page including the first, so it is visible before you have paged anywhere. On an agent below 0.4.103 nothing marks such a page, and the check above is the only way to make it.
One more reason source and path can move under you, from agent 0.4.103. Some appliance and router firmware keeps /var/log in RAM, so the init script's capture of the agent's output is erased at every reboot. Such a node writes the agent's own durable mirror as well and reads that first, so path becomes <config-dir>/nobgp.log where the same machine used to answer with a /var/log file — and the history before the last boot is there rather than gone. This is Linux only and is measured rather than assumed; the service manager's own files are still offered behind the mirror.
On a Synology the journal is not the log
A Synology DSM node reads /var/log/systemd/<unit>.log before its journal, from agent 0.4.159, so source is syslog and path names that file where the same NAS used to answer journald with no path at all.
DSM keeps journald volatile and small — a few megabytes for every unit on the box — and its own syslog-ng copies each unit's output out to a file. So journalctl -u nobgp holds only the newest lines that have not been evicted yet, and because the node takes the first mechanism that yields a line, a journal holding one line won the probe. The page then looked clean and complete, with truncated: false, while the file beside it held the log.
- It is still probed, not assumed. A file that is absent, empty, or closed to the agent declines like any other candidate and the walk goes on to the journal, exactly as before.
- ⚠ DSM rotates that file and keeps the older generations compressed (
.1.xz,.2.xz, …), andnode_logsdoes not read compressed files. A walk that reaches the beginning of the live file therefore ends withunreachablenaming those files rather than claiming you have the whole log; read them on the NAS itself, for example withxz -dc. - Nothing here stats the generations, and nothing reads this file on a schedule. The DSM system partition is mirrored across every installed drive, so a periodic read there would keep the NAS's disks awake — only a
node_logscall ornobgp service logsopens it. - ⚠ It applies to Synology DSM only and is detected from DSM's own paths, so a container running on a DSM host is not affected. Every other Linux node keeps the order it had.
This is also the reason to spend the call rather than shelling out. A command script that reads the log and the process table and the file mtimes in one round trip is a reasonable thing to reach for while diagnosing a node, and it wins on round trips; what it does not get for free is which mechanism this node actually uses and whether the file it read belongs to the process it is describing. The order that works is this tool first — it names the file and says whether the node can serve a log at all — then command for process state.
Searching the file path names
⚠⚠ A log file can contain NUL bytes, and both of the follow-ups path invites are blind to one. An agent log is ordinary text until something writes a stray NUL into it — a crash dump, a truncated write, a device that logs a raw frame — and a tool that classifies the file as binary then answers as if it held nothing. ⚠ The two follow-ups classify on different evidence, so they do not fail together:
fs_grepreads the first 512 bytes and nothing else. A NUL in that first block makes the whole file binary to it and the file is skipped entire; a NUL further in is invisible to the check and the file is searched normally. When it does skip, the answer comes back with no matches,scanned_files: 0andskipped_files: 0— so it reads as nothing was examined rather than as a file I declined to open, and there is no override to turn it off.- A plain
grepthroughcommandinspects what it reads, so a NUL anywhere in the file suppresses the matching lines; it prints binary file matches or nothing at all. There the fix is on your side:grep -a.
So a log with an early NUL is invisible to fs_grep and searchable with grep -a, and a log with a late one is the other way round. scanned_files: 0 on a path you know exists is the tell for the first.
node_logs itself is unaffected — it reads the lines through the node's own mechanism, and this is a trap only for what you do with the path next. Reported in the field's own description from router 0.4.143.
How much your filters removed
A filter that removed almost everything looks exactly like one that removed nothing — both answer with a short, clean, successful page. From router 0.4.113 the one-line summary that comes back with every call does the subtraction and says so:
node_logs: 11 lines from journald, 115 scanned, 104 not returned (active filters: since — drop it to see them)
That is scanned minus the length of lines[], and it needs no new field on either side: the node already reports scanned, and the router built the request. Three things are worth knowing before you act on the number.
- ⚠ It counts, and it never attributes. The node reports one
scannedfor the page, not one per filter, so a linesincedropped because the node could not stamp it and a linepatternsimply did not match are the same number here. The summary therefore names which filters were active and never which one did it: with one filter set the attribution is exact by elimination, and with two or three, drop them one at a time. - ⚠ It is silent when there is nothing to say, and each silence means something different. No filter set at all: nothing was removed, so a clause there would report paging as loss.
scannedabsent (an agent below 0.4.95): no measurement was made, and subtracting from a zero the node never reported would manufacture a number. - ⚠ It is also silent at a difference of exactly one on a
truncatedpage. That one line is a matching line the node counted and then held back for the next page — a paging artefact, not a filter drop — so naming a filter beside it would blame one that removed nothing and offer drop it where the cursor is the real answer. On a truncated page, at most one line of the difference is that held-back line and the rest are genuine drops.
The case it was built for is the clockless node below: measured on a KVM-over-IP appliance, since: "2h" returned 11 lines of 115 examined ten minutes after a reboot, dropping 104 lines of the boot — and through router 0.4.112 nothing in that answer said so.
A since bound trusts the node's clock
On a node with no real-time clock, since can remove the most interesting half of a boot. Such a machine starts its clock at the epoch and keeps it there until NTP lands, so the agent stamps its whole startup window in 1969 — and a since bound drops every line of it, for the same reason it drops an unstamped line: those lines cannot be placed in the window you asked for.
Measured on a KVM-over-IP appliance ten minutes after a reboot: a "2h" bound removed 104 of the first 115 lines of the boot — profile load, TUN setup, the filesystem probe, the first registration.
From agent 0.4.103 the node says so. Where since excluded a line stamped before any noBGP build existed, unreachable carries a sentence naming that cause: those lines are not old, they were written by a machine whose clock had not been set, the startup window is the part you cannot see, and the remedy is to re-read with since omitted. Through agent 0.4.102 the same query returned a short, clean-looking answer with nothing in it to say the interesting part was gone.
And from router 0.4.113 the summary line says how many lines went, on any node whose agent reports scanned (0.4.95+) — 11 lines from journald, 115 scanned, 104 not returned (active filters: since — drop it to see them). See How much your filters removed.
So read a boot with since omitted first, on appliance-class hardware in particular, and narrow with limit or pattern instead. Supplying since is still worth it on a machine whose clock is right: it makes truncated exact rather than conservative, because the node can prove it reached the bound.
A line's time is the instant it was written
From agent 0.4.180 every line the agent writes carries its own UTC offset, and at is read from it — 2026/10/21 11:43:34.389188 +0800 [INFO] …, where earlier agents wrote the same line without the +0800. So at is the instant the line was written, whatever time zone the machine is in now.
That is what it was not before. A line with no offset could only be read in the node's current zone, which is a guess about a line written at some point in the past: measured on a KVM-over-IP appliance whose zone changed between boots, every line of the earlier boot came back stamped 15 hours in the future. Three fields read that stamp, so a wrong one is not cosmetic — at itself, the oldest-first ordering within a page, and the since bound, which drops a line it cannot place in the window you asked for and says nothing about it.
- Lines written by earlier agents stay on disk and still have no offset of their own, so the node falls back to reading them in its current zone, exactly as before. Only lines written from 0.4.180 carry one.
- Where the mechanism wraps the line in a zoned instant of its own, that is used ahead of the fallback — the journal is read in a form that carries one — so even a line from an older agent is placed correctly on a
journaldnode. - The offset is in the lines
nobgp service logsprints too, and in the filepathnames.
Paging runs backward in time
cursor continues into the lines older than the page it came from — the reverse of fs_list's forward next walk, and reversed on purpose: a log is read newest-first and paged into the past.
It resumes by counting lines back from the newest end rather than by a byte offset, because the mechanisms share no such offset. So a live log growing between two pages can repeat a line across them; a repeat is possible, a gap is not. Dedupe on (at, text) if you are assembling pages.
A page continues across a rotation
From agent 0.4.87 a walk that reaches the top of a rotated file carries on into the generation before it. Where a node's log is the file the agent mirrors for itself, it keeps one previous generation beside the live one (nobgp.log and nobgp.log.1) — on every platform, and on Windows that mirror is the only source there is. Through agent 0.4.86 paging stopped dead at that boundary with truncated: false and no cursor: the node has nothing older, said with the older file sitting right there unread.
From agent 0.4.94 the same applies to a service manager's own log files. On macOS (launchd) and on the OpenRC and sysv Linux flavours the agent's output goes to /var/log/nobgp.err.log / .out.log (or nobgp.err / nobgp.log), which the agent copy-truncates at 100 MB keeping one previous generation — and through 0.4.93 those generations were not stitched, so on exactly the nodes whose logs are largest a page ended at the boundary with up to 100 MB unread beside it.
- It only ever continues into an older generation of the same log, never into the next mechanism on the node's list. A node offers ordered candidates — on Linux journald,
logread, the OpenRC files, the mirror, syslog — and walking from one into another would hand you an unrelated log as this node's own history. - The next generation is opened and read before a cursor for it is minted, so an absent or empty rotated file ends the walk honestly rather than handing you a cursor that leads nowhere.
- A
since-bounded walk that reached its bound does not continue: everything in the older generation is older still, so there is provably nothing there to find and you are not sent a round trip to be told so.
A cursor always moves
From agent 0.4.94 the cursor a page hands back is always strictly past the one it was given, and where it cannot be, the call says so instead. Two failures sat behind that, both on nodes with large logs:
- A page could mint the cursor it was handed. Each page bounds how much it will examine, and that bound used to be measured from the newest end of the log rather than from where the cursor pointed — so once a walk had skipped its way past the budget, it examined nothing and re-minted the same cursor. A
patternthat matched nothing paged forever without moving, and the older two thirds of a 100 MB file could not be reached by any query at all. The budget now starts at the cursor's position, so a page always looks at something and always advances. - A busy
journaldunit reported no more to read. The node renders the journal in bounded chunks, and a chunk that came back completely full read as the journal ended here —truncated: false, no cursor — on precisely the units with the most to say. A full chunk is now reported as capped, with a cursor, and the continued page asks the journal for enough to reach past it.
Where a mechanism genuinely cannot render as far back as a cursor points — a command-backed source keeps only the newest 8 MiB of its output, and a single log line longer than 8 MiB is one a backward file read cannot cross — the walk ends, and it is deliberately not the same cursor a second time.
From agent 0.4.95 that ending is a success, not an error. The page comes back with no lines, no cursor, truncated: false, and unreachable naming the ceiling it hit and where the older lines can still be read (nobgp service logs, at the machine). Through agent 0.4.94 the same page was unsupported — so the last call of an ordinary exhaustive search was a tool error, and a defensive caller unwound the whole operation on it. The verdict is unchanged; it now arrives in a field rather than in an error string, which is what makes does this node ever log X? a question you can page to the end of.
What unreachable is telling you
unreachable is prose, and it answers what this page does not show you, and where to get it. It carries five causes with five different remedies, more than one of which can apply to the same page, so every sentence in it names its own cause. Read it; do not treat the field's presence as one verdict.
| Cause | What it means | What to do |
|---|---|---|
| This mechanism's reach | The walk ended at the limit of what the node can render — a command-backed source keeps only its newest 8 MiB, or one log line is too long for a backward file read to cross. From agent 0.4.159 a file whose older generations the node keeps compressed is this cause too, and the sentence names those files instead: node_logs cannot enter them, which is the Synology case | Read the rest at the machine, with nobgp service logs — or, for compressed generations, read the files the sentence names on the node itself (xz -dc). See On a Synology the journal is not the log |
Your own since bound | The walk ended at the bound you supplied rather than at the log's beginning — older lines are there and were not read. It speaks a second time, and separately, when since dropped lines stamped before any noBGP build existed: those are not old lines but a node whose clock had not been set, and the part removed is its startup window | The one you can lift: widen or drop since — omit it entirely for the clockless case |
| The running agent is not writing this file | The lines came from a file the live agent does not write to, so they were produced by an earlier process however recent they look. Its current output goes wherever it was started from | Check the provenance before you act on the lines — see Read source and path |
| An earlier mechanism refused the identity the read ran as (agent 0.4.105) | A node offers several log mechanisms in order. One of them served this page, and one tried before it exists and said no — so its lines are not here. The page is what that identity can see, not everything the node recorded. It names a refusal and never a count: the refused mechanism was never read, so nothing knows how much is behind it | From agent 0.4.156 the refused identity is the agent's own, so no field on the call changes it: read those lines at the machine, as root, with nobgp service logs. On an agent 0.4.105–0.4.155 the sentence names root: "true" as the retry — and on a node whose owner set allow-root: false it says so instead and points at the machine, because that retry had already been refused in advance |
| The log is a bounded ring buffer and you reached its beginning (router 0.4.170, agent 0.4.142) | On OpenWrt the agent's log is logread, procd's in-memory ring — shared with every other program on the node and holding only its newest lines, 64 KiB by default. Reaching its beginning is not reaching the beginning of this agent's log: older lines were overwritten, or lost at a reboot. The sentence gives the time of the ring's oldest line, from any program, when the node can read it | node_logs cannot page past the ring. Where the agent also keeps its own log file, the sentence names that file — read older lines there with fs_read or fs_grep. The ring's size is the node owner's own configuration |
⚠ Causes one, two and five are facts about a page — and the fifth appears only on the page where the walk ends. The third and fourth are facts about the whole read, so they appear on every page, the first one included, which is what makes them visible to a caller who reads the newest lines and stops. The third is the one that makes real, recent-looking lines belong to a process that has already exited; the fourth is the one that makes a page look complete when a mechanism beside it was refused. So unreachable being present does not imply truncated: false.
⚠ Only the first cause reaches you from an agent below 0.4.103. The field itself is agent 0.4.95, and it carried the mechanism's reach and nothing else; the since and dead process causes are agent 0.4.103, and the refused mechanism cause is agent 0.4.105. The ring cause is the node's to report too, and is agent 0.4.142: a node below it reads a ring to its beginning and says nothing about the ring, exactly as every page did before — read the sentence you get, never absence. On an older agent the other pages are still what they always were: a page that stopped at your since bound is indistinguishable from one that reached the beginning of the log, lines from a file the live agent has stopped writing to arrive with nothing marking them, and a page served by one mechanism while another refused arrives looking complete. node_list reports each node's agent_version (with view: "full").
⚠ The one-line summary says the answer is incomplete on any page carrying the field, from router 0.4.110 — including page one. Until this release truncated won that line, so the third cause reached the summary only on the terminal page, which is the one page a caller who has already found what they were looking for never fetches: the lines they acted on were a dead process's and the summary never said so.
⚠ You can read which cause you have; you cannot branch on it without parsing the string, and that is deliberate rather than an omission — the remedies are for a person or a model to act on, not a switch to write.
A cursor is refused, never resumed somewhere else
A cursor names the source it was minted against — the mechanism and, for a file, which file. When that no longer holds, the call comes back invalid_args telling you to drop the cursor and start a fresh query, rather than paging you through a log the cursor never named. Three cases, all of them ordinary rather than exotic:
- The source is gone — the node no longer offers that mechanism at all.
- The source is still offered but will not open — a journal vacuumed between two pages, a file rotated away, a permission lost. The node can still serve its log; the newest lines will come from whatever now serves it, which is what a fresh query gets you.
- The file rotated between two pages. The path still opens, but it no longer names the same bytes — the node fingerprints that — and a line count applied to new content would re-serve delivered lines as older history or skip unseen ones.
Each of these fails the call rather than the page, so a cursor loop should treat invalid_args here as start again from the newest lines, not as an error to retry with the same cursor.
An empty list and a refusal are different answers
lines: []means the query ran on the node and matched nothing. Read it besidescanned(agent 0.4.95+): empty with a largescannedis this node did not say that, empty withscanned: 0is nothing was examined. ⚠ Readunreachablebeside both: from agent 0.4.105 an empty page whose node held lines behind an identity refusal says so there, and before that release the same page said nothing at all.- A node that cannot serve logs at all fails with
unsupported, naming why and naming where the log can be read — rather than returning an empty list, which would read as "the node is quiet", the worst thing a diagnostic can say. Whether a node is in that state is its own answer and is not predictable from its platform: call it and read the refusal rather than deciding in advance. Containers usually can serve it, since the agent mirrors its own output to a file where no service manager captures it. - ⚠ That refusal is about the node, never about a walk running out of reach. A page that hits the limit of what the node's mechanism can render succeeds with
truncated: falseandunreachableset, from agent 0.4.95. - A node that holds its log and could not read it is a different answer — and which code it is depends on the agent. From agent 0.4.156 it is
unsupportedsaying the log is here, that the agent itself may not read it, that no field on the call changes that, and to read it at the machine as root withnobgp service logs. On an agent 0.4.88–0.4.155 it isfailed_preconditionnaming the one retry,root: "true". - A timeout now names the cause noBGP's own records support, from router 0.4.178, in one of three shapes. An agent older than 0.4.86 (or one whose version is unknown) cannot answer the request at all and looks exactly like a slow node, so the message says so and names
net_metricsas the probe that settles it — an agent that answersnet_metricsand not this needs upgrading, andnode_listreports each node'sagent_version(withview: "full"). An agent that does answer this tool, on a machine whose last host sample — thehealththatnode_listreports withview: "health"— shows real load or memory pressure, gets that sample quoted back with its age — and is told not to fall back tocommand, which starts a process on the same starved machine and fails the same way. Otherwise it is a plain timeout: retry once, check the machine withnet_metrics, and read the log withcommandmeanwhile. ⚠ Through router 0.4.177 every timeout carried the old-agent explanation, so a recent agent on an overloaded machine — measured at load 218 on 4 CPUs with 62 MB of memory free — was told to check its agent version and to use the one fallback that could not work. - ⚠ A node that never answered the session open is a
timeoutfrom router 0.4.91 — with the same message, namingnet_metricsas the probe andcommandas the fallback. Through router 0.4.90 that half came back asinternalcarrying the router's own plumbing wording (failed to connect: connection timeout after 3s on attempt 3/3), which reads as the router broke for a node that simply did not answer, and named no next step at all — while the other door of the same tool, a read that opened and then stalled, had answeredtimeoutwith a cause since the tool shipped. Only this tool narrows the verdict; every other node-addressed call still answersinternalfor a connect timeout, because there the silence could be the network, the router instance or the node and nothing can say which. Retryability does not change: both codes are retryable. - Through agent 0.4.90 a timeout could also be the log itself, on slow hardware. The node used to find its log mechanism before accepting the session, and that search is a real walk — 2.6 s on a Pi Zero against 132–383 ms for
command,fs_listandnet_metricsover the same connection — so it could overrun the few seconds a session is given to open, on a node that was online and answering everything else. From agent 0.4.91 the session opens first and the log is read after, so a timeout here means a slow or absent node rather than a slow log. Nothing else moved: the refusals below arrive with the same codes and the same messages, and an argument the node rejects is still refused before it reads anything.
"This node cannot reach its log" and "this identity cannot" are different
⚠⚠ From agent 0.4.156 there is one identity here and this whole distinction is gone. The read runs as the agent process itself, so a permission refusal is a refusal of the agent — an agent running unprivileged that cannot read its own journal or its own file — and nothing on the call can change it. Such a node answers unsupported saying the log is here, that the agent itself may not read it, and to read it at the machine as root with nobgp service logs; it quotes what was attempted and why each mechanism declined, as before. The rest of this section is the contract for agents 0.4.88–0.4.155, which are still in the field.
From agent 0.4.88, a walk that opened no source because every candidate refused the identity it ran as comes back failed_precondition naming root: "true" — the same code and the same one-retry shape as every other identity refusal on this surface, because the caller changes one field and the call works. An agent below 0.4.153 writes that retry as admin: "true", the old name of the same field.
Through agent 0.4.87 that case answered unsupported, which is a claim about the node rather than about the identity, and its remedy sends you somewhere else entirely. Measured on a container whose owner had configured an account: an unelevated call ran as that account, the agent's own mirror was readable only by the superuser, every candidate came back permission-denied, and the caller was told no mechanism inside this node can reach it and pointed at docker logs on a different machine — while the very next call, with root: "true", returned the log.
A journal that answered nothing because the identity may not read it counts as a refusal too, from agent 0.4.94. An unprivileged journalctl does not fail: it exits 0, prints -- No entries --, and mentions on the side that it is not showing messages from other users and the system — which is exactly what a system unit's lines are. Through agent 0.4.93 only a refused file was recognised, so a journald-only host — a Raspberry Pi OS service install, where there is no syslog file and no mirror because the service manager captures the agent's output — answered unsupported under root: "false" and advised installing the agent as a service, which it already was. That case is now the same failed_precondition naming root: "true".
A journal that answered some lines is a refusal too, from agent 0.4.105 — and this is the one that could report a clean log. Through agent 0.4.104 that recognition also required the output to hold nothing but journald's own frame lines, and an unprivileged journalctl frequently holds more: it returns the records carrying the caller's own login uid, which on a node being worked through this surface means the sudo records the agent itself produced. One such line switched the guard off, and the page built from what that identity happened to be allowed to see came back as a complete answer — truncated: false, no cursor, nothing marking it — and with min_level: "warning" it filtered down to lines: [], the exact shape a sweep reads as this node is clean. Measured on an Ubuntu 24.04 node running agent 0.4.104: the unelevated call returned 11 lines and reported a clean, complete log, while root: "true" one minute later returned 441 lines at warning and above. The hint on the side is journald's own statement that the read is partial, so from 0.4.105 it is the whole signature: the lines that came back are discarded, the walk carries on to the node's remaining mechanisms, and where none of them serves the answer is failed_precondition naming root: "true".
⚠ The cost is named rather than hidden. An agent that genuinely runs as that account — a user unit, or a system unit with User= — has a journal it can read in full, and 0.4.105 declines it. That costs one extra call on such a node: the elevated read serves it, because the superuser reads every journal, user journals included.
A page that served while another mechanism refused now says so (agent 0.4.105). A node tries its mechanisms in order and the first that produces a line of the agent's own wins. Where a candidate tried earlier existed and refused the identity the call ran as, that refusal used to be dropped the moment a later candidate served — so a caller received a clean-looking page from mechanism four while mechanism one held lines that identity may not read. The page is real and is still returned; what it gains is a sentence in unreachable saying a mechanism refused this identity, that its lines are not in this page, and that root: "true" is the retry. It is a fact about the whole read, so it rides every page including the first.
Two things bound it, and both matter when you read a refusal:
- A walk that was already elevated and still hit permission denials stays
unsupported. There is no further identity to name, so naming one would send you round a loop. The distinction is the identity the call ran as, and nothing else about the request changes it. - A node that has refused elevation in advance says so instead of naming the retry (agent 0.4.94). Where the owner set
allow-root: falseand the node's ambient identity is the superuser,root: "true"was never going to be answered, so the refusal isunsupported— the log is here, it is unreachable from the network on this node, and it can be read at the machine withnobgp service logs. Through agent 0.4.93 it namedroot: "true"anyway and that second call came backpermission_denied, so the log took two calls to be told it was out of reach;node_listreports each node'sallow_root(withview: "full") if you would rather know before the first one.
Either refusal quotes what was attempted and why each candidate declined, so the message says which of the two you are in.
⚠ No redaction. The lines are what the node wrote; nothing here filters secrets out of them. If that matters for where the output is going, narrow with pattern and read what you get.
What a node's own settings do to it, and what the identity field does not
One of the three node settings reaches this tool: allow-tools. From agent 0.4.153 the agent's log is its own capability domain, logs, so a node whose owner left logs out of allow-tools — or emptied the list — refuses node_logs with permission_denied carrying the agent's own wording. It is deliberately not the command or fs domain: those two bound what a caller steers, an arbitrary command string and an arbitrary path, and this tool offers neither, so gating it on command would leave a systemd node unable to read its journal on a box that serves fs. Before 0.4.153 there was no domain for it and allow-tools: [] still served the log.
allow-roots is not consulted, for the same reason: the paths and the argv are the agent's own, fixed, and about the agent's own output.
⚠⚠ And from agent 0.4.156 neither is allow-root, nor the root field on the call. The node reads its own log as the agent process itself — it runs no command of the caller's and opens no path the caller chose, so it is not "running as root for the caller" and the superuser veto has nothing to bind. Two things follow, and both are improvements on a node where the log and that veto used to collide:
- A node whose owner set
allow-root: falseno longer hides its log from the people who may ask for it. Through agent 0.4.155 such a node answeredunsupportedsaying the log was unreachable from the network entirely. - A caller that omitted the field, or sent
"false", on a node with a configured account is no longer refused for an account that cannot read a journal unit or aProgramDatafile.
What is left is the node's own permissions: where the agent itself cannot read its log, the answer is unsupported naming the machine, and where one mechanism refused the agent while a later one served, the call succeeds and unreachable carries the refusal so a partial read is visible on a page that would otherwise look complete (agent 0.4.105).
On an agent 0.4.86–0.4.155 the identity gate applied in full. root picked between the node's two identities exactly as everywhere else (Execution identity): root: "true" on a node whose owner set allow-root: false was refused rather than downgraded, and an omitted root on that node resolved to the configured account. When no source was readable by that identity the answer from agent 0.4.88 was failed_precondition naming root: "true", with the permission error quoted in what it tried; on a node that also refused elevation the answer from agent 0.4.94 was unsupported in the first place. Either way the refusal said which mechanisms were attempted and why each declined, which is unchanged.
command with a canned stringA canned journalctl/type line needs you to know the platform, meets the cmd.exe quoting the shell parameter exists to route around, hands back unstructured text, and returns nothing at all — silently, with exit 0 — when the mechanism you guessed is not the one that node uses.
nobgp service logs showsFrom agent 0.4.89 the on-box command resolves its source through this same list, by trying each candidate rather than deciding from the platform — so source here names the mechanism someone standing at that machine would also be reading, and the two can no longer disagree about where a node keeps its log. The two surfaces differ only in what they are for: this one filters on the node and pages backward, that one is a live tail -f.
node_config_get
Surfaces: public only
Read a node's own agent settings — the keys in its default.yml that it is willing to report, with their current values. Router 0.4.85+, and the node's own agent must be 0.4.87 or newer: the node is what answers, so an older agent never replies and the call ends in timeout saying nothing was changed. node_list reports each node's agent_version (with view: "full"). Pair with node_config_set, which takes the same keys back.
It rides the control channel, not a session
This is the reason both tools exist, and it is worth reading before the schemas.
A node whose sessions are all dead — every command, fs_* and net_* call timing out — still answers these two. They travel on the node's control channel, the same link that keeps it online in node_list, rather than on the session machinery every other node-addressed tool opens.
That matters because a config write is precisely the operation that must not depend on the subsystem it reconfigures. The case that produced these tools: one edit of a node's fs: settings on a live Windows machine left the control channel perfectly healthy — the directory still said online: true and duly picked up the new fs_backend — and every session dead. The way back was closed at both ends: the file tools refuse the agent's own configuration directory at either identity, so fs_edit could not revert it, and the only tool that could was command, which was exactly what had stopped working. Recovery needed someone at the keyboard.
So reach for these when a node reads online in node_list but nothing you run on it works.
The router knows nothing about the keys
Which keys exist, what each means, which apply immediately and which are refused is the node's answer, not a table held on the router. The node classifies and the router forwards its verdict verbatim — the same posture node_list's fs and node_logs's source have: reported, never obeyed.
Practically: call node_config_get and read what comes back rather than guessing a key name in advance. And read node_config_set's verdicts as the node wrote them — a verdict word this router has never heard of still reaches you intact rather than being flattened into a zero value.
Input Schema
{
"node": "string (the node's id or its name)"
}
The node reference is node, at the top level, as on every node-addressed tool. The bare call is the whole tool; there is nothing else to send.
Output Schema
{
"settings": [ { "key": "string", "value": "string" } ]
}
This is the payload under node_config_get; done and the node are on the envelope.
settings- The keys this node is willing to report, sorted by key, never null. Values are text — the node's own rendering of the YAML scalar it holds — which is the same spellingnode_config_settakes, so a read/edit/write round trip is lossless.
⚠ What comes back is not the whole file, and from agent 0.4.94 it is a good deal less of it. A node reports only the keys it considers operational knobs a network Admin legitimately tunes from afar — the ones that change what the node does for the network without changing whose node it is, what code it runs, or what it can reach on its own host. Today that is the diagnostic and datapath keys — log-level, debug, compress, mtu, ping-interval, idle-ttl — the two filesystem keys fs and fs-cache-ttl, the three QUIC race timers quic-race-headstart / quic-race-deadline / quic-reprobe-interval, a local profiling switch, from agent 0.4.113 transport-family, and from agent 0.4.132 overlay-ipv6. Read the list that comes back rather than this paragraph: it is the node's answer, so a node newer than this page may report one more. Everything else the agent reads is absent, not blanked — absence is the same claim the file tools already make about that directory, where a redaction marker would advertise the shape of what it hides.
⚠ Through agent 0.4.93 it worked the other way round, and the difference is worth knowing if you have read this page before: the node reported and accepted everything except a short veto list — the owner's own settings and the local server's token — so a read carried the keys naming the node's own identity — router, insecure, quic-cert-pin, quic-endpoint, encrypt, transport — and a write could set them, because nobody had thought to veto them. Repointing a node's control plane does not widen what the node does for you, it changes who "you" are, and insecure alone disables the verification that makes the certificate pin mean anything. A list of exceptions is wrong by omission the day a setting is added; a list of what is exposed is wrong in the safe direction. So this answers what can I tune here, never what is in default.yml.
Roles
Control of the node — its owner, or an Owner or Admin of its organization — the same as node_config_set: the two halves of one read-modify-write are not worth splitting, and these are a node's operating configuration rather than the fleet metadata every member reads. From router 0.4.179 the node's own owner may call it at any role; before that it was Owner/Admin only. A caller who controls neither receives forbidden naming the node.
node_config_set
Surfaces: public only
Change a node's own agent settings. Router 0.4.85+ with the node's agent on 0.4.87 or newer, as on node_config_get. set writes keys, remove deletes them, and both merge with what is already there — keys you do not name are untouched. It rides the control channel, exactly as node_config_get does and for the same reason.
Input Schema
{
"node": "string (the node's id or its name)",
"set": { "key": "value as text" },
"remove": ["key"]
}
Parameters:
set- Settings to write, merged with what is on the node. ⚠ Values are the YAML scalar as text: send"8000","true","webdav"— a JSON number or boolean is not what this field takes. The node parses each value into the type its own key expects and refuses with a reason when it cannot. A value is capped at 4096 bytes: a config scalar is a word, a path or a number, and anything larger is a file being sent through the wrong tool.remove- Keys to delete, so the node falls back to its own default for them. Merged the same way.
At most 64 keys across set and remove in one call. A key is 1–128 characters with no leading or trailing whitespace.
⚠ Unlike node_label, a bare call is not read as a read. node_config_set with neither set nor remove is invalid_args naming node_config_get, because here the read is its own tool.
Output Schema
{
"verdicts": [ { "key": "string", "applied": "live | restart_required | refused", "reason": "string (when there is something to add)" } ]
}
⚠ Read every verdict — there is no overall success. One call can apply one key live, park a second until the agent restarts, and refuse a third. A caller that stops at the first entry acts on a change that has not happened.
applied | Means |
|---|---|
live | Written and in effect now. Nothing further to do. |
restart_required | Written but not in effect — the running agent is still on the old value until it restarts. |
refused | Not written, and reason says why. |
⚠ live costs a bounce, and the verdict's reason says so. A node applies a config change by reloading itself: it notices the file within about five seconds and comes back on the new value, so its sessions drop for that moment. That is a blink rather than an outage, but a caller holding a command session on the node will see it end.
A write that changes nothing costs nothing, from agent 0.4.94: setting a key to the value it already holds leaves the file byte for byte as it was, so there is no reload and no session drop. The verdict is still live — the key is in effect, which is what the word says.
From agent 0.4.157 a live verdict is also durable against the node writing the same file at the same moment — its own records, or somebody running nobgp config at the machine. Through 0.4.156 a key this tool wrote in that window could be rendered back to its previous value by the other writer, with live already reported; on such an agent read the keys back with node_config_get if the node was registering or restarting. The verdicts themselves are unchanged. Details
- A key the node said nothing about arrives as a caveat in
notes[]on the envelope, naming the keys and telling you to read them back withnode_config_get— that is the one failure this shape could otherwise hide.
A restart_required key is not in effect
Which keys are restart-only is the node's answer, and today it is one: fs, the mounted-drive backend. For that key a reload is not equivalent to a restart — the backend is chosen by a capability probe at startup and the mount it makes is held by the kernel after the process that made it is gone, which is the exact failure that produced these two tools. Everything else a node will write applies on the reload.
Two consequences the verdicts report, and both are easy to misread as an error:
- The reload is all-or-nothing, so a call that writes
fsalongside anything else parks every key in it. A reloading agent re-reads the whole file, so letting the neighbour through would apply the pending backend switch with it. Expectrestart_requiredon keys that would otherwise have beenlive. - A later call is held behind it. While a node's file holds a restart-only value the running agent did not start with, nothing written to that node can be live — so an unrelated key set an hour later still comes back
restart_required. Setting the pending key back to the value the agent started with lifts that immediately; so does a restart.
⚠ Withdrawing a pending fs change reports live, not restart_required (agent 0.4.94), with a reason saying the pending change is withdrawn rather than applied and that the hold it placed has lifted. It is genuinely live: the file now matches what the process started on, so the agent reloads within five seconds and its sessions drop for that moment, exactly as any other live key. Through agent 0.4.93 that same write came back restart_required — written and NOT applied — which was wrong in the reassuring direction, since the node bounced anyway. A write of fs that changes nothing at all, on a node with nothing pending, is the ordinary live above and costs no bounce.
nobgp service restart through commandRun inside a command session, that command stops the agent as its own parent and on some platforms never brings it back — which is exactly the lockout these tools exist to remove. Either leave the key to take effect the next time that node restarts, or restart it out of band (its own service manager from the console, or a host reboot) and confirm with node_config_get afterwards.
The node decides which keys it will write
Nothing on the router holds a table of what a key means, what type it takes, or which keys are off limits. The node classifies each one and this reports its answer verbatim.
⚠ From agent 0.4.94 the writable set is an allowlist, and it is exactly the set node_config_get reports: the operational knobs listed there, and nothing else. Anything outside it comes back refused with the node's own reason — never written, while the keys around it in the same call still are. Three shapes of refusal, and the reason says which:
- The node's veto settings —
allow-root,allow-tools,allow-roots,user,webdav-proxy-ownersand the local server'smcp-token. A config surface that could widen the gates it is itself bounded by would be a privilege escalation with a friendly name. From agent 0.4.98peer-key-pinningis refused for a sharper version of the same reason: it decides what the node does when a peer's key changes, and that check is aimed at the very control plane this surface travels over — so the party being watched must not hold the switch. - The node's identity and its owner's say over the machine —
router,insecure,quic-router,quic-cert-pin,quic-endpoint,encrypt,transport, plusauto-upgrade(what code runs on the box),enabled(an off switch with no remote way back on),mount(where the drive lands on the owner's filesystem), from agent 0.4.124windows-firewall— what a host lets in is the owner's security posture for their own machine, the same class asallow-roots— and from agent 0.4.128lan-namesand the two mDNS keys, which decide how far into the owner's own LAN this node reaches and what it says about the machines on it. Through agent 0.4.93 the first seven of those were writable, which is the change worth knowing about: setting them does not widen what the node does for you, it changes who "you" are. ⚠transport-familyis writable althoughtransportis not, and the difference is what each key decides:transportpicks the trust path, whiletransport-familypicks which address of the same endpoint to dial first — same endpoint, same certificate pin, same credentials — and both of its values fall back to the other family, so it cannot put a node out of reach. ⚠ From agent 0.4.132overlay-ipv6is writable for the same shape of reason, and deliberately so: it is the off switch for a node's IPv6 datapath, and a node whose IPv6 path misbehaves keeps its IPv4 overlay and its control channel — so this surface is the one still working when the thing you need to turn off is not. The verdict islive; the agent reloads within about five seconds. Its companionoverlay-cidr6is refused likeoverlay-cidr, below: turning the family off is a knob, changing a slice re-numbers every peer. ⚠lan-deliverywas writable in agents 0.4.129 and 0.4.130 and is gone from agent 0.4.131: a node now always delivers from its own socket, so there is nothing to choose. A write to it is refused withthis agent reads no "lan-delivery" setting, and it is absent from anode_config_get. - Everything else the agent reads —
mcp-port,nfs-port,interface,overlay-cidr,overlay-cidr6,runtime-dir,last-mount-pointand theapi-socket-*pair — is refused with one generic reason saying it is not a knob tuned from here and pointing atnobgp configon the node. Deliberately the same wording for all of them: naming which is which would tell a caller more about the node than a key it cannot touch is worth.
This is not a remote way back into a node you have locked down — see Recovering a node you have locked down.
Roles
Control of the node — its owner, or an Owner or Admin of its organization. A caller who controls neither receives forbidden. Neither tool is reachable from a node's local server: a node must not rewrite a peer's operating configuration. noBGP staff may read and change a node's configuration, audited: that and reading the agent's own log are the two exceptions to staff never controlling a customer node — see noBGP staff access.
Errors
Both tools mint no new codes.
forbidden— a Member.target_unreachable— the node has no live control channel (it is offline). Checked before anything is sent, so an offline node costs no timeout. ⚠ A node held below its channel's floor is exempt from that check, from router 0.4.168: it reads offline in the directory while its control channel is live, and it is the node most likely to need these tools — one whose automatic upgrades are off. Both tools work there, and a node that is genuinely gone still answerstarget_unreachablefrom the request itself.timeout— 30 seconds without an answer. ⚠ An agent that predates this surface never answers and looks exactly like a slow node, so the message says so and states that nothing was changed. Check the node's agent version withnode_list(agent_version, withview: "full") before retrying;net_metricsanswering where these do not is the probe that tells an old agent from a slow one.internal— the round trip failed on the router's side and the node was never asked (router 0.4.94+): a replica still starting up, a reply subscription or a publish that failed. The message names the real cause. Through 0.4.93 every one of these was reported as thetimeoutabove, which blamed a node that had never been reached and sent you to check an agent version that was not the problem. Both codes are retryable, so what changed is what you are told rather than what to do next.invalid_args— a barenode_config_set, an over-long key or value, more than 64 keys.- Whatever code the node names on a whole-request failure — a malformed request, an unreadable config file. ⚠ That is a different answer from a per-key
refused: a whole-request failure fails the call, while arefusedverdict is a successful call (done: true) whose answer for that key is no.
Node selectors
node_list, node_label, node_grant, node_revoke, net_reach_many and the three subscribe tools all take the same selector, the select object, so one call can address a single node or a whole cohort.
{
"select": {
"network": "string (optional; an id or a name)",
"nodes": ["string (a node's id or its name)"],
"platform": "string (optional)",
"os": "string (optional)",
"arch": "string (optional)",
"name": "string (optional; a glob)",
"online": "boolean (optional)",
"labels": { "key": "value" },
"owner": "string (optional)",
"lan": "string (optional; a node's id or name — router 0.4.207+)"
}
}
Parameters:
network- Network to act in, by id or by name. It may be omitted when you have exactly one network — judged inside your personal organization if you belong to several. An id binds exactly: membership is checked against the id itself, so a shared org's network id is never rebound to a personal network of the same name.nodes- Restrict to these nodes, each by its id or its name — the idsnode_listreturns, or the names.platform/os/arch- Restrict to nodes whose reported platform (debian,alpine,darwin,windows…), OS (linux,darwin,windows), or architecture matches. Case-insensitive.archtakes the spellings the rest of the product uses:arm64,amd64,armv7,armv6,386, and — from router 0.4.199 —armv5,mipsandriscv64, withmipslea value of its own from router 0.4.200. Kernel spellings are accepted as synonyms (aarch64=arm64,x86_64=amd64), so the value you read fromnode_list'sarchworks here verbatim. An architecture this list doesn't know is compared as given rather than becoming unselectable.- ⚠ It matches the agent BUILD the node runs, not what the kernel reports (router 0.4.200) —
archinnode_list, which falls back to the kernel's answer only for a node that has not reported a build. That is the number that decides which binary a node can execute, and the two differ honestly: a 32-bit agent on a 64-bit kernel isarmv7here andaarch64inkernel_arch, and an ARMv7 core with no floating-point unit runs thearmv5build while its kernel saysarmv7l, so it matchesarmv5. Through router 0.4.199 the kernel's answer was what matched, so both of those nodes selected the other way. - ⚠
mipsandmipsleare different builds and are not folded together.mipsleselects little-endian builds only, andmipsselects big-endian builds only. On a node that reported no build the fallback is the kernel'smips, which names no byte order, so amipsleselector does not match it and amipsselector does, whatever its byte order — readbuild_architectureto see which nodes can be selected by byte order at all. - ⚠ Do not take the value from
net_metrics. Itsarchis the Go runtime's, which isarmfor every ARM build, so it cannot tellarmv5fromarmv7and folds toarmv7if you send it.
name- Restrict to nodes whose name matches this glob (path.Matchsyntax, no braces).online-truekeeps connected nodes,falsekeeps disconnected nodes. Omitted, it keeps connected nodes.labels- Restrict to nodes carrying all of these labels. Values match exactly and by type:3matches the number3, not the string"3".owner- Restrict by owner:"me","any", or a member's email address.command_subscribeexplains the default.lan- Router 0.4.207+. Name one node, by id or by name, and the selector keeps the nodes on that node's LAN segment, that node included. It is the way to act on a whole site: find the segment innode_list'slans, then send here any node on it — a row that named it, or one of itsothers— to list or address every member. A node whose public and LAN addresses the router does not have is on no known segment and isfailed_preconditionnaming it, rather than a silent empty cohort.
All fields are ANDed. Omitting every node-narrowing field selects the whole network.
⚠ Naming a node that is not in the network you named is failed_precondition, and nothing runs, from router 0.4.204. The refusal counts them and says which network each is in — or in no network, or in a network of another organization where you cannot see it — and tells you to make one call per network, or to omit the network. Before, such a node was acted on anyway, while the same selector written with labels or a platform left it out: one selector, two answers. A watch works in one network, so fs_subscribe and presence_subscribe refuse it even when you named no network at all.
Through a node's local server network 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, unless online is false — 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 nodes 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.
Labelling needs control of the node — its owner, or an Owner or Admin of its organization — the same as node_grant: labels are the selector a grant's targets are written in, and targets are evaluated live, so relabelling a node changes which peers a granted node may reach without anyone touching a grant.
Input Schema
{
"set": { "key": "value" },
"remove": ["string"],
"select": "object (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.
⚠ It takes the cohort selector, never the singular node reference. Name nodes in select.nodes, each by its id or its name. The singular node that the per-node tools accept is refused here. select.network is accepted. Router 0.4.146 says this in the tool's own description, because the refusal on its own reads like a cache problem.
Output Schema
{
"nodes": [
{
"id": "string",
"name": "string",
"labels": { "key": "value" }
}
]
}
Behavior
- Requires control of each node — its owner, or an Owner or Admin of its organization. Reading labels back — calling with neither
setnorremove— is the same tool, so it carries the same requirement. - ⚠ It is checked per node, from router 0.4.179: a selector that spans nodes you control and nodes you do not relabels the ones you control and lists each other node in
refusedwith the reason. Readrefused. When you control none of them, the call fails withforbiddenand writes nothing. Before that release the role was checked once per organization and a refusal in any one of them failed the whole call. - 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 same requirement as
node_grant, and for the same reason: a grant'stargetsare label pairs evaluated live, so relabelling a peer moves it into or out of a granted node's reach with no grant touched. See Roles & Permissions. ⚠ The refusal no longer names an action ("node.role requires a higher role…" is gone with the verb it named): it names the node you do not control. - The
role.*namespace is reserved for grants and is refused here in bothsetandremove— use node_grant / node_revoke. That refusal is unconditional and applies at every role. - A label change re-evaluates the network's live subscriptions immediately, so labelling a node adds it to an already-running
fs_subscribeorpresence_subscribewithout re-subscribing.
Example Usage
Request:
{
"select": { "network": "production", "os": "linux" },
"set": { "role": "edge", "replicas": 3 }
}
node_grant
Surfaces: public only
Turn on a node's local MCP server, so it can act on the other nodes its owner may use.
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 fleet through that machine's local MCP server. The switch 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.
role takes no value: omit it. "manage" is accepted as the old spelling of on; "observe" is refused by name, and a node that held that read-only tier is off until someone turns it on. What a call may do on a peer is no longer a subset of tools but the node owner's current right on that peer, checked per call.
Input Schema
{
"role": "string (omit it)",
"targets": "string (optional, label pairs)",
"select": "object (see Node selectors)"
}
Parameters:
role- Omit it."manage"is accepted as the old spelling of on;"observe"isinvalid_argswith a message saying the tier no longer exists.targets- Narrow 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 of the network the owner may use. It only ever narrows — the local MCP never reaches a node its owner may not use.
The whole allowlist — 34 router tools from router 0.4.195, 29 before it — is available when it is on: whoami, the net_* diagnostics, node_logs, command with command_read / command_input / command_signal, the seven per-op fs_* tools, fs_copy, fs_fetch_url, fs_lock / fs_unlock, fs_grep, the four subscribe tools, the four event_* tools and feedback_submit. ⚠ node_config_get and node_config_set are absent: they write the file that holds a node's own veto settings, and a node must not rewrite a peer's operating configuration. So are network_directory, node_label, node_rename, node_transfer and everything that creates, destroys, bills or delegates. The node's own two tools (status, resolve) are served by the agent itself. tools/list on the node's endpoint is filtered to exactly what is allowed, so the advertised surface is the enforced one.
Output Schema
{
"nodes": [{ "id": "string", "name": "string" }],
"role": "string (always \"on\")",
"targets": "string",
"refused": [{ "id": "string", "name": "string", "reason": "string" }]
}
Behavior
- Requires control of each node — its owner, or an Owner or Admin of its organization — checked per node. ⚠ A selector can name nodes you control and nodes you do not: the change is applied to the ones you control and each other node is listed in
refusedwith the reason. Readrefused. When you control none of the selected nodes, the call fails withforbiddenand changes nothing. From router 0.4.179 the node's own owner may turn it on at any role; before that it was Owner/Admin only. - It acts for the node's owner, with the owner's current rights, re-checked on every call: it reaches the peers the owner may
use, runs as root only on a peer where the owner holds root, reads a peer's agent log (node_logs) only where the owner controls that peer, and stops when the owner leaves the organization or loses a right. It never exceeds what the owner may do. - ⚠ Who else can run on the node does not narrow it. A person with
useof the machine can call its local MCP — it is a loopback port with a bearer token — and reach, through it, what the owner may reach. Turning it on is a delegation of the owner's reach to everyone who can run on that box. - A change of the node's owner turns it off (
node_transfer, and a member leaving), so the new owner decides whether to turn it on again. - It does not expire. It stands until
node_revoketurns it off. targetsmust parse to at least onekey=valuepair. Values are typed the same way labels are (trueis a boolean,3is a number).- ⚠
targetstake effect from router 0.4.195. Before that release a grant that named them stored none, while the result reported them as given — so the node reached every peer its owner may use. A grant made before it carries no restriction: sendnode_grantagain with thetargetsyou want, which is the only way to narrow an existing grant. - Turning it on is what starts the server (see Local MCP Server): a node that is off serves nothing on
127.0.0.1, and the endpoint comes up within seconds — nothing to run on the box. Turning it off stops the server again. - The router switch 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 is still bounded on the box by its capability settings (
allow-tools,allow-roots,allow-root), which cover the MCP tools, the event-bus sources and published terminal services alike. - Not callable by a node: a node can never turn itself on, or turn a peer on.
- It 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:
{
"select": { "network": "production", "nodes": ["studio-mac"] },
"targets": "role=edge"
}
node_revoke
Surfaces: public only
Turn off a node's local MCP server, and its reach over the network, on one node or a whole cohort.
Input Schema
{
"select": "object (see Node selectors)"
}
Output Schema
{
"nodes": [{ "id": "string", "name": "string" }],
"matched": "number"
}
Fields:
nodes- Nodes whose local MCP was on and is now off. Empty when none of the matched nodes had it on.matched- Nodes the selector covered, whether or not it was on.
Behavior
- Control of each node, exactly as
node_grant— checked per node, with the ones you do not control listed inrefused. - 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 switch is what runs it — its own tools go with its reach over peers. The endpoint's port and token are kept, so turning it on later brings the same endpoint back and clients registered with
nobgp mcp installresume without a re-install.
node_rename
Surfaces: public only
Change a node's name, and change nothing else. Router 0.4.120+.
Purpose
The node keeps everything but the name: the same node_id, its labels, its role grants, its published services and their public URLs, and its own storage area. This is the only way to change a node's name without losing all of that — leaving the network and rejoining mints a new node_id and breaks every shared service link.
Before this release the rename lived only in the web app. It is the same act at the same tier; what is new is that an assistant can perform it.
Input Schema
{
"node": "string (the node's id or its current name)",
"name": "string (required)"
}
Parameters:
nodeis the ordinary node reference: the node's id, or its current name. The id is stable across renames, so prefer it when you already have it.nameis what you want it called.
The name rule is the one node creation uses: letters, digits and hyphens, 63 characters or less, no leading or trailing hyphen. The name is lower-cased before it is stored, so Edge-01 and edge-01 are the same request. It must be free within the network — names compare case-insensitively — and a name another live node holds comes back as already_exists.
Output Schema
{
"previous_name": "string"
}
nodeon the envelope - Itsidis unchanged by a rename; this is what to key on. Itsnameis the name as stored, lower-cased.previous_name- The name the node held before this call. It is free within the network now, and it is the name already-connected peers still resolve.
Behavior
- Control of the node — its owner, at any role, or an Owner or Admin of its organization. From router 0.4.179 the person who owns the node may rename it without being promoted; before that it was Owner/Admin only, and a Member could not rename a machine they had provisioned themselves. A caller who controls neither receives
forbiddennaming the node. See Roles & Permissions. - Renaming a node to the name it already has succeeds and changes nothing, so a retry after a dropped connection is safe.
- Reversible by renaming back — as long as the old name is still free.
- The new name wins it outright. From router 0.4.141 a network has one name space and a node name takes precedence in it, so if the new
namewas a name some member had discovered on its own LAN, that discovered entry is removed and withdrawn from the member holding it — the name means this node from the rename onwards. See One name, one answer.
What is correct at once, and what is stale
noBGP resolves the new name immediately: node_list, and a node's name lookups through the router, both read the node's record, so they answer with the new name from this call onwards.
A peer that is already connected does not. It holds the copy it was given when that peer registered, this call pushes nothing, and a peer's directory only ever gains entries — so each peer keeps resolving the old name until it reconnects, after which both spellings work on that peer until it reconnects again.
That costs staleness and never correctness: a session is keyed on the node, not on the string, so nothing black-holes and nothing reaches the wrong machine. The response carries a notes[] entry saying so.
The node itself is not told, by design. A machine is given a name once, at registration, and noBGP owns it after that — the agent goes on calling itself whatever its own node-name says and goes on being identified by its key, so the two never have to agree. Nothing needs restarting. See Node identity.
What a rename does to the name it frees
provision_nodere-adopts by name, so a rename changes what it re-adopts: after this call, provisioning the old name creates a new node, and provisioning the new name re-adopts this one.- The old name is freed for reuse, and noBGP records that this node released it. A machine that reinstalls later and still presents the old name is never handed a different node's record: if the node now holding that name is not answering, the reinstalled machine is admitted under a suffixed name (
old-1,old-2) with a newnode_idand an empty storage area. - ⚠ It is admitted, not refused — so a script watching for a failed registration will not see one. Compare the name the agent ended up with, in
node_list, against the one it asked for. - The freed name is open to LAN discovery again, from router 0.4.141. While this node carried the name, a device of that name on a member's own network could not be discovered under it; once the name is freed, it can be.
Example Usage
Request:
{
"node": "web",
"name": "web-edge-01"
}
Response:
{
"op": "node_rename",
"done": true,
"node": { "id": "9c2e5a10-…", "name": "web-edge-01" },
"notes": [
"peers that are already connected still resolve \"web\" and do not learn \"web-edge-01\" until each of them reconnects; the router itself answers with \"web-edge-01\" from now on"
],
"node_rename": {
"previous_name": "web"
}
}
node_transfer
Surfaces: public only
Give a node to another member of its organization. Router 0.4.179.
The new owner controls the node from then on: they decide who else may use it, and they manage its services and its compute.
Input Schema
{
"node": "string (the node's id or its name)",
"user": "string (required; an email address or a user id)"
}
Parameters:
nodeis the ordinary node reference, at the top level.useris the recipient's email address or user id. Prefer the email address: a Member who owns a node cannot read the organization's member list, so an address is the only name they have.- The recipient must be a member of the node's organization. Moving a node to another organization is not possible.
⚠ An address or id that is not a member gets one fixed refusal — the same wording for an unknown address, an outsider and someone who left — so the answer never says which of those it was. It does not hide membership: the refusal tells you that the one address you named is not a member of the node's organization, and a success tells you it is. A node's controllers learn the same thing from node_access_grant and from the visitors of service_share. The organization's member list stays with Owners and Admins, and the who-may check runs before the address is looked up, so someone who may not transfer the node learns nothing at all.
Output Schema
{
"owner_id": "string",
"previous_owner_id": "string",
"local_mcp_turned_off": "boolean",
"root_delegations": [
{
"kind": "root_entry | root_terminal",
"user_id": "string",
"email": "string",
"expires_at": "timestamp",
"blocked_by_node": "boolean",
"service_id": "string",
"name": "string",
"title": "string"
}
]
}
What changes: the owner, and the node's local MCP turns off (the new owner, or an org Owner/Admin, turns it on again with node_grant). Nothing else — the node keeps its id, its name, its networks, its services and the people who may use it.
⚠ Read root_delegations. It lists every way the node gives root that the new owner now controls: each root entry on its access list (root_entry) and each of its root terminals (root_terminal). A node is not handed over with silent root grants on it; the new owner answers for each one from now on.
Control of the node — its owner, or an Owner or Admin of its organization. The change is recorded in the organization's audit log with the old and the new owner. Transferring to the current owner changes nothing and reports success.
Removing a member, or a member leaving, does the same thing for everything they owned — see What happens when a member leaves.
node_channel_set
Surfaces: public only
Set a node's release channel, and nothing else. Router 0.4.179.
Parameters: the node reference, and channel (required). An empty string clears it, so the node follows its network's channel, or the default channel when the network has none. Setting the value the node already has writes nothing and succeeds.
The new channel reaches node_list's version_status and the agent's own nobgp upgrade at once. The automatic upgrade offer reads the row the node's current connection took, so it follows from the node's next connection; the result says so.
Control of the node — its owner, or an Owner or Admin of its organization. This is the router door for the one node setting the web app used to write directly to the database. noBGP staff are refused: a release channel is a router-side setting, not one of the node's own.
node_location_set
Surfaces: public only
Say where a node actually is, in place of the place noBGP derives from its public address. Router 0.4.200.
An address is registered where the provider terminates it, not where the machine sits — a Starlink household is placed at its exit point, a VPN or a cloud egress at the egress — so the derived place can be a long way out. This sets the place a controller knows to be right, and node_list (places, with view: "full") and whoami show it instead, with location_source: "set". noBGP's app shows the same place on its map.
Input Schema
{
"node": "string (required; the node's id or its name)",
"latitude": "number (required, -90 to 90)",
"longitude": "number (required, -180 to 180)",
"city": "string (optional)",
"state": "string (optional)",
"country": "string (optional)"
}
Parameters:
node- The node's id, or its name. See Addressing a node.latitude/longitude- Both required. Sending only a place name isinvalid_args, rather than putting the node at0, 0. Out of range isinvalid_argstoo.city/state/country- How the place is named where it is shown —Boulder,CO,US. Each is optional and at most 100 characters. ⚠ Never a street address. Send the town, the region and the country, nothing finer: every member of the node's organization can read this.
What it stores
- The coordinates are rounded to two decimals — about a kilometre — before they are stored, and
accuracy_radiusreads1afterwards. If you found the point from a typed address, that text never reaches noBGP; only the rounded point does. - A second call replaces the place. There is one set place per node.
- The organization's other machines at that site follow it, from router 0.4.201 — a node that registers from the same network operator, the same derived point and the same LAN takes this place instead of its own derived one, and reports
location_source: "site". See the place a controller sets for the rule and its bounds. - It overrides, it does not overwrite. The derived place stays on the node's metadata and comes back when the set place is cleared.
asn,as_org,ispandconnection_typeare untouched — they describe the address, which has not changed.
Output Schema
{
"node_location": {
"location_source": "set",
"latitude": "number (as stored, rounded)",
"longitude": "number (as stored, rounded)",
"city": "string (as stored)",
"state": "string",
"country": "string"
}
}
Control of the node — its owner, or an Owner or Admin of its organization. A Member who does not own the node gets forbidden. The node itself cannot set it, and neither can a node's local MCP server: a machine does not assert its own position.
node_location_clear
Surfaces: public only
Remove the place node_location_set stored for a node, so readers show the place derived from its public address again. Router 0.4.200.
Parameters: the node reference, node.
The result carries location_source: "address". A node that had no set place is answered with done: true and a note saying nothing was changed, rather than an error — so clearing twice is safe.
Control of the node, the same rule as node_location_set.
node_delete
Surfaces: public only
Remove a node. Router 0.4.179.
Where deprovision_node takes a task, this takes a node — whichever way the node came into being:
| The node | What happens |
|---|---|
| Provisioned (its latest task is still open) | It is deprovisioned first, through the same path deprovision_node uses, and then removed |
| Registered by hand, offline | Removed |
| Registered by hand, online | failed_precondition naming the remedy, unless you send force: true |
Parameters: the node reference, and force (boolean).
With force, the node is removed and then disconnected — in that order, so its agent cannot reconnect with a still-valid token between the two. Without force, nothing is disconnected.
The removal sticks. The node's token is refused afterwards, and a re-enrollment with the network key mints a new node_id: the old identity, its labels, its grants and its own storage area are not adopted by whatever installs next. Only provision_node restores a row, by re-adopting the name it provisioned.
Control of the node — its owner, or an Owner or Admin of its organization. This is the same rule deprovision_node, task_stop and task_deadline_set ask from router 0.4.179; before that any Member of the organization could stop or remove any node in it.
node_access_list
Surfaces: public only
Read who may use a node. Router 0.4.179. The full model is What you can have on a node.
Parameters: the node reference, node.
Output Schema
{
"node_access": {
"access": "follow | private",
"shared_with_org": "boolean",
"networks": [{ "id": "string", "name": "string", "access": "org | private" }],
"allow_root": "boolean (absent when the agent does not report it)",
"entries": [
{
"user_id": "string",
"email": "string",
"name": "string",
"level": "use | root",
"expires_at": "timestamp",
"granted_by": "string",
"created_at": "timestamp",
"blocked_by_node": "boolean"
}
],
"pending_requests": [
{ "request_id": "string", "node_id": "string", "user_id": "string", "email": "string", "name": "string", "created_at": "timestamp" }
]
}
}
accessis the node's own setting:follow(it follows its networks) orprivate.shared_with_orgis the answer after the networks are taken into account:trueonly when the node follows and every network innetworksisorg. An emptynetworksmeans the node is in no network, so it is private.entrieslists the named members,rootfirst. The node's owner and the organization's Owners and Admins are not listed and cannot be removed.blocked_by_node: trueon arootentry means the node reportsallow-root: false: the entry is kept but gives nothing beyonduse, and works again if the node's owner lifts the veto.- A note appears when the node offers only its root identity — no other account is configured, or the account is root — because a
useentry and sharing then let nobody do anything there. Setuserat the machine, or grantroot.
Control of the node — its owner, or an Owner or Admin of its organization. Any member can read their own access to every node they can see, in node_list's my_access (with view: "full"), which needs nothing.
node_access_set
Surfaces: public only
Set whether a node follows its networks' access or is private. Router 0.4.179.
Parameters: the node reference, and access: follow or private.
follow(the default) — the node is shared with its organization when every network it is in isorg. Every member of the organization may then use it: run commands, read and write files, read its telemetry, as its configured account. Never as root, and never its own agent log —node_logsneeds control from router 0.4.183.private— only its owner, the organization's Owners and Admins, and the members on its access list.
A node can be more private than its network, never more open.
The change takes effect on the next call. A member who used the node only through sharing is refused on their next command, file, log or telemetry call; their next poll of an open command or file session is refused and the session closed; their /xfer/ URLs stop being redeemable. Access-list entries count whatever the setting is.
The result reports opened or closed for what this call did, and networks so a follow that is not shared explains itself — a network it is in is private, or it is in no network.
Control of the node — its owner, or an Owner or Admin of its organization. The typical use is a person's own machine in a team network: it must reach the team's services, and the team must not be able to run commands on it.
node_access_grant
Surfaces: public only
Add a member of the node's organization to its access list, at use or root. Router 0.4.179.
Input Schema
{
"node": "string (the node's id or its name)",
"user": "string (required)",
"level": "use | root",
"confirm_root": "boolean"
}
Parameters:
user- The member's email address or user id. They must be a member of the node's organization; anyone else isnot_found.level-use(the default) orroot. A second grant to the same member replaces the level. A pending request from that member is marked granted.confirm_root- Required withlevel: "root".
use lets the member run commands, read and write files, and read the node's telemetry as its configured account. It does not include the agent's own log, which is control from router 0.4.183. An entry counts whatever the shared setting is, so it keeps working when the node stops being shared.
root is confirmed, or refusedroot lets the member run as root (administrator on Windows), which gives everything on the machine — every file and account, the node's own settings (allow-tools, allow-roots, allow-root, which only root can change at the machine) and its key and token.
- Without
confirm_root: truethe call isfailed_preconditioncarrying that warning, and grants nothing. From router 0.4.192 it also carriesdetails.confirm_root_required, so a client can ask its own confirmation without parsing the sentence. - While the node reports
allow-root: falsethe grant is refused outright: nobody can run as root there, and the node's owner changes that at the machine. - Sharing a node with its organization never gives root. A
rootentry includesuse. - A revoke later closes the router's doors only; it does not undo what the holder did on the machine.
Somebody who has not joined yet
The access list names members of the node's organization and nobody else, so naming anybody else is not_found. From router 0.4.193 that refusal is enough to act on: it names the node's org_id and org_invite_create, so inviting them is one more call with nothing to look up. Before it, the refusal said only that they were not a member.
- Name them by email address and the refusal gives the id and the tool. That address is what the invite needs.
- Name them by user id that matched nobody and the refusal adds that the invite needs their email address — a user id nobody holds yields none.
- They are a member only once they accept the invite; until then the grant keeps refusing them.
- ⚠ Inviting is an Owner's or an Admin's call. A Member who controls the node can grant access on it and cannot bring anyone into the organization, so the refusal says who can.
node_access_revoke names no remedy for the same person: somebody who is not a member has no entry to remove, and an invite is not how you remove anyone.
Control of the node — its owner, or an Owner or Admin of its organization. A root entry does not let its holder grant anything. The node's owner and the organization's Owners and Admins already control the node, which includes root, so naming one of them is invalid_args.
node_access_revoke
Surfaces: public only
Remove a member's entry — use or root — from a node's access list. Router 0.4.179.
Parameters: the node reference, and user (email address or user id).
It takes effect on the next call: the member's next command, file, log or telemetry call is refused, their next poll of an open command or file session is refused and the session closed, and their next /xfer/ redemption is refused.
⚠ A revoke closes the router's doors only. It does not undo what the member did on the machine — an SSH key added to an account, a cron job, a tunnel — and root can do more of that than use. Revoking is not cleanup.
Two things the result tells you, which are the two ways a revoke can fail to be the whole answer:
shared_with_org: true— the node is shared with its organization, so the member can still use it (never as root). Set the node to private withnode_access_setto stop that.terminals_naming_member— every terminal service on the node that still opens for them: their address is on its visitor list, or the service is set toorg: true. A revoke does not edit visitor lists, which are a separate delegation: close each withservice_shareremove, or setorg: falsewithservice_update. Each entry says whether that terminal runs asroot.
Removing a member who had no entry succeeds with removed: false and changes nothing. Naming somebody who is not a member of the node's organization at all is not_found saying there is nothing to revoke — and, unlike the grant, it names no invite: an invite is not how you remove a person. The node's owner and the organization's Owners and Admins cannot be removed.
Control of the node — its owner, or an Owner or Admin of its organization.
node_access_request
Surfaces: public only
Ask to use a node of your organization. Router 0.4.179.
Parameters: node, or all_nodes: true with network to ask for every node of a network (one request per node). Nodes you can already use are skipped and listed in skipped with the level you hold.
A request already pending is returned again, not duplicated. The request is for use — root is only ever granted, never requested.
The node's controllers see it in node_access_list's pending_requests and answer with node_access_decide.
Any member of the node's organization. A node's local MCP cannot ask — a person asks, from the app or their own MCP session.
node_access_decide
Surfaces: public only
Grant or refuse one pending request, in one step. Router 0.4.179.
Parameters: request (the request_id from node_access_list), and decision: grant or refuse.
grantwrites auseentry for the member, which counts whatever the node's shared setting is.refuserecords the refusal and writes nothing.- A request that was already decided is
failed_preconditionsaying so — including when someone else decided it first. - A member who has left the organization since they asked cannot be granted: the list names members only. Refuse the request instead.
Control of the request's node — its owner, or an Owner or Admin of its organization. A request for a node you cannot see is not_found, with the same wording as a request id that does not exist.
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 the subscription's
id. - Read with event_tail, passing
waitso 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.
All four return the same result type, so their payload sits under subscription rather than under each tool's own name — the one exception, with fs_delete and fs_mkdir, to the rule in The result envelope. Events are not tool results and carry no envelope: an event is the shape below, delivered inside event_tail's payload.
Event shape
Every event is a JSON object:
{
"node": { "id": "string", "name": "string" },
"status": "string (absent for ordinary data)",
"reason": "string (why, on refused / overflow / failed)",
"at": "timestamp",
"fs | command | presence | payload": "one of these"
}
⚠ node is an object from router 0.4.207 — the node the event is about or that reported it, as {id, name}, so a fleet-wide stream reads as machine names without a second call. name is absent when the router cannot name the node now (it was deleted, or you can no longer see it); id is always there, and it is the value to send back to any tool. Through router 0.4.206 the field was the id as a bare string, so a client that reads event.node as text now has to read event.node.id instead — the same shape the result envelope and call_list already use for a node.
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 its timeout |
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-root: false vetoes. A caller who was not entitled to ask for elevation is a different failure and never reaches this point — the router returns forbidden on the subscribe call itself and dispatches nothing, so an elevated fan-out is all-or-nothing rather than partly refused. Defaults are permissive, so this is a deliberate local decision rather than a missing opt-in. matched_nodes is who the work was sent to, not who accepted it.
Limits
| 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": [...], "progress": { ... }}. From agent 0.4.121eventsis always an array, empty when nothing is pending. Below that it can benull.
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.
Against a router on 0.4.83 or later, an agent below 0.4.121 reads the reply shape the router stopped sending and delivers nothing: resources/read answers {"events": null} and no notifications/resources/updated ever fires. It looks exactly like a subscription nobody is publishing to, which is why it went unnoticed for three weeks — a client waiting this way waits forever. Nothing else was affected: event_tail on the same endpoint returns the events, and a subscription that has expired was still reported as gone rather than as quiet. On such a node, upgrade the agent or poll event_tail. From 0.4.121 a reply the agent cannot read is an error it logs and retries, so the next shape change arrives as a log line rather than as silence. ⚠ That null is not an empty list. A client that loops over the field without a guard throws on such a node.
The payload carries the dispatch progress block too (agent 0.4.121) — the same dispatched / accepted / settled / outstanding counts event_tail returns, and it is how a push-path client knows a dispatch is done:
- It is omitted for an observation subscription —
fs_subscribeandpresence_subscribehave no dispatch, so there is nothing to be finished. Absent is the answer there, never a block of zeros, which a caller branching onoutstanding == 0would read as finished. - A move in the counts is itself a notification, even when no event arrived. A node that never acknowledged the work, or acknowledged it and then went offline, is written off without producing any event — so a dispatch can settle in complete silence, and before this a push-path client had no way to learn that except by falling back to
event_tail. - A session that reads without subscribing still gets
progresswhile another session is watching — but no events, because the router's queue is drained once and those events belong to the sessions that subscribed, where progress is a fact about the dispatch that costs nothing to read twice. Subscribe if you want the events. A read on a resource nobody is watching is unchanged: it drains the queue once and returns what was there. - A subscription the router has dropped is reported as an error on
resources/read, not as an empty success.
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
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": "string (optional UUID)",
"keep": "number (optional; idle lifetime, default 600, max 86400)",
"select": "object (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. ⚠ It must already exist — see A path is not re-evaluated the way the selector is.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- Optional UUID you generate, which makes the call idempotent on retry. Omitted: one is generated and returned.keep- Router 0.4.91+. How long this subscription survives without anevent_tailcall. See Staying subscribed while you are away.
Output Schema
{
"id": "string",
"matched_nodes": "number",
"nodes": [
{ "id": "node-uuid", "name": "string", "status": "running | refused", "code": "string", "reason": "string" }
],
"owner_filter": "string (the owner filter that applied)",
"not_mine_skipped": "number (nodes the owner filter left out)",
"trimmed": "string (MCP only; the result was larger than a model client accepts)",
"omitted": "number (MCP only; rows of nodes left out for size)",
"ttl": "number of seconds"
}
This is the payload under subscription, the key all four subscribe tools share, inside the envelope. root is absent here: a file watcher executes nothing as anybody, which is also why fs_subscribe takes no root. nodes is one row per node, sorted by name, with its code and reason where it was refused; trimmed and omitted appear from router 0.4.204 when the cohort is larger than a model client accepts, keeping the refused rows first.
ttl is the idle lifetime as applied, which from router 0.4.91 is where you see a keep that was clamped or floored — see below.
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.
Staying subscribed while you are away
keep (router 0.4.91+) sets the idle lifetime of an fs_subscribe or presence_subscribe subscription — how long it survives with no event_tail call. Omit it for the default 600 (10 minutes); the maximum is 86400 (24 hours).
{ "watches": [{ "path": "/etc/nginx", "recursive": true }], "keep": 14400 }
- A value above the maximum is clamped, not refused, and
ttlon the response is what was actually applied — the only place a clamp is visible, since the request is not echoed back. A value below the default is raised to it: the field is an extension, so sending30must not give you a subscription that dies sooner than sending nothing. A negative value isinvalid_args. - Every
event_tailstill resets the clock, exactly as before. This changes how long a subscription tolerates silence, not how it is kept alive. - ⚠ It buys no replay. The queue still holds the newest 256 events and drops the oldest, so a client away for twenty hours gets what fits, not what it missed —
event_tailreports how much was dropped indropped, but it cannot hand back what the queue no longer holds. Raisekeepto keep the watchers alive; do not read it as durability for the events. - Only the two observation subscriptions take it.
command_subscribeandfs_grep_subscribeare bound to work rather than to idleness — a dispatch closes when every node has settled and a walk closes when it finishes — so there is no idle clock to extend, and sending the field to either isinvalid_args. - Before router 0.4.91 the ten idle minutes were the only answer, so watch this and tell me later quietly meant later, within ten minutes for the two callers most likely to go quiet: an AI client between turns, and an agent that restarted and lost its session state. Both lost every watcher they had, fleet-wide, with nothing said.
Event Payload
{
"node": { "id": "node-uuid", "name": "edge-01" },
"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 node names ({id, name} from router 0.4.207, the id as a bare string before it).
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.
⚠ Before you filter on ops: the noBGP file tools write atomically, so a change they make arrives as a create and never a write. fs_write, fs_edit, fs_copy and op=fetch_url all land bytes in a sibling temporary file and rename it onto the destination, which the watcher sees as a new file appearing. An ops: ["write"] filter therefore misses every change made by these very tools — watch for create too. The temporary sibling itself is dropped on the node and does not reach you; an agent older than that fix still reports it, under a name that no longer exists by the time you read the event.
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.
From agent 0.4.89 a node that could watch none of your paths names each one and why — in the order you declared them and in the spelling you sent, rather than the symlink-resolved form — so a refusal reads as a list of answers: /srv/app (no such file or directory); /root/private (permission denied). The three causes have three different fixes: a path that is not there, one the agent itself could not register a watch on (a permissions problem on the node, or the host's own watch limit — on Linux, fs.inotify.max_user_watches), and the node's 1024-directory watch cap already being reached. Through agent 0.4.88 all of them came back as none of the declared paths could be watched, one sentence for every cause.
⚠ A partial failure is still only a log line on the node. If at least one path registered, the subscription runs and the ones that did not are not reported to you — including a recursive tree that stopped part way at the 1024-directory cap, where you get events from the top of the tree and nothing from below it. Subscribe to a directory you are unsure about on its own if you need to know it is being watched.
A path is not re-evaluated the way the selector is
⚠⚠ A watch path must already exist when the node registers the watch, and a path that does not is never re-examined while that watch runs. The two halves of a subscription behave differently on purpose, and the difference is easy to read the wrong way round:
- The selector keeps being evaluated, so a node enrolled or relabelled later joins the subscription on its own.
- The path does not. A path the node answered no such file or directory for is not tried again by the watch that is running; nothing on the node is waiting for it to appear.
How you find out depends on how many paths you declared, and the two answers could not be less alike:
- Every path missing — nothing registered, so the node has no watch to run and says so: a
refusedevent whose reason names each declared path and what the node answered for it (/var/log/app.log (no such file or directory)). Read it fromevent_taillike any other event. ⚠ The subscription itself stays live, so this is a refusal to watch rather than a subscription that ended — and because nothing is running for it, the node re-attempts the whole watch the next time the router re-sends the subscription to it, which a reconnect or an agent restart does. A path created in between is picked up then. - Some paths missing — the watch runs on the ones that registered and the rest are silently absent, which is the case the partial-failure note above describes. Nothing reports the missing one, and its events never arrive for as long as
keepsays. Subscribe to a path you are unsure about on its own, so a failure is the first kind rather than the second.
To be told when something appears, watch the parent directory with recursive and let the create tell you. To watch a path you are about to make, make it first — fs_mkdir or fs_write — and subscribe after.
command_subscribe
Surfaces: public · local
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 (one command line — this or script, never both)",
"script": "string (a script body — this or command, never both)",
"shell": "string (optional)",
"workdir": "string (optional)",
"env": "object (optional)",
"timeout": "number (optional, default 300, max 3600)",
"stream_output": "boolean (optional)",
"root": "boolean (optional)",
"redact": ["string (optional; one short label per secret this dispatch carries)"],
"dry_run": "boolean (optional; resolve and check the cohort, dispatch nothing)",
"subscription": "string (optional UUID)",
"select": "object (see Node selectors)"
}
Parameters:
-
command- Command line, run through each node's shell — the same authority thecommandtool gives you. Provide this orscript, never both. 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. -
script- A script body, from router 0.4.83 — the same fieldcommandtakes, lowered per node. See Sending a script body to a cohort. -
shell- Optional interpreter wrapper, identical tocommand's. Omit (or"auto") to pass the body to each node's native shell;"powershell"runs it from a temporary.ps1, or as-EncodedCommandwhere the body is too large for that form. ⚠ It applies to the whole cohort, and"powershell"is Windows only: from router 0.4.204 each node that reports a non-Windows OS comes back as its ownrefusedevent naming the OS it reported, and the Windows nodes run — before, every such node tried and failed on its own, withpowershell.exenot on itsPATH. Scope the selector when you set it. -
workdir- Working directory for the run on every node. Omitted, the run inherits the agent's own working directory — usually/or the service manager's, not a user's home, which is what a single-nodecommandsession falls back to — so write absolute paths or set this. A node where the path is missing, or is not a directory, does not run the body somewhere else: from agent 0.4.85 it reports arefusedevent naming the path. ⚠ The node is what appliesworkdirandenv, and from router 0.4.144 it is the only thing that does — a node still running an agent below 0.4.85 ignores both and runs the body in its own working directory, reporting success. Until router 0.4.143 they were also folded into the command line as a fallback for such an agent; that fallback is retired, since a node that reconnects takes the release its channel offers within about a minute. It matters only for a node that is not being upgraded — one withauto-upgrade: falseor pinned to a channel that offers it nothing. ⚠ One path for the whole cohort, so it has to exist on every node the selector matched. Any character is accepted from router 0.4.144 — the path travels to the node as a field of its own and no shell parses it on the way, so the old refusal of a double quote, a percent sign, a newline or a null byte is gone. A Windows directory holding a percent sign works now. -
env- Extra environment variables, merged over each agent's default environment: your value wins where a name already exists, and an empty value sets that variable empty rather than leaving the node's own. Values may contain any character from router 0.4.144, and a name only has to avoid an equals sign and a null byte — the pair travels as fields the node applies, so the old restrictions (names limited to letters, digits and underscores; values refused for a double quote, a percent sign, a newline or a null byte) no longer apply and no workaround is needed for them. ⚠ Names are case-insensitive on Windows and case-sensitive everywhere else, so two names differing only in case are one variable on part of a mixed cohort. ⚠ A handful of names are refused by the node, from agent 0.4.85 on this tool and on every earlier release ofcommandand published terminals: the loader and interpreter-startup variables that would inject code into the run —LD_PRELOAD,LD_LIBRARY_PATH, theDYLD_*trio,BASH_ENV,ENV,ZDOTDIR,PYTHONSTARTUP,PERL5OPT,RUBYOPT,NODE_OPTIONSandCLASSPATH. Such a dispatch comes back as that node's ownrefusedevent; set what you need inside the script instead. Two variables the node sets for itself cannot be cleared by this map either —NOBGP_SUBSCRIPTION, and the agent's own directory appended toPATH. -
timeout- 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. -
root- which identity the command runs as on every node in the cohort.adminis its old name, retired from router 0.4.201. A boolean,trueorfalse. From router 0.4.83 until router 0.4.200 it was a string,"true"or"false". Omitted (the default) is the answer to a cohort whose nodes disagree about which identities they have: each node runs the work at the identity it offers — its configured user where it has one, its ambient identity where that is all it has.falsepins every node to its configured user — not uniformly root (typically the installing account; nothing at all on headless installs, where unelevated is therefore root), and a node whose unelevated identity would be the superuser reports arefusedevent instead. Withtrue, caller authority is checked on each node of the cohort, before anything is dispatched (router 0.4.179+): an elevated dispatch is all-or-nothing, because a partly-elevated fan-out — root on some peers, refused on others — is not a state worth reconstructing fromevent_tailafterwards. If you do not hold root on one of the nodes, the whole call returnsforbidden, the refusal names those nodes, and nothing is dispatched. Withrootomitted, a caller who may not elevate instead gets arefusedevent on each node that would run elevated, and the nodes with an account still run; the call fails withforbiddenonly when every node is refused. Past that gate, a node whose owner setallow-root: falsereports arefusedevent rather than running the work unprivileged. The response'srootlist names the nodes that run as root. Nodes differ: readuserandallow_rootfromnode_listwithview: "full"before assuming a cohort is uniform, or senddry_run: trueto see the whole answer before anything runs. -
redact- Router 0.4.85+. The same declarationcommandtakes, and it means the same thing here: short labels for the secrets the body carries, never the secrets. See Declaring that a call carries a secret. -
dry_run- Resolve the cohort and ask every gate per node — tenancy, use, root, and the per-node lowering — then return the rows without dispatching anything. No subscription is created and nothing is marked settled or refused, so this is the safe way to see who a selector reaches before a fleet-wide command runs. Each row carries the node's owner here, which a real call does not include, and a node that would run has nostatuswhile one that would be refused has"refused"with its reason.fs_grep_subscribetakes it too. -
subscription- Optional UUID you generate. A retry with the same id and the same request echoes the existing dispatch instead of running it twice; the same id with anything about the run changed returnsinvalid_args, telling you toevent_unsubscribefirst or use a fresh id. The whole request is compared as you sent it —commandorscript,shell,workdir,env,root, and from router 0.4.94timeoutandstream_outputas well. Those last two were missing, so a retry that only moved the kill deadline or turned streaming on was answered already dispatched about a run that would end at a different moment and report differently.
Sending a script body to a cohort
script (router 0.4.83+) is the same field command takes, and this is the tool where it matters most: a cohort spans platforms by definition, and a one-liner written for POSIX fails on the Windows node as one refusal among a dozen successes — the easiest thing in a fan-out to skim past.
⚠ The body is lowered per node, not once for the cohort. Each node gets the form its own platform needs: verbatim under the login shell on Unix, a temp .cmd with CRLF endings run through %ComSpec% /d /c on Windows with shell omitted, and a temp .ps1 run with -File under shell: "powershell". That is what lets one dispatch span Raspbian, macOS, OpenWrt and Windows.
Three refusals follow from it, and each arrives as that node's own refused event while the rest of the cohort runs:
- A node that has never reported its OS — there is nothing to lower the body for.
- A node that reports a non-Windows OS, under
shell: "powershell"(router 0.4.204) — the lowering is apowershell.execommand line, so it is refused for that node rather than failing on it. - A node whose lowered line exceeds its platform's command-line limit. The encodings inflate (base64 of UTF-16LE is about 2.67×, and the Windows batch path encodes twice), so the same body can fit on Linux and not on Windows — and the Windows limit is much tighter from router 0.4.204, where it became
cmd.exe's real 8,191 characters rather than the 32,000 assumed before. Past that, write the body to each node withfs_writeand dispatch its path.
Only a cohort where no node can be served fails the call outright, and nothing is created: failed_precondition, or forbidden where what refused every node was your authority to elevate.
Note that OpenWrt is BusyBox ash: a bashism that passes on Debian fails there. Write POSIX sh for a mixed cohort.
Output Schema
{
"id": "string",
"matched_nodes": "number",
"nodes": [
{
"id": "node-uuid",
"name": "string",
"status": "running | refused",
"code": "string (refused rows only)",
"reason": "string (refused rows only)"
}
],
"root": ["node-uuid (the nodes that run as root)"],
"root_only": "number (only with root: false)",
"owner_filter": "string (the owner filter that applied)",
"not_mine_skipped": "number (nodes the owner filter left out)",
"dry_run": { "would_run": "number", "refused": "number" },
"trimmed": "string (MCP only; the result was larger than a model client accepts)",
"omitted": "number (MCP only; rows of nodes left out for size)",
"ttl": "number of seconds"
}
This is the payload under subscription — the key all four subscribe tools share — inside the envelope. ttl is always the 600-second default here: a dispatch is bound to the work rather than to idleness, so it takes no keep and sending one is invalid_args.
nodes is one row per node, sorted by name — the one place to read per-node state. A row that was refused carries its code and reason, and the node also gets a refused event. The cohort's identity is root, the ids of the nodes that run as root; a node not listed there runs as the account its owner configured, or was refused. There is no network field on a row: a node may be in no network, or in more than one.
Here nodes 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.
⚠ A cohort large enough to break a model client is trimmed over MCP, from router 0.4.204. The refused rows are kept first, then the others in name order while they fit; root then names only the kept rows, and omitted counts the rest. On a dry_run the owners lose their name and email and keep their id before any row is dropped. The subscription's id and every count stay whatever is trimmed, and REST never trims.
root is a list of node ids rather than one value, because an omitted root resolves per node and a single answer would be wrong on any mixed cohort. A node not in it runs as the account its owner configured, or was refused before dispatch and ran nothing — in which case its row carries the reason, and so does its refused event. It replaces the root_used map keyed by node id (router 0.4.83+; old name admin_used). fs_grep_subscribe reports it too; fs_subscribe and presence_subscribe run nothing as anybody and omit it.
root_only counts, before anything runs, how many matched nodes cannot serve root: false (router 0.4.155) — nodes that report no configured account, or root as the account. Its old name is no_unelevated_identity, which no result carries from router 0.4.201. Each of them also sends its own refused event, so this is the number in front rather than a second source of truth: it tells you the shape of the cohort without reading the stream. It is present only on a dispatch that sent root: false.
Event Payload
{
"node": { "id": "node-uuid", "name": "db-02" },
"status": "completed",
"at": "2026-07-29T10:15:04Z",
"command": {
"output": "migrated 42 rows\n",
"duration_ms": 1840
}
}
node is {id, name} from router 0.4.207 — a dispatch report reads as a machine name rather than a uuid — and was the id as a bare string before it. See Event shape.
output is text (router 0.4.201), the form command already answered in, where this surface sent a lines array of {line, stderr} objects: every line in the order the command wrote it, stdout and stderr together, with stderr repeating the error lines for a reader that needs them apart and absent when the command wrote none. ⚠ From agent 0.4.180 each line is the command's exact bytes, its terminator included, so a \r\n survives and a last line with no newline stays without one; an older agent sends each line stripped of its terminator and the router appends \n. A cohort can span both — read each node's agent_version in node_list (with view: "full") if a byte-exact tail matters.
exit_code is present on the event that ends a run and only when it is not 0 — status: "completed" already says the run exited 0 — and absent from the streaming output events in between, where there is no exit status yet. A run killed on Unix (timeout / cancelled) reports 128 + the signal number — 143 for SIGTERM, 137 for SIGKILL — on agent 0.4.38 and later, and -1 on earlier ones; a killed Windows process reports 1.
command.signal names what ended the run (router 0.4.84+), as the signal's name and never a number — the same field the single-node command tool reports, so the two surfaces say the same thing. It is absent when the run ended on its own or the node does not report one. ⚠ It is not gated on a terminal status, deliberately: a run killed at its deadline reports cancelled with exit_code overwritten to -1, so the name is the whole answer exactly where a status gate would have dropped it.
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-root: false, reportsrefusedwith the reason instead of running. - A node that cannot serve the
workdiror theenvyou sent refuses before it runs anything, from agent 0.4.85 — aworkdirthat is not a directory there, or anenvname on the refused list above, lands as that node's ownrefusedevent carrying the reason while the rest of the cohort proceeds. The point of refusing rather than running is that a dispatch nobody is watching must not quietly happen in the wrong directory or with the wrong environment and reportcompleted. - 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
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 node_list.
Input Schema
{
"subscription": "string (optional UUID)",
"keep": "number (optional; idle lifetime, default 600, max 86400)",
"select": "object (see Node selectors)"
}
keep (router 0.4.91+) is the idle lifetime, and this is the tool it matters most on: waiting for a machine to come back from a reboot, a rebuild or an overnight window is exactly the case where nobody calls event_tail for hours. See Staying subscribed while you are away.
Output Schema
Same SubscribeResult shape: id, matched_nodes, nodes, ttl. As with fs_subscribe, nodes is a snapshot — the selector is re-evaluated, so later joiners are included without appearing in this list. ttl is the lifetime as applied, which is where a clamped keep shows.
⚠ Here matched_nodes counts nodes in any state, online or not, from router 0.4.204 — unlike the other three subscribe tools, which count online nodes because an offline machine runs no watcher. This tool exists to wait for an offline machine, and it delivers that machine's events, so counting only the online ones answered matched_nodes: 0 for a selector naming two rebooting nodes: matches nothing, about a subscription that was working. select.online still narrows it when you send it.
Event Payload
{
"node": { "id": "node-uuid", "name": "edge-01" },
"at": "2026-07-29T10:15:04Z",
"presence": {
"transition": "registered",
"node_name": "edge-01",
"version": "0.4.15",
"platform": "raspbian"
}
}
transition is online, ready, 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. node is the node the transition is about, as {id, name} from router 0.4.207 and as the id alone before it.
ready, not online, before you use a nodeready is router 0.4.84+, and it is not a debounced online. online keeps its exact meaning — the node's bridge came up — which is true again after every reconnect, and during a provision it is true for a fraction of a second before the node re-registers and drops. ready is published only when that same session has held for 5 seconds, so it is the one that says the node will still be there when your next call lands.
The number is measured rather than chosen: during a real provision_node the fleet was seen going registered → online → offline → registered → online in 1.5 s, and the doomed bridge behind that first online lived 188 ms. A session that dies inside the window publishes no ready at all; its replacement arms its own.
Keep watching online when the question is the bridge itself — a gap, a bounce, a flap count. A client that does not know the token simply sees one extra event.
A subscription's queue starts empty and holds only what arrives after it exists. event_tail's cursor re-reads what that queue already holds; it does not reach back past the subscription's creation. Reboot a node and then subscribe and the ready you are waiting for has already gone by — event_tail returns empty for as long as you are willing to wait, and an empty tail is indistinguishable from a node that has not come back yet, so the mistake reads as patience.
The order is subscribe → do the disruptive thing (reboot, upgrade, provision, restart) → event_tail with wait. If you also need the state before it changes, read node_list after subscribing, never before — that way nothing can fall between the two calls.
⚠ And do not wait with a timer. A guessed sleep, or node_list polled in a loop, is wrong in both directions: it calls a node up before its session has held, and it burns the whole guess when the node was back in two seconds. node_list answers is it up now; this answers tell me when that changes.
offline is stamped when the router gave up, not when the node failedThe at on an offline event follows the keepalive window, so it lags the failure it reports — measured at about 73 seconds against the node's own kernel log for the same wedge. Read it as the moment the router noticed. The failure time is on the node, in whatever it managed to write before it went: node_logs once it is back, or its own console. Router 0.4.143 says so in the tool's description.
The lag is a property of detection and not an error to correct for — a node that fails quietly is noticed at the end of its keepalive window, and one that closes its connection cleanly is noticed at once — so never subtract a fixed figure from the timestamp to recover a failure time. node_list's offline_at is stamped by the same observation and carries the same lag.
A node that went down before you subscribed produces nothing here. For those, node_list's offline_at carries the moment the connection ended, and online_at (with view: "full") — re-stamped on every reconnect but a planned one — is how a directory read tells a new session from the one you just ended, which is the fallback when you did subscribe too late.
Every transition here is one a live connection was observed making, which is also the shape of the one gap: a node whose record is closed by the router's hourly repair sweep publishes no offline event, because by then there is no connection left to observe. That is a rare case — it needs the router process serving the node to have restarted while the node was already silent — but it is the reason a client that must not miss a departure should reconcile against the directory rather than treat this stream as exhaustive.
⚠ A node held below its channel's floor announces nothing here (router 0.4.168+) — no registered, no online, no ready — because it is connected without being in the network, and a watcher must not act on a node no peer can reach. It announces its first transition once it has taken the upgrade and reconnected. A client waiting on a node that never arrives should read its version_status in node_list.
A planned reconnect is silent
From router 0.4.200 a node that replaces its own control connection on purpose publishes nothing here — no offline, no registered, no online. Two cases do that: a node behind a gateway that cuts connections by age registers again just before the cut, and a node moving from WebSocket to QUIC. The node never went away, so there is no transition to report, and node_list's online_at (with view: "full") keeps the start of the interval too.
Before this, such a node published offline → registered → online every few minutes while nothing was wrong, and a watcher could not tell those from a real flap. Where the reconnect matters to you, read connection.rotations_24h in the directory, which counts them apart from drops.
⚠ It needs agent 0.4.178+ — that is the release where a node says how its previous connection ended. Below it a rotation still publishes the three events. And noBGP checks the claim against its own record: a node whose old connection had already died is treated as an ordinary reconnect, so you still get the offline and the online. ready is unaffected — a caller waiting for it still gets it.
event_tail
Surfaces: public · local
Read pending events from a subscription, oldest first.
Input Schema
{
"subscription": "string (required)",
"limit": "number (optional, default 50, cap 256)",
"wait": "number (optional, cap 100)",
"cursor": "number (optional; event position to read from)",
"until": "string (optional: \"done\" — collect a dispatch in one call)"
}
Parameters:
limit- Maximum events to return.wait- 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. Until router 0.4.200 the field waswait_seconds, andwaitwas an alias of it from router 0.4.146.cursor- Router 0.4.84+. Event position to read from. Omit for the normal case: reading resumes where the last call stopped. See Re-reading events.until-"done": collect acommand_subscribeorfs_grep_subscribein one call. It waits until every node has settled orwaitpasses, then returns the events together with anodestable, one row per node.
Output Schema
{
"nodes": [{ "...": "with until: \"done\", one row per node of the dispatch" }],
"events": [{ "...": "see Event shape" }],
"next": "number (present on every response)",
"truncated": "boolean (the requested cursor had aged out)",
"dropped": "number (how many were lost, with truncated)",
"trimmed": "string (MCP only; present when the result was larger than a model client accepts)",
"omitted": "number (MCP only; events left out for size — next is the first of them)",
"progress": {
"dispatched": "number",
"accepted": "number",
"settled": "number",
"outstanding": "number",
"refused": { "permission_denied": 2 }
}
}
Fields:
events- Pending events, oldest first. Empty when nothing is pending — that is normal.nodes- Withuntil: "done", one row per node of the dispatch:id,name,status, and on a failurecodeandreason;exit_codeandsignalfor a command; and, from router 0.4.207,skipped_files,unreadable_pathsandunreadable[]for anfs_grep_subscribewalk, copied from that node's completion — so did every machine actually search? is answered in the table rather than by reading each completion event.next- Event position one past the events delivered here. Send it back ascursor.trimmed/omitted- Router 0.4.204, MCP only. A collect over a large fleet can outgrow what a model client accepts, so the result keeps thenodestable and as many of the first events as fit, each one whole. ⚠ Nothing is lost: the events left out stay in the queue andnextis the first of them, so callevent_tailagain — with nocursor, or withnextascursor— to read them.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.progress.refused- Router 0.4.155+. The nodes that refused, counted by reason code, so a sweep over a mixed fleet learns twelve nodes vetoed this without reading every event. Absent when nothing refused. Each of those nodes still has its ownrefusedevent carrying the reason in words.
| Code | What it means |
|---|---|
forbidden | You may not elevate on that node |
failed_precondition | The node cannot run at the identity you asked for — root: false on a node whose only identity is the superuser, for instance. Omitting root runs there |
permission_denied | The node's owner vetoed it — allow-tools or allow-root |
invalid_args / too_large | The command could not be prepared for that node — see Sending a script body to a cohort |
target_unreachable | The node was not serving |
unclassified | A reason this router did not recognise |
⚠ Read it as an open set. Other codes can appear, so branch on the ones you handle and treat the rest as refusals rather than as an error.
Re-reading events
Reading no longer destroys what it returns (router 0.4.84). Until then the queue was drained on read, so an event a response carried was gone if that response never reached you — the same defect command output had, on a second surface, and fixed with the same primitive.
The three rules are the ones the command cursor follows:
- Omitting
cursorresumes exactly where the drain used to leave off, so no existing caller changes anything. nextrides every response, empty ones included. Send it back ascursorand the same events are delivered again, so a lost response costs a retry rather than the events.- The newest 256 events are retained. An older
cursoris served from the oldest event still held, withtruncated: trueanddroppedsaying how many were missed. Acursorpast the end isinvalid_args— it belongs to a different subscription.
⚠ The cursor belongs to the subscription, not to you. There is one position per subscription and every reader shares it, so a second reader — or a call of your own whose response never reached you — moves it, and your next cursor-less call starts later with nothing saying so. truncated cannot report that: nothing aged out, the events simply went somewhere else.
Compare next against the one you last held, and subtract the number of events you were just given — that is where this read started. If it is past where you left off, the difference was delivered to another reader, and those events are still there: send your own remembered value as cursor and they come back. Two limits on that recovery, both worth knowing before you rely on it (router 0.4.146 states them):
- Only while retained. A gap larger than what is left of the 256 has genuinely aged out, and that read is the one that returns
truncatedwithdropped. Atruncated: falseon the response in your hand describes the read you just made, not the one you are about to make. - The re-request moves the shared cursor too. A
cursorread that ends past the current position advances it for everyone, so recovering your own gap can open one for the other reader. With more than one reader on a subscription, ask for the narrowest range you need and expect the other reader to make the same comparison.
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.
- 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
End a subscription from fs_subscribe, command_subscribe, presence_subscribe, or fs_grep_subscribe.
Input Schema
{ "subscription": "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
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": "string (required)",
"payload": "string (optional, max 8192 bytes)"
}
Output Schema
{ "delivered": "boolean" }
delivered is true when the payload was queued for this subscription. A subscription that has expired, been removed, or was never yours does not arrive here as false — that is a not_found error — so false means only that the event store refused the write, and the payload is worth retrying.
Behavior
It works for as long as the subscription does — including after every node has settled (router 0.4.88+). Writing a marker once a fan-out has finished, recording what you concluded from it, is a normal thing to do, and the event is there on the next event_tail. The subscription's own lifetime is the only limit: once it expires or is unsubscribed, this is a not_found error rather than a silent no-op.
outstanding: 0 was lost before router 0.4.88Through router 0.4.87 a publish into a subscription whose nodes had all settled was dropped. The call succeeded and reported delivered: false, the subscription was otherwise perfectly alive — event_tail served it, event_subscriptions listed it, event_unsubscribe removed it — and the event was simply not there afterwards. It was inheriting the past-deadline refusal that belongs to a node reporting late into finished work, which is not the same act as the owner annotating a result they are still reading. Publishing a completion marker after a fan-out finishes is the most natural use of this tool, and it was exactly the case that lost the event.
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
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": "string (optional; id or name)"
}
Output Schema
{
"subscriptions": [
{
"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.
task_stop
Surfaces: public only
Stop a running machine and keep its node. From router 0.4.149.
Destructive operation. All data on the container's own disk is permanently lost. The node and its storage area stay.
Purpose
Stop a provisioned machine, and its billing, without removing the node. The node stays in the directory as an ordinary offline node, with the same node_id, name, labels, role grants and storage area. To start it again, call provision_node with the same node name: that starts a new machine on the same node, unless restartable_by_name is false.
The difference from deprovision_node is the node. Both stop the machine and destroy the container's disk. deprovision_node also removes the node from the directory. task_stop does not.
| You want to | Use |
|---|---|
| Stop the machine and keep the node, to start it again later | task_stop |
| Stop the machine and remove the node | deprovision_node |
| Remove a node whichever way it was created | node_delete |
| Keep the machine running for longer | task_deadline_set |
Control of the node — its owner, or an Owner or Admin of the node's organization — from router 0.4.179. The same rule covers deprovision_node, task_deadline_set and node_delete. Before that release any Member of the organization could stop or remove any machine in it, including one another member had provisioned.
Input Schema
{
"task": "string (required)"
}
Parameters:
task— the task to stop. It is the handleprovision_nodereturned, andnode_listreports it per node astask(withview: "full").
Behavior
- Idempotent. A task that had already stopped — at its deadline, for billing, or by an earlier
task_stopordeprovision_node— is success, withalready_stopped: true. Nothing is written, and the recorded reason the machine stopped stays the same. Two more cases also returnalready_stopped: true. When another stop (the deadline sweep ordeprovision_node) closes the task while this call runs, that stop's reason is kept. When the provider reports that the container had already stopped on its own (a crash, a capacity reclaim), the call records the reasonprovider, notuser_stop. - When this call does the stop (
already_stopped: false), the task history records it as a stop by you, with the reasonuser_stop. A terminate withdeprovision_noderecordsuser. So the node's task list can show which of the two ended each machine. - It waits, up to 35 seconds, for the node to read offline, for the reason
deprovision_nodewaits: the stop is asynchronous, and aprovision_nodeon the same name while the node still reads online takes a suffixed name and a newnode_id.node_offlinereports the outcome. - An account whose provisioning has been revoked can still call it, the same as
deprovision_node. It stops compute and starts none. - A node cannot call it. It is not on the local server, and a node's grant does not reach it.
- A stopped node is still a node.
network_deleterefuses a network that still has nodes in it, so to tear a network down usedeprovision_node. - ⚠ A kept node is not always started again by its name. A node that has been revoked is one case:
provision_noderefuses its name.restartable_by_name(from router 0.4.151) says whetherprovision_nodeon the node's name starts this node again, andnotes[]says why not.
Will the name start it again?
From router 0.4.151 the response answers that outright, in restartable_by_name — the one thing a caller has to know before promising "I'll start it again tomorrow", and the one thing the fields around it could not say. Keeping the node is only worth anything if provisioning its name comes back to this node, and several things break that link: the node was revoked, a newer task already owns it, another live node holds its identity key, or the name is contested. All of them read the same from the outside — a kept node with a name — so the router runs provision_node's own name rule against the kept name at the moment it answers, and reports the verdict.
restartable_by_name | What it means | What to do |
|---|---|---|
true | provision_node on the node's name starts a new machine on this node — same node_id, labels, role grants and node storage | Provision the same name when you want it back |
false | It does not: that name is refused, or it would get a different node with an empty storage area. notes[] carries the cause | Read notes[], and do not plan on the kept node's labels, grants or storage coming back — provisioning that name now makes a new node or nothing at all |
| absent | Either there is no node, or nothing refuses the name but the node still reads online, so the answer is not settled yet | Poll node_list until the node reads offline, then ask again |
⚠ Absent never means "wait and it will be true." It means not known yet: a collision that is still resolving is reported as absent because it clears on its own, while a refusal is reported as false even while the node still reads online, because that one does not clear.
⚠ true is about the name, not about your account. provision_node can still refuse the call for reasons that have nothing to do with the node — provisioning revoked, a lapsed subscription, an exhausted compute allowance.
Output Schema
{
"task_stop": {
"task_id": "string",
"node": { "id": "string", "name": "string" },
"already_stopped": "boolean",
"stopped_at": "string (optional)",
"node_offline": "boolean (optional)",
"restartable_by_name": "boolean (optional)"
}
}
| Field | Meaning |
|---|---|
already_stopped | false: this call stopped the machine. true: this call stopped nothing — the task had already ended (before or during the call), or the provider had already stopped the container and the call recorded provider |
stopped_at | The recorded end time, when already_stopped is true. Absent only when a concurrent stop's time could not be read back |
node | The node that was kept: its id, and its current name. Absent when there is no node to keep: the container never registered, or deprovision_node removed the node |
node_offline | true: provision the same name now to start a new machine on this node. false: the stop succeeded, but poll node_list until the node reads offline before you provision the name. Absent: no wait ran (the task had already ended when the call read it, it has no node, or a newer task owns the node). A call that reached the provider waits, also when it returns already_stopped: true |
restartable_by_name | Router 0.4.151+. Whether provisioning the node's name starts this node again. See Will the name start it again? — true, false and absent are three different answers, and it is reported on every path, including already_stopped: true |
Example Usage
Request:
{
"task": "5f0c2a8e-3b1d-4c7e-9a42-1d6f0e8b7c35"
}
Response:
{
"op": "task_stop",
"done": true,
"notes": [
"Node build-runner read offline 912ms after the stop; provision_node on this name starts a new machine on it now.",
"Node build-runner (node_id 3c1b9e2a-…) is kept offline with its labels, role grants and node storage; provision_node on this name starts it again."
],
"task_stop": {
"task_id": "5f0c2a8e-3b1d-4c7e-9a42-1d6f0e8b7c35",
"node": { "id": "3c1b9e2a-…", "name": "build-runner" },
"already_stopped": false,
"node_offline": true,
"restartable_by_name": true
}
}
task_deadline_set
Surfaces: public only
Give a running machine more time, or less.
Purpose
A provisioned machine stops at its deadline and its container's disk is destroyed then. Moving the deadline is the only way to keep a machine that is still working. Re-provisioning under the same name is not the renewal it looks like: the running machine's task still holds the name, so the call takes the next free name and starts a second, empty container beside the first — two machines billing, and the one you meant to keep still stops at its deadline. Its disk survives only for as long as its task does.
Input Schema
{
"task": "string (required)",
"hours": "integer (optional)"
}
Parameters:
task— the task whose deadline moves. It is the handleprovision_nodereturned, andnode_listreports it per node astask(withview: "full").hours— how many hours from now the machine may run.
hours counts from NOW, not from when the machine started4 means four more hours, whatever the machine has already run — it is not "four hours of total life". Read the other way on a machine that has been up for twenty hours it would name an instant in the past, and the machine you meant to keep would be stopped within about ten minutes.
The response says the resolved instant in deadline_at. Read it; do not work it out.
hours | Result |
|---|---|
| omitted | reset to your plan's default (24 hours) from now — what renewing a machine you still want looks like |
| a number of hours | that many hours from now, up to the plan's maximum — 168 (seven days) on Free, none on the paid plans (router 0.4.155) |
0 | no deadline, on every plan but Free — the machine runs, and bills, until deprovision_node |
| smaller than the time remaining | accepted — it brings the stop forward |
⚠ Shortening does not stop the machine now. The stop happens at the new deadline and is swept for within about ten minutes of it. To stop a machine immediately and keep its node, use task_stop. To stop it and remove the node, use deprovision_node.
⚠ Only a machine that is still running can be changed. A machine that has already stopped is refused (failed_precondition) naming the instant it stopped — its container and disk are gone, so there is no compute left to bound. provision_node on the same node name brings the node back and takes a fresh hours.
⚠ Which plan decides is the plan of the organization that is billed for the machine — the one that owns the node where there is one, and the network's otherwise. The same organization provision_node resolves its deadline against.
⚠ A plan that cannot be read is a refusal, not a removal. Where the plan of the organization that pays for the machine cannot be resolved, a machine that has a deadline is refused and keeps the one it had — the refusal names the instant that still stands. Silently dropping a bound on a call that asked to extend one is the outcome this avoids. A machine with no deadline has nothing to lose and is left alone.
This tool is withheld from an account whose provisioning has been revoked, exactly as provision_node is — it is the only call that makes a container run longer. deprovision_node stays available to such an account, which is cheaper than any shortening this tool could offer, so nothing is left running with no way to stop it.
⚠ Money does not refuse this call, and it does not spare the machine either. There is no billing check here: an organization with no credit left, or behind on a payment, still moves a running machine's deadline. That is deliberate — refusing provision_node costs a caller a machine they do not have yet, while refusing this one destroys a disk they are still using. It is not a way around a limit: a machine stopped for the compute allowance, an empty balance or a lapsed payment is stopped whatever its deadline says — and from router 0.4.128 an empty balance is one of those, so a deadline moved on such a machine buys nothing. Clear the cause; the deadline is not what is holding that machine up.
Where to read the current deadline
node_list reports deadline_at per node (with view: "full"), and provision_node returns deadline_at for the task it just created.
Output Schema
{
"task_deadline_set": {
"task_id": "string",
"node_name": "string (optional)",
"deadline_at": "string (optional)",
"previous_deadline_at": "string (optional)",
"shortened": "boolean"
}
}
| Field | Meaning |
|---|---|
deadline_at | The instant the machine now stops. Absent means it has no deadline and runs until it is deprovisioned |
previous_deadline_at | What it would have been before this call. Absent means it had none |
shortened | true when the new deadline is earlier than the old one. It is a field rather than something to work out from the two instants, because neither comparison is possible when either is absent |
node_name | The machine's current name. Absent while the container has not registered yet |
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": "string (optional)",
"name": "string (optional)"
}
Parameters:
network- Network to register the node into. If omitted, it resolves to your sole network — judged inside your personal organization if you belong to several. Name it to register into a shared org's network.name- Name for the node. If omitted, the machine's hostname is used. ⚠ It is a request rather than a guarantee: from router 0.4.113 a machine presenting a name that a live node is answering under, or one that is contested, enrols under the next free name in the family —web-1— instead of being refused. The machine goes on calling itself what its own configuration says, sonode_listis where the name it landed on appears.
An enrollment key is the network's reusable credential, so it is no longer handed to every Member:
- The network's owner, or an org Owner/Admin gets the key-bearing commands, as before (
enrollment: "network_key"). - Any other member gets the same installer with no key (
enrollment: "signed_in"). It runsnobgp registerand prints a sign-in link: the person who signs in owns the node that results, which is also what makes them its controller.
Only a forbidden from the key gate selects the keyless form — a database fault is returned as itself, so a transient error never quietly reads as "not a manager".
⚠ These commands enrol a machine; they are not how you rename or move one that is already enrolled. Running one on a machine that is already a node re-registers it, and from router 0.4.116 noBGP matches it on its identity key first — so it comes back as the same node under the name noBGP already holds, whatever name the command carries.
Output Schema
{
"shell_install_command": "string",
"windows_install_command": "string",
"enrollment": "string (network_key | signed_in)",
"network_id": "string (the network the commands are for)"
}
Fields:
shell_install_command— Shell command for Linux/macOS. Pass it on verbatim and run it as an ordinary user: the installer elevates itself, sosudois not a prerequisite. It carries the registration key.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. It carries the registration key, so it needs nobody at the console and runs unattended.enrollment— Router 0.4.179+. Which of the two command kinds you were given:network_key(they carry the network's reusable enrollment key — treat the output as a secret) orsigned_in(they carry no key; the machine prints a sign-in link, and whoever signs in chooses the network and owns the node). See the box above.network_id— Router 0.4.179+. The network the commands are for. Withsigned_in, this is the network to choose on the sign-in page.
⚠ The Windows command ends in Invoke-Expression, not the iex alias, from router 0.4.187. Pass it on exactly as returned. Microsoft Defender can refuse the form with the alias, and the user then sees Access is denied. Do not shorten the command when you quote it to a user. See Windows Defender.
⚠ Both commands carry the network's registration key, which enrols any machine into the network until it is rotated. Whatever you paste it into keeps it — a shell history, a chat log, a ticket, and on Windows the security log where process-creation auditing is on. Rotate the key after an install you would rather not have on that record. A brief router release (0.4.151) returned a Windows command that prompted for the key instead, plus a separate registration_key field; both were withdrawn in 0.4.152.
⚠ The shell command no longer carries sudo, from router 0.4.149, and adding it back puts the key in the system log. The command it used to return was … | sudo NOBGP_KEY=<key> sh, and sudo writes the environment assignments on its own command line to the system log — so the network's full, reusable registration key was logged on every machine installed that way. install.sh elevates itself, so the command needs no sudo in front of it. Run it as returned — and note that the key still reaches the elevated pass on a command line, so a machine's system log can record it either way; rotate the key after a bulk install.
Example Usage
Request:
{
"network": "production",
"name": "web-server-1"
}
Response:
{
"shell_install_command": "curl -fsSL https://downloads.nobgp.com/agent/install.sh | 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 | Invoke-Expression\"",
"enrollment": "network_key",
"network_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
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 can be renamed here too, from router 0.4.78. A personal org holds no name of its own until one is set — its label is derived from the owner's profile name, falling back to their email address. Naming it through this tool pins it: the explicit name wins over the derivation from then on, so a later profile-name edit no longer moves the label. Earlier routers returned
failed_preconditionfor a personal org. - 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
Report the authenticated caller's identity, organizations, and access level.
Purpose
Identity verification and session metadata — and the answer to "may I elevate?" before a call spends itself finding out. Read-only and cheap: call it once at the start of a session when you are unsure which account you are operating as, before your first elevated call, or before acting on another tool's unauthorized / forbidden / not_found error.
Input Schema
{}
No input parameters required.
Output Schema
{
"user_id": "string",
"email": "string (absent in node context)",
"node": { "id": "string", "name": "string" },
"can_root": "boolean (absent when it depends on the node)",
"orgs": [
{
"id": "string",
"name": "string",
"role": "string (owner | admin | member)",
"can_root": "boolean"
}
],
"location": {
"city": "string",
"state": "string (ISO 3166-2 subdivision without the country)",
"country": "string (ISO 3166-1 alpha-2)",
"latitude": "number (approximate)",
"longitude": "number (approximate)",
"accuracy_radius": "number (kilometres; 1 for a place a person set)",
"source": "string (router 0.4.200+: address | set)"
},
"router_build": "string (the router build serving this call)",
"tools": [
{"name": "string", "fields": ["string (top-level argument names, sorted)"]}
],
"tools_count": "number (entries in tools)"
}
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- The node the call was proxied through (idandname), present only for those calls. Such a caller is bounded to that node's own network — which is why it may address peers by name alone.can_root- Old namecan_elevate, in no result from router 0.4.201. Whether you may sendroot: trueon every node you can see.true: an Owner or Admin in every one of your organizations, so root calls pass the router's gate (a node whose owner setallow-root: falsestill refuses).false: every root call returnsforbidden, so never send it — and on a node that offers only its root identity an omittedrootis refused for the same reason, so sendroot: falsethere. Absent means it depends on the node — readnode_list'smy_access(withview: "full"), andorgs[]for the organization-wide half.orgs[]- The organizations you belong to and your role in each, with the samecan_rootanswer scoped to that organization. ⚠ Amemberrow'sfalsedoes not mean never: from router 0.4.179 a Member is root on a node they own, and on one where they hold arootentry. Matchidagainstnode_list's per-nodeorg(withview: "full") to find the row that governs a given node. Absent in node context, and for a caller who belongs to no organization.nameis the org's display label, which for a personal organization nobody has renamed is derived from the owner's profile name, or their email address when there is none (router 0.4.78+) — never an empty string.location- Router 0.4.187+, and on the REST door only: it is derived from the address the router saw, andPOST /api/v1/tools/whoamiis the only surface that carries one, so a call over/mcpor/actionsreads no location at all. Where it is present it is the approximate place of this call's address, with the same meaning and the same caveats the fields have per node: it is where your network's exit is registered, not where you are, andlatitude/longitudeare the centre of a circle of radiusaccuracy_radiuskm. Absent when the address is private, and when the lookup has no place for it.sourceis router 0.4.200+ and says which place you are reading:addressfor the derived one, andsetwhen a node of one of your organizations sits behind this same address and its controller set a place for it. Your own address is wrong in the same way as that node's — a Starlink household is placed at its exit point — so the correction follows for you too, rounded to about a kilometre withaccuracy_radius: 1.
router_build- Router 0.4.86+, and always present. The build serving this call, e.g."0.4.86+20260820". Compare it with the build your tool list came from — see below.tools[]/tools_count- Router 0.4.170+. Every tool this router offers you over MCP, with each one's top-level argument names, sorted — the same set and the same fields a freshtools/listwould return, because it is built from one. Compare it with your own list: a tool here that yours does not have, or a field here your cached schema does not carry, exists on this router and your list is stale.fieldsis[]for a tool that takes no arguments, nested fields are not listed, and the list is absent only if the router could not build it. In a node context it is cut to the router tools that node's grant allows; the agent's own local tools are not in it.
router_build says your tool list is stale; tools[] says what is missingYour tools/list is a cache, and a router newer than it may be offering tools you cannot see. Unlike a new field, which announces itself by rejecting a call and naming the fix, a tool missing from your list is simply one you never call — nothing rejects anything, so nothing tells you.
If router_build is newer than the build your list came from, refetch the tool list (in most clients, reconnecting is what does it) before concluding that a capability does not exist, and before telling a user it does not. Measured 2026-08-20: a session connected minutes before a deploy could not see three tools that deploy had added.
When you cannot refetch — a non-interactive session has no reconnect to make — read tools[] instead. It is a result field and it is unconditional, which is the point: a result reaches every connected session at once, while a new tool or a new input field is invisible to exactly the stale client that needs to hear about it. Name the tool or the field you found here and say your list is stale, rather than reporting the capability as absent.
can_root is only the router's half of the gate. The node's half is allow-root, reported per node as allow_root by node_list (with view: "full") — a node whose owner refused root still refuses a caller this field says true for.
What it answers, by caller:
| Caller | can_root |
|---|---|
| A user who is an Owner or Admin in every organization they belong to — a solo account is always this case, since you are the Owner of your own personal organization | true |
| A user who is a Member anywhere, or who holds different roles in different organizations | Absent. From router 0.4.179 root is held per node — its owner, an org Owner/Admin, or a root entry on its access list — so a single verdict would be wrong for some of their nodes. Read my_access per node |
| A node context (local server) | false only when the local MCP may not act at all; otherwise absent, because root there is its owner's root on the target node, which no single answer covers. node_list there reports allow_root for the nodes the grant reaches; for the rest, send root and read the refusal |
| A user belonging to no organization at all | false, with orgs[] absent. There is nothing for it to depend on |
An absent can_root is never a "no" — a prediction that disagrees with the gate would be worse than none, so the router stays silent rather than guessing, and a lookup that fails reports absent rather than false.
Example Usage
Request:
{}
Response:
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"can_root": true,
"orgs": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Acme Platform",
"role": "owner",
"can_root": true
}
],
"router_build": "0.4.187+20261002"
}
Response (a Member in one organization, an Admin in another):
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"orgs": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Acme Platform",
"role": "admin",
"can_root": true
},
{
"id": "1f0c2b9e-5d44-4a17-8b6e-3c9a7d2e5f81",
"name": "Contoso",
"role": "member",
"can_root": false
}
],
"router_build": "0.4.187+20261002"
}
can_root is absent at the top level here: whether root: true passes depends on which of the two organizations owns the node you are aiming at.
Response (called through a node's local MCP server):
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"node": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "edge-01" },
"can_root": false,
"router_build": "0.4.187+20261002"
}
A call through a node's local MCP server: orgs[] is absent because the node acts for its owner, not under an organization role.
Behavior
- Returns
unauthorizedif no valid token is supplied. - The prose summary states the elevation verdict too, so it is readable without parsing the structured body.
feedback_submit
Surfaces: public · local
Tell us where this tool surface fought you. Router 0.4.85+.
Purpose
A task that cost five calls when it should have cost one. A description that was wrong, or that was missing the one fact you needed. An error that named no next step. A parameter whose behaviour surprised you. One call, free text, nothing to follow up.
Reach for it as the friction happens — not in a summary at the end, where the observation survives and the detail does not. Naming the tool is what ties the report to the call: from router 0.4.196 the router attaches your last call of that tool itself.
It matters most for the caller with no human in the session — a scheduled job, an unattended agent, anything with nobody to ask at the end. A job running at 03:00 has nowhere to put the friction it hit, and that friction is the friction nobody has written down.
⚠ A human in the session is not a reason to skip it, and telling them is not a substitute (router 0.4.109 widened the guidance to say so, after it read as unattended callers only). A person can fix the problem on their own machine or file it in their own tracker, and neither reaches the people who write these descriptions. A session with someone in it is also where friction gets diagnosed rather than merely noticed — where it can be established whether the thing that fought you was the tool or the call — which is what makes those reports the ones most worth having. It is the only channel from an AI operator back to the people who write these tools, and it is read.
Any member may call it, and it is on a node's local server too, because it touches no peer at all.
Input Schema
{
"report": "string (required, 1–1000 characters)",
"call_id": "string (optional, REST only from router 0.4.196)",
"tool": "string (optional)"
}
Parameters:
report- What you were trying to do, what the surface did instead, and what it cost — calls, retries, a wrong answer you had to notice. Concrete beats polite: the exact argument you sent, the exact sentence in a description that misled you, the number of calls a one-call task took. Counted in characters, up to 1000 from router 0.4.195 (8000 before it) — a report describes a problem with a tool, and the shortest account that still names the call and the cost is the one worth having. Over the limit the call isinvalid_argsnaming both your length and the bound. ⚠ Describe the tool, not the task: a report must not carry the user's prompt, file contents, command text, command output, or the names of their data.call_id- REST only from router 0.4.196, and not published on MCP at all: a result over MCP carries nocall_id, so an MCP caller has none to quote. Over REST it is thecall_idthe result envelope carried on the call you are reporting, and it is what lets us find exactly what happened. ⚠ A client whose tool list predates 0.4.196 and still sends it does not lose its report — the field is dropped and the call goes through, rather than being refused for an unknown property.tool- The tool this is about, if it is about one —"fs_read","command","event_tail". Omit for a report spanning several. From router 0.4.196 this is also how a report gets attached to a call: with nocall_id, the router adds your last call of this tool, or your last call of any tool when you name none. So name the tool whenever the report is about one call.
Three fields and no more. A category enum, a severity, a want-a-reply flag are all things a caller would have to guess at, and every one of them is schema weight on a tool whose whole job is to be cheap enough to reach for.
⚠ The call noBGP attaches is remembered on one router replica, in memory, for an hour: the newest 16 calls of one sign-in session, MCP calls only. A report filed long after the call it is about, or one that lands on another replica, is stored with no call attached — which is what a report with no call_id has always been. It is a convenience for whoever reads the report, never something the report depends on: the text is what matters.
Output Schema
{
"status": "recorded",
"used": "number",
"quota": "number",
"resets_at": "string (RFC3339 UTC)",
"router_version": "string (REST only from router 0.4.196)"
}
status- Alwaysrecordedon success. There is no other value: a report that could not be stored is an error, not a status.used/quota- Your organization's reports in the current window including this one, and the bound. What is left isquota - used; through router 0.4.200 the receipt also carried it asremaining.resets_at- When the window's oldest report rolls off, freeing one slot. The window rolls continuously; it is not a calendar day.router_version- The build that recorded the report. REST only from router 0.4.196 — it is a diagnostic, likecall_idandduration_mson the envelope, and its one use was to be quoted in a bug report. The report row stores it either way, so nothing is lost;whoami'srouter_buildis where an MCP caller reads the build it is talking to.
⚠ Every field of the receipt is server-generated and nothing you sent is echoed back — not the report, not the tool name, not the call_id. Reflecting caller text would turn this store into a channel between tenants, so counts, a status, a timestamp and — over REST — the build are the whole of what may come back.
Limits and shape
- Write-only, by design. There is no tool that reads these back, and there will not be one — so this is not a way to store or retrieve anything, only a way to say something. The reports are read by people, on a staff-only surface.
- Not deduplicated on the way in. Two identical calls are two reports, deliberately.
- 50 reports per organization per rolling 24 hours, enforced by the database, so it is the same bound whichever replica you reach and whichever session you retry from. At the bound the call is
resource_exhausted, nothing is stored, and the message names both the bound and the moment the next slot opens. - A caller belonging to no organization gets
failed_precondition— there is nothing to attribute the report to.
Example Usage
{
"report": "fs_list on /usr/lib with recursive:true and limit:3 took 5.5s to return 3 of 23968 entries. Paging cost me 4 calls and ~22s for a listing I only needed the top of. Nothing in the description said limit bounds the response and not the walk.",
"tool": "fs_list"
}
Naming fs_list is enough: the router attaches your last fs_list call. Over REST you may add the call_id of a specific call instead.
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",
"call_id": "string (REST only from router 0.4.196)",
"retryable": false,
"details": {}
}
}
call_id is on REST failures too, from router 0.4.83 — the same identifier the result envelope carries, minted for the call rather than for the result, so a partial result and the error beside it agree. It is the id to quote in a bug report or a log search. Nothing may parse it.
⚠ An error over MCP carries no call_id, from router 0.4.196, for the reason no MCP result carries one. A report about a failed call needs no id: name the tool in feedback_submit and the router attaches the call itself. A plan-limit refusal over MCP also carries no details.upgrade_url — every other details field, limit_type included, is unchanged, and the message still names the limit and the ways out of it.
On REST the shape above has always been the whole answer. On MCP it had not been reaching you. A failing tools/call came back carrying the message text and nothing else — no structured body at all, so code survived only as a prefix you would have had to parse back out of the prose, details was dropped entirely, and a genuinely retryable failure such as timeout or target_unreachable arrived indistinguishable from a permanent one. A client branching on retryable, as this page has always told you to, was branching on a field that was never sent.
From router 0.4.85 a failed tools/call carries the same {code, message, retryable, details} object in structuredContent, so both adapters answer identically — except for the two fields router 0.4.196 keeps to REST, call_id and details.upgrade_url. The human-readable content text is code: message, so nothing that was reading the prose breaks.
Three things about that envelope settled in router 0.4.203:
- An error result conforms to the tool's own
outputSchema. The four envelope fields are published on every output schema as optional properties, and nothing is required at the top level — an error carries noopand nodone, so a requiredopwould have refused it. A client that validatesstructuredContentagainst the schema it was given now passes error results instead of rejecting every one of them. Nothing about a successful result changed: the fields a tool answers with are the same, and still documented per tool. - An argument the schema rejected is
invalid_args. It is the code REST has always answered such a rejection with; on MCP it was the one failure that carried no structured body at all, so it was also the one a client could not branch on — and it is the commonest failure a stale tool list produces. The text keeps the refetch your tool list postscript and now readsinvalid_args: …like every other error. - Every error reads
code: message, whichever door refused it. A rate-limited call, a call a node's local server may not make, and a refusal from the stale-schema layer used to readError: …instead, so a client that split the text on the first colon readErroras the code on exactly those three.
One deliberate limit remains: a tool name that does not exist keeps the protocol's own error shape rather than being given a code nobody chose. Nothing below the tool surface invents one either.
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 | The node refused — a filesystem permission failure, or one of its owner's vetoes: allow-root, allow-tools, allow-roots, or the agent's own configuration directory. Distinct from forbidden, which is the router's verdict on you | Read the message; it names which one. Not fixable by retrying — see Owner vetoes |
not_found | Resource doesn't exist | Verify resource name/ID |
invalid_args | Bad request parameters — including an fs_grep pattern that is not valid RE2 | Check input schema |
target_not_found | The node exists, in a different network of yours. From router 0.4.83 this is what you get when the node name resolves in another network you own, and the message names it: "node pi-01 not found in network home (currently in network lab)". A name that matches nothing anywhere stays not_found | Re-send with the network the message names, or send the node's id as node |
target_unreachable | Node is offline or unreachable — or, from agent 0.4.81, the node is up but could not open the channel this call needed because its own connection to noBGP failed. From router 0.4.187 also a command session whose node connection closed before the exit code arrived | Usually retryable, and noBGP already asks the node a second time; if it persists, check the agent's connection and its name resolution. ⚠ Read the retryable flag: the closed-session case is not retryable (the flag is absent), because that command is finished — start a new session |
already_exists | Resource already present | Use update or pick a new name |
too_large | Payload exceeds limits — including a node's own answer overrunning the 64 MiB it can send in one reply (an oversized recursive listing, glob match set or search result) | Narrow the request: a smaller root, a tighter pattern, a lower limit. Paging with cursor does not help against a node's own ceiling — the node builds the whole answer before the router pages it |
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 — creating a resource beyond the included quota; (router 0.4.93+) a write, mkdir or copy destination on a storage tree whose organization is at its storage limit; (router 0.4.103+) a provision_node whose organization has spent its included compute, run out of credit or lapsed on payment; (router 0.4.106+) a call that moves file or command bytes for an organization over its bandwidth allowance; (router 0.4.146+) a node that outran the router mid-stream, on a read, a copy or any one-shot answer; (router 0.4.147+) the same loss on a command session's output; or the streaming-slot pool is saturated | Read details.limit_type (bandwidth, compute, credit_empty — router 0.4.127+ — lapsed, organizations, …) and branch on it rather than on the message. ⚠ spend_cap is no longer among them from router 0.4.132: the spend cap bounds automatic credit refills and refuses nothing. For limit hits, upgrade the plan (over REST, details.upgrade_url links to it; the MCP surface sends no such link from router 0.4.196 and the message names the limit instead), buy credit, or remove existing resources — for the storage cap, fs_delete is never refused; 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. enforcing SSO before an IdP is connected, leaving your own personal organization, a node whose unelevated identity would be the superuser, a node that is not itself running privileged enough to become the account it was asked to, a write to a file system that node has mounted read-only, or — agent 0.4.182+ — a file another program on the node holds open or locked) | Change the state, then retry |
precondition_mismatch | 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. The codes that are usually retryable are timeout, target_unreachable, rate_limited and internal; 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. ⚠ Branch on the flag, not on the code. From router 0.4.187 one target_unreachable is not retryable — a command session whose node connection closed, where the same call can only ever get the same answer, so the flag is absent on it — and resource_exhausted has to be read the same way, for the reason two notes below.
⚠ retryable is only ever sent when it is true. A permanent failure omits the field rather than sending false, so test for the flag's presence and treat absence as do not retry — which is the safe reading either way. The same is true of details, which is absent when a code carries nothing extra.
⚠ resource_exhausted is the one code whose retryable must be read rather than assumed. Its two producers pull opposite ways: a plan cap, a lapsed subscription, an empty credit balance or a stopped bandwidth allowance is cleared by an upgrade, by buying credit or by the period rolling over, never by a retry (retryable: false), while a saturated streaming-slot pool clears on its own and answers retryable: true with a Retry-After. Router 0.4.83 is the release that made the flag say so; before it the streaming refusal claimed false while sending Retry-After: 1, and this page claimed the code was retryable in general, which is wrong for the commoner producer.
⚠ The mid-stream overrun added in router 0.4.146 is a third producer, and it carries retryable: false while its own message names a retry. That is not a contradiction to reconcile in the flag: the loss is transient and the same call may well succeed, but the message also names the shape change that makes success likely — resume at the offset it gives you, copy in pieces, narrow the request. Read the message on this one rather than branching on the flag, and do not retry the identical call in a loop.
A call addressed at an offline node names it, from router 0.4.83. Where such a call used to come back as internal quoting the node's UUID twice and its name never, it is now target_unreachable naming the node the way you addressed it, saying that nothing reached it and nothing ran, and carrying details.node_name and details.offline_at — so a client can say "offline since the 17th" without a second call, and can wait for it with presence_subscribe instead of retrying blind.
A refusal that asks for confirm_root carries details
From router 0.4.192 the failed_precondition that asks for confirm_root: true is machine-readable, so a client that puts its own confirmation in front of a root grant reads the facts rather than the sentence:
| Field | What it says |
|---|---|
details.confirm_root_required | true — re-send the identical call with confirm_root: true and nothing else changed, or abandon it. Nothing was granted |
details.outside_org | The named addresses that are not members of the node's organization, as an array. Empty when every one of them is a member |
details.node_id, details.node_name | Router 0.4.203+. The node root is being given on, so the question a client asks can name the machine |
details.granted | Router 0.4.203+. Every address this call gives root to, as an array — outside_org is the subset of it that is outside the organization |
Four calls raise it: service_share add and approve, service_publish and service_update when a root terminal names a visitor, and node_access_grant at level: "root". On node_access_grant outside_org is always empty — the access list takes members of the organization and nobody else — so an empty array is not evidence of anything there.
⚠ Read details, never the message. The sentence is written for a person or a model and may be reworded; the fields are the contract. The message still carries the whole warning in prose, so a client that shows it to a person is showing what root gives and which named addresses are outsiders.
The other two refusals that name a retry carry details too
Router 0.4.203 gave the same treatment to the two remaining refusals whose remedy is "send this one field and nothing else", so a client that confirms with a person reads the facts in all three places:
| Refusal | Fields |
|---|---|
A recursive fs_delete refused by the depth guard | details.force_required (true), details.path (the path as you sent it), details.depth, and details.clean_path — the path the depth was counted on, after . and .. were resolved. ⚠ Show clean_path in any question you ask: /tmp/../etc is /etc, and a question quoting the spelling hides what is about to be deleted |
| A node name more than one of your nodes carries | details.candidates[], one entry per node, each with node_id, node_name, org_name, online, network_name where the node is in a network, and — from router 0.4.206 — offline_at for one that is offline. The retry sends the node_id as node; the rest is what a choice has to show, and from 0.4.206 the message says it too |
A client that can ask the person is asked instead of refused
From router 0.4.203, a client that declares form elicitation gets a question where it used to get one of those refusals. Instead of failed_precondition or invalid_args, the tools/call comes back as an input request (the protocol's inputRequests with a signed requestState): the client asks the person, then re-sends the same call carrying inputResponses and that requestState, and the router runs it with the answer in place.
Three questions are asked, and they are exactly the three refusals above:
| The call | The question |
|---|---|
node_access_grant at level: "root", and service_share / service_publish / service_update naming a visitor on a root terminal | Give <who> root on node "<name>"? Root is everything on that machine. — plus, where any named address is outside the node's organization, which ones |
fs_delete refused by the depth guard | Delete "<path>" and everything under it on node "<name>"?, quoting the resolved path |
| A node name several of your nodes carry | Several of your nodes are named "<name>". Which one?, with each candidate shown as name · network · online/offline · organization |
What matters to a client author:
- A declaring client is always asked, and
confirm_root/forcefrom it are ignored. The router strips those fields from such a call before it runs, so a model cannot confirm on the person's behalf — which is the whole point of the question. The fields keep working exactly as before for a client that declares nothing. - Only an explicit yes runs the call. Declining, cancelling, an empty answer, a node that was not one of the offered choices — each is a refusal, and nothing ran.
- A declined question names a next step, from router 0.4.205. It never names
confirm_rootorforce— those are ignored for this client, so naming them would loop — and what it names instead depends on the question. A declined node choice is no node was chosen, so nothing was run, followed by the ordinary shared-name refusal:invalid_args, every candidate'snode_id, send one of these node_ids in place of the name, anddetails.candidates[]beside it. Sending an id asks nothing, so that is a retry which works for this client; for a name so many nodes carry that the candidates would not fit the signed state, the ids are listed on their own. A declined root grant and a declined delete arefailed_precondition, nothing was changed, and each says that only the person's yes in their client goes on — call again only if they ask for it, and they are asked again. The root one also names the way that needs no confirmation at all: a level below root onnode_access_grant, or the sameservice_share/service_publish/service_updatecall without naming those people on the root terminal. ⚠ A client can decline without ever putting the question to anybody, and noBGP cannot tell that apart from a person saying no, so none of these refusals claims anyone was asked. - An answer is good for five minutes and for one call.
requestStateis signed and bound to the caller, the tool and the arguments, so it cannot be replayed, moved to another call, or used twice — a yes to one root grant can never settle a second. A state that does not verify, or belongs to another call, isinvalid_argsand nothing ran; an expired one simply asks again. - Changing the arguments invalidates the state. The retry has to be the same call, with the answer and the state added and nothing else touched.
- A call that needs two answers keeps the first. Deleting a shallow path on an ambiguous node name asks about the delete first, because the depth guard runs before the node is resolved, and then asks which node, without asking the first question again.
- Nothing changed for every other client. A client that does not declare form elicitation — on a protocol version before
2026-07-28, declaring URL mode only, or declaring nothing — gets the refusal and itsdetailsexactly as documented above. That refusal is the compatibility path, which is why it keeps carrying everything a client needs to ask the question itself.
While a question is outstanding the call has not run, and the audit log says so: the row carries ok: false with failed_precondition — waiting for the person to answer — rather than reading like a grant or a delete that happened. The answered retry writes its own row.
When the failure came from the node
A failure that happened on the machine — an owner's veto, a permission error, a reply too large to send — carries the code that describes it, whether it surfaced when the call opened or partway through a transfer or a search. Router 0.4.37 is the release that made that true; before it, several of these arrived as internal / 500 / retryable: true, which told a well-behaved client to keep retrying a decision that was never going to change:
| The node says | Code you get now |
|---|---|
allow-root: false — elevated execution refused | permission_denied |
the path is outside allow-roots | permission_denied |
the domain is not in allow-tools | permission_denied |
| the path is inside the agent's own configuration directory | permission_denied |
| the answer is bigger than the node can send in one reply | too_large |
| the agent is not running privileged enough to become the account asked for | failed_precondition |
| the identity an unelevated call would run as is the superuser | failed_precondition |
the node holds its log and the identity the read ran as may not read it (agents 0.4.88–0.4.155 only; from 0.4.156 the read runs as the agent itself and such a node answers unsupported) | failed_precondition |
| the write landed on a file system the node has mounted read-only (agent 0.4.141+) | failed_precondition |
The failed_precondition rows are not vetoes and are worth reading differently from the rest: each says the node cannot be what was asked for, so permission_denied would be the wrong answer — it would send an org Owner off to widen a role that was never the problem. Router 0.4.42 is the release that classifies the unelevated-identity one; before it the refusal would have surfaced as internal / 500 / retryable. See Unelevated never means root for its two remedies, and node_logs for the log one, whose single retry is root: true — and which agent 0.4.156 retires, by reading the log as the agent's own identity so there is no retry to name.
None of these is retryable, and none of them is about your authority — see Owner vetoes for what each one means and who can change it. A caller who was not entitled to ask fails earlier and differently, with forbidden from the router.
The message names the node, from router 0.4.155. A node writes its own refusals and names itself by its hostname, which is not how you addressed it — and two nodes can share one — so noBGP now puts the node's name in front: node "dgx-kvm": host "glkvm" is not configured for …. Nothing else about the message changes, the error code is still chosen from the node's own words, and a message that already begins with that node's name is not doubled.
The node names its own code from agent 0.4.38. Only the agent knows whether it refused or faulted, so from that release it says which on the wire and the router validates the answer against the codes an agent is allowed to name. Below 0.4.38 the router infers the code from the wording of the node's message, which covers the six above and is why they are classified on any agent — but a refusal phrased in a way that table has no pattern for still arrives as internal. Upgrading a node is what makes its refusals reliably self-describing.
That release covered the decisions a node makes. A permission denial is not one of those — nobody on the node chose it, the filesystem did — so it stayed uncoded and fell back to the router's reading of the message until agent 0.4.47 coded it too. The distinction that matters to a caller is not who decided but whether retrying can help, and on that question a denial is as permanent as any veto.
Ordinary filesystem and argument failures are unchanged: a missing path is still not_found, an unreadable one permission_denied, an fs_edit whose old_string matched more than once invalid_args. One arm moved: an old_string that matched nothing is now precondition_mismatch, because the file is not what the caller read and the answer is to re-read and rebase rather than to fix the arguments — and from agent 0.4.85 its message carries the file's current hash so the retry needs no extra read.
A read-only file system is the newest of the failed_precondition rows, from agent 0.4.141. Writing under a path the node has mounted read-only — a container image layer, a ro bind mount, an appliance's own root — used to come back as internal / 500 / retryable: true, so a client retried a mount that was never going to change. It is now the node saying it cannot be written to, and the message adds the one thing a caller would otherwise try next: root: true does not help, because the superuser meets the same mount. Write somewhere writable — node_list reports the node's own mount and its writable_roots (with view: "full").
A failed write names the file you asked about, from agent 0.4.141. Every write lands in a sibling temporary file and is renamed into place, so an operating-system error on that temporary named it — a write to /mnt/data/x failed with "/mnt/data/x.nobgp-write-3141: read-only file system", naming a path that is the agent's own and that no longer exists by the time you read it. The scratch name is now removed from the message; the error number and the code are untouched.
Windows nodes reached that same answer at router 0.4.48. Windows words a permission refusal its own way — Access is denied. rather than permission denied — so until that release an unreadable path on a Windows node came back internal / 500 / retryable: true: the identical unelevated read answered permission_denied on a Linux node and, on a Windows one, told a client to keep retrying an ACL it would never satisfy. Windows phrases a missing path its own way too, and that one has been recognised for longer. That router-side match is on the English wording, so on an agent below 0.4.47 a node whose system language renders the refusal differently still answers internal.
Agent 0.4.47 settles it for every language. From that release the node codes a permission denial from the operating system's error number rather than from the sentence it prints, so an unreadable path answers permission_denied on Linux, on macOS and on Windows, in any system language — and it answers the same whether the denial was met by the node's elevated or its unelevated identity. Nothing about what a node permits changed; what changed is that the failure reliably says it is permanent.
A missing path is coded the same way from agent 0.4.152. It has always been not_found — the router recognised the English wording — so on a Linux or macOS node nothing about the answer changes. What changes is the two places that wording could not reach: a Windows node renders the operating system's message in its own system language, so a missing path there answered internal / 500 / retryable: true on a machine whose language is not English, and it is now not_found like everywhere else; and the node no longer records a caller's mistyped path as an error in its own log, so node_logs with min_level: "error" shows the node's own faults rather than one line per typo.
One code on that list is not a refusal at all, from agent 0.4.81. Every entry above is the node judging your request; target_unreachable from a node is the opposite — it says the node could not open the channel this call needed, because its own connection to noBGP failed, so nothing about the request was ever judged and the operation never started. Until 0.4.81 the node had no way to say that: the failure came back as an ordinary, final error, so a machine whose resolver had broken went dark to command, the file tools and net_* at once while its control channel stayed up and the directory showed it online. It is the one code that changes what noBGP does rather than only what you are told — an unreachable answer makes it ask the node again instead of returning the first reply. The node uses it only for its own failed dial of noBGP; a command or a file operation that merely touched the network is not this, because retrying one of those could repeat work that has already had an effect. See Node Is Online but Every Command and File Call Fails.
A refusal the node sent no text with now says what it means and what to do, from router 0.4.199. A node can refuse with a bare status code and no message, and what you got back was that bare number — which names nothing, because it belongs to no vocabulary this surface publishes. It is now a sentence plus a next step:
| What the node said | The message now says | And then |
|---|---|---|
| it could not open the session | the operation did not start, or stopped early | Send the call again — unless the node named a code saying a retry cannot help, in which case that code is the verdict |
| the session failed after it opened | part of the operation can have run | Check the result on the node before you send a change again |
| the operation had already ended | it ran to its end before the node answered | Read the result before you send it again |
Every one of them also says that the node's controllers can read why with node_logs — that tool needs control of the node, so a caller holding only use is told who to ask rather than being sent at a refusal. The exception is a refusal on node_logs' own session, which would point back at the call that just failed and so says nothing. ⚠ None of this changes the error code or the retryable flag: a refusal with no text is still internal unless the node named a code, and only a code the node named ever starts a retry.
A second ask runs the work once, from agent 0.4.142, and is answered every time from agent 0.4.143. noBGP asks a node again when the node's first answer is lost on the way back, and the node recognises the second ask as the same operation: it repeats the answer it already gave rather than deleting a file twice or re-applying an edit it has applied — an fs_read, fs_list, fs_stat, fs_glob or fs_grep is simply run again, because reading is where the result is the answer. Agent 0.4.143 closes the rest of it: a node preparing a slow operation — a write that has to create parent directories can take longer than noBGP waits before asking again — now answers each ask separately once the operation is under way, so one answer lost no longer fails the whole call; and a short operation that finished while it was still being set up, such as an fs_delete, no longer reports a failed connection for work that ran.
That last half is fixed for the nodes you have not upgraded, from router 0.4.171. A one-shot operation — an fs_delete, an fs_edit, a file op: "fetch_url" — can be finished before noBGP has read the node's opened answer, and when that answer is lost on the way back the finished one arrives first. noBGP now reads it as the operation having opened and run, rather than as a connection the node refused, so the call reports the work that was done on any agent version. Before, such a call failed for work that had already happened, and a caller that retried the failure ran the operation a second time.
One refusal that reads like a denial is not one, from agent 0.4.48. When the operating system refuses the identity switch at the moment a process is created, the node reports it as failed_precondition rather than permission_denied, because it is a fact about the node — it cannot become the account, whoever asks — and not a verdict on the caller. That matters for the retry: permission_denied on an unelevated call is answered by root: false, which is exactly the call that just failed, while failed_precondition is answered by root: true, which succeeds on such a node because elevated work assumes no account. Two shapes reach it — an agent that is not the superuser, and a root agent that has lost the capability to change identity (a container started with dropped capabilities, or a seccomp/LSM policy) — and the message names whichever remedy applies rather than one that is already true of that node.
A message quotes your own words back. A node's error carries the path or the pattern it was handed — Go's regexp error embeds the expression verbatim — so on an agent below 0.4.38, where the router reads the code out of the message text, a phrase you typed could be read as a decision the node never made. Searching for allow-roots(, or stat-ing a file named allow-roots.yaml, answered permission_denied; a pattern containing does not exist( answered not_found, and too large( answered too_large. Router 0.4.38 fixed both halves of that: the node's refusals are matched on its full wording rather than on a token you could type, and an unparseable pattern is recognised as an argument error before any of them. All of these are invalid_args now.
One more of that family closed in router 0.4.94, on the net_* diagnostics. Their session-open path decided this timed out by looking for timeout or deadline anywhere in the error text, so a refusal the node had already coded for itself — whose own message happened to contain either word — was rewritten as timeout and the node's code thrown away. It is now decided by the failure itself rather than by its wording, so a node's refusal keeps the code it named; a genuine connect timeout is unchanged. node_logs answers that question the same way.
Example Error
Both examples are REST responses; over MCP the same two failures carry no call_id.
{
"error": {
"code": "forbidden",
"message": "you are not permitted to provision nodes",
"call_id": "0b7d51e2-…"
}
}
No retryable key: this one is permanent, and the flag is sent only when it is true. A retryable failure looks like this instead:
{
"error": {
"code": "target_unreachable",
"message": "node pi-01 is offline",
"call_id": "3f1a90c4-…",
"retryable": true,
"details": { "node_name": "pi-01", "offline_at": "2026-08-17T09:12:44Z" }
}
}
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" 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. |
| The node's local MCP server is off | A controller of the node turns it on, 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 (Unix reports 128 + the signal number). Set shell: "powershell" for non-trivial PowerShell scripts, and send anything multi-line as script (router 0.4.81+) — a batch body is written out and run from a file, so cmd.exe's line-length limit and quoting never apply |
command quoting | The command line reaches cmd.exe verbatim, so its own quoting rules apply — dir /b "C:\Program Files\nobgp" works as typed. Agents before 0.4.38 escaped every " on the way, so quoted arguments came back with literal \" and quoted paths failed outright |
command with shell: "powershell" | Output is plain text. Agents before 0.4.38 interleaved PowerShell's CLIXML serialization (#< CLIXML followed by an <Objs …> blob) into the output of every call; the agent decodes that stream now, so an error's text survives as text |
command with shell: "powershell" — where it runs | From router 0.4.204 the body is written to a temporary .ps1 in %TEMP% and run with -File, so an error names that file and its line and stdout and stderr interleave as the script wrote them. A body too large for that form falls back to -EncodedCommand, where each error record repeats the whole script and all stdout precedes all stderr. The file removes itself, so a killed command leaves nothing behind |
command with shell: "powershell" — platform | Windows only from router 0.4.204: a node reporting any other OS is invalid_args naming what it reported. To run PowerShell on a Linux or macOS node, install PowerShell 7 there and call pwsh -File <path> with command |
command / script output encoding | Non-ASCII output came back mangled (café → caf?, CJK → ???) before router 0.4.86: the body always arrived intact, but PowerShell writes its output through the console's legacy code page. From 0.4.86 both lowered Windows forms — shell: "powershell", and a script body of either kind — emit UTF-8. ⚠ A bare command with shell omitted still reaches cmd.exe untouched, so send it as script if it prints anything outside ASCII |
script, shell omitted | Run as a batch script from a temp .cmd. From router 0.4.86 the generated file opens with @echo off, so cmd.exe's prompt and the echo of each command line no longer arrive ahead of what the script printed — before it, one dispatch produced a different lines[] shape on Windows than on Unix |
| Command-line length | Every line reaches the node as cmd.exe /c <line>, and cmd.exe refuses more than 8,191 characters with The command line is too long. and exit 1 — nothing of the body runs. From router 0.4.204 noBGP checks against that figure instead of the 32,000 of CreateProcess, which never applied, so an over-long body is invalid_args here with its measured size rather than a silent exit 1 on the node. In practice about 1.8 KiB of batch and 2.8 KiB of PowerShell, or more where the body compresses |
Temporary files in %TEMP% | Both generated forms clean up after themselves from router 0.4.204, including after a kill, unless the body is close to the length limit (then it runs in the earlier form, without the cleanup): the .ps1 removes itself on its first line, and each Windows command first removes the nobgp-<pid>-<guid>.ps1 / .cmd files whose writing process is gone. Files left by an earlier release are named nobgp-<guid> with no pid and are not swept — delete them by hand if a node has collected some |
command stderr timing | A completed line on stderr is forwarded as soon as it is complete. On agent 0.4.38 the CLIXML decoder — which wraps the stderr leg of every Windows command session, not only PowerShell ones — held finished lines back while the stream sat mid-line, so a command that printed a prompt or a progress line and then worked quietly returned nothing until it wrote another newline or exited. Delayed, never lost |
command App Execution Aliases | Windows ships zero-byte alias stubs on the PATH for python, python3, winget and others. Running one where the program is not installed prints "…was not found; run without arguments to install from the Microsoft Store" and exits 9009 — the same code cmd.exe returns for a name it could not resolve. From router 0.4.89 the response's hint names that rather than blaming quoting: install the program on the node, or turn the alias off in Settings › Apps › Advanced app settings › App execution aliases. Neither script nor shell: "powershell" changes the outcome. Matched in en-US only |
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. node_list's mount reports a Windows mount with its trailing separator (N:\) for exactly this reason — N: on its own is invalid_args |
file / fs_* UNC and device paths | A path beginning with two separators in any mix of / and \ — \\server\share, //server/share, //?/C:/x — is invalid_args from router 0.4.186, on every path-taking file tool. A UNC path names a file on another host, and a device path reaches anything, neither of which looks like the local path it is written beside; the agent refuses one as well. Reach another host's share by running a node on it. See Path format |
file / fs_* on the node's own drive | On a node using the webdav backend, the mapped drive belongs to the logon session that created it and is invisible to the SYSTEM service the agent runs as, so info.mount_readable reads false and paths under the mount are refused. There is no consent grant to give here — use the router-served routes, or move the node onto a winfsp mount. See A node's own mounted drive |
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. From router 0.4.87 anything strictly inside C:\Windows\Temp is exempt from the depth rule whatever its depth, with the folder itself still guarded; the comparison folds case there because the filesystem does |
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 |
|---|---|
file, fs_*, fs_grep, fs_glob, fs_subscribe, fs_grep_subscribe | A path under the agent's own mount point (/Volumes/nobgp by default) may be refused with a bare Operation not permitted, at either identity — macOS's consent layer (TCC) blocking the agent's daemon from a network volume, while the machine's own user reads the same path fine. It is not universal: a Mac whose owner has granted the daemon Full Disk Access serves those paths normally. node_list reports mount_readable per node (with view: "full") — read it rather than assuming, and see A node's own mounted drive |
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 |
Walks rooted at /tmp, /etc, /var | All three are symlinks into /private. fs_list with recursive, fs_glob and fs_grep resolve the root they are handed before walking it on agent 0.4.38 and later. Earlier agents walked no further than the link and returned count: 0 / scanned_files: 0 successfully, so "nothing matched" and "we never looked" were indistinguishable — name /private/tmp explicitly when talking to one |
| Naming of those results | Entries come back under the root you asked about — fs_list /tmp reports /tmp/…, not /private/tmp/… — on agent 0.4.39 and later. Agent 0.4.38 named them under the resolved path, so prefix-matching an entry against the root you sent failed; the same applies to fs_stat, which reports the link you named rather than its target |
Provisioning targets
provider | Classes offered |
|---|---|
aws (default) | small, medium, large, xlarge — see Compute classes |
A provider name this router does not implement is invalid_args naming the ones it does, not a server error.
All four are placeable from router 0.4.144; through 0.4.143 large and xlarge were priced and accepted while the capacity behind them was still being added, so a request for one could be accepted and then fail to start. Several big machines at once may still wait on capacity — see Compute classes.
Best Practices
1. Subscribe Instead of Polling
Don't repeatedly call node_list - cache results and refresh periodically. To follow a fleet's state over time, use presence_subscribe plus event_tail with wait: 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 with command_input or send SIGTERM with command_signal 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-rootfrom 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