MCP Reference
The tool-by-tool reference for both noBGP MCP surfaces: the public server that noBGP hosts at https://mcp.nobgp.com/mcp, and the local server that any registered node can serve on 127.0.0.1.
They are one registry, not two. A tool that appears on both takes the same parameters and returns the same shape on both, so this is one catalogue with each entry marked for where it is available.
How to read this
Every entry carries a Surfaces line directly under its heading:
| Line | Means |
|---|---|
| Surfaces: public · local (observe) | On both. A node granted either tier may call it. |
| Surfaces: public · local (manage) | On both, but a node needs the manage tier. |
| Surfaces: public only | Not reachable through a node, at any tier. |
| Surfaces: local only | Served by the agent itself, never proxied. Present at both tiers. |
The local surface is a deliberate subset: 27 proxied tools, of which 19 are observe-tier, plus the two local-only tools. What it excludes — provisioning, network and organization management, service publishing, network_directory, and the labelling, renaming and granting tools that would let a node widen its own reach or re-address a peer — is listed on the public server page.
tools/list is the authoritative inventoryOn both surfaces the advertised list is already filtered to what the caller will actually be allowed to do — by organization role on the public server, by granted tier on a node. Treat this page as the map and tools/list as the territory.
Concepts
Addressing a node
Every tool that acts on one existing node — command, file, the fs_* tools, the net_* diagnostics, service_publish — takes the same node reference: either node_id or the network_name + node_name pair. Sending both, or neither, returns invalid_args.
node_id— the node's UUID. Stable across network moves, so prefer it when you have one.network_directoryreturns them on the public surface;statusandnet_peersreturn them on the local one.network_name+node_name— human names.
node_name on its own is enough whenever the network is unambiguous: when you own exactly one network, or when the call is proxied through a node's local server, which is bounded to that node's own network. A caller with several networks gets an invalid_args asking which one — with the one qualification below, which matters once you belong to a team organization.
One spelling everywhere: the flat node reference
The three fields are accepted at the top level of the request on every tool that takes a node reference — command, file, all seven per-op fs_* tools, fs_grep, fs_glob and the five net_* diagnostics. Published in router 0.4.92, and working on all sixteen of them from 0.4.94 — read the note below before relying on it against an older router:
{ "node_name": "pi5", "path": "/etc/hostname" }
Before it, the same call had to know which of two nestings that particular tool used — target on the file and net_* tools, session on command — while service_publish, node_logs and node_config_get already took the fields flat. There was nothing to learn from the difference, and a caller that guessed wrong got invalid_args about an unknown field.
- The nested spellings still work and are not deprecated. Nothing is scheduled for removal: these schemas reject unknown fields, so dropping one would break every client holding a cached tool list, and the alias costs nothing to keep. Existing calls need no change.
- Send it once. Naming the node both flat and nested is
invalid_argseven when the two agree — two copies can drift, and silently preferring one would make which-one-wins a rule nobody wrote down. The message names both places. fs_copyandfilewithop: "copy"have no flat form, and that is deliberate: a copy has two ends, so one top-levelnode_idcannot say which it means. Both ends carry their owntarget— seefs_copy. A top-level node reference there is refused by name rather than ignored.- Everything else is unchanged: which of
node_idor the name pair you send, the defaulting rules above, and the authority checks are all the same whichever spelling you use.
The alias shipped in 0.4.92, and on twelve of the sixteen tools the router's own request validator rejected the flat spelling before it was ever read: fs_list, fs_stat, fs_delete, fs_mkdir, fs_edit, fs_grep, fs_glob and the five net_* diagnostics all still marked their nested target object required, so a call naming the node flat came back invalid_args — missing properties: ["target"] — about a container the tool had just published as optional.
Only command, file, fs_read and fs_write accepted it, and by accident: their container is optional anyway, because a continuation names a file_id or command_id instead of a node. All sixteen accept it from 0.4.94. Calls using the nested spelling were never affected on any of them.
When you belong to more than one organization
Every tool that lets you omit the network — the node reference above, the selector fields, provision_node, register_node, network_delete — resolves an omitted name the same way, and the rule is written to make a shared organization's network something you name rather than something you land in:
- An omitted name means your sole network, judged inside your personal organization when your networks span several orgs. One personal network wins outright, even if you also belong to team orgs holding a dozen more. Only if your personal side is itself ambiguous — or if you have no personal networks at all and several across shared orgs — do you get the
invalid_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 name that exists in more than one of your organizations resolves to the personal one first, then to the oldest. Network names are unique per creator, not across an org boundary, so two orgs you belong to can each hold a
home. - An id binds exactly.
network_idis checked for membership by id and never resolved through its name, so passing the id of a shared org'shomereaches that network and not your personal namesake.
Node context is a boundary, not a default. A call proxied through a node can only reach that node's own network — naming another one fails rather than widening. Other networks need the public server.
Addressing storage instead of a node
The file tools also reach noBGP's own file storage — a network's shared drive, and each node's storage area — without going through any agent (router 0.4.56+). What you put in target decides which of three places you land in:
| What you send | Where it goes |
|---|---|
target.node_id, or target.network_name + target.node_name | that node's own disk, through its agent — unchanged |
target.network_name alone, no node | that network's shared drive |
target.node_id and storage: true | that node's storage area |
storage: true needs node_id. A network_name + node_name pair is refused with invalid_args rather than resolved, because reaching a node by name is a Member-tier check on the way to a surface gated at Owner/Admin — two authority questions in one call, weaker one first. Take the id from network_directory.
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 — network_directory reports info.mount_readable per node — 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. The name is resolved among your own networks, so one you are not in is not addressable.
- 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 granted the read-onlyobservetier couldread,listandstatany of their shares. - A node's storage area — Owner or Admin in the organization that owns the node. A Member gets
forbiddennaming the requirement; someone outside that 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 at either tier, a peer's only with
manage(router 0.4.73). A node reachesnodes/<its own id>/whatever its grant — that area is its own. Another node's area is answered by the node's granted tier and its own network, never by its owner's organization role: a peer outside the node's network, or outside the grant'stargets, comes backnot_found, and a peer inside them needsmanage—observegetsforbiddennaming the tier. Before 0.4.73 this path asked what the owner held, so a node granted 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, andfilewith 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.op=fetch_urlis the one op that names a tree and is refused rather than served — 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.lockandunlockexist here and nowhere else (router 0.4.62+) —fileonly, since nofs_*tool carries them. See Locking a path on a storage tree.filewithop: "batch"works too (router 0.4.59+): the batch'sstorageapplies 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
file_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 owntruncatedandnext_offsetfor 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+).
max_bytesis 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 withoffsetadvanced bysizeuntildone: true. Before 0.4.59max_byteswas ignored and anything over 1 MiB failed withtoo_largeat every offset. Anoffsetpast 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 nofile_idis 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. expected_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.adminis 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. file with op: "lock" and op: "unlock" gives them one (router 0.4.62+), on a network's shared drive and on a node's storage area alike:
{ "target": { "network_name": "production" }, "op": "lock", "path": "/reports/q3.csv" }
{ "locked": true, "lock_token": "1786...", "lock_expires": "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
fileproxied 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 withop: "lock"on 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_expiresis 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 appear on these two ops 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 file 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. network_directory reports what a given node's mount actually guarantees as info.fs_locking; 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_grep, fs_glob, fs_subscribe and fs_grep_subscribe. On macOS the refusal arrives as a bare Operation not permitted.
network_directory 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):
info.mount_readable | Means |
|---|---|
true | the agent read its own mount, and paths under info.fs_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 — admin: 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 — info.fs_backend 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 the directory still learns it at the moment it matters. A permission_denied on a path under the node's own info.fs_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 soadmin: 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" | target.network_name: "production" alone, path: "/report.pdf" |
path: "/Volumes/nobgp/node/backup.tar" | target.node_id 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 a node-less network_name — 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 a node-less network_name, 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 target.network_name alone or 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.
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, expected_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: with shell: "powershell" that body travels as -EncodedCommand UTF-16LE and is unaffected.
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" travels as UTF-16LE inside -EncodedCommand, so there is no file on disk and no code page to guess at. 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.
Roles and tiers
On the public server, authorization is your organization role; the per-tool notes below say which roles may call what, and Roles & Permissions is the full matrix.
On the local server, authorization is the node's granted tier. An Owner or Admin sets it with node_grant (role: "observe" | "manage"), which also takes:
targets— label pairs ("role=edge,site=irvine") restricting which peers the node may act on, evaluated live, so relabelling a peer changes reach with no re-grant. Omitted, the grant covers every node in the network.
Observe grants the read-only set — 19 tools as of this writing; manage grants all 27. The node's own two local tools (status, resolve) are present at either tier, whenever the server runs.
node_revoke takes the grant back and stops the server. Neither tool is callable from a node, which is what prevents a granted node from widening its own reach.
Authority is evaluated per call, not at session start: a grant changed mid-session takes effect on the node's next call, and connected clients are told to refetch their tool list.
Owner vetoes
Independently of the caller's role or tier, each node's owner bounds what their machine serves to MCP callers, to event-bus sources, and to published terminal services:
| Key | Default | Governs |
|---|---|---|
allow-tools | fs, command | Capability domains served (fs = all file tools and fs watches; command = command execution). |
allow-roots | / | Filesystem roots the fs tools and watches may touch (symlink-resolved). The agent's own configuration directory is excluded whatever the value. |
allow-admin | true | Whether execution or file access as the superuser is permitted. |
A veto refuses, never downgrades: an explicit elevated request against allow-admin: false fails — or reports a refused event — rather than silently running unprivileged.
A vetoed tool call comes back as permission_denied (HTTP 403, not retryable), with a message naming which of the three refused it, so a veto is never mistaken for a transient failure worth retrying. Router 0.4.37 is the release that made that reliable at every point a refusal can land — mid-read, mid-write and mid-search included; before it these arrived as internal and clients retried them forever. See When the failure came from the node.
allow-admin gates the resolved uid 0, not the name root. No caller can name an account at all now, so what this catches is a node whose configured user is itself uid 0 — root, or a second superuser account an operator made — where unelevated work would otherwise run elevated without anyone asking for it. It bounds the superuser identity, not privilege in general: an account with passwordless sudo, or one that can write systemd units, is effectively root and no uid check sees that.
These vetoes apply to every execution surface — the command tools, event-bus command sources, and published terminal services. A terminal published to run as root is refused on a node whose owner set allow-admin: false, the same as any other request.
One path is refused independently of all three: the agent's own configuration directory (/etc/nobgp, /usr/local/etc/nobgp on macOS, C:\ProgramData\nobgp on Windows). The fs tools and fs watches never serve it, no allow-roots value opens it, and symlinks are resolved before the check — it holds the node's key and the config these vetoes are read from. command is unaffected, so a session running as an account that can read those files still can.
The tools that walk a tree — fs_list with recursive, fs_glob, fs_grep — ask the same question at every directory they descend into and prune the subtree on a refusal, rather than vetting only the root they were handed (agent 0.4.37+). On earlier agents a recursive walk rooted at a permitted path reported what a single-path call on the same node refuses.
allow-admin: false can lock you 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-admin: false refuses admin: true, and unelevated never means root refuses admin: false — so remote execution is off entirely, terminal included. Undoing it then needs console, RDP or ssh access to the machine. On a node with a working user, sessions keep running as that account. The agent warns about this when the setting is applied.
Full treatment, including how to set them, is in Node Access Control.
Execution identity
A node offers exactly two identities, and one field picks between them: admin. Four rules cover the whole model, and they hold on every tool that carries the field — command, command_subscribe, file and the fs_* tools, fs_grep_subscribe, service_publish and service_update:
- You never run as admin unless you ask.
- The node decides what unelevated work runs as — not the caller, not us.
- A node can refuse admin entirely.
- A node that refuses admin must name an account, or it cannot run anything.
Roles & Permissions explains each in prose and is the page to send someone who is meeting the model for the first time. What follows is the per-tool contract.
admin | Runs as | Who may ask |
|---|---|---|
omitted / "false" | the node's configured user — captured from the installing account at registration, so a normal sudo install runs as that person, not root | anyone who can reach the node |
omitted / "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 | org Owner/Admin, or a node holding a manage grant |
"auto" | whichever of the two this node can offer — never a third thing, and never the default | same authority as "true" |
Where the field goes
One field, three positions — and it is a lookup, not a pattern. Do not carry one tool's spelling to its neighbour. 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 admin sits |
|---|---|
command | inside session — this tool alone, and only on the call that creates the session |
fs_copy, file with op: "copy" | inside each of source and destination — 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_grep, fs_grep_subscribe, node_logs, service_publish / service_update | at the top level of the request |
command_subscribe is top-level despite mirroring command field for field: it is a cohort dispatch and has no session object at all.
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.
admin is a string on the tools that run something
From router 0.4.83 the field is a string enum — "true", "false" or "auto" — on every tool that runs at the identity: command, command_subscribe, file, the per-op fs_* tools and fs_grep / fs_grep_subscribe. The boolean spellings are kept verbatim, so "true" and "false" mean exactly what true and false meant.
- A JSON boolean still works. A client holding a cached boolean-typed schema keeps sending
true, and the server coerces it to the string form rather than rejecting it. Nothing has to be changed on your side; a reconnect is what makes the string form visible in the schema. - It is a plain string, 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 a string and the boolean is accepted by the decoder instead. service_publishandservice_updatekeep the plain boolean, deliberately. They record an identity rather than running at one, and a standing grant has no"auto"to take. Their own field descriptions say which shape they are.
"auto" means: run at whatever identity this node can offer. It exists for a fan-out across a fleet whose nodes disagree about which identities they have — one call instead of one per shape. Three things bound it:
- It is never the default and never implicit. Omitting
adminstill cannot give you the superuser;"auto"is you saying you have thought about it and do not mind which you get. - It needs the same authority
"true"does, because it may resolve to elevation, and it is refused with the sameforbidden. A node whose owner setallow-admin: falsenever resolves to the ambient identity. - The result says which one ran. Every executing tool reports
admin_used— a boolean, never"auto"— so a request that did not name an identity still tells you the outcome. Oncommandit is present on the call that created the session and absent when polling an existingcommand_id, whose identity was settled at creation; on the router-served storage trees it is 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 acceptedadminincluding"auto", and each one's own field description promised the result would name the identity — and none of them returned it. The same operation throughfiledid, sofile {op: "stat", admin: "auto"}answeredadmin_used: trueon a node wherefs_stat {admin: "auto"}answered nothing at all. On a router below 0.4.89, read the outcome of an"auto"call throughfilerather than through its per-op spelling. Every other executing tool —command, both cohort tools,fs_grep,fs_copy,file— has reported it since 0.4.83.
Choosing which to send
Choose by what the work needs. Send admin: true for anything that requires the superuser — system paths (/etc, /var, /root, the Windows system directories), services, package managers, another account's files. Omit it otherwise.
Windows is no exception to that rule, and there is deliberately no platform rule here to learn: a Windows node has a second identity only where its owner configured an account, and where none is configured admin: false is refused exactly as on any other node whose only identity is the superuser. Which of the two you are on is what the refusal below tells you.
The rule is stated that way round on purpose. Sending admin: false first and treating the refusal as the signal to elevate costs a guaranteed wasted call on every node whose only identity is the superuser, and those are common: service installs, bare-root containers, and any Windows node that captured no install account.
Each refusal names one retry, so a wrong guess costs one call and never a loop:
| Error | What it means | The one retry |
|---|---|---|
failed_precondition | You omitted admin on a node whose unelevated identity would be the superuser — unelevated never means root | Retry with admin: true |
forbidden | You asked to elevate without the authority — see gated twice | Retry with admin omitted. The same call never succeeds on a retry, and it is never quietly run unelevated |
Both halves of the gate are also answerable before you call, which is worth one lookup on a large fan-out rather than a refusal per node:
whoamireportscan_elevate— your half.falsemeans every elevated call returnsforbidden, so never sendadmin: true.network_directoryreports each node'suser,allow_adminanduser_is_admin— the node's half.
Unelevated never means root
Read that second row twice, because it is the one that surprises people: unelevated is not the same thing as unprivileged. The node resolves what admin: false would run as — the configured account if there is one, its own ambient identity otherwise — and if that comes out uid 0, it refuses the call instead of running it as root. The refusal is failed_precondition (HTTP 403) and is not retryable: the request is not denied, it is incoherent, because "unelevated" would mean the superuser on that box.
Three shapes reach it:
| Node 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 — admin: true.
A Windows node on 0.4.46 or later can still reach this code, for a reason that is about the node rather than the caller: it fails closed, so an account whose token cannot be obtained, whose profile cannot be loaded, or whose environment cannot be built refuses the call instead of running the work as LocalSystem.
Both remedies are named in the message, and either one works:
- Configure an account on the 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
admin: true, if the superuser is genuinely what you meant. That path is gated, which is the point — an elevation then appears in the record as an elevation, for whoever reads it later.
From agent 0.4.126 the message also names admin: "auto", but only where that value would actually elevate on the node answering. It is the cheaper retry when you are sweeping a mixed fleet — "auto" takes whichever identity each node can offer, so one retry covers the nodes that refused and the nodes that were fine — and the refusal is where a caller learns it exists. A node whose owner set allow-admin: false does not offer it, because "auto" resolves to unelevated there and the second call would be refused exactly like the first; neither does a node whose configured user is a uid-0 account not named root (for example user: toor), where "auto" resolves the same way. Where it is named, the sentence says which account it would run as.
An empty user is a warning, not a verdict. A node whose agent is already unprivileged — a rootless container, a nobgp agent started by an ordinary account — serves admin: false as itself, account or no account. Refusing there would prevent no elevation, since admin: true on such a node resolves to the very same uid. So an empty user does not by itself mean you must elevate — choose by what the work needs, and let a failed_precondition be the one retry that says otherwise.
This is not a privilege reduction, and it is worth being exact about that: a node with no account moves from everything implicitly root to only admin: true, explicitly root. The same privilege is reachable. What changes is that asking for it is now visible.
The rule is the node's, evaluated from its own config at the moment of the operation — the router forwards the flag and resolves nothing. Agent 0.4.41 is the release that introduced it, replacing a silent fall-through to root.
network_directory reports each node's user and allow_admin so you can tell which shape you are dealing with before choosing.
A name reported there is not a promise. The node decides at the moment of the operation, so root — or any second superuser account — is reported by name and refused just the same, and an empty value is not a verdict in the other direction either. From agent 0.4.41 the name is the one the node resolved, so an account since deleted reports empty rather than reading as usable. A Windows node reports empty unless it has a configured account, which since agent 0.4.44 is its second identity — a name there is what file operations run as, and from agent 0.4.46 what execution runs as too (on 0.4.44 and 0.4.45 an unelevated execution call was still refused).
Absent — the field missing rather than empty — means only that the agent predates the field. Such a node also predates admin itself: it ignores the flag and runs the work as its configured user, so admin: true there does not elevate. Agent 0.4.34 is the release that honours it; both directions of that skew fail closed, and neither elevates on its own.
The gate is on the resolved uid 0, not a spelling, so any superuser account is covered.
Elevation is gated twice
admin: true must pass the router and the node. Either one can refuse, and an omitted or false call passes both trivially — it is the default and needs no authority at all.
| Gate | Asks | What a refusal looks like |
|---|---|---|
| Router (caller authority) | May this caller ask? Org Owner or Admin on the public server; a manage grant on a node's local server. A Member, and an observe-tier node, cannot. | forbidden, before the request ever leaves the router — "execute.elevated requires a higher role in this organization", or one naming node_grant role=manage |
Node (allow-admin) | May anyone be the superuser here? The node owner's setting, on the box. | permission_denied, carrying the node's own wording — or a refused event, on the bus |
A Member keeps every ordinary capability: they run commands, read and write files, and publish services, all as the node's configured account. What they cannot do is choose the superuser. Handing a caller root on a machine is an administrative act rather than an operational one — the same line node_grant draws when it lets a Member run a command themselves but not delegate that authority to a machine.
Before this, admin was checked only at the node. allow-admin defaults to true, so on a stock node anyone who could reach it could be root on it.
forbidden on an elevated call is not worth retryingRetry it with admin dropped — that runs as the node's configured account, and is what most work wants anyway. If the operation genuinely needs the superuser (a 0600 file the service account cannot open, a service that must bind a privileged port), the remedy is one of: an org Owner or Admin running it, an operator raising the node's grant with node_grant role=manage, or the node's owner clearing allow-admin: false on the machine. None of them is something a client can fix by trying again.
A published terminal service is a standing grant: it runs at the identity stored on it, and it hands that identity to whatever command you later point it at. So service_update asks what the service will run as after your change, not whether you sent admin — updating a service already published with admin: true needs the same authority whether or not you send the field. Lowering it to the configured account never needs authority.
Elevation also sheds allow-roots (agent 0.4.37+; earlier agents confine both identities). The node owner's path confinement binds the configured account; the ambient identity is not bound by it, so an elevated call reaches paths an ordinary one is refused. What it never reaches is the agent's own configuration directory — that refusal is unconditional at both identities, because it holds the node's key, its JWT, and the settings these vetoes are read from. Elevating is not a way to read a node's credentials.
The decision follows the admin flag you sent, not the uid the work resolves to. That matters on Windows, where a default node's two values both land on LocalSystem: keyed on the uid, every Windows file call would shed allow-roots and the owner's confinement would evaporate on the whole platform.
To run as some third account, use su or runas inside the command itself. There is no way to name one, deliberately: an arbitrary username shipped for a single release and every way it went wrong came from the expressiveness — whether root was a spelling or a uid, and a Windows contract that said one thing while the agent did another.
A Windows node with no configured account has one identity. The service runs as LocalSystem, so both values land in the same place and allow-admin: false is an off switch rather than a downgrade there. Since agent 0.4.44 a configured user gives it a second identity: fs_* and file run as that account, and from agent 0.4.46 so do command, terminal sessions and dispatched bus commands (on 0.4.44 and 0.4.45 those refused an unelevated call rather than running as LocalSystem). Which of the two shapes a node is, is the node's answer and not the platform's. See A second identity on Windows.
File operations run as the same identity, and it genuinely changes who acts. The agent drops privilege before opening the file, so permissions and ownership apply as they would to that account — a file written with admin=false is owned by the configured user, not by root. (Before this, the file tools always ran as the agent's own account, which is why a path readable via fs_read could be denied via command on the same node. That gap is closed.)
Every file tool takes the flag. file and the per-op fs_* tools all carry admin, and it means the same thing on each. Set it on the first call of a read/write session; continuations carry the session's identity. One deliberate exception: fs_glob (REST-only) has no admin 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, its two refusals arrive in different places: the caller's authority is checked once, on the network's organization, and a forbidden fails the whole call, so a cohort is never partly elevated; a node whose owner set allow-admin: false — or whose unelevated identity would be root — reports that as that node's event and the rest of the cohort walks on. fs_subscribe has no admin 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 admin 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 admin: 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
admin: 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 admin omitted and fail with exit 127 — command not found — under admin: "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.
Declaring that a call carries a secret
Router 0.4.85+. command (inside session) and command_subscribe take an optional redact: an array of short labels, one per secret the call carries.
{ "session": { "node_name": "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.
| Hint | Meaning here |
|---|---|
readOnlyHint | The call changes nothing — not on a node, not in the router. |
destructiveHint | The call can overwrite or destroy something: file contents, a service, a network, a running process. |
idempotentHint | Repeating the call with the same arguments has the same effect as making it once. |
openWorldHint | The tool reaches an endpoint outside your own fleet. |
destructiveHint and openWorldHint are always sent explicitly, including when false — under the MCP specification an absent openWorldHint means true, so staying silent would advertise the opposite of what these tools do.
file is the only open-world tool, because its op=fetch_url takes a caller-supplied URL that the agent then fetches. Everything else operates strictly on nodes in your own networks — including command, where the fact that a shell command could itself curl something is authority destructiveHint already covers.
Three annotations worth knowing about before you auto-approve on them:
fs_subscribeis not read-only. Every matching node stands up a real file watcher — that is state, not a read.event_tailis read-only but not idempotent. It touches only your own subscription — 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 anoffsetre-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.
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+). Four fields answer the questions asked of any call — which operation, on what, did it finish, how long did it take — plus two channels a tool uses to say something its own fields cannot:
{
"call_id": "9e4c…",
"op": "fs_read",
"done": true,
"duration_ms": 41,
"node": "8f2a…",
"path": "/etc/hosts",
"fs_read": { "chunk_b64": "…", "offset": 0, "size": 221, "file_size": 221, "sha256": "…" }
}
| Field | Always present | Means |
|---|---|---|
call_id | yes | 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 failures too, so the call that went wrong is the one you can name. |
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.state: "running". |
duration_ms | yes | Wall-clock duration of this call, measured on the router across everything it did, including any round trip to the node. |
node / path / service_id | no | The subject the call acted on — at most one, and absent where a tool has no single subject (network_directory, whoami). 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. Two exceptions: the four subscribe tools share one result type and therefore one key, subscription; 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.
Each tool's ### Output Schema is the object under that tool's own key. Read it inside the envelope above — call_id, op, done, duration_ms 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 up to three things:
- A one-line human-readable summary, as the first
TextContentblock. - For tools whose response is the payload you asked for —
command,file,fs_read,fs_list,fs_stat, thenet_*diagnostics,network_directory,event_tail,event_subscriptions,service_publish,service_share,whoami— a secondTextContentblock holding compact JSON of the response, prefixed with--- result ---. Tools whose response is an acknowledgement (network_create,service_delete,register_node, …) send the summary line only, so a trivial call doesn't pay twice for the same information. structuredContent, carrying the full typed response.
The second text block is capped at 32 KiB. Past that it truncates on a UTF-8 boundary and ends with ... [truncated; full N bytes in structuredContent] — the partial JSON is there for a model to read, not to parse. Read structuredContent when you need the whole payload programmatically: it is never truncated.
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 network_directory, 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, locking, renaming | work — the way back to a usable state is never blocked |
file op=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:node_names,node_ids,labels,watches,idle_timeout,max_secondsand every other typed parameter are callable directly. - Optional means omit it. Optionality is expressed by absence from the schema's
requiredlist. Leave an optional parameter out rather than passing an explicitnull— an explicitnullis rejected. - Unknown argument names are rejected. A misspelled parameter (
nmaesfornode_names) fails withinvalid_argsinstead of being silently dropped, so a typo surfaces immediately rather than as a call that quietly did the wrong thing.
Available tools
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 | — | both tiers | This node's own agent status, answered on the box |
| resolve | — | both tiers | Resolve a peer name in this node's overlay zone |
| Network management | |||
| network_directory | ✓ | — | Discovery and listing |
| network_create | ✓ | — | Create networks |
| network_delete | ✓ | — | Delete networks |
| Organization management | |||
| org_create | ✓ | — | Create an organization |
| org_update | ✓ | — | Rename an organization (REST/OpenAPI only) |
| org_sso_setup | ✓ | — | Mint a WorkOS Admin Portal link (Owner only, 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_rename | ✓ | — | Change a node's name and nothing else |
| node_label | ✓ | — | Set, remove, or read a node's labels |
| node_grant | ✓ | — | Start a node's local server and let it reach its peers |
| node_revoke | ✓ | — | Take that reach back |
| node_config_get | ✓ | — | Read a node's own agent settings, over the control channel |
| node_config_set | ✓ | — | Change them, over the control channel |
| 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 | ✓ | manage | Run commands and manage interactive shell sessions |
| Filesystem | |||
| file | ✓ | manage | Unified file super-tool (thirteen ops) |
| fs_read | ✓ | observe | Stream a file from the agent |
| fs_write | ✓ | manage | Stream a file to the agent |
| fs_edit | ✓ | manage | Atomic diff-style edits |
| fs_list | ✓ | observe | Directory listing |
| fs_stat | ✓ | observe | Single-entry metadata |
| fs_delete | ✓ | manage | Remove a file or directory |
| fs_mkdir | ✓ | manage | Create a directory |
| fs_copy | ✓ | manage | Copy one file from one machine to another, in one call |
| fs_grep | ✓ | observe | RE2 search across one node's files |
| Diagnostics | |||
| net_peers | ✓ | observe | Agent's peer directory |
| net_interfaces | ✓ | observe | Host network interfaces |
| net_metrics | ✓ | observe | Agent Go runtime counters, plus a host block for the machine |
| net_routes | ✓ | observe | Kernel routing table (Linux) |
| net_dns | ✓ | observe | Resolver configuration (Unix) |
| node_logs | ✓ | observe | The node's own agent log, by whatever mechanism that node has |
| Event bus | |||
| fs_subscribe | ✓ | observe | Watch files and directories across a cohort |
| command_subscribe | ✓ | manage | Dispatch a command across a cohort and collect reports |
| presence_subscribe | ✓ | observe | Node online / offline / registered transitions |
| fs_grep_subscribe | ✓ | observe | RE2 search across a whole cohort |
| event_tail | ✓ | observe | Read pending events (and keep the subscription alive) |
| event_unsubscribe | ✓ | observe | End a subscription and stop the work |
| event_publish | ✓ | observe | Publish your own event into a subscription |
| event_subscriptions | ✓ | observe | List your live subscriptions |
| Identity | |||
| whoami | ✓ | observe | Caller identity, organizations, and elevation authority |
| Feedback | |||
| feedback_submit | ✓ | both tiers | 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, targets[] with each peer's name, node_id, node-local address and local flag, and sessions[] with node_id, name, encrypted, compressed); mcp (granted, running, port — port only while running); allow (tools, roots, admin, unelevated or unelevated_refusal, an optional unelevated_is_admin, and an optional warning); optional fs mount state and environment block.
The targets[].node_id values are the handles every proxied tool takes.
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, either that no /20 of 100.64.0.0/10 was free or that the machine's routing table could not be read. 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. See nobgp status for the 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 network_directory), 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.
allow answers the identity question for this node (agent 0.4.42+), which matters here because network_directory — the tool that reports it fleet-wide — is organization-scoped and deliberately absent from the local surface, so a peer's user and allow_admin cannot be read from this endpoint at all:
unelevatedis what anadmin: falseoperation runs as, resolved by the same gate the real call uses — not the string in the config file.unelevated_refusalappears in its place when unelevated work cannot run here, which means every call on this node needsadmin: true— unelevated never means root.unelevated_is_admin(agent 0.4.46+) appears only whentrue, and says that account is itself administrative — a member of the localAdministratorsgroup on Windows — soadmin: falsehere drops from the ambient identity to Administrator and no further. It is the on-box half ofnetwork_directory'sinfo.user_is_admin. Absent is notfalse: a node that did not check says nothing.allow.admin: falseis the other half: elevation is refused too. With both refused, nothing runs remotely on the node.
For a peer, the rule stands in for the lookup: choose by what the work needs and let the refusal name its one retry.
Served by the agent itself, never proxied. Both local tools are pure reads of on-box state and advertise that (readOnlyHint, idempotentHint, closed world), so a client that auto-approves on hints does not stop to ask about them.
resolve
Surfaces: local only
Resolve a peer name in this node's overlay zone.
Parameter: name (string, required — bare or fully-qualified in this node's zone; names under other zones are refused).
Response: name (normalized), node_id, address (a node-local overlay handle — never valid on another machine), fqdn, found. found: false is an authoritative miss; found: true with an empty address means no data yet, and is retryable.
network_directory
Surfaces: public only
List networks, nodes, and published services accessible to the authenticated user.
Purpose
Primary discovery tool - shows the current state of all infrastructure.
Input Schema
{
"network_name": "string (optional)",
"online": "boolean (optional)",
"node_name_glob": "string (optional)",
"hostname_glob": "string (optional)",
"platform_glob": "string (optional)",
"brief": "boolean (optional)"
}
Parameters:
All filters compose with AND — supply any combination to narrow the result.
network_name- Specific network name to filter results. If omitted, returns all networks.online- When set, restrict to nodes with this online state.true= currently connected only;false= disconnected only. Omit for all nodes.node_name_glob- Shell-glob (path.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). ⚠ That is the free-form name a node calls itself, not its OS family — there is noosfilter here, soplatform_glob: "linux"matches nothing on most fleets rather than every Linux node. Readinfo.osin the response instead — see the note under the output schema below.brief- Whentrue, omit each node'sservicesarray, 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 two things only and is not a general make-it-smaller knob:info.health,info.fs_mount,info.mount_readable,info.writable_rootsand the capacity fields (info.cpu_count,info.total_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.
A network no node matched is omitted entirely. Once online or any glob is set, a network whose nodes all fail the filter drops out of the response rather than appearing with an empty nodes array — a glob aimed at one node used to return every network you belong to, all but one of them empty, and you paid context for each. Two deliberate exceptions:
- A network you named with
network_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, 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)",
"info": {
"hostname": "string",
"agent_version": "string",
"version_status": "string (current | behind | ahead | empty)",
"channel_version": "string",
"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",
"hardware_model": "string (absent when the node can't name its hardware)",
"cpu_count": "number (logical CPUs the machine has, SMT threads included; absent when the node hasn't reported it)",
"total_memory": "number (bytes of RAM the machine has; absent when the node hasn't reported it)",
"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_bytes": "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 total_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_free_bytes": "number",
"disk_free_bytes": "number (free space where the agent is standing; absent when the node didn't measure it)",
"load1": "number (absent on Windows)",
"at": "string (RFC3339 UTC — the sample is up to 60s old)"
},
"user": "string (may be empty; absent on older agents)",
"allow_admin": "boolean (absent on older agents)",
"user_is_admin": "boolean (absent unless the node checked)",
"virtualization": "string"
},
"task_id": "string (provisioning UUID, if applicable)",
"task_deadline_at": "string (RFC3339 UTC — when this node's compute stops on its own)",
"task_image_name": "string (the container image this machine runs, e.g. \"nobgp/nobgp\"; open tasks only)",
"task_image_version": "string (that image's version tag, e.g. \"0.4.123\"; absent when it could not be read)",
"services": [
{
"id": "string",
"title": "string",
"url": "string",
"public_url": "string",
"command": "string"
}
]
}
]
}
]
}
org_id is the organization that owns the network, and therefore its nodes. It is the join key for whoami's orgs[]: elevation authority is granted per organization, so a caller who holds different roles in different organizations reads can_elevate off the orgs[] row whose id matches the org_id of the network holding the node.
files_url is the network's shared drive as a 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 Owner or Admin in the organization that owns the node — a step above the network share and above the fs_* tools on a node's disk, since a write landing there passes none of the node's own owner vetoes.
The URL is for bytes; the tools are for everything else (router 0.4.56+). Both trees are also addressable from file and the fs_* tools without an HTTP client and without going near a node — network_name alone for a share, node_id + storage: true for a node's area — at the same two authority tiers. See Addressing storage instead of a node; the URL is still what streams a file past the tools' 1 MiB per-call ceiling.
The info.virtualization field is emitted as system/role (e.g. docker/guest, kvm/host) and is omitted on bare-metal nodes. The info block is absent for nodes that have never connected.
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 — there is no os filter on this tool, so a glob is the wrong instrument for "every Linux node". Filter on info.os in the response, or use the node selector the subscribe and cohort tools take, which has a real os axis alongside platform and arch.
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.
⚠ 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 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.
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.
task_image_name and task_image_version say which image a provisioned machine started from, from router 0.4.149 — nobgp/nobgp and 0.4.123, the repository without registry host or tag and the version tag the provider actually started. They are recorded on the task when the machine is provisioned, so reading them costs nothing and does not touch the node; /etc/nobgp-image on the machine itself answers the same question from the inside.