Skip to main content

MCP Reference

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

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

How to read this

Every entry carries a Surfaces line directly under its heading:

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

The local surface is a deliberate subset: 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 inventory

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

Concepts

Addressing a node

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

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

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

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_args even 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_copy and file with op: "copy" have no flat form, and that is deliberate: a copy has two ends, so one top-level node_id cannot say which it means. Both ends carry their own target — see fs_copy. A top-level node reference there is refused by name rather than ignored.
  • Everything else is unchanged: which of node_id or the name pair you send, the defaulting rules above, and the authority checks are all the same whichever spelling you use.
It needs router 0.4.94 on most of those tools

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_argsmissing 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_args asking which one. If every network you can see lives in one org, personal or shared, nothing changes: a single one is still picked automatically.
  • A name that exists in more than one of your organizations resolves to the personal one first, then to the oldest. Network names are unique per creator, not across an org boundary, so two orgs you belong to can each hold a home.
  • An id binds exactly. network_id is checked for membership by id and never resolved through its name, so passing the id of a shared org's home reaches that network and not your personal namesake.

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

Addressing storage instead of a node

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

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

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

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 mountnetwork_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_found rather 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-only observe tier could read, list and stat any of their shares.
  • A node's storage areaOwner or Admin in the organization that owns the node. A Member gets forbidden naming the requirement; someone outside that organization gets not_found, since the node's existence is not theirs to learn. A node you have deleted keeps its area 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 reaches nodes/<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's targets, comes back not_found, and a peer inside them needs manageobserve gets forbidden naming the tier. Before 0.4.73 this path asked what the owner held, so a node granted the read-only observe tier could read and write the 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, mkdir and delete — so fs_read, fs_write, fs_list, fs_stat, fs_mkdir, fs_delete, and file with those op values. Anything else is refused with unsupported naming what is supported, rather than quietly reading the node's disk instead.
  • copy too, on either end (router 0.4.84+): fs_copy addresses each side independently, so a node → share, share → node or node's-storage → node copy is one call. op=fetch_url is 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 accepted storage: true and wrote to the node's real disk anyway, reporting success.
  • lock and unlock exist here and nowhere else (router 0.4.62+) — file only, since no fs_* tool carries them. See Locking a path on a storage tree.
  • file with op: "batch" works too (router 0.4.59+): the batch's storage applies 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 own unsupported while its siblings still run. Before 0.4.59 a batch ignored storage and ran every sub-op against the node's real disk, reporting success.
  • path is relative to that tree's own root, not to any filesystem: /reports/q3.csv is that file in the share and / is the top of it.
  • One call, never a session. There is no file_id to poll: the response carries the bytes.
  • done says the call finished (router 0.4.90+). Every operation here but read completes inside one response and reports done: true; read is the only one that can page, and there done: false means the object continues past what you asked for. Before 0.4.90 list, stat, mkdir, delete, lock and unlock reported done: false on calls that had completed, which is why a listing could disagree with its own truncated flag — read fs_list's own truncated and next_offset for 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_bytes is 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 with done: false; call again with offset advanced by size until done: true. Before 0.4.59 max_bytes was ignored and anything over 1 MiB failed with too_large at every offset. An offset past the end of the object is invalid_args from router 0.4.94, carrying the object's true size in details.file_size — the answer a node read has always given. Through 0.4.93 it seeked there regardless, read nothing and answered done: true with file_size equal 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_large and points at the tree's HTTPS URL, which PUTs 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, done is ignored and no file_id is issued, so following the chunked recipe fs_write and file describe 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, mkdir and the destination end of a copy are refused with resource_exhausted (HTTP 429), and the message names how much the organization is using against its allowance and that fs_delete still works. read, list, stat, delete, lock, unlock and 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 refused PUT, COPY and MKCOL over the tree's HTTPS URL with 507 Insufficient Storage since 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): read and write are refused with resource_exhausted and details.limit_type: "bandwidth", while list, stat, mkdir, delete, lock and unlock keep 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 a read is refused by one and never by the other.
  • expected_sha256 is 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. See fs_write.
  • admin is ignored. There is no second identity to choose when no agent runs the operation, so nothing is elevated and nothing is gated on elevation.
  • recursive means the same thing it does on a node (router 0.4.59+): list walks the subtree, and delete needs 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 /etc to protect, so a top-level directory needs no force: true, though sending one is still accepted and means nothing. Deleting the tree's own root is always refused. mode is not applied.
  • A delete answers 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-trash is 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_grep and fs_glob take no storage field 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 a PUT answers 409 rather than the misleading 404 it would otherwise be.
  • It reaches implied creates too. fs_write and recursive fs_mkdir create 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.txtFoo.txt), which is how a stored casing gets corrected.
  • Lookups stay exact. fs_read on /README.md does not find /readme.md; the mounted drive folds on the node where the platform does — a Windows winfsp volume from agent 0.4.74, where a Linux fuse or nfs mount 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 flock has locally, and lock_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 every PUT, DELETE, MOVE or COPY on the share's URL — which is refused with 423 while 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: lock against a node's own disk is refused with unsupported, 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 file proxied 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 fuse or nfs mount takes the same lease — a Linux fuse mount from agent 0.4.64, an nfs mount from agent 0.4.69. A program that locks a file on such a node's mounted drive contends with op: "lock" on the same path, and the other way round. On winfsp the mount and this tool remain separate worlds. On webdav the split is finer: your program's own flock never leaves the machine, but the WebDAV LOCK the 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 with 423 while another holder has the path.
  • It expires after two minutes unless re-taken. lock_expires is 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. details says what a contender needs to decide whether to wait: expires_at, held_by_self (true when it is you from another client or another tool in the same session), and held_by_kinduser for a person's session, node for a call proxied through a node, in which case held_by also 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.txt and /x/../a.txt are one path, not three locks.
  • unlock releasing a lock you do not hold is not an error. It reports locked: false and 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.

On some backends this is not the same thing as locking on a mounted drive

A lock taken through file is coordinated by noBGP. A lock taken by a program on a node's mounted driveflock, 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_readableMeans
truethe agent read its own mount, and paths under info.fs_mount work. Do not route around a mount that reports true.
falseevery 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.
absentthe 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 so admin: true fails 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_readable absent — 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 not false.
  • mount_readable: true — nothing is added. The mount is readable and something else denied this path (mode bits, an allow-roots confinement), 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 sudo from 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 /Volumes is refused the same), not the backend (webdav is refused exactly like nfs), not the mount options. The owner lifts it with a Full Disk Access grant — see below.
  • Windows on the webdav backend — 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 a winfsp mount, 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 ofSend
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 fallback

Those 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 ASCIIThe 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 insteadscript 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 yourselfFor 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:

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

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

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

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

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

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

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

allow-admin: false can lock you out

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

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

Execution identity

A node offers exactly two identities, and one 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:

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

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

adminRuns asWho may ask
omitted / "false"the node's configured user — captured from the installing account at registration, so a normal sudo install runs as that person, not rootanyone who can reach the node
omitted / "false", where that identity would be uid 0nothing — the call is refused, rather than run as the superuser
"true"the node's ambient identity — gated twice, and both gates refuse rather than downgradeorg 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 defaultsame 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:

ToolWhere admin sits
commandinside 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_updateat 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 nameinvalid_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_publish and service_update keep 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 admin still 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 same forbidden. A node whose owner set allow-admin: false never 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. On command it is present on the call that created the session and absent when polling an existing command_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_mkdir and fs_edit each accepted admin including "auto", and each one's own field description promised the result would name the identity — and none of them returned it. The same operation through file did, so file {op: "stat", admin: "auto"} answered admin_used: true on a node where fs_stat {admin: "auto"} answered nothing at all. On a router below 0.4.89, read the outcome of an "auto" call through file rather 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:

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

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

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

Unelevated never means root

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

Three shapes reach it:

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

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

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

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

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

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.

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

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

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

A forbidden on an elevated call is not worth retrying

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

Inheriting an elevated service is a raise

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

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

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

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

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

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

Every file tool takes the flag. file and the per-op fs_* tools all carry admin, and it means the same thing on each. Set it on the first call of a read/write session; continuations carry the session's identity. 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 127command 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.

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

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

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

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

  • fs_subscribe is not read-only. Every matching node stands up a real file watcher — that is state, not a read.
  • event_tail is read-only but not idempotent. It touches only your own subscription — 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 an offset re-delivers a batch you already saw.
  • event_unsubscribe is destructive. It kills commands still running under the subscription and tears down watchers on every node in the cohort.

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

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": "…" }
}
FieldAlways presentMeans
call_idyesOpaque 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.
opyesThe tool that produced the result — always the tool's own name. The multi-operation tools (file) report which operation ran inside their own payload.
doneyestrue 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_msyesWall-clock duration of this call, measured on the router across everything it did, including any round trip to the node.
node / path / service_idnoThe 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.
hintnoPresent 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.
notesnoCaveats 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 namefs_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.

The Output Schema blocks below show the payload

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:

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

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

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.

CallWhile stopped
commandstarting a sessionrefused (a session already running keeps delivering its output)
fs_read, fs_write, fs_edit, and file read / write / edit / multi_editrefused, on a node's disk and on a storage tree alike
fs_copyrefused 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, renamingwork — the way back to a usable state is never blocked
file op=fetch_urlworks — the node performs that fetch itself and the bytes never cross noBGP
Everything that is not file or command bytesunaffected

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_seconds and every other typed parameter are callable directly.
  • Optional means omit it. Optionality is expressed by absence from the schema's required list. Leave an optional parameter out rather than passing an explicit null — an explicit null is rejected.
  • Unknown argument names are rejected. A misspelled parameter (nmaes for node_names) fails with invalid_args instead of being silently dropped, so a typo surfaces immediately rather than as a call that quietly did the wrong thing.

Available tools

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.

ToolPublicLocalWhat it does
This node
statusboth tiersThis node's own agent status, answered on the box
resolveboth tiersResolve a peer name in this node's overlay zone
Network management
network_directoryDiscovery and listing
network_createCreate networks
network_deleteDelete networks
Organization management
org_createCreate an organization
org_updateRename an organization (REST/OpenAPI only)
org_sso_setupMint a WorkOS Admin Portal link (Owner only, REST/OpenAPI only)
org_sso_set_enforcedTurn SSO login enforcement on or off (Owner only, REST/OpenAPI only)
Node management
provision_nodeCreate compute resources
deprovision_nodeRemove compute resources
task_stopStop a running machine and keep its node
task_deadline_setGive a running machine more time, or less
register_nodeGenerate install commands for existing machines
node_renameChange a node's name and nothing else
node_labelSet, remove, or read a node's labels
node_grantStart a node's local server and let it reach its peers
node_revokeTake that reach back
node_config_getRead a node's own agent settings, over the control channel
node_config_setChange them, over the control channel
Service publishing
service_publishExpose services publicly
service_updateModify service settings
service_deleteRemove services
service_shareManage authorized email lists
service_checkAsk whether a published service's backend actually answers
Command execution
commandmanageRun commands and manage interactive shell sessions
Filesystem
filemanageUnified file super-tool (thirteen ops)
fs_readobserveStream a file from the agent
fs_writemanageStream a file to the agent
fs_editmanageAtomic diff-style edits
fs_listobserveDirectory listing
fs_statobserveSingle-entry metadata
fs_deletemanageRemove a file or directory
fs_mkdirmanageCreate a directory
fs_copymanageCopy one file from one machine to another, in one call
fs_grepobserveRE2 search across one node's files
Diagnostics
net_peersobserveAgent's peer directory
net_interfacesobserveHost network interfaces
net_metricsobserveAgent Go runtime counters, plus a host block for the machine
net_routesobserveKernel routing table (Linux)
net_dnsobserveResolver configuration (Unix)
node_logsobserveThe node's own agent log, by whatever mechanism that node has
Event bus
fs_subscribeobserveWatch files and directories across a cohort
command_subscribemanageDispatch a command across a cohort and collect reports
presence_subscribeobserveNode online / offline / registered transitions
fs_grep_subscribeobserveRE2 search across a whole cohort
event_tailobserveRead pending events (and keep the subscription alive)
event_unsubscribeobserveEnd a subscription and stop the work
event_publishobservePublish your own event into a subscription
event_subscriptionsobserveList your live subscriptions
Identity
whoamiobserveCaller identity, organizations, and elevation authority
Feedback
feedback_submitboth tiersTell 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+ — transportquic 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, portport only while running); allow (tools, roots, admin, unelevated or unelevated_refusal, an optional unelevated_is_admin, and an optional warning); optional fs mount state and environment block.

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

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:

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

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

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


resolve

Surfaces: local only

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

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

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


network_directory

Surfaces: public only

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

Purpose

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

Input Schema

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

Parameters:

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

  • network_name - Specific network name to filter results. If omitted, returns all networks.
  • online - When set, restrict to nodes with this online state. true = currently connected only; false = disconnected only. Omit for all nodes.
  • node_name_glob - Shell-glob (path.Match syntax) matched against each node's name: *, ? and […] character classes. Brace expansion is not part of that syntax{web,db}* is refused with invalid_args naming the cause rather than matching a node literally named {web,db}…, which is what it used to do, and it read as "none of your nodes are online". List the alternatives, or use a broader pattern and filter the result. Malformed patterns return invalid_args.
  • hostname_glob - Shell-glob matched against each node's reported hostname.
  • platform_glob - Shell-glob matched against each node's platform string (e.g. raspbian, ubuntu, darwin, openwrt, synology). ⚠ That is the free-form name a node calls itself, not its OS family — there is no os filter here, so platform_glob: "linux" matches nothing on most fleets rather than every Linux node. Read info.os in the response instead — see the note under the output schema below.
  • brief - When true, omit each node's services array, and the detail sentence inside info.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.scope and fs_locking.honest survive, 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_roots and 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_name is returned even when the other filters leave it empty. Its presence is the answer that the network exists and simply holds no matching node; a name you don't own is already a distinct not_found.
  • brief on its own prunes nothing. It narrows what is reported about a node, never which nodes match, so a brief call still lists every network.

Output Schema

{
"networks": [
{
"id": "string",
"name": "string",
"org_id": "string",
"files_url": "string (WebDAV base URL for this network's shared drive)",
"nodes": [
{
"id": "string",
"name": "string",
"online": "boolean",
"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 URLhttps://files.nobgp.com/networks/<network-id>/. It is a WebDAV endpoint: PROPFIND to list, GET to download, PUT to upload, MKCOL / MOVE / COPY / DELETE to manage, authenticated with the same bearer token that authenticated this call and carrying the same access. Router 0.4.54 is the release that made it resolve: earlier routers advertised a name-keyed form that had never served anything.

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

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

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

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.149nobgp/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.

It is the image at provision time, not the agent version running now. The agent upgrades itself inside the container, so the two diverge the first time it does — read info.agent_version for what is running. Provisioning the name again is what moves a node onto a current image.

Both follow task_deadline_at's rule and are reported for an OPEN task only: a completed task's image is the image of a container that no longer exists. They are also absent on a node that is not cloud-provisioned, and on a task provisioned before router 0.4.149 recorded images. task_image_version is absent on its own when the router could not resolve a version — a moving tag such as latest is never reported as one.

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

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

info.fs_backend is which shared drive backend the node actually mounted with — nfs, fuse, webdav, or winfsp on a Windows node that has the driver installed and is running agent 0.4.82+ (through 0.4.81 that backend had to be pinned) — reported from router 0.4.61. It is not predictable from anything else in info: the backend is chosen by probing the host, with the local fs key able to pin it, so two nodes on the same platform running the same agent version land differently when one has a mount helper the other lacks. That makes it the only way to answer the questions a mixed fleet raises — who is still on WebDAV, whether a backend rollout has moved, whether a problem is confined to one backend. It is what nobgp status prints locally as fs.type, reported centrally.

Two values are not backends and are worth reading as answers in their own right. off is the node saying it mounts nothing on purpose — its fs key is set to off — and error is a node that tried and has no mount: a pinned backend the host cannot serve, a failed mount, a backend that declined. nobgp status on the box is where the reason is; fs.error names it.

It is omitted when the node has not said anything at all: an agent too old to report it, or one that has connected but not yet reached its first mount attempt. Absence means unreported, never that the node has no filesystem. Like user and allow_admin, it is a fact the node asserts about itself that the router reports and never acts on — the node owns its own mount decision.

It keeps up with the node from agent 0.4.63. Before that the value only travelled with a registration, so a node that mounted after connecting — which is every node, since the mount comes up after the control channel — kept whatever it had said at connect time until it next reconnected, typically hours later. The directory could therefore show error, or nothing at all, for a node whose drive had been mounted and healthy the whole time. The agent now pushes the value as soon as a mount cycle settles it, paced so the router's own rate limit cannot swallow the real answer behind the scratch one that precedes it. Against an older agent, read a stale-looking fs_backend as "last reported at connect", not as the node's current state.

info.fs_mount is where that backend is mounted, and info.mount_readable is whether the agent can read it (router 0.4.83+, agent 0.4.85+ — both are reported by the node, so an older agent sends neither and the directory shows neither). Together they are the pair fs_backend was missing: which drive letter or directory the node actually chose, and whether paths under it work through the tools at all.

  • fs_mount is an addressable form, not the node's most natural spelling. nobgp status on Windows reports the mount as N: and that exact string is refused by the file tools (invalid_args: path must be absolute), so a Windows value always carries its trailing separator — N:\. Forward slashes are accepted on input on every platform, so composing fs_mount + "/" + child is safe everywhere. The node picks the location, from its own config, a platform default, or the first free drive letter, so it is not derivable from platform or agent_version.
  • mount_readable is about the agent, never about you. true means the agent read its own mount and paths under fs_mount work; false means every file and command call on such a path is refused there at both identities. Absent means the agent never probed, which usually means nothing is mounted — fs_backend says off or error. Full treatment, and the two causes with their separate remedies, in A node's own mounted drive.
  • It is re-probed on every mount cycle, including while a mount is failing (agent 0.4.97+). A mount that fails its health check is re-probed a few times before the node condemns and rebuilds it, so that a passing network blip does not tear down a healthy drive — and through agent 0.4.96 the node published nothing during that stretch, leaving whatever it had reported when the mount came up. A node whose mount had been destroyed under it therefore went on reporting mount_readable: true for as long as it kept striking, measured at about 105 seconds. The value now degrades with the mount rather than a minute or two after it. ⚠ It is still re-probed rather than assumed false while striking: the probe reaches the router, so a network hiccup that fails the health check on a perfectly readable mount leaves the field alone instead of flapping it.
  • Both are withdrawn rather than answered when nothing is mounted: a node with no mount reports neither, instead of claiming a mount point that does not exist or a false about a mount that was never there.

info.writable_roots is where you may actually write under that mount. The mount root is a synthesised read-only view, so a write to fs_mount itself fails — and nothing said so before the attempt until this field existed. It lists the node's own area plus one entry per network the node is in, absolute and ready to use. It is derived by the router from fs_mount and the node's network list rather than reported, so it is absent exactly when fs_mount is.

info.fs_locking is what taking a lock on that node's mount actually buys (router 0.4.62+) — the question fs_backend raises and does not answer. Three fields:

FieldMeaning
scopeHow far a lock is enforced: cluster (between nodes), node (between processes on that machine only), or none (not at all). Read node as "real locally, worth nothing against a second node". Two backends reach cluster, each from the agent release that started forwarding its locks (router 0.4.68+): an nfs mount on agent 0.4.69+, and a Linux fuse mount on agent 0.4.64+. Below those releases the same backend name keeps its measured pre-forwarding row — nfs reads none, fuse reads node. webdav is node or none on every platform, and winfsp is node (router 0.4.71+).
honestWhether a lock this backend cannot enforce at least fails instead of falsely succeeding. false is the dangerous case: the caller is told it holds a lock it does not hold.
detailOne sentence naming what that mount does and does not guarantee, and the workloads to keep off it.

It is derived from fs_backend, platform and agent_version, not reported by the node — the answer is a property of those three, and the router holds all of them. Every value comes from a measurement on real hardware; a combination nobody has measured says so rather than guessing, which is why Linux davfs2 reads honest: false with a detail saying it is unmeasured. The field is omitted entirely when fs_backend is, because a node that has not said which backend it mounted has said nothing to derive from. Every backend a node can report now has a row: winfsp was the last one without, and got one in router 0.4.71 — until then a node running it was the only node in a directory with no fs_locking key at all, so a caller asking "is this share safe for SQLite" got nothing back to read.

The row is the one for that node's agent, not for your newest one (router 0.4.68+). Forwarding locks arrived in a specific agent release per backend, and a node below it reports the same backend name while doing something different, so the derivation reads agent_version too: nfs reads cluster from 0.4.69 and none below it, fuse reads cluster from 0.4.64 and node below it. That matters because old agents do not leave — a node that is offline is described from what it last reported, and a channel rollback or a late joiner puts a pre-forwarding agent back in the directory long after the rest of the fleet has moved. An agent whose version the router cannot read gets the pre-forwarding row as well: under-promising costs you a lock re-test, while a cluster claim nothing supports is a false all-clear on exactly the question this field exists to answer. A genuinely new capability still lands here only when the router's own derivation learns of it, so a node can read below what its mount does for as long as that takes.

honest is separate from scope because the difference is a bug versus a catastrophe. A lock that cannot be enforced and refuses is recoverable. One that reports success is what macOS over WebDAV does: two processes each take an exclusive flock on one file and both are told they hold it. Windows over WebDAV is the other trap, and it reads node, honest: false from router 0.4.78 — it enforces an exclusive open between local processes and reports that truthfully, which is what scope: node records, and then a held handle's write-back on close overwrites a concurrent writer on another node, leaving a file that is neither version with success reported to both. The field does not ask whether the enforced part is truthful; it asks whether the part the backend cannot enforce at least fails. Across nodes this one does not, which is why the row read honest: true through router 0.4.77 and why that was wrong.

A winfsp volume on that same platform shares the node scope and the unenforced cross-node lock, and is honest: true, because what a losing write costs there is different: its write-backs are conditional on the version the handle read, so the race is refused rather than blended and the rejected bytes are kept. The two Windows rows are therefore told apart by honest — and their detail sentences say which loss you are facing.

brief: true drops detail and keeps scope and honest. The sentence is the same ~350 characters on every node sharing a backend, which is exactly the repetition brief exists to avoid — but a compact response that dropped the warning would be the wrong kind of small, so the dangerous case still shows.

From agent 0.4.79 the node answers the same question on the box, as fs.locking in nobgp status — one sentence about the backend it actually mounted with. The two are independent: this row is derived by the router from what the node reported, so a node that has not reported a backend has no row here at all, while nobgp status still answers on the machine itself and is always current.

See Which backend mounts the drive for the same ground from the node's side, and Locking a path on a storage tree for the lock that is coordinated between nodes.

info.health is the node's most recent host-pressure sample (router 0.4.83+) — cpu_percent, mem_free_bytes, disk_free_bytes (router 0.4.144 with agent 0.4.119), load1 and the at it was taken. The agent has sampled these every 60 seconds for a long time; until 0.4.83 no tool returned them, and the one whose name promised them (net_metrics) answers about the agent's own Go runtime rather than about the machine. Nothing new runs on the node for this, and no agent release was involved — disk_free_bytes is the one exception, being a measurement the node had not been taking.

  • Read at before treating the numbers as "now". The sample is up to a minute stale by construction, so a node can look healthy 55 seconds into whatever is killing it. On a node that has gone unreachable, at is simply the last thing it managed to say.
  • The sample belongs to the machine that is running now, and a reboot is what invalidates it (router 0.4.112). These numbers all measure the host, so a sample taken before the machine last booted is not this node's health and is not served — while a bridge reconnect and an agent restart change nothing about the host and no longer suppress anything. The node's reported boot time is the key, and there are two ways it falls back to the older connection-based rule below: a node that has never reported one (an agent that could not read it has that recorded as unknown rather than as 1970, from router 0.4.113), and a boot time that is later than the connection it was read on, which is not a fact about a reboot but about the node's clock and the router's disagreeing.
  • Through router 0.4.111 the key was the node's current connection, which was a proxy for the wrong event: the router re-stamps online_at on every reconnect, so a node with a flapping link had its newest sample refused again and again. Measured across an hour: a node reconnecting every 45 seconds reported health at 0% of instants, and one reconnecting every 30 seconds likewise — the node most likely to be under pressure was the one missing from the answer to which node is under pressure. Keying on the boot time removes that completely.
  • A reboot still withholds health briefly — about 90 seconds, once — because a sample taken in the same minute the machine came up may predate the boot, and the whole of that minute is refused rather than risk serving a pre-reboot reading. This is the case the rule exists for: health is read after a reboot because of the reboot.
  • Absent health therefore means "no sample from this host" — the node has never reported one, or every sample it has predates the current boot. Through agent 0.4.86 the first case also covered the first minute after a node came up, since it sent nothing until its first interval elapsed; from agent 0.4.87 it sends one as it starts. An offline node keeps its last sample, because that is the last thing anyone knew and nothing will refresh it. net_metrics's host block applies the identical rule, so the two surfaces cannot disagree about the same node.
  • load1 is absent on Windows, and that is a platform fact rather than a collection failure: Windows has no load average, and what a collector synthesises there is a number that decays toward zero regardless of load. Absent is the honest answer; a zero would not be. Present on Linux and macOS.
  • disk_free_bytes is free space where the agent is standing (router 0.4.144, measured by agent 0.4.119) — the filesystem holding the agent's own working directory, which is where a dispatched build lands. ⚠ It is not the shared drive: writable_roots is remote share storage, so a node with 2 GB free here can still write gigabytes there. It is omitted when the node could not measure it, and on every node whose agent predates 0.4.119 — never a claim that the disk is full. ⚠ It is a good proxy rather than a promise about a particular call: where a node has a configured account and you leave workdir unset, an unelevated command starts in that account's home directory, which on a machine with /home on separate storage is a different filesystem from the one measured here.
  • Sizes are bytesmem_free_bytes, disk_free_bytes, never megabytes, like every other size on this surface.
  • On a container, cpu_percent and mem_free_bytes are the host's, not the container's. A 512 MB container on a 128 GB machine reports the machine. Read info.virtualization before treating them as this node's own.
  • These numbers are the load; cpu_count, total_memory and gpu are the capacity. Neither answers the other's question, and "which machine could run this" needs both.
  • It survives brief: true, deliberately: one brief directory call is the fleet-wide "which node is under pressure" question, and a brief response that dropped it could not answer the thing the field was added for.

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

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

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

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

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

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

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

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

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

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

What a machine could do, as opposed to what it is doing

info.cpu_count, info.total_memory and info.gpu are the node's capacity (router 0.4.144) — what the machine has, where info.health above says how busy it is right now. Read them together: a 1-core Pi at 20% and a 32-core server at 20% are not the same offer, and these three are what answer which of my machines could build this, or run this model in the one call you already make first.

FieldWhat it says
cpu_countHow many logical CPUs the machine has — SMT threads included, so a 16-core/32-thread server reports 32.
total_memoryTotal physical memory, in bytes, like every other size on this surface.
gpuWhat the node can accelerate work with — see below.

On a container both cpu_count and total_memory are the host's, not the container's limit — the same caveat health carries, for the same reason. Read info.virtualization beside them before treating either as this node's own.

cpu_count is not net_metrics's num_cpu. That one is the CPUs the agent process can actually use, and it honours CPU affinity, so on a pinned container it is the smaller and truer number; this one is what the machine has. They agree everywhere except a pinned container.

cpu_count and total_memory are reported at registration and have been collected for a long time — this release publishes them rather than starting to gather them, so they are there for essentially every node that has connected. They are omitted for a node that never reported them, which is not a claim the machine has none.

info.gpu names the programming interface, not a vendor and not a framework, because the interface is what decides whether a job can run. It is a capability and it is static — an accelerator cannot appear under a running agent — so it rides registration rather than the 60-second health sample, and it is on this tool only:

FieldMeaning
kind"none", "cuda", "rocm", "metal", "rknpu", or a value newer than this page — the vocabulary belongs to the node and is open, so treat one you do not recognise as real rather than as an error.
countDevices of that kind reachable from the agent's own namespace. On a cuda node they are counted one by one, up to 16 — a machine with more reports 16. On rocm, rknpu and metal the node finds a single compute interface rather than enumerating cards, so 1 there means "there is one", not "exactly one". Absent when the node did not say, which includes a machine reporting kind: "none".
memory_bytesThe accelerator's dedicated pool — a discrete card's VRAM, and nothing else. ⚠ Per device, not a total: a machine with four identical cards reports one card's VRAM beside count: 4, because one card is what a job gets. Absent on a unified-memory machine and wherever nothing measured it.
unifiedtrue when the accelerator draws on the same physical memory as total_memory. From router 0.4.146 with agent 0.4.121 false is a claim — this accelerator has a pool of its own — and absent is nobody established it. Below either half there was no absent: false was written for a probed discrete card, for a rocm or rknpu node nobody measures and for a probe that failed, alike.

⚠⚠ The block being absent and kind: "none" are different answers. Absent means the node did not say — an agent that predates the probe, a platform that has no probe at all (Windows — see below), or a probe that could not run. "none" means it looked and found nothing reachable. Reading absence as no GPU tells you the Apple Silicon machines have none, and those are among the ones worth finding.

⚠⚠ A Windows node reports no gpu block, from agent 0.4.121, and its silence is the honest answer. There is no accelerator probe on that platform: every branch of the probe looks for a device file that cannot exist on Windows, so the block is simply absent there. Through agent 0.4.120 such a node answered kind: "none" instead — a probe result it had never obtained — and that was wrong for the whole Windows fleet at once, measured on a machine reporting none while holding a 4 GB Radeon. A Windows node that already reported none takes that answer back as it upgrades, because a node rewrites the whole block on every registration, absence included (router 0.4.146 — see the withdrawal note below). Until somebody decides what a Windows probe should cost, read a Windows node's accelerator as unknown and ask the machine.

⚠⚠ memory_bytes and unified are read together, and neither answers the capacity question alone. Both an RTX 5090 and a DGX Spark report kind: "cuda" — one has 32 GB of its own and the other 128 GB it shares with the CPU, and that second number is what a large model has to fit in. kind cannot separate them:

The machinememory_bytesunifiedWhere its capacity for a large job is
A discrete card (RTX 5090)its VRAM in bytesfalse — see belowmemory_bytes
Unified-memory NVIDIA (GB10 / DGX Spark)absenttruetotal_memory
Apple Siliconabsenttruetotal_memory
A rocm or rknpu nodeabsentabsentunknown — not measured yet
A probe that could not runabsentabsentunknown
A Windows nodeabsentabsentunknown — the whole block is absent, there is no probe

The two absent · absent rows above need agent 0.4.121 as well as router 0.4.146: below that agent a rocm or rknpu node, and a machine whose measurement failed, both sent unified: false — the same bare false a genuinely discrete card sends.

unified is a capability claim, not a warning against double-counting. It says this accelerator can use total_memory, which is what makes a 128 GB unified machine findable for a large model. There is no sum to guard against: such a machine reports memory_bytes absent, so there is no number to add.

⚠⚠ Do not infer unified from memory_bytes being absent. Absence has three causes — no separate pool, a probe that could not run, and a kind whose memory nothing measures — and only unified tells the first from the other two. Treating absence as unified reports every AMD and Rockchip node as unified-memory, and a caller then reads a 4 GB board's whole RAM as accelerator capacity.

No GPU field is published as a fabricated answer any more. memory_bytes and count stopped being reported as 0 in router 0.4.145, and unified stopped being reported as false in router 0.4.146 — each because the wire carried no way to tell did not say from said zero, and each of those values is a claim nobody was making. All three are now simply omitted where nothing established them, so absence is the single non-answer.

That means most unified: false answers disappear on the 0.4.146 router, and that is the point of it. An agent on 0.4.120 sends a bare boolean, so on the wire its false and its unknown are the same thing and all of them now read as absent — including a genuinely-probed discrete card, whose false was a real claim. Those answers come back as nodes upgrade to agent 0.4.121, the half that sends unified only where the probe decided: true for Apple Silicon and for an NVIDIA part the driver says has no separate pool, false for a card whose own VRAM it just measured, and nothing at all for a rocm or rknpu node or a probe that failed. Until a node has upgraded, read absence as unknown, and place large work on memory_bytes and total_memory rather than on unified alone.

A discrete card reports a real memory_bytes from agent 0.4.120, which is also the release that stops answering unified: false for non-Apple unified memory — a GB10 (DGX Spark) read as though it had a pool of its own before it. Below that agent, treat memory_bytes as unavailable rather than as zero, and unified as answering for Apple Silicon only.

The figure is the NVIDIA driver's own answer, taken once at registration and only on a node where a CUDA device was already found — so a machine with no accelerator still starts nothing to establish that, which is what keeps the probe below cheap on a fleet of small boards. It is also what separates the two cuda machines: a discrete card has a separate pool and the driver reports its size, while a unified-memory part has no separate pool to size and says so. ⚠ A failure claims nothing in either direction: if that tool is absent, refuses, or does not answer within a couple of seconds, both memory_bytes and unified come back absent.

metal is reported on Apple Silicon only. An Intel Mac reports none even where it has a working discrete GPU, because the node cannot describe that memory honestly and an under-claim was preferred to a wrong one. Read kernel_arch beside gpu on a Mac.

It answers "can the agent reach an accelerator", not "does this hardware have a display controller". The node probes from where it is standing, so a container without device passthrough reports honestly rather than claiming the host's card. And whether a framework is installed — MLX, PyTorch, llama.cpp — is a different question that changes the moment somebody installs one: an Apple Silicon machine reports metal, and MLX runs wherever that is true.

gpu needs agent 0.4.119, the release that carries the probe. A node whose agent predates it reports nothing, so the block is absent across most of a fleet until it has upgraded — which is exactly why absence must not be read as "no accelerator".

A node on 0.4.119 or later can also take the block back (router 0.4.146). Such a node rewrites all four fields every time it registers, absence included, so a machine that stops reporting an accelerator — because a later agent corrected what it used to claim, or because the device is no longer reachable from where it stands — is shown as having said nothing rather than keeping its old answer. Through router 0.4.145 there was no withdrawal path: a block once written stayed until it was replaced by another one, so a stale claim could outlive the agent release that stopped making it. Nodes below 0.4.119 are untouched, which is what keeps their silence readable as too old to say.

count covers the reported kind only. A machine with two accelerators of different kinds is described by one of them — cuda wins where both a CUDA device and an AMD compute device are reachable — so a node reporting cuda may also have something else the block does not mention.

Example Usage

Request:

{
"network_name": "production"
}

Response:

{
"call_id": "5d0f2b81-…",
"op": "network_directory",
"done": true,
"duration_ms": 63,
"network_directory": {
"networks": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production",
"org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"files_url": "https://files.nobgp.com/networks/550e8400-e29b-41d4-a716-446655440000/",
"nodes": [
{
"id": "8a1f2d5c-7b3a-4e9f-9c1b-2e5a0d8f4c12",
"name": "web-server-1",
"online": true,
"online_at": "2026-08-19T06:41:52Z",
"info": {
"hostname": "web-server-1",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"os": "linux",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64",
"fs_backend": "fuse",
"fs_mount": "/mnt/nobgp",
"mount_readable": true,
"writable_roots": ["/mnt/nobgp/node", "/mnt/nobgp/networks/production"],
"health": {
"cpu_percent": 12.4,
"mem_free_bytes": 10087788544,
"load1": 0.31,
"at": "2026-08-19T09:22:00Z"
}
},
"services": [
{
"id": "svc_xyz789",
"title": "staging-app",
"public_url": "https://abc123.nobgp.com"
}
]
},
{
"id": "3c7b91ae-55d2-4f08-8a6d-1b9c4e70f233",
"name": "web-server-2",
"online": false,
"online_at": "2026-08-04T18:02:55Z",
"offline_at": "2026-08-04T21:14:07Z",
"info": {
"hostname": "web-server-2",
"agent_version": "0.3.45",
"version_status": "current",
"channel_version": "0.3.45",
"os": "linux",
"platform": "debian",
"platform_version": "12",
"kernel_arch": "x86_64"
}
}
]
}
]
}
}

web-server-1 is connected, so it carries no offline_at and its online_at is when the live connection began. web-server-2 dropped at the timestamp shown and has not been back since — its online_at is the start of that last session, not a claim that it is up, so the pair also says the session lasted about three hours.

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

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

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

Use Cases

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

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

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

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


network_create

Surfaces: public only

Create a new isolated network.

Purpose

Provision a new network namespace for connecting nodes.

Input Schema

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

Parameters:

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

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

Output Schema

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

Fields:

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

Example Usage

Request:

{
"name": "staging"
}

Response:

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

Use Cases

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

network_delete

Surfaces: public only

Delete an existing network.

Purpose

Remove an empty network.

warning

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

It also takes the network's shared drive with it: files_url answers 404 from the moment the network is deleted, and there is no way to restore a network or reach its files afterwards. Copy out anything you want to keep first.

Role requirement

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

Input Schema

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

Parameters:

note

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

deprovision_node removes the node it provisioned from the directory, so a network built entirely with provision_node becomes deletable once every task has been deprovisioned. Nodes you registered by hand still have to be removed in the app first.

Output Schema

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

Example Usage

Request:

{
"network_name": "old-testing"
}

Response:

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

Safety Features

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

org_create

Surfaces: public only

Create a new organization owned by the authenticated user.

Purpose

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

Personal organizations

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

Input Schema

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

Parameters:

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

The new organization starts on Free

From router 0.4.127 the plan is part of the row this call writes: the organization is created on the Free plan, with Free's allowances and Free's hard caps, and it counts towards three free organizations per owner from the moment it exists.

Through router 0.4.126 it was created on no plan at all. That is not a neutral absence — an organization with no plan was read as the unmetered tier everywhere downstream, so no allowance bounded it and the free-organization limit could not see it. Subscribing has always set a plan, so an organization that went through checkout was never in that state, and the ones that were have been moved to Free.

Three free organizations per owner

From router 0.4.103 an account may own at most three organizations on the Free plan, counting the personal organization created at signup. A fourth is refused with resource_exhausted and details.limit_type: "organizations", quoting how many you own against the limit, and naming the two ways forward: upgrade one to a paid plan, or have someone else own the next one.

Only free organizations you own are counted — being invited into any number of others never is, and paid and Enterprise organizations are not counted at all. The same check applies to org_transfer_ownership, so a free organization cannot be handed to someone the transfer would take past the limit. See Plans & Billing.

Output Schema

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

Fields:

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

Example Usage

Request:

{
"name": "Acme Corp"
}

Response:

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

Roles

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

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

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

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


provision_node

Surfaces: public only

Provision a new compute instance on a cloud provider.

Purpose

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

Who may provision

Every account may provision, on every plan including Free, from router 0.4.110. Through router 0.4.109 the tool was gated on an allowlist written from the caller's email domain, which admitted three internal domains and no customer at all, while every plan sold an amount of included compute — so the one person who ever learned was the customer who had paid for an allowance the tool refused to let them spend.

What bounds a caller is money and nothing else: the included compute, an empty credit balance, and a lapsed subscription. Those refuse with resource_exhausted naming the number, never forbidden. Your organization role still applies — provisioning is a Member-tier action, like the rest of the node lifecycle.

A forbidden from this tool now means one thing: provisioning was revoked for that account specifically, which only happens deliberately. Such a caller is not offered provision_node on tools/list at all, nor task_deadline_set, which makes a container run longer and is withheld for the same reason (router 0.4.125) — but is still offered deprovision_node and task_stop, deliberately, so a revocation can never strand running compute that keeps billing with nothing able to stop it.

Input Schema

{
"node_name": "string (required)",
"network_id": "string (optional)",
"network_name": "string (optional)",
"provider": "string (optional)",
"class": "string (optional)",
"max_hours": "integer (optional)"
}

Parameters:

  • node_name - Name for the provisioned node. It is a request, not a guarantee: the response's own node_name is the name actually used, normalized and — when yours is already held or contested — suffixed.
  • network_id - Target network, by id — the UUID network_directory reports. From router 0.4.149. An id names exactly one network, so use it when two of your organizations have a network with the same name. An id in an organization you are not a member of is not_found, with the same message as an id that does not exist. A value that is not a UUID is invalid_args.
  • network_name - Target network, by name. ⚠ When two of your organizations have a network with this name, the one in your personal organization is used; when neither is in your personal organization, the oldest is used. Send network_id for the other.
  • Send network_id or network_name, not both. Both is invalid_args (provide network_id or network_name, not both), even when they name the same network. Send neither and the tool uses your sole network — judged inside your personal organization if you belong to several, so a shared org's network is never provisioned into by accident.
  • provider - Cloud provider: "aws" (default)
  • class - Compute size, and the thing that sets the price. Default "small".
  • max_hours - How many hours this machine may run before its compute stops on its own, from router 0.4.117. Omit it and you get your plan's default, which is 24 hours. 0 means no deadline and is accepted on every plan but Free. The resolved instant comes back as deadline_at — read it rather than computing it, and move it later with task_deadline_set. See Every machine has a deadline.

Compute classes

Size and price are one choice, from router 0.4.101. There are four classes, the last two added in router 0.4.103:

classShapePrice
small (default)0.25 vCPU / 0.5 GiB$0.02 / hour
medium1 vCPU / 2 GiB$0.06 / hour
large2 vCPU / 4 GiB$0.12 / hour
xlarge4 vCPU / 8 GiB$0.24 / hour

Billed per minute of running time, from provision until deprovision_node — rounded up to whole minutes, with a one-minute minimum per task, which is why billed minutes rather than elapsed time are what the invoice charges. From medium up the rate is $0.06 per vCPU-hour, and it is the same on every plan: a larger plan includes more hours, not cheaper ones. The included compute is one budget spendable on any class at any concurrency. An hour is an hour of a small machine: a medium hour draws three of them, a large hour six. See Provisioned compute.

All four classes are placeable, from router 0.4.144large and xlarge were priced and accepted before the capacity behind them existed, so a request for one could be accepted and then fail to start. That is over; an xlarge node has been provisioned and run. ⚠ An xlarge task fills a whole machine, so several of the big classes at once need capacity to be added and the later ones wait for it rather than starting immediately.

The memory in a class is a hard limit (router 0.4.144). A process that grows past it is killed by the kernel — an out-of-memory kill inside your container, not a noBGP refusal — so a build that needs more than the class buys wants a bigger class rather than a retry. The vCPU figure is a share, not a ceiling: it decides how much CPU the task wins when the machine is busy, and on an otherwise idle machine a task can use more than it. Size on memory, and read the class you were actually given back out of the response.

cpu and memory are gone, and sending either is now invalid_args. They let you name a shape nobody had priced — the request was then silently rounded up to a power of two, so the shape you got was often not the shape you asked for and had no rate behind it either way. The default did not move: an omitted class is small, which is exactly the 0.25 vCPU / 0.5 GiB an omitted cpu/memory pair used to produce, so a call that named neither behaves identically. A class name is matched case-insensitively and trimmed, and an unknown one is refused with invalid_args naming every offered class and its shape — the published schema carries no enum, so the refusal is where the vocabulary lives.

Every machine has a deadline

From router 0.4.117 a provisioned machine stops on its own, and by default that is 24 hours after it starts. max_hours is what sets it, and the resolved instant comes back in deadline_at — the response is the only place that instant exists, so read it rather than recomputing it from the number you sent.

max_hoursResult
omittedthe plan's default: 24 hours, on every plan
18760that many hours from now — up to one year
0no deadline, on every plan but Free; the machine runs, and bills, until deprovision_node
negativeinvalid_args-3 is not a duration
above 8760invalid_args — past a year the number is a typo (minutes sent as hours), not a deadline anyone means

Omitting the argument is the protection, and 0 is the escape. An omitted max_hours never means "no deadline" — it means the plan's default. Turning the deadline off is a per-task, deliberate choice on every plan but Free; there is no way to set it for a whole organization and no way to make it the default.

Free cannot switch it off. max_hours: 0 on Free is invalid_args, and the refusal names the default so the retry is obvious: the free plan cannot run a task without a deadline; omit max_hours for the default of 24 hours, or name a longer one. The deadline and the included compute are the only two things bounding a Free machine.

Enterprise takes the same 24-hour default as Business, from router 0.4.125. It used to have no deadline at all — an unmetered organization had no default applied and max_hours was not read. Being unmetered is a statement about the invoice, not about the machine: an Enterprise container nobody remembers still occupies capacity, still holds its name, and still destroys its own disk whenever it eventually stops. It turns the deadline off per machine with max_hours: 0, like Pro and Business.

Which plan decides is the plan of the organization that will be billed — the same organization the money gates are checked against, which for a node being replaced is the one that owns the node rather than the one that owns the network.

What happens at the deadline

The task's compute stops, and what is left is an ordinary offline node — exactly the state a machine stopped for the compute allowance is left in:

KeptGone
The node's identity, name, labels and role grantsThe container, and everything written to its own disk
Its node storage areaWhatever was in memory — processes start fresh next time

Starting it again is provision_node on the same name, which re-adopts the identity and hands out a fresh deadline.

⚠⚠ To KEEP a machine that is still working, move its deadline with task_deadline_set — do not re-provision it. Re-provisioning renews the node, not the machine, and while the first machine is still running it does not even hand back the name: an open task holds it, so the new container is provisioned under the next free name as <name>-1. You end with two machines billing, the second one empty, while the one you meant to keep still stops at its deadline and takes its disk with it. This paragraph used to end "a running task's deadline cannot be extended in place; there is no tool for it", and from router 0.4.125 there is.

A machine may run a few minutes past its deadline. Deadlines are checked on a sweep rather than by a per-task timer, so the stop lands at or shortly after the instant in deadline_at, never before it.

It is not an enforcement action — it is the bound you asked for at provision time — so it applies whatever your organization's usage state, and no allowance, cap or payment status is consulted to honour it.

Tasks provisioned before router 0.4.117 have no deadline and are unaffected. Nothing was applied retroactively: a container whose owner was told it runs until they deprovision it is not stopped by a rule written after it started.

Read the notes[] on the envelope. Every provision carries a sentence naming the instant, or saying there is no deadline. In the rare case where noBGP started the container but could not record the task, the notes say so explicitly — that container has no deadline, nothing will stop it, and there is no task_id to deprovision it with, so it has to be found by node name.

The money gates

Every provision is checked, from router 0.4.103, against the organization that will be billed for it — which for a node being replaced is the organization that owns the node, not necessarily the one that owns the network:

CheckApplies toRefusal
Compute allowanceFree — blocked, never billed for overageresource_exhausted, limit_type: "compute", quoting used and included in hours
Empty credit balancePaid plans, where usage past the allowance is drawn from credit — router 0.4.127+resource_exhausted, limit_type: "credit_empty"
Lapsed subscriptionPaid plansresource_exhausted, limit_type: "lapsed"

Every refusal carries details with limit_type, used, included, hard_cap and an upgrade_url, so a client can branch on the kind rather than on the message text.

The money gate on a paid plan is the balance, not the cap, from router 0.4.127. It refuses only when an included allowance is used up and the credit balance behind it is empty, and it names both ways out — buy credit, or wait for the period to roll over. Through router 0.4.126 it compared your period-to-date overage against the spend cap, which has bounded automatic top-ups only since router 0.4.122: an Owner could hold a large balance and the default $0 cap at once, be refused every provision, and find that buying credit did not clear it. ⚠ limit_type: "spend_cap" is a refusal the router no longer produces at all, from router 0.4.132. It survived only where a deployment invoiced usage past the allowance instead of drawing it from a balance, and that mechanism is retired; the spend cap now bounds automatic credit refills and refuses nothing. A client branching on it can drop that branch. See an empty balance is what refuses creation.

There is no node-count gate any more, from router 0.4.106. No plan caps how many devices an organization may have — Free's cap of 25 was the last one — so limit_type: "nodes" is a refusal provision_node no longer produces. What still refuses a provision is money: the compute allowance, an empty credit balance, or a lapsed subscription. See Plan limits.

Output Schema

{
"task_id": "string",
"provider": "string",
"network_id": "string",
"network_name": "string",
"node_name": "string",
"class": "string",
"resource_summary": "string",
"deadline_at": "string (absent when the machine has no deadline)",
"readopted_node_id": "string (only when this replaces an existing node)"
}

This is the payload under provision_node. Any caveat about the outcome arrives as notes[] on the envelope around it, from router 0.4.83 — it used to sit in the payload.

  • task_id - The deprovision handle. Keep it: the node runs, and bills, until task_stop or deprovision_node is called with it — or until deadline_at, whichever comes first.
  • network_id - The id of the resolved network the node will register into, from router 0.4.149. Subscribe to readiness with it (presence_subscribe {network_id}): networks in two of your organizations can share a name, and an id names exactly one.
  • network_name - The resolved network the node will register into — half the network_directory filter for finding it.
  • node_name - The name actually used, which is not always the one you asked for. It is normalized (case, whitespace), and from router 0.4.112 it is suffixed when the name you asked for was held by a live node — or, from router 0.4.113, when it is contested. See A name already in use is suffixed. Address the node by this value everywhere afterwards; never assume it echoes your input back.
  • class - The class actually provisioned, and the one that bills. Present on every provision, including one that named no class and got the default.
  • resource_summary - The class and the shape it allocated, as a person reads it — "small (0.25 vCPU / 0.5 GiB)". It is derived from the shape the provider actually gave you rather than echoing the class you asked for, so read it back rather than assuming.
  • deadline_at - RFC3339 UTC instant at which this machine's compute stops on its own, from router 0.4.117. It is the resolved value — from your max_hours or from the plan's default — so read it rather than recomputing it. Absent means no deadline: the machine runs, and bills, until deprovision_node. It can be moved later with task_deadline_set, and re-read at any time from network_directory's task_deadline_at. See Every machine has a deadline.
  • readopted_node_id - Present only when this call replaces an existing node rather than creating one: the node_id the container will register as, keeping that node's labels, role grants and node storage. Absent means a new node will be minted.
  • notes (on the envelope) - Present only when there is something to report about the outcome.
The node does not exist yet

There is deliberately no node_id in this response — unless readopted_node_id is set, which is the one case where the id is known in advance. Otherwise the node row is minted when the container's agent self-registers, which happens after this call returns — typically within a minute. A network_directory call issued immediately will not find it, and that is not a failure.

Re-provisioning replaces the node it names

Provisioning a name that a node you previously deprovisioned held brings that same node back: the same node_id, and with it the node's labels, its role grants and its own storage area. The response says so in readopted_node_id. The container's own disk is new — only the identity and what hangs off it survive.

The storage area comes back only if the node has been gone for less than 30 days (router 0.4.73). Past that window its area has been queued for deletion and the node returns with an empty one; the identity, labels and grants return either way. Re-provisioning inside the window cancels that deletion outright, so a node parked for a week is unaffected.

A rename changes what this re-adopts, because re-adoption is by name: after a node is renamed, provisioning its old name creates a new node and provisioning its new name brings that node back.

Re-provisioning is also how a node whose compute was stopped comes back — there is deliberately no resume tool. A node the platform stopped for the compute allowance, an empty credit balance, a lapsed payment or its own deadline — or one you stopped yourself with task_stop — is left as an ordinary offline node with its identity intact, so bringing it back is exactly the re-adoption above: provision the same name into the same network once the cause is cleared. What does not come back is the container's memory or local disk — the replacement starts its processes fresh.

From router 0.4.112 there is nothing extra to look for. Through router 0.4.111 such a node carried a suspended_at in the directory and the provision response said it had been resumed; both are gone, because a stopped machine is an offline node and there was never a fourth state to report. See network_directory.

A name already in use is suffixed

From router 0.4.112, provisioning a name that a live node holds is no longer refused — the container is provisioned under the next free name in the family instead (<name>-1, then -2, and so on, up to eight steps before the call gives up and returns already_exists). The walk starts after any numeric suffix the name you asked for already carries, so web-3 is offered web-4 and never web-3-2.

Three things move a name, and none of them is about who created the node:

  • the node holding the name is online, or
  • an open provisioning task still holds it — a container that was started and never deprovisioned, which is what the online flag cannot see when a node's connection dropped while its container kept running, or
  • the name is contested (router 0.4.113): another node that still exists was renamed off this name and is not answering, so noBGP cannot tell which machine the name belongs to. See A contested name costs the name, not the connection.

Read node_name off the response. The suffix is reported there and nowhere else, and it is the only thing standing between the caller and addressing the wrong machine: every later command, fs_* or network_directory call keyed on the name you typed reaches the node that already held it. A notes[] entry and the result message say the same thing for a person — from router 0.4.113 they name both names ("Named web-1, not web: the name you asked for is already in use. Address this machine as web-1.") and no longer state a cause, because the three collisions above have three different ones and only two of them involve a running machine.

readopted_node_id beside it separates the outcomes:

node_namereadopted_node_idWhat happened
the name you asked forabsenta new node under your name
the name you asked forpresentan existing identity re-adopted
suffixedabsenta new node, because your name was taken or contested
suffixedpresenta node already holding that suffixed name was re-adopted — its identity, not the one behind the name you asked for

The whole of what a name already in use does — the last three rows are the already_exists refusals that remain, and each names the case that fired:

The node holding the nameResult
Is offline or deprovisioned — whoever created itReplaced, keeping its node_id, labels, grants and node storage
Is online, or has an open provisioning taskSuffixed — provisioned under the next free name
Was renamed off this name by a node that still exists and is not answeringSuffixed (router 0.4.113) — the identity behind the name you asked for is not re-adopted, because noBGP cannot confirm which machine it belongs to; read readopted_node_id to see whether anything was re-adopted under the name you got
Is revokedRefused — a revocation is about the identity, not the name, so moving you to a free name would quietly hand you a working node instead
Holds a key that a different live node has since registeredRefused — the name is free but the identity cannot be restored; the message names the node holding the key
Would suffix past a valid node name, or past eight attemptsRefused — deprovision one of them, or ask for a shorter or different name

Who created the node is deliberately not one of the conditions. Provisioning is a registration, so a name an offline hand-registered node holds is replaced too, exactly as pointing a second register_node install at that name already would — what is new is that a container claims the identity immediately. If that is not what you want, deprovision or rename deliberately rather than relying on a refusal.

From router 0.4.111 the open-task check finds the container behind a node that has since been renamed. It matched the name each task recorded at provision time, which nothing updates — so one rename made a still-running container invisible to the guard whose whole job is to see it, and the name was accepted. Provisioning onto it then put two containers under one identity, evicting each other's connection, while the first task went on billing. The lookup is now keyed on the node itself.

Waiting for the node

Subscribe before the node exists, then read the bus:

  1. presence_subscribe with the network_id from the response and no node_names — a node_names selector resolves against nodes that already exist, so it cannot name one that does not.
  2. event_tail until a presence event carries the returned node_name with transition ready — router 0.4.84+, and not online. A provisioning node's bridge comes up, drops and comes back (measured: five transitions in 1.5 seconds), so the first online names a session that is about to die, while ready means one has held for 5 seconds. On an older router, or against a client that does not know the token, online is what you have — expect to see the flap.

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

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

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

Example Usage

Request:

{
"node_name": "api-server-2",
"network_name": "production",
"class": "medium",
"max_hours": 72
}

Response:

{
"task_id": "3f8c1a92-5d47-4b0e-9c21-7a6e0d5f3b18",
"provider": "aws",
"network_id": "8b2e4f10-6c3a-4d7e-9f15-2a0b7c9d1e44",
"network_name": "production",
"node_name": "api-server-2",
"class": "medium",
"resource_summary": "medium (1 vCPU / 2 GiB)",
"deadline_at": "2026-09-05T18:00:00Z"
}

Provisioning Flow

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

Typical duration: 1-2 minutes


deprovision_node

Surfaces: public only

Terminate and remove a provisioned compute instance.

warning

Destructive operation. All data on the container's own disk will be permanently lost.

Purpose

Remove provisioned infrastructure and stop billing.

Input Schema

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

Parameters:

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

This stops the container and its billing, and removes the node from the directory — so the network can then be deleted with network_delete, and teardown round-trips without an app-side step.

The node's identity is retained, not destroyed. The row is kept precisely so that provisioning the same name again brings back the same node_id with its labels, role grants and node storage intact; the notes on the response say which of the two you will get. Deleting outright would free the name and lose all three.

Its storage area is retained too, but for 30 days (router 0.4.73), after which it is queued for deletion and the bytes go about a week later. The identity is kept indefinitely; only the files have a clock on them.

Only the node this task created is removed, and only while this task is still the newest one for that node. A node that was registered by hand onto the same name is left alone, and re-running a deprovision that a later provision_node has already superseded leaves the node alone rather than removing the one the new container is running as.

That guard asks about the node, not about the name, from router 0.4.111. It used to compare the name each task recorded at provision time — a copy nothing updates — so on a node that had been renamed and then re-provisioned, the newer task carried the new name, the older one looked unsuperseded, and re-running its deprovision removed the node the new container was running as. Both halves are now keyed on the node_id the container registered as.

Deprovisioning is idempotent

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

It waits for the node to go offline

From router 0.4.120 this call waits, up to 35 seconds, for the node to stop reading online before it returns — so a script may chain deprovision_node straight into provision_node on the same name.

Stopping the container is asynchronous: the provider is asked to stop it and answers at once, while the machine then takes its shutdown signal, its agent's connection closes, and only then is the node marked offline. Through router 0.4.119 the call returned at the first step, so a caller that immediately re-provisioned the name arrived while the node still read online, was taken for a live collision, and got a suffixed name with a new node_id and an empty storage area — success-shaped, and visible only by comparing the name returned against the name asked for.

node_offline on the response reports the outcome, and it is the field to read before re-provisioning:

node_offlineWhat it meansWhat to do next
trueThe node's connection closed. The name is re-adoptable nowProvision the same name; it comes back as the same node_id
falseThe wait ended without seeing that. The deprovision still succeededPoll network_directory until the node is gone, then provision — provisioning now can suffix
absentNo wait ran: either this task had no node, or a newer task has since re-provisioned this node and owns it nowRead notes[], which says which

It is the node's connection that is observed, not the container. A node is marked offline when its connection to the router drops, and an agent whose connection dropped while its container kept retrying reads offline and is still alive.

A false is an answer, not a failure. The teardown happened either way; what did not happen is the confirmation that the name is reusable. The result message and notes[] say so too, for a person reading only the sentence.

A superseded task waits for nothing. Re-running a deprovision that a later provision_node has already replaced returns immediately with node_offline absent — the node it names belongs to a newer container and is supposed to be online.

A deprovision whose bookkeeping fails now fails the call (internal), rather than succeeding with a warning. The container is stopped either way, but until the task is recorded as closed the name cannot be re-provisioned — one lost write used to make that name suffix on every later provision, permanently, with nothing to close it. Retry deprovision_node.

Output Schema

{
"task_id": "string",
"reference": "string",
"provider": "string",
"node_offline": "boolean (absent when no wait ran)"
}

This is the payload under deprovision_node; notes[] sits on the envelope beside it, from router 0.4.83 — it used to be part of the payload. It carries the human-readable detail: the node the task was associated with, whether the provider reported it already stopped, how long the offline wait took, whether provisioning the name again will re-adopt the same node_id (a revoked node will not be), and the task's total lifetime.

  • node_offline - Whether the node reached offline before this call returned (router 0.4.120+). See It waits for the node to go offline — absent, true and false are three different answers.
Give the call time to return

The wait means this call can take up to 35 seconds where it used to return in about one. A client whose per-call timeout is shorter than that cancels mid-wait; the teardown is unaffected and completes, but you lose the answer node_offline was about to give.

Example Usage

Request:

{
"task_id": "task_provisioning_123"
}

Response:

{
"call_id": "d1a4b7e0-…",
"op": "deprovision_node",
"done": true,
"duration_ms": 1204,
"notes": [
"Node dev-server-1 read offline 892ms after the stop; the name is reusable now.",
"Task ran for 2h 15m before deprovisioning."
],
"deprovision_node": {
"task_id": "task_provisioning_123",
"reference": "arn:aws:ecs:...",
"provider": "aws",
"node_offline": true
}
}

Safety Features

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

service_publish

Surfaces: public only

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

Purpose

Expose HTTP applications or terminal access through secure public endpoints.

Service Mode

The service mode is determined by which parameter is provided:

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

Input Schema

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

Parameters:

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

Output Schema

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

Verifying your work

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

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

service_check is the answer (router 0.4.84+). It probes through the same channel a real visitor's request rides and reports reachable / unreachable / not_applicable, with the backend's own status code or the node's own error text:

{ "service_id": "svc_abc123" }

Two older ways still work and are worth knowing:

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

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

Example Usage

Proxy Service:

Request:

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

Response:

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

Terminal Service:

Request:

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

Response:

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

Default Shell Service:

Request:

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

Response:

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

service_update

Surfaces: public only

Modify an existing service's configuration.

Purpose

Change service settings without deleting and recreating.

Input Schema

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

Parameters:

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

Output Schema

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

Example Usage

Request:

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

Response:

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

service_delete

Surfaces: public only

Delete a published service.

Purpose

Remove a service and free up its public URL.

Input Schema

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

Parameters:

  • service_id - ID of the service to delete

Output Schema

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

Example Usage

Request:

{
"service_id": "svc_abc123"
}

Response:

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

service_share

Surfaces: public only

Manage the authorized email list for a published service.

Purpose

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

Input Schema

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

Parameters:

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

Output Schema

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

Example Usage

List authorized emails:

Request:

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

Response:

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

Add emails:

Request:

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

Revoke all access:

Request:

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

Response:

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

Actions

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

service_check

Surfaces: public only

Ask whether a published service's backend actually answers. Router 0.4.84+; any organization role may call it.

Purpose

A successful service_publish proves the route was recorded, not that anything is listening behind it — and until this tool there was no way to ask. With authentication on, which is the default, fetching public_url answers 307 to the login flow whether the backend is alive or dead, so the status code you get says nothing. The old advice was to run a curl on the node with command; that was a recipe, not an answer.

service_check probes through the same channel a real visitor's request rides — a mesh dial addressed by service id, which the node answers by connecting to its own proxy_target_url — so it verifies the path people actually use rather than some other one.

Input Schema

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

Deliberately nothing else: the service already knows its node, its target and its mode, so anything you could add here would be a chance to contradict it.

Output Schema

{
"backend": "reachable | unreachable | not_applicable",
"check": "http | tcp",
"status_code": "number (http checks that got a response)",
"latency_ms": "number",
"error": "string (present when unreachable)",
"target": "string (the backend URL that was probed)"
}

This is the payload under service_check; done, duration_ms and the service_id are on the envelope.

Fields:

  • backend - The verdict, and a three-value string rather than a boolean:
    • reachable — the node connected, and on an HTTP check the backend answered. Read status_code for what it said.
    • unreachable — it did not. error carries the upstream failure verbatim from the node that tried, e.g. dial tcp 127.0.0.1:8080: connect: connection refused.
    • not_applicable — this service has no backend to reach. A terminal service runs a command, so there is nothing to probe and nothing is wrong: that answer is a success, not a fault.
  • check - Which probe ran. http for an http / https target — a real request, redirects not followed, since a 301 is itself an answer. tcp for any other scheme (ssh, redis, postgres), where a successful connect is the whole verdict and no HTTP meaning should be read into it. Absent when no probe ran.
  • latency_ms - How long the probe took end to end — the node's own connect (and request) plus the router↔node hop, so it is an upper bound on the backend's own latency rather than a measurement of it.

What "reachable" does and does not say

It is not a health check. Any status counts as reachable, 500 included: the backend answered, which is a different fact from a refused connection, and status_code carries the rest. A caller that needs "healthy" reads the code itself.

A disabled service answers unreachable naming that — service is disabled; the router does not proxy to it — rather than the dial layer's misleading "service not found" about a service that plainly exists.

A self-signed certificate on the backend is not a failure: the node already terminated the hop to its own backend, and the reverse proxy that serves real visitors makes the same choice.

The call never fails for a verdict. Only the arguments — an unknown service, a caller who cannot reach it — are errors.


Cross-replica session forwarding

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

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

Which sessions are forwarded: command sessions (command_id), and from router 0.4.81 file / fs_read / fs_write transfers (file_id) — a continuation that lands on another replica now returns the bytes instead of reporting the session missing mid-transfer. The one-shot file operations that can also hand back a file_id on a slow node — list, stat, delete, mkdir, glob, grep, fetch_url — are not forwarded: a follow-up on one still comes back naming the replica that owns it, so retry the call rather than the poll.

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

Everything only the owning replica can observe crosses the hop too (router 0.4.84): a command call's output cursor and its signal verdict come back with the same values a local call would give, and an out-of-window output_offset is the same invalid_args with the same window numbers. From router 0.4.147 an output overrun crosses it too, as the same resource_exhausted with the same message, rather than as the retryable internal an unrecognised error becomes on the way across. During a rolling upgrade a session owned by a not-yet-upgraded replica simply answers without those fields, and notes[] says so — never a false or a zero standing in for an answer nobody made.


command

Surfaces: public · local (manage)

Execute commands and manage interactive shell sessions on remote nodes.

Purpose

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

Input Schema

{
"command_id": "string (optional)",
"session": {
"node_id": "string (optional)",
"network_name": "string (required unless node_id provided)",
"node_name": "string (required unless node_id provided)",
"command": "string (one command line — this or script, never both)",
"script": "string (a script body — this or command, never both)",
"shell": "string (optional)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; a JSON boolean is also accepted)",
"workdir": "string (optional)",
"env": "object (optional)"
},
"input": "string (optional)",
"raw": "boolean (optional)",
"signal": "string (optional)",
"redact": ["string (optional; one short label per secret this call carries)"],
"idle_timeout": "number (optional; default 5, clamped to 100)",
"output_offset": "number (optional; byte position in this session's output to read from)"
}

Parameters:

  • command_id - Session identifier from a previous call. Omit to create a new session.
  • session - Session creation parameters. Required when command_id is omitted.
    • The node reference may also go at the top level (router 0.4.92+): node_id, or network_name + node_name, beside session rather than inside it — the one flat spelling every node-addressed tool now takes. The rest of session (command, script, shell, admin, workdir, env) stays where it is, and naming the node in both places is invalid_args.

    • node_id - Node UUID. Alternative to network_name + node_name — provide exactly one of the two forms. A node id is stable across network moves, so prefer it when you already have it (e.g. from network_directory).

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

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

    • command - One command line to execute (e.g., "bash", "df -h", "python3 script.py"). Provide this or script, never both — sending both, or neither, is invalid_args. Backslashes are literal here; no C-style escapes are resolved (unlike input).

    • script - A script body, from router 0.4.81 — send it the way you would write the file: real newlines, no escaping, no shell quoting, no trailing-backslash continuations. Use it instead of command for anything multi-line, anything with an embedded quote, and anything you would otherwise hand a person to paste. The router lowers it for the target platform, so nothing between you and the interpreter can re-quote it — see Sending a script body.

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

    • admin - run elevated. A string from router 0.4.83"true", "false" or "auto"; a JSON boolean is still accepted and coerced, so an older client keeps working. Omitted or "false" runs as the node's configured user, which is not uniformly root. "true" runs as the node's ambient identity, and is gated twice: the router refuses a caller below org Owner/Admin (or a node below the manage tier) with forbidden, and the node's owner may refuse everyone with allow-admin: false. Both fail the call rather than downgrading it, so a forbidden here is answered by dropping admin, not by retrying. The refusal also runs the other way: a node whose unelevated identity would be uid 0 — no account configured on a root-running agent, an account that no longer resolves, or user: root — returns failed_precondition for an unelevated call rather than running it as root, so retry that node with admin: "true" or configure an account on it. "auto" takes whichever identity the node can offer and needs the same authority "true" does; the response's admin_used says which one ran. network_directory reports each node's user and allow_admin so you can tell the shapes apart. See Execution identity. ⚠ Elevating also changes the session's environment, not only its identity: on macOS an exit 127 under admin: "true" that works without it is root's PATH lacking /usr/local/bin, not a missing install — see Elevation also changes the environment.

    • workdir - Working directory for the session, defaulting to the home directory of the identity it runs as — or, where the session runs at the node's ambient identity with no account configured, to the agent's own working directory. A path that is missing, or that is not a directory, is ignored — the session starts in that default anyway rather than failing, so check pwd if a relative path behaves oddly. ⚠ command_subscribe answers the same mistake the other way, refusing the node's run, because nobody is watching a fan-out.

    • env - Additional environment variables (e.g., {"DEBUG": "1"}), merged over the identity's own environment: your value wins where the name already exists, and an empty value sets that variable empty rather than leaving the inherited one. Names that would inject code into the session are refused by the node — LD_PRELOAD, LD_LIBRARY_PATH, the DYLD_* trio, BASH_ENV, ENV, ZDOTDIR, PYTHONSTARTUP, PERL5OPT, RUBYOPT, NODE_OPTIONS, CLASSPATH — as are names containing = or a null byte; set what you need inside the command instead.

  • input - Text to send to stdin. C-style escape sequences (\n, \t, \xHH) are resolved by default. Set raw: true to disable. ⚠ It does not close stdin, and sending it is not how you rescue a command that is blocked reading stdin — see A command that says running with no output. From router 0.4.77 input that could not be delivered — a session that ended while the call was in flight — fails the call rather than reporting success, and two concurrent calls sending input to the same command_id are serialized so their bytes cannot interleave on the process's stdin.
  • raw - Send input bytes as-is without resolving escape sequences.
  • signal - Signal to send to the process: SIGINT, SIGTERM, SIGHUP, SIGQUIT, or SIGKILL. From router 0.4.84 the response says what the node's mechanism actually did with it — see What a signal did.
  • redact - Router 0.4.85+. Declare that this call carries secrets, as one short label per secret (["deploy token", "db password"]) — never the secret itself. It changes nothing about what runs or what comes back. See Declaring that a call carries a secret.
  • idle_timeout - Seconds to wait with no output before returning (default: 5). Use 0 for non-blocking. The command keeps running regardless. ⚠ Clamped to 100 from router 0.4.84, and the ceiling is the client's patience rather than the router's: a 107-second wait was measured returning normally with its output, while a 400-second one was abandoned by the MCP client with its own timeout string — outside the protocol, where the router can neither see it nor report it. Asking for more is silently treated as 100 rather than refused. For anything longer, poll again with command_id (the output waits for you — see Re-reading output) or dispatch it with command_subscribe and read it from the event bus. See A poll always comes back for the guarantee the number now carries.
  • output_offset - Router 0.4.84+. Byte position in this session's output to read from. Omit for the normal case — reading resumes where the last call stopped. See Re-reading output.

Output Schema

{
"command_id": "string",
"output": "string (omitted if empty)",
"stderr": "string (the subset of output written to stderr; omitted if none)",
"exit_code": "number (absent if still running)",
"signal": "string (name of the signal that ended the process, if one did)",
"signal_delivered": "boolean (verdict on a signal sent by this call; absent = the node did not confirm)",
"signal_mechanism": "string (which mechanism carried it)",
"signal_target": "string (what it was delivered to)",
"admin_used": "boolean (present on the call that created the session)",
"state": "running | exited",
"next_output_offset": "number (present on every response)",
"output_truncated": "boolean (the requested offset had aged out)",
"output_dropped_bytes": "number (how much was lost, with output_truncated)"
}

This is the payload under the command key; call_id, op, done, duration_ms, the node it ran on and the optional hint are on the envelope around it.

Fields:

  • command_id - Use this in subsequent calls to send more input or read more output

  • output - Everything the command has said starting at output_offset — or, as usual, where the previous call stopped: stdout and stderr interleaved in the order they were written, the stream a terminal would have shown. Omitted if no output was produced.

  • stderr - Router 0.4.84+, and agent 0.4.86+ on the node. The subset of output the command wrote to stderr, in order. The bytes are repeated rather than removed from output, so an error message can be told apart from program output without losing where it fell in the stream. Absent when the command wrote nothing to stderr — and also on a node whose agent does not tag its streams, which is every agent before 0.4.86, where notes[] on the envelope says so rather than leaving the absence to read as "nothing went to stderr". On such a node output is still complete and still ordered. ⚠ From router 0.4.147 the same note also appears on the poll carrying the last bytes of a session cut short by an overrun, whatever the node's agent does: the router stops vouching for the labels at the point the loss began.

  • exit_code - Present only when the command has exited. 0 = success, non-zero = failure. A process killed by a signal reports 128 + the signal number on Linux/macOS — 130 for SIGINT, 143 for SIGTERM, 137 for SIGKILL — the encoding every shell already uses, so the number names the signal; on Windows a killed process returns 1. Absence means the process is still running. Agents before 0.4.38 reported -1 for a signalled process with nothing else to distinguish it, which a caller could not tell apart from a program that genuinely returned -1. ⚠ From router 0.4.84 you no longer have to read the number to learn whether a signal ended it: signal carries the name, and the code cannot answer that question anyway, since a program may legitimately return any number a kill would produce. ⚠ From router 0.4.147 it is also withheld from a session whose output was cut short by an overrun — reporting one there would announce a clean finish over incomplete output — and notes[] says so.

  • signal - Router 0.4.84+, and agent 0.4.86+ on the node — the name comes from the node's own wait status, so an older agent sends nothing and you are back to reading exit_code. Name of the signal that ended this process — "SIGTERM", "SIGKILL" — spelled the way you send it in the request. Present only on a process the node reported as signalled; never inferred from exit_code. Absent means it exited on its own, or that this node does not report the signal: Windows has none to report, since a process there ends by returning or by being terminated, and only a termination the agent itself performed is named. ⚠ On Windows through agent 0.4.93 a signal arriving in the instant a process was exiting on its own was attributed the kill anyway, so a natural exit came back naming SIGTERM; from agent 0.4.94 a process whose exit the node has already recorded is refused — signal_delivered: false, process already exited — which is what the Linux and macOS path has always said of a reaped child.

  • signal_delivered, signal_mechanism, signal_target - Router 0.4.84+ with agent 0.4.86+, and only on a call that sent a signal. See What a signal did.

  • next_output_offset, output_truncated, output_dropped_bytes - Router 0.4.84+. See Re-reading output.

  • admin_used - Which of the node's two identities this session actually runs as: true = its ambient identity (root / LocalSystem), false = the account its owner configured. Always a boolean, never "auto""auto" is a request and this is the outcome. Present on the call that created the session; absent when polling an existing command_id, whose identity was settled at creation. Router 0.4.83+.

  • state - Always present. "running" while the command is still executing (poll again with command_id); "exited" once the agent reports exit. Prefer branching on state rather than inferring from exit_code's nil-ness. ⚠ It is not the same question as the envelope's done, which says whether the call finished: a call that returns while the process runs is done: true with state: "running". ⚠ A session whose output was cut short by an overrun stays "running" and never reaches "exited": the next poll refuses rather than reporting a finish nobody can vouch for.

  • hint (on the envelope) - Router 0.4.81+, and usually absent. It appears only on a result whose own fields cannot explain it, and then it names the one thing to try. Ordinary results — including ordinary failures that printed something of their own — carry no hint, so one that is there is worth reading rather than a field to skip. It fires on one cause: a Windows node, no shell set, no script sent, a non-zero exit, and cmd.exe having either mangled an embedded PowerShell line's quoting or not recognised the command at all. The hint points at the two ways through: send the body as script (the router writes it out for the interpreter, so cmd.exe never sees it), or set shell: "powershell" and send the script as-is. Only session-creating calls can carry it, because shell is a session parameter.

    Router 0.4.83 widened what it catches, because the original signature never fired. It required exit_code: 255 and completely empty output, and cmd.exe is loud: its usual answer to a mangled PowerShell line is to echo 'Get-ChildItem' is not recognized as an internal or external command, on stderr, which makes the empty-output test false — and one of the two real mistakes measured exited 1, not 255. It now fires on empty output, on exit 9009 (cmd.exe's own command-not-found code), or on cmd.exe's own refusal wording. The first two are locale-independent and are the gate; the wording match is an English-only refinement on top, so a node running a non-English Windows still gets the hint from the first two.

A poll always comes back

One command call returns within 100 seconds whatever the command does (router 0.4.146). The idle_timeout clamp on its own never delivered that: the idle timer resets on every chunk, so a command that keeps printing — a heartbeat loop, a progress line every 30 seconds — never idled out, and the call simply ran until the MCP client gave up. One of the two reports of this came from a caller printing a heartbeat in order to keep the session alive — the surface's own advice, taken, producing the failure.

What that cost was the handle. A client that abandons a call abandons it outside the protocol, so nothing comes back: no envelope, no call_id and — decisively — no command_id. The command kept running on the node with nothing able to poll it, read it or kill it, and command is built so a long job is recoverable (command_id plus output_offset) — so the recovery was defeated exactly when it was needed, because the id only ever arrived in the response that never came.

A separate wall-clock timer is now armed once per poll and never reset. Hitting it is an ordinary result, not an error and not an exit: the call returns whatever has arrived with state: "running", byte-identical to an idle expiry, and you poll again with command_id and the offset you were given. So idle_timeout bounds the silent gaps and this bounds the call, and the two can no longer disagree.

A forwarded poll behaves identically, because the forwarding replica's own deadline is sized off the same ceiling rather than off idle_timeout. Those two halves shipped together and had to: a replica that gave up at idle_timeout while the owning replica held the poll for longer would hand you a transport error in place of the output it was about to return.

Re-reading output

Reading output no longer destroys it (router 0.4.84). Until then a poll consumed the bytes it returned, so a response that never reached you took the output with it: echo LINE-ONE; sleep 60; echo LINE-TWO polled at idle_timeout: 3 returned LINE-ONE, the next poll returned {"state":"running"}, and LINE-ONE was gone — indistinguishable from a command that had said nothing.

Three rules, and each matters:

  • Omitting output_offset resumes where the last delivery stopped, byte for byte what a poll did before. No existing caller changes anything.
  • next_output_offset rides every response, including one that carried no output. Send it back as output_offset and the same bytes are delivered again — so a lost response costs a retry rather than the output. You can only ever re-request a position you were told about.
  • The newest 1 MiB is retained. An offset older than that is served from the oldest byte still held, with output_truncated: true and output_dropped_bytes saying how much was missed — never silence at a different offset. Poll more often, or run the work through command_subscribe, to stop it recurring.

An offset past the end is invalid_args, not a truncation report: nothing was dropped, and that position belongs to a different session.

The cursor crosses a replica hop unchanged, so a forwarded call is the same shape with the same numbers. During a rolling router upgrade a session whose owning replica has not been upgraded yet answers without next_output_offset, and notes[] says so; the field returns once the deploy finishes.

A command that outruns the router now fails the poll

Router 0.4.147. A command's output is streamed from the node as fast as the command produces it, and nothing paces it. If it outruns what the router can take, the frames that do not fit are dropped and the session is ended — and until this release that loss was reported as the command finishing: the last poll returned the bytes that had survived, stamped the process's exit code onto them and said state: "exited". So a command that had in fact succeeded reported exit_code: 0 over output that was missing an unknown amount, with nothing anywhere saying so — and for most of what this tool is used for the output is the answer.

This is the same defect the read path had, on the other session type, and it is worse here: on a read the bytes are a file you can re-read, while here they are the only record that the command ran at all.

An overrun session never reports an exit code, however cleanly the process finished:

  • The poll that carries the last surviving bytes returns them as an ordinary result, with state: "running", no exit_code, and a note on the envelope saying the output was cut short and why the exit code is withheld. It is said on that response rather than only on the next one, so a caller that takes those bytes and never polls again is still told.
  • The next poll refuses with resource_exhausted, naming the session. No further output is coming, and the session cannot be continued.
  • Stream labelling stops where the loss began. The dropped frames took the bookkeeping with them, so stderr is reported for the bytes accounted before anything was lost and not for the ones delivered after — a wrong stream is worse than none. On that last poll notes[] reports the absence in the words it uses for a node whose agent does not label its streams, so read it as not labelled here rather than as a statement about the node. output itself is complete and in order for the bytes you were given, and output already delivered keeps the labels it had.
  • What you were given is correct; what is missing is unknown. The dropped frames were never counted, so there is no figure for them and no offset to resume at — output_dropped_bytes stays 0, because that field reports what aged out of the retained 1 MiB, which is a different thing. Re-reading an earlier output_offset still works while the retained bytes last.
  • Sending input to such a session is refused the same way, with the same code and message, rather than reporting a clean exit — an interactive session is the shape that reached this through the other door.
  • A forwarded poll answers identically — the same code and the same message, rather than the retryable internal an unrecognised error becomes on the way across, which would have made one truncation two different answers depending on which replica you reached. ⚠ The explanatory note is the one part that does not cross that hop: the reply between replicas carries the bytes and the withheld exit code but has no field to carry a note on, so on such a session that poll arrives with output, state: "running" and nothing saying why. The refusal on the next poll is what tells you — which is why a response that carried output and no exit_code is worth one more poll rather than being left as "still running, ask later".

The remedy is to produce less output through the session, not to retry harder. Redirect the command's output to a file on the node and read it back with fs_read; filter at the source (grep, tail -n); poll more often with a shorter idle_timeout; or run the work through command_subscribe, which reports a node that out-talks it as an overflow event instead. Re-running the identical command with the identical polling is likely to lose the output again.

What a signal did

Router 0.4.84 answers what happened to a signal you sent, rather than only that the call succeeded. Sending one also adds a short wait for that verdict, even with idle_timeout: 0. The verdict is the node's, so it needs agent 0.4.86+ as well; an older agent delivers the signal exactly as before and simply reports nothing about it.

FieldWhat it says
signal_deliveredWhether this node's mechanism accepted the signal. true with state: "running" means delivered and ignored — escalate to SIGKILL. false means the mechanism refused it (already exited, no such process group, unsupported here) and notes[] carries the reason. Absent is neither: the node did not confirm — an agent older than 0.4.86, or a reply that did not arrive in time — so it may or may not have landed. From agent 0.4.94 a verdict for a signal that landed while the command was exiting is sent out ahead of the exit status rather than being lost to the teardown, so the common case for an absent field is now an old agent.
signal_mechanismWhich mechanism the node used, in the node's own words: kill (a real signal, to the process group), stdin_ctrl_c (Windows SIGINT0x03 written to stdin), terminate_job / terminate_process (Windows SIGTERM and SIGKILL, which are the same act). The list is open — a node may report one this page does not name.
signal_targetWhat it was delivered to: a process group (pgid 4242), a pid, or a handle.

Read signal_mechanism before treating signal_delivered: true as "the process was signalled". On Windows a delivered SIGINT means 0x03 reached stdin, which only interrupts a process that actively reads stdin — the mechanism is what stops the verdict from overstating itself. Where a platform has no mechanism for that signal name at all (SIGHUP, SIGQUIT on Windows) the field is absent, and that is a permanent refusal rather than a failed attempt.

Sending a script body

command is one command line. script (router 0.4.81+) is a script body — real newlines, no escaping, no shell quoting, no trailing-backslash continuations. Provide exactly one of the two.

Reach for script whenever the work is more than one line, contains a quote, or is something you would otherwise ask a person to paste. The failure it removes is not cosmetic: a pasted $f -Raw arriving as $f-Raw made PowerShell look for a file that did not exist, so the replacement silently did nothing — and the restart on the next line still ran, leaving the machine in the wrong state while the call reported success.

The router lowers the body for the target, so nothing between you and the interpreter re-quotes it:

TargetWhat the body becomes
Linux / macOSRun verbatim under the session user's login shell. Write POSIX sh unless you know the node.
Windows, shell omittedA batch script: written to a temp .cmd with CRLF line endings, run through %ComSpec% /d /c, its exit code returned and the file deleted. cmd.exe's 8191-character line limit and its quoting layer never apply.
Any platform, shell: "powershell"A PowerShell script, dispatched as -EncodedCommand. Set this whenever the body is PowerShell — it works on command too.

Two refusals, both invalid_args and both naming the way through:

  • A node that has never reported its OS. There is nothing to lower the body for, so it is refused rather than guessed at — send command with a single line instead, or set shell: "powershell" if the body is PowerShell.
  • A lowered form over the platform's command-line limit — roughly 9 KiB of batch, 12 KiB of PowerShell, 117 KiB of shell. The encodings inflate, so the check is on the lowered string, not on what you sent. Past that, write the body to the node with fs_write and run its path with command.

input is unaffected: stdin still reaches the script.

command_subscribe takes script too, from router 0.4.83 — with the same three lowerings, applied per node rather than once for the cohort, which is what lets one dispatch span Linux, macOS and Windows. See Sending a script body to a cohort.

A command that says running with no output may be waiting on stdin

stdin is an open pipe, for command and script alike, and nothing closes it before the session ends. That is what makes input work and what makes a REPL usable — and it is also why a command that reads stdin when stdin is not a terminal simply waits, forever, with the call reporting state: "running" and no output at all. A script stops at that line and nothing after it runs.

The tools this catches are ordinary ones, and none of them looks interactive: ausearch, aureport, cat with no file argument, mysql, python3 with no script, and ssh whose remote command reads stdin.

Redirect stdin on the line that reads it:

  • Linux / macOS — end the line with </dev/null.
  • Windows, batch (shell omitted) — end the line with <NUL.
  • shell: "powershell" — put $null and a pipe in front of the command.

Sending input is not the general fix. It helps only a tool that stops on a line it reads — a REPL's quit. A tool that reads to end of file never finishes however much you send, because nothing closes the pipe. Stop that session with signal: "SIGKILL" and run it again with stdin redirected.

command_subscribe behaves differently: a cohort run gets an empty stdin, so the same line ends rather than hanging. A one-liner that works there can still hang here.

Session Lifecycle

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

A session that is still running is reaped after one hour idle, and from router 0.4.146 polling counts as activity: every call with a command_id resets that hour, whether or not it returns any output. Until then the clock ran from the last byte of output, so a long silent job — a large build, a restore, a sleep-then-report script — was killed at the one-hour mark however diligently its caller polled. That was the one thing the surface tells you to do, and the 100-second poll ceiling means anything quieter than that must be polled.

A poll after the command has exited does not extend anything. The 30-second grace above is deliberate: polling for the exit code is the normal end of a command session, so refreshing there would hold every finished session — and up to 1 MiB of retained output each — for another hour. Read the exit code within the grace, as before.

Usage Patterns

One-Shot Commands

Start a command and read output in a single call:

Request:

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

Response:

{
"call_id": "3f8c1a90-…",
"op": "command",
"done": true,
"duration_ms": 312,
"node": "8a1f2d5c-…",
"command": {
"command_id": "cmd_abc123",
"output": "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 12G 36G 25% /\n",
"exit_code": 0,
"admin_used": false,
"state": "exited"
}
}

Multi-line scripts

Send the body as script rather than stringing lines together with && or ; (router 0.4.81+):

{
"session": {
"network_name": "production",
"node_name": "web-server-1",
"script": "set -e\ncd /srv/app\ngit pull --ff-only\nsystemctl restart app\n"
},
"idle_timeout": 30
}

The same on a Windows node, in PowerShell — no pre-escaping, whatever the body contains:

{
"session": {
"node_id": "9f1c…",
"shell": "powershell",
"script": "$f = 'C:\\ProgramData\\app\\default.yml'\n(Get-Content $f -Raw) -replace 'debug: true','debug: false' | Set-Content $f\nRestart-Service app\n"
}
}

Omit shell on Windows and the body is run as a batch script instead.

Interactive Shell Sessions

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

Start shell:

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

Response:

{
"call_id": "b21e77c4-…",
"op": "command",
"done": true,
"duration_ms": 145,
"node": "8a1f2d5c-…",
"command": {
"command_id": "cmd_xyz789",
"output": "user@web-server-1:~$ ",
"admin_used": false,
"state": "running"
}
}

Send a command:

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

Response:

{
"call_id": "c93a04f1-…",
"op": "command",
"done": true,
"duration_ms": 89,
"node": "8a1f2d5c-…",
"command": {
"command_id": "cmd_xyz789",
"output": "total 1024\n-rw-r--r-- 1 root root 45234 Nov 4 14:32 syslog\n...\nuser@web-server-1:/var/log$ ",
"state": "running"
}
}

Close the shell:

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

Polling Long-Running Commands

For commands that take a while, poll with command_id until exit_code appears. From router 0.4.147 there is a second way such a loop ends: a command that produces output faster than it can be read loses the frames that do not fit, so exit_code never arrives and the poll refuses with resource_exhausted — see A command that outruns the router for what to do instead.

Start:

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

Poll:

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

Sending Signals

Interrupt a running process:

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

The response carries the node's verdict on it (router 0.4.84+) — here, delivered to the process group and ignored, which is the case that tells you to escalate:

{
"command": {
"command_id": "cmd_xyz789",
"signal_delivered": true,
"signal_mechanism": "kill",
"signal_target": "pgid 4242",
"state": "running"
}
}

Shell semantics by platform

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

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

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

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

For anything longer than one line — batch or PowerShell — send it as script instead of command (router 0.4.81+). A multi-line batch body is written out to a temp .cmd and run from there, so cmd.exe's line-length limit and quoting layer never apply; a PowerShell body still wants shell: "powershell". When a Windows node answers a non-zero exit that cmd.exe itself produced — no output at all, exit 9009, or cmd.exe's own "is not recognized" wording — that is cmd.exe having mangled a PowerShell command line, and the response's hint field says so.

Exit 9009 is not always cmd.exe's, and from router 0.4.89 the hint stops assuming it is. Windows ships zero-byte App Execution Alias stubs on the PATH for python, python3, winget and others; run one on a machine where the program is not installed and the stub prints …was not found; run without arguments to install from the Microsoft Store and exits 9009 — the same code cmd.exe uses for a name it could not resolve. Through 0.4.88 that got the quoting hint, whose two remedies provably do not apply: sending the same body as script exits 9009 with a byte-identical message, and shell: "powershell" exits 1 with the same message again. The hint now names the real cause instead — install the program on that node, or turn the alias off in Settings › Apps › Advanced app settings › App execution aliases — and says outright that this is not a quoting problem.

Two edges of that, both deliberate:

  • A line that failed both ways gets both answers. command takes one line, so python3 --version & Get-ChildItem makes the stub print its message and cmd.exe print its own refusal beside it. There the hint gives the alias diagnosis and the quoting hint for the rest of the line, rather than denying a quoting problem the output on your screen contradicts.
  • An exit 9009 whose output is neither of those now hedges rather than asserting. A program may return 9009 for reasons of its own, so the hint ends by telling you to prefer the output's own account of itself if it names one. Where the diagnosis is earned — no output at all, or cmd.exe's own wording — it is stated flatly, as before.

The alias signature is matched in en-US only, the same limit the cmd.exe wording carries: a node in another display language still gets the quoting hint, which is the pre-0.4.89 behaviour rather than a regression.

Signals by platform

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

On Linux and macOS a process that dies from one of these reports exit_code as 128 + the signal number (130, 143, 137 for SIGINT, SIGTERM, SIGKILL), so a signalled command is distinguishable from one that exited on its own. Windows reports 1. From router 0.4.84 you do not have to read the number at all: signal carries the name of whatever ended the process, and signal_delivered / signal_mechanism / signal_target say what the node's mechanism did with the one you sent.

Cross-replica behaviour

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


file

Surfaces: public · local (manage)

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

Purpose

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

Operations

opPurpose
readStream a file from the agent (supports encoding=utf8 or base64, and offset resume)
writeStream a file to the agent (atomic via tmp+rename on done=true; missing parent directories are created)
copyCopy one file between two machines the router can reach — the only op with two subjects, so it takes source / destination instead of target + path. Same operation as fs_copy
editSingle old_stringnew_string replacement (atomic)
multi_editOrdered batch of replacements (atomic, all-or-nothing)
listDirectory listing (recursive=true walks subdirs)
statSingle-entry metadata (size, mode, mtime, is_dir)
deleteRemove a file or directory (recursive=true for dirs)
mkdirCreate a directory (recursive=true for mkdir -p)
batchRun N one-shot ops against the same target in one round-trip (best-effort; not transactional)
fetch_urlAgent does an HTTP(S) GET and atomic-writes the bytes to path — router never sees the bytes
lockStorage trees only. Take a two-minute advisory lock on one path, held across routers and nodes
unlockStorage trees only. Release a lock you hold; releasing one you don't is not an error

Input Schema

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

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

Key behaviors:

  • Provide target on the first call (via node_id OR network_name + node_name). Continuations pass only file_id. From router 0.4.92 the same three fields are also accepted at the top level, beside path, instead of inside target — the one flat spelling every node-addressed tool takes. The two are equivalent everywhere target is read, storage trees included; naming the node both ways is invalid_args, and op=copy takes neither, since it names each end for itself.
  • op=write against a node is how bytes reach a machine your own computer cannot — it needs no inbound route to the target, only the node's own agent connection. op=fetch_url is the same move from the other side: the node does the GET from its network position, so a mirror only it can see works and the bytes never cross the conversation. Router 0.4.72 puts that in the tool description and the server handshake, so it is picked before a plan forms around a second machine, a copy by hand, or publishing something private. See Which of the three to reach for.
  • target also selects noBGP's own storage (router 0.4.56+): network_name alone reaches that network's shared drive, and node_id with storage: true reaches that node's storage area — see Addressing storage instead of a node. On those two trees the ops are read, write, list, stat, mkdir, delete, lock and unlock; edit, multi_edit and fetch_url are node operations and say so with unsupported. op=batch is served there too from router 0.4.59 — the outer storage applies to every sub-op, and a sub-op the trees do not have fails on its own without taking its siblings with it. On 0.4.56–0.4.58 a batch dropped storage and ran every sub-op against the node's real disk.
  • ⚠⚠ op=write on those two trees is a single whole-file call, and chunking it keeps only the last chunk (said in the tool description from router 0.4.146). No file_id is issued, done is ignored and each call replaces the file, so the streaming recipe two bullets down applies to a node target only. Past the 1 MiB inline cap the call is refused with too_large. See fs_write for what to do instead.
  • op=copy moves one file between two machines in a single call (router 0.4.84+). It is the one op with two subjects, so it takes source and destination objects instead of the top-level target / path — and the top-level target, path, storage and admin are refused on it rather than quietly ignored. The router streams the bytes between the two ends, so neither needs a route to the other and nothing passes through the conversation. Full contract on fs_copy; a copy cannot be a sub-op of op=batch, which inherits one target, and says so by name.
  • transfer: "url" returns a URL for the bytes instead of the bytes on read and write (router 0.4.84+) — for handing to something that can fetch. See Moving bytes without reading them.
  • Read/write are streaming: first call returns file_id; poll with the same file_id until done=true. A read returns 32 KiB by default from router 0.4.84 (it was 256 KiB, which no model client would accept); the 1 MiB hard cap is unchanged. See fs_read.
  • Write lands in a sibling .nobgp-tmp file and is atomically renamed on finalize — aborted writes leave the destination untouched. From router 0.4.77 the finalize carries the whole-file SHA-256 and an agent on 0.4.79+ refuses the rename unless the finished file hashes to it, so a corrupted transfer fails rather than committing. Agent 0.4.81 extends that check to the router's older commit signal, which a write falls back to when the ordinary commit is lost on the way to the node — see fs_write.
  • op=write and op=fetch_url create a missing parent chain for path (agent 0.4.45+), so neither needs an op=mkdir ahead of it. It saves a round-trip and grants nothing new: the same recursive mkdir was already available to the caller, the destination is still vetted against its deepest existing ancestor before anything is created, and the new directories are owned by the identity admin selected. An agent below 0.4.45 fails these calls with the parent's not_found instead.
  • Router-computed SHA-256 on a write is cumulative and returned on every response, over the bytes the router accepted; from router 0.4.77 the final one also travels to the node with the commit and is verified there (agent 0.4.79+). Because it is a hash of what the router accepted, it certifies the router→node leg and cannot speak for your bytes reaching the router — send expected_sha256 on the done: true call to cover that half (router 0.4.79+). ⚠ On a read it is no longer cumulative (router 0.4.84): sha256 is the whole file and appears only on the done: true response of a read that started at offset 0, while chunk_sha256 verifies the bytes in one response. Either way a read's digest is one-sided — compare it against sha256sum on the node. A read also reports truncated / next_offset now, as fs_read describes.
  • op=write returns not_foundsession ended before write completed — for a chunk it could not deliver (router 0.4.77+), instead of the success it used to report for bytes that never reached the node. Open a fresh session and resend; the path is not the thing that is missing.
  • Use edit/multi_edit (not read+write) when changing a few lines of a text file — it sends ~200 bytes instead of the whole file.
  • admin picks which of the node's two identities the operation runs as, exactly as it does on command. The agent drops privilege before touching the file, so ownership and permissions are the ones that identity would get — a file written with admin: false is owned by the node's configured user. Asking for true needs org Owner/Admin (or a manage grant) and the node's allow-admin; either gate fails the call rather than downgrading it. Asking for false can fail too: a node whose unelevated identity would be uid 0 returns failed_precondition rather than acting as root. An elevated call is also not bound by allow-roots (agent 0.4.37+) — but never reaches the agent's own configuration directory, at either identity. See Execution identity. Set it on the first call of a read/write session; continuations carry the session's identity. From router 0.4.83 it is a string"true", "false" or "auto", with a JSON boolean still accepted — and the response reports admin_used, the boolean identity the operation actually ran as. admin_used is absent on the router-served storage trees, which run as nobody on a node — and from router 0.4.88 an admin sent for one of those trees is reported rather than quietly accepted: the result carries a notes[] entry saying the field applied to nothing and quoting what you sent. It is not refused, because sending it is harmless; what the note prevents is a caller concluding an elevated read happened. fs_copy has said the same per side since 0.4.84.
  • Paths inside the agent's own configuration directory (/etc/nobgp, /usr/local/etc/nobgp, C:\ProgramData\nobgp) are refused on every op, including with admin: true — see Owner vetoes. Widening the node's allow-roots will not help; that refusal names itself so you can tell it apart from an out-of-roots one.
  • A script this tool writes to a Windows node is parsed as CP1252 unless it is ASCII (router 0.4.96). write, edit and multi_edit land UTF-8 with no BOM, and a BOM-less .ps1 / .bat / .cmd is parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so the write, its digest and a read-back are all correct, and only the parse is wrong, against a later line than the damaged one. See A script written to a Windows node.
  • A path under the agent's own mount point may be refused to the agent on every op, at both identities — admin: true fails identically, because it is not a privilege problem. network_directory answers it per node as info.mount_readable; where it is false, reach that content through the router with network_name alone or storage: true instead. See A node's own mounted drive.
  • op=list responses are capped at 200 entries by default (hard max 5000). Page with limit / offset and read total_entries / truncated / next_offset — same contract as fs_list. With recursive: true the walk re-checks allow-roots at every directory it enters (agent 0.4.37+), naming a refused directory and then pruning it.
  • op=stat with allow_missing: true returns success with entry: null and exists: false for a missing path instead of not_found, so probes don't need a try/catch path.
  • op=delete with recursive: true is refused on a node's own filesystem for paths shallower than three segments (/, /etc, /home/user, C:\Users\bob). Set force: true when you really do mean it — fs_delete takes the same field. On the router-served storage trees that floor is gone from router 0.4.84; only the tree root is refused there.
  • expected_sha256 is one field over three different sets of bytes, and the op says which: on edit / multi_edit it is the file's hash before the change (a guard against lost updates), on fetch_url the hash of the fetched bytes, and from router 0.4.79 on write the hash of the whole file you meant to send — passed on the done: true call and compared against what the router assembled before it commits anything. A mismatch fails with HTTP 412 (precondition_mismatch) and returns the observed hash in details.observed_sha256. On write nothing is written and no temporary file is left on the node. A write sub-op inside op=batch takes it too. On edit / multi_edit you may not need the guard to learn the hash: an old_string that matched nothing is itself a precondition_mismatch, and from agent 0.4.85 its message ends with the file's current sha256, ready to pass back as expected_sha256 on the rebased retry — see fs_edit.
  • op=batch runs N one-shot sub-ops against the same target in one round-trip. Each entry in ops[] carries its own op + per-op fields (no nested target, no file_id). Sub-ops run serially and are best-effort — a failing sub-op doesn't abort siblings; per-op outcome lands in batch_results[i]. Allowed sub-ops: mkdir, stat, list, delete, edit, multi_edit, write (implicit done=true), fetch_url. read and nested batch are rejected.
  • op=lock / op=unlock exist on the storage trees only (router 0.4.62+) and are refused with unsupported against a node's own disk. lock takes a two-minute advisory lock on path, held across routers and nodes, and answers locked: true with a lock_token and an RFC3339 lock_expires; re-locking renews it. A path someone else holds returns already_exists, retryable, with details.expires_at, details.held_by_self and details.held_by_kind. unlock answers locked: false, whether or not you held it. Those three response fields are populated on these two ops and no others.
  • op=fetch_url makes the agent fetch an HTTP(S) URL directly from its own network position and atomic-write the bytes to path — the router never sees the body. Useful for internal LAN mirrors / authenticated proxies the router can't reach. Optional expected_sha256 verifies the fetched bytes pre-rename; a mismatch leaves the destination untouched and surfaces as precondition_mismatch with details.observed_sha256. A path whose parent directories don't exist yet gets them created, same as op=write — and from agent 0.4.89, a fetch that does not land (a bad digest, a refusing server, a stream over the size cap) removes the directories it created on the way in, so a failed fetch leaves nothing behind at all.
  • op=fetch_url honours admin from router 0.4.88; before that it silently ignored it. The field was accepted, passed the schema, and never reached the node — so every fetch ran as the node's configured account whatever you sent, and the result reported no identity. Two consequences on older routers: a fetch to a path only the superuser can write failed with permission_denied while the same admin: "true" on fs_write succeeded, and on a node whose unelevated identity is the superuser the refusal named admin: true as the retry and then answered that retry with the identical refusal — the one place on this surface where a named retry looped. From 0.4.88 the fetch runs at the identity you pick, the result reports admin_used, and asking to elevate is gated like every other elevated call. See Execution identity.

Use Cases

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

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

A write's bytes come from the conversation

op: "write" (and fs_write) sends content the caller has already produced — there is no client-side upload, so a file on your own disk has to pass through your context to get here, and a large one cannot. Bytes already on another machine are fs_copy; bytes the node can fetch for itself are op: "fetch_url".


fs_read

Surfaces: public · local (observe)

Stream a file from a remote agent to the client.

Input Schema

{
"file_id": "string (optional)",
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"offset": "number (optional, resume)",
"encoding": "base64|utf8 (default: base64)",
"max_bytes": "number (default: 32768, max: 1048576)",
"transfer": "\"url\" (optional; return a URL for the bytes instead of the bytes)",
"storage": "boolean (read the node's own storage area instead of its disk; needs target.node_id)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run elevated — a JSON boolean is also accepted)"
}

Behavior:

  • First call returns a file_id. Pass it back on subsequent calls until done=true.
  • The node reference may be sent flat instead of under target (router 0.4.92+ here) — node_id, or network_name + node_name, beside path. The same holds for fs_write, fs_edit, fs_list, fs_stat, fs_delete, fs_mkdir and fs_grep, which all take the same target — though on all but fs_write it was rejected in schema validation until router 0.4.94. See One spelling everywhere.
  • max_bytes defaults to 32 KiB from router 0.4.84 — it was 256 KiB, and that was a bug rather than a preference. Measured: a 256 KiB chunk comes back as roughly 349,700 characters and a model client refuses it outside the protocol — the harness writes the response to a local file — so the caller got neither its bytes nor a tool error, while 32 KiB was delivered. The 1 MiB hard cap is unchanged: raise max_bytes for a client that consumes bytes rather than reading them (REST, a script). A file over the limit pages by done / offset as it always has; to keep the bytes out of the conversation entirely, use fs_copy, op=fetch_url, or transfer: "url".
  • target.network_name alone reads the network's shared drive, and storage: true reads the node's storage area (router 0.4.56+). Either way the router answers from its own storage, with no file_id and no session. max_bytes is honoured there from router 0.4.59, and from router 0.4.84 it defaults to the same 32 KiB a node read does — an over-default object pages by done / next_offset, where before it arrived in one 1 MiB response. sha256 is present only when the whole file came back in that one response — there is no session to carry a running hash — so verify against the tree's HTTPS URL, which is also what moves a large file in one go. encoding=utf8 may be paged here, unlike a node read: a chunk that would split a character is trimmed back to the last whole one, and offset + size still resumes exactly. From router 0.4.94 an offset past the end of the object is invalid_args with details.file_size, matching what a node read answers — before that it came back as an empty done: true read whose file_size was the offset you sent rather than the object's size.
  • encoding=utf8 returns chunk as a plain string; default base64 returns chunk_b64.
  • UTF-8 mode is single-shot on a node read: it does not support offset-based resume (partial-codepoint state is per-session), and sending offset with encoding: "utf8" is invalid_args rather than a silent restart. If the file may not fit in one response, start with base64 rather than discovering the limit on the second call. The storage trees are the exception — they page utf8 fine, trimming each chunk back to the last whole character.
  • Small files fit in a single response with done=true.

Output Schema

{
"file_id": "string",
"chunk_b64": "string (base64 mode)",
"chunk": "string (utf8 mode)",
"offset": "number",
"size": "number",
"truncated": "boolean (bytes remain past this chunk)",
"next_offset": "number (where the next chunk begins; set while truncated)",
"file_size": "number (the whole file; present on the done=true response)",
"chunk_sha256": "string (this response's bytes)",
"sha256": "string (the whole file; only on the done=true response of a read that started at 0)",
"transfer": "object (present instead of the bytes when transfer:\"url\" was asked for)",
"admin_used": "boolean (which identity the read ran as; router 0.4.89+)"
}

This is the payload under fs_read; the path read, done and duration_ms are on the envelope.

admin_used (router 0.4.89+) is the identity the read actually ran as — true for the node's ambient identity, false for the account its owner configured, and never "auto", which is a request rather than an outcome. It is set on the call that opens the session; a continuation carrying a file_id omits it, because that session's identity was settled when it opened. It is also absent on the router-served storage trees, which run as nobody on any node. Read it beside a short or empty read: unelevated, the read sees only what that account can, which is how one comes back as permission_denied on a path you can list. See the seven fs_* tools for what an older router returns.

offset is where this chunk starts and size is what this call delivered, so offset + size is where the next call resumes. From router 0.4.84 a read reports that like a listing does: truncated says bytes remain past this chunk — read it rather than deriving it from done — and next_offset is where they start. Continuing with file_id is still cheaper, since the session tracks the position itself; next_offset is what you pass as offset to open a new session when the old one was lost.

Two digests, and they answer different questions (router 0.4.84). chunk_sha256 covers the bytes in this response, so it verifies a chunk on arrival. sha256 covers the whole file — the same promise fs_write's sha256 makes, so the two tools agree on what the word means — and it is present only on the done: true response of a read that started at offset 0, where it is comparable against sha256sum on the node. A read resumed at an offset carries none, because the router hashed only the tail it forwarded. Before this, sha256 was cumulative and a resumed read published a hash of a fragment under the name of the file's.

A node that outruns the router now fails the read

Router 0.4.146. An agent streams a read as fast as it can and nothing paces it. If it outruns what the router can take, the mesh drops the frames that do not fit — and until this release that loss was reported as the end of the file: done: true, a file_size measured over the part that survived, and no error anywhere. A caller reading a 5 MiB file was told it had all of it at 1 MiB.

Such a read now fails with resource_exhausted, and the message carries the two things you need:

  • What you already read is correct, and the file is not finished. The loss is at the tail, so nothing you were given is wrong — but how much was lost is unknown, so offset + size is not where to resume.
  • Where to resume, and that this file_id is dead. The error names the first byte that was not delivered; open a new read at that offset. Continuing the old session is not possible. With encoding: "utf8" — which cannot take an offset at all, because the codepoint boundary died with the session — the message says so and points at encoding: "base64" for the resumption.

The same loss reaches the node's other one-shot answers as resource_exhausted rather than the internal they used to report: fs_list, fs_stat, fs_delete, fs_mkdir, fs_edit, fs_grep, fs_glob, op=fetch_url, node_logs and the net_* diagnostics. There it is a whole answer that did not fit, so the remedy is to retry or to narrow the request — a smaller root, a tighter pattern, a lower max_count — rather than to resume. fs_copy answers it in its own way, since the destination is what has to be protected there, and a command session's output answers it in its own way from router 0.4.147.

Moving bytes without reading them

Router 0.4.84 adds transfer: "url" to reads and writes — on fs_read, fs_write and file. Instead of the bytes, the response carries a descriptor for a URL you can hand to something that can fetch:

It needs router 0.4.87

transfer: "url" did not work on 0.4.84 through 0.4.86: every mint, download and upload alike, answered internal: could not record the transfer authorization. Two separate defects sat on that one path, and the first fix — shipped in 0.4.86 and announced there as the fix — closed only the first of them. Both are closed from 0.4.87. No URL was ever issued in that window, so there is nothing to re-check or revoke; reissue the call. whoami reports router_build if you need to know which router is serving you.

{
"transfer": {
"uri": "https://router.nobgp.com/xfer/…",
"name": "build.tar.gz",
"size": 5242880,
"method": "GET",
"auth": "none",
"expires_at": "2026-08-19T10:45:00Z"
}
}

Decide who is going to dereference it before you ask for one, because a URL you cannot fetch wastes the call:

What you are doingWhat to use
Reading bytes yourselfThe ordinary session protocol with a small max_bytes — an assistant has no fetch. 32 KiB is the size that reliably survives a model client's per-result budget.
Moving bytes node to nodefs_copy. They never need to reach you at all: one call, no URL, nothing to stand up or clean up.
Handing bytes to something that can fetch — a person's browser, a CI job, a script, a node's own op=fetch_urltransfer: "url". This is what the field is for.
  • A download URL comes from a read. It is single-file, single-direction and short-lived, and it carries its own authority — so treat it as a secret, and read expires_at for when it stops working. The response's X-Nobgp-Sha256 trailer carries the digest, which is why the descriptor has none: knowing it at mint time would mean reading the whole file first, the cost this route exists to avoid.
  • An upload URL comes from a write with transfer: "url" and no chunk. PUT the body to it; the commit it performs — digest check, atomic rename — is the one the session protocol already does. Content-Range resumes an interrupted upload against the same router replica. ⚠ A Content-Range must name the totalbytes 0-7/8, never bytes 0-7/*. An unknown total is refused up front with 400 and kind: "bad_content_range" from router 0.4.94, because the total is the only thing telling the router a part from the whole: through 0.4.93 a * parsed as no total, so the first part of a multi-part upload was committed as the finished file — 200, digest and all — and the second part then failed against a destination already holding a truncated one. If you do not know the size, send the file in a single request with no Content-Range at all.
  • Read auth rather than assuming. "none" means the URL carries its own authority and you send no Authorization header. "bearer" — which is what the two router-served storage trees return, since those are ordinary files.nobgp.com paths rather than capabilities — means send the same bearer token you called the tool with, and stripping it is a 401.
  • A capability is re-checked when it is redeemed, not frozen when it is minted: a URL minted before a membership is revoked stops working with the membership. Every failure on one says whether to mint another or to fall back to the session protocol.
  • Handing one to a node's own op=fetch_url reports the capability's verdict, not the status line (router 0.4.92+). The recipe the transfer field recommends — mint a download URL, have the destination node fetch it — used to lose its diagnosis on the way back: the node reported the HTTP status it saw and the router classified that like any foreign origin, so a spent capability came back as not_foundthe origin has no such object, check the URL and the artifact name — about a URL that was perfectly correct. The router now recognises its own transfer route by host and answers with the refusal's own kind, code and next step (token_spentfailed_precondition, mint another with transfer: "url"), carrying details.xfer_kind beside details.http_status. A URL on any other origin is classified from its status line exactly as before, and a noBGP router reached under a different hostname falls back to that too — a miss, never a wrong answer.

total is gone, and file_size is what replaced it (router 0.4.83). total meant "bytes delivered so far", which is offset + size and therefore derivable, and it sat beside size reading like a second name for the same number — while the thing a reader actually wanted from it, how much file is left, it never answered. file_size is the size of the whole file. It appears on the done: true response and not before: the agent streams to EOF without announcing a length, so an earlier response says nothing rather than guessing. To size a file up front, use fs_stat. total survives on writes and edits, where it genuinely means bytes written so far.


fs_write

Surfaces: public · local (manage)

Stream a file from the client to a remote agent.

Input Schema

{
"file_id": "string (optional)",
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"mode": "string (octal, default: \"0644\")",
"encoding": "base64|utf8 (default: base64)",
"chunk_b64": "string (base64 mode)",
"chunk": "string (utf8 mode)",
"done": "boolean (finalize)",
"expected_sha256": "string (optional; whole-file hash, sent on the done=true call)",
"transfer": "\"url\" (optional, with no chunk; return an upload URL instead)",
"storage": "boolean (write to the node's own storage area instead of its disk; needs target.node_id)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run elevated — a JSON boolean is also accepted)"
}

Behavior:

  • This is how bytes get onto a machine your own computer cannot reach — code, configs, build artifacts. The node needs no inbound route and nothing installed on your side, only its own agent connection, so a target behind NAT, CGNAT or a firewall is addressed like any other. Router 0.4.72 says so in the tool descriptions and the server handshake, so an assistant reaches for it instead of proposing a second machine on the target's LAN, a manual copy, or making a private repository public. (file with op: "fetch_url" is the other half: the node fetches a URL from its network position, so a mirror only it can see works and the bytes never cross the conversation.)
  • The bytes come from the conversation, and there is no client-side upload (stated in the tool description from router 0.4.143). chunk / chunk_b64 is content the caller has already produced, so a file sitting on your own disk has to pass through your context to reach here — and a large one simply cannot. transfer: "url" is not the way round it either: that mints a URL you upload to with PUT, which an assistant has no way to make. Two things need none of this — bytes moving node to node are fs_copy, and a file the node can fetch for itself is file with op: "fetch_url".
  • First call opens a session and returns a file_id. Stream chunks and call with done=true to finalize.
  • transfer: "url" with no chunk returns an upload URL instead of opening a chunked session (router 0.4.84+) — PUT the body to it from something that can make an HTTP request, and it commits through the same digest check and atomic rename. To move a file between two machines, prefer fs_copy: no URL, one call. See Moving bytes without reading them.
  • target.network_name alone writes to the network's shared drive, and storage: true to the node's storage area (router 0.4.56+). Both are one call, not a session: send the whole file in chunk / chunk_b64 and it lands, up to 1 MiB. A larger file goes through the tree's HTTPS URL with a PUT. Missing parent directories are created there too, from router 0.4.59 — before it, the same call that works on a node's disk answered not_found on a storage tree. Writing to a node's storage area needs Owner or Admin in the organization that owns the node — see Addressing storage instead of a node. A write to either tree is refused with resource_exhausted once the organization is at its storage limit (router 0.4.93+, and on paid plans too from 0.4.101); a write to a node's own disk is not counted or capped.
  • ⚠⚠ Do not chunk a write to either of those two trees — it silently keeps only the last chunk (spelled out in the tool descriptions from router 0.4.146). There is no session there: no file_id is issued, done is ignored, and every call replaces the whole file. So the chunked recipe above — the one this tool and file describe, and which both offer these destinations — answers every chunk with a success and leaves a file holding the last one alone, carrying a sha256 of that fragment, with no error anywhere. It is the one place on this surface where following the documented pattern destroys the payload. Past the 1 MiB inline cap the call is refused with too_large rather than truncated; for anything bigger write to the node's own disk, which does have the session, or PUT the tree's HTTPS URL. transfer: "url" also lifts the cap but mints a URL something else has to upload to.
  • Finalize triggers fsync + chmod + atomic rename on the agent. Aborted writes leave the destination untouched.
  • The finalize carries the whole-file SHA-256, and the node checks it (router 0.4.77+ with agent 0.4.79+). The agent hashes the finished temporary file and refuses the rename unless it matches, so a transfer corrupted on the way to the node fails the call instead of landing a file of the right length with the wrong contents. Until this, nothing anywhere compared what the node received against what the router sent: the sha256 on the response was computed by the router over the bytes it accepted, so a caller checking "did it all arrive?" was reading the sender's own account of itself. Against an agent below 0.4.79 the digest is sent and simply not verified — behaviour is exactly as before, and hashing the file on the node is still the way to be sure. From router 0.4.79 the digest rides both of the router's finalize signals, and from agent 0.4.81 the node checks the second one as well — so a commit lost on the way to the node can no longer downgrade the write to an unchecked one: the fallback finalize carries the same hash, and since the lost commit took the trailing bytes with it the node hashes a short file and the call fails rather than committing a truncation. On agents 0.4.79 and 0.4.80 that fallback was still the one path with nothing to check against, so a write that lost its commit committed unverified even though the router had sent the hash. Agent 0.4.81 fixes the other half of that path: a finalize that failed there was reported back as a clean exit, so a refused commit could answer done: true with nothing written. Agent 0.4.94 closes the last of it: every refused commit — the digest check, the fsync, the mode change, the rename into place — records a failing exit before the session closes, so the failure the node reports is no longer followed by a synthesised clean exit whose arrival order decided which of the two you were told. Through agent 0.4.93 that was a race, and it could still answer done: true for a write that never landed.
  • expected_sha256 is your half of that check (router 0.4.79+). The digest above is taken over the bytes the router accepted, so it certifies the router→node leg and structurally cannot say whether those were your bytes — mangle the chunks on the way in and every check downstream passes on the wrong file. Send the whole file's hash as expected_sha256 on the done: true call: the router compares it with what it assembled before it commits anything, so a mismatch is precondition_mismatch (HTTP 412) with nothing written and no temporary file left on the node, and the assembled hash and byte count come back in details.observed_sha256 / details.observed_size. It is optional: omit it and the first leg stays unverified, send it and a done: true response becomes an end-to-end claim about the bytes on disk rather than a claim about the second leg alone.
  • Send it on a chunked write; a single-call write does not need it (router 0.4.144 states which, having recommended it for every write before). A multi-call write is the one your side assembles, so it is the one a dropped or reordered chunk can shorten — and the check refuses that before the file is renamed into place. A single-call write's bytes came straight from the conversation with nothing but TLS between them and the router, so the guard adds nothing there, and a hash you cannot genuinely compute over them turns a correct write into precondition_mismatch.
  • It covers the storage trees too, from router 0.4.91. A write there is a single call served by the router, and the digest is compared before anything is opened — so a mismatch is the same precondition_mismatch, with the same details.observed_sha256 / details.observed_size, and an existing file at that path is left byte-for-byte intact. Through router 0.4.90 those two trees computed the digest and reported it without ever comparing it to what you sent: a write of mangled bytes committed and answered like a success, carrying the hash of what actually landed rather than of what you meant to send. The field was documented without qualifying by tree throughout, so the contract you were reading was the one the node path implemented and this one did not. fs_copy was never affected — it has always compared.
  • A write that could not be delivered is now reported as one (router 0.4.77+). A chunk sent into a session that ended while the call was in flight comes back not_foundsession ended before write completed — where it previously returned success, along with a sha256 and a total for bytes the node never received. Retry by opening a fresh session; it does not mean the path is missing. Two concurrent calls carrying the same file_id are also serialized now, so they can no longer interleave their bytes inside one file.
  • chunk and chunk_b64 are mutually exclusive — pick one encoding per session.
  • A missing parent directory is created (agent 0.4.45+). Writing to /opt/app/config/settings.json on a node where /opt/app/config does not exist creates the chain and proceeds, instead of failing on the first call and requiring an fs_mkdir with recursive: true first. No authority is added — that fs_mkdir was always available to the same caller, and the destination is still vetted against its deepest existing ancestor — so a path outside the node's allow-roots is refused exactly as before. The directories are created by the same identity admin selects, so they are owned by whoever will own the file. On an agent below 0.4.45 the call still fails with the parent's not_found; issue the fs_mkdir yourself.
  • A script written to a Windows node is parsed as CP1252 unless it is ASCII (stated in the tool description from router 0.4.96). These bytes go out as UTF-8 with no BOM, and a BOM-less .ps1 / .bat / .cmd is parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so a non-ASCII character survives the write, the digest and a read-back intact and fails only at parse time, blaming a later line than the damaged one. See A script written to a Windows node.
  • A chunked write the router stops talking to is given up on by the node itself (agent 0.4.121+). A write session is normally ended by the router, so a session that lost its other end used to sit on the node for the life of the agent with its temporary file on disk. The node now gives up after two hours with no chunk and no finalize: the temporary file is discarded, nothing is written, the destination is untouched, and the write is reported as failed rather than left pending. A finalize that arrives after that is refused rather than renaming a file that is about to be deleted — the error says the write was abandoned and to retry it, which opens a fresh session and sends the bytes again. ⚠ The two hours are deliberately longer than the hour the router allows a session to sit idle, so a write you are making across several calls with long gaps between them is never the thing that gets reaped: the clock is silence from the router, not slowness on your side.
  • A write that never lands takes those directories back with it (agent 0.4.89+). A session abandoned before done: true, a finalize the digest check refused, or a transfer that failed part way now removes exactly the directories that write created, innermost first — so a failed write no longer leaves an empty tree behind that reads like a half-finished install. Directories that already existed are never touched, and the unwind stops at the first one that is not empty, so a directory another write has since put a file into is left alone. Through agent 0.4.88 the chain simply stayed, including when the fs_mkdir itself failed part way down. If you want the directories, issue fs_mkdir yourself — one made that way is yours and is never unwound.

Output Schema

{
"file_id": "string",
"offset": "number",
"total": "number",
"sha256": "string",
"admin_used": "boolean (which identity the write ran as; router 0.4.89+)"
}

This is the payload under fs_write; the path written, done and duration_ms are on the envelope.

admin_used (router 0.4.89+) is the identity the write actually ran as, and therefore who owns the file that lands — true for the node's ambient identity, false for the account its owner configured. Never "auto": that is a request, this is the outcome. It is set on the call that opens the session and omitted on a continuation carrying a file_id, whose identity was settled at open, and it is absent on the router-served storage trees, which run as nobody on any node.

sha256 is the router's hash over the bytes it accepted for the node, cumulative across the session. On the done: true response it is the whole-file hash — and, against an agent on 0.4.79 or later (0.4.81 or later to cover a write that finalized through the router's older commit signal), the node has checked the finished file against it before committing, so that response means the bytes on disk are these bytes. What it does not cover is your bytes reaching the router; pass expected_sha256 on that same call to close it.


fs_edit

Surfaces: public · local (manage)

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

Input Schema

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

Behavior:

  • Provide either {old_string, new_string, replace_all} (single edit) OR edits[] (ordered batch). Mixing them is an error.
  • There is no edit on noBGP's own storage trees. storage: true, or a target.network_name with no node, is refused with unsupported naming the operations those trees do have. Read the file, change it, write it back.
  • When edits[] has more than one element, the tool dispatches to multi_edit semantics: later edits see earlier edits' results.
  • All-or-nothing: if any edit fails (old_string not found, or non-unique without replace_all), no edits land.
  • expected_sha256 fails with HTTP 412 (precondition_mismatch) when the agent-observed file hash differs; the observed hash is returned in old_sha256 so callers can rebase and retry.
  • An old_string that matched nothing names the file's current hash, from agent 0.4.85. That failure means the file is not what you read, so it is a precondition_mismatch whose remedy is to re-read and rebase — and the message ends … is sha256 <64 hex> — re-read it and rebase the edit, so the retry can send that value as expected_sha256 without spending a second read to learn it. An old_string that matched more than once is a different failure: invalid_args, because the file was read correctly and the request was ambiguous — pass replace_all: true or make old_string more specific. Re-reading changes nothing there.
  • Editing a script on a Windows node can introduce a character it will not parse (stated in the tool description from router 0.4.96). The file is written back as UTF-8 with no BOM, and a BOM-less .ps1 / .bat / .cmd is parsed on Windows in a legacy code page instead — CP1252 for Windows PowerShell — so a new_string containing a curly quote or a dash lands correctly, reads back correctly, and breaks the script at parse time. Keep the replacement ASCII. See A script written to a Windows node.
  • On a Windows node this needs agent 0.4.75. Through agent 0.4.74 every edit on a Windows node failed with Access is denied, single and batch alike: the edit is written to a temporary and renamed over the original, Windows refuses to replace a file anybody still holds open, and the agent was itself holding the one it had just read. Nothing was changed on disk when it failed, and Linux and macOS were never affected — renaming over an open file is allowed there. On an older Windows agent, fs_read the file and fs_write it back instead.

Output Schema

{
"edits_applied": "number",
"file_size": "number (file size after)",
"sha256": "string (after)",
"old_size": "number (before)",
"old_sha256": "string (before)",
"admin_used": "boolean (which identity the edit ran as; router 0.4.89+)"
}

This is the payload under fs_edit; the path edited, done and duration_ms are on the envelope. ⚠ The size after the edit is file_size from router 0.4.83; it was total before, which meant something different on a write and is now confined to that.

admin_used (router 0.4.89+) is the identity the edit actually ran as — true for the node's ambient identity, false for the account its owner configured, never "auto". There is no edit on the router-served storage trees, so on this tool the field is populated on every successful call.


fs_list

Surfaces: public · local (observe)

List a directory on a remote agent.

Input Schema

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

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

target.network_name alone lists the network's shared drive, and storage: true the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. Entries come back sorted by name, and limit / offset / truncated / next_offset work exactly as below. recursive walks the whole subtree from router 0.4.59, each entry carrying its path and the listing sorted by it; a directory the walk cannot read is reported once, as its own entry with error set, rather than costing you the rest of the listing. On 0.4.56–0.4.58 recursive was accepted and ignored — you got the top level, with truncated unset and a total_entries matching it, which is indistinguishable from an empty subtree.

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

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

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

A recursive walk is built whole before it is paged

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

Paging therefore bounds the response, not the work, and the tool descriptions say so from router 0.4.84: limit and offset cut bytes and not time, and paging through a listing re-walks the tree once per page. Measured: /usr/lib with recursive: true and limit: 3 took 5,523 ms to return 3 entries of 23,968. Page to bound a response you already know is large; to make a listing cheap, narrow path, or use fs_glob / fs_grep, which filter on the node.

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

Output Schema

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

This is the payload under fs_list; the directory path, done and duration_ms are on the envelope.

admin_used (router 0.4.89+) is the identity the walk actually ran as — true for the node's ambient identity, false for the account its owner configured, never "auto". Read it beside a short listing: unelevated, a walk sees only what that account can read, which is how a directory comes back looking emptier than it is. It is absent on the router-served storage trees, which run as nobody on any node.


fs_stat

Surfaces: public · local (observe)

Return metadata for a single path on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"allow_missing": "boolean (optional)",
"storage": "boolean (stat inside the node's own storage area instead of its disk; needs target.node_id)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run elevated — a JSON boolean is also accepted)"
}

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

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

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

Output Schema

{
"entry": {
"name": "string",
"size": "number",
"mode": "string (octal)",
"mtime": "number (unix timestamp)",
"is_dir": "boolean"
},
"exists": "boolean (only when allow_missing was set)",
"admin_used": "boolean (which identity the stat ran as; router 0.4.89+)"
}

This is the payload under fs_stat; the path you asked about, done and duration_ms are on the envelope.

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

admin_used (router 0.4.89+) is the identity the stat actually ran as — true for the node's ambient identity, false for the account its owner configured, never "auto". It is absent on the router-served storage trees, which run as nobody on any node. It is what tells you which identity a permission_denied here was refused for, and — on a call that sent admin: "auto" — which of a mixed fleet's two shapes this node turned out to be.


fs_delete

Surfaces: public · local (manage)

Remove a file or directory on a remote agent.

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"recursive": "boolean (required for non-empty directories)",
"force": "boolean (optional; bypass the depth-guard — not needed inside /tmp and the other scratch roots)",
"storage": "boolean (delete inside the node's own storage area instead of its disk; needs target.node_id)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run elevated — a JSON boolean is also accepted)"
}

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

There is no allow_missing here, unlike fs_stat — treating the not-found as success is the whole of it, so the field would add nothing. Sending it anyway is invalid_args naming the offending property, not a silent no-op. Router 0.4.88 says so in the tool's own description: it had been naming the exact need allow_missing serves without mentioning that the field is absent on this tool.

Deleting on a storage tree

target.network_name alone deletes from the network's shared drive, and storage: true from the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. From router 0.4.59 a non-empty directory needs recursive: true there exactly as it does on a node, and deleting the tree root itself is always refused. A missing path is not_found, as on a node.

The depth floor no longer applies on these trees (router 0.4.84). It is calibrated for a machine with an /etc on it, and a router-served tree has no system to destroy — its top-level directory is the natural unit of work, so the guard fired on the ordinary case (path: /reports, recursive: true → refused at depth 1) and trained force: true as a reflex. A guard that is routinely overridden protects nothing. force is still accepted here and means nothing, so a caller that sets it defensively — or replays a call that worked against a node — is unaffected. Only the tree root is refused.

On 0.4.56–0.4.58 neither guard existed here: a delete with no recursive removed a whole subtree and reported success. Check with fs_list before deleting on these trees either way — nothing on them passes an agent, so no node-side veto stands between the call and the bytes.

Depth-guard on a node's own filesystem

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

Anything strictly inside a scratch root is exempt from the depth rule entirely (router 0.4.87+), so /tmp/build needs no force even though it is depth 2. The roots are /tmp, /var/tmp, their macOS /private spellings (/private/tmp, /private/var/tmp) and C:\Windows\Temp. /tmp is the most common working directory on this surface — the recipes in these pages use it — so every routine cleanup was being refused, which taught force: true as a reflex, and a caller that sends force for scratch work sends it at /etc too.

  • The scratch root itself is not exempt. /tmp still needs force: true; deleting the contents of a scratch directory is routine, deleting the scratch directory is not.
  • A .. segment forfeits the exemption and takes the ordinary guard, so /tmp/../etc is refused. From router 0.4.94 the guard counts the path after resolving . and .. lexically, which is what makes that refusal real: /tmp/../etc is /etc at depth 1. Through 0.4.93 the exemption correctly declined to exempt it and the guard then counted the raw spelling at depth 3, so the one path the two halves agreed was suspicious went through without force. The exemption itself still reads the spelling you sent and still refuses to exempt anything containing ...
  • Case is folded only behind a drive letter. C:\WINDOWS\TEMP is the same directory; /TMP on Linux is a different one and keeps the guard.
  • The per-user Windows temp (C:\Users\<name>\AppData\Local\Temp) is not on the list and does not need to be — it is already depth 5.

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

Output Schema

The envelope carries the answer — path plus done is what a delete has to say — and from router 0.4.89 an fs_delete key appears beside it on a delete that ran on a node, carrying the one thing path and done cannot: which identity did the deleting.

{
"call_id": "string",
"op": "fs_delete",
"done": true,
"duration_ms": "number",
"node": "string",
"path": "string",
"fs_delete": {
"admin_used": "boolean (which identity the delete ran as; router 0.4.89+)"
}
}

The fs_delete key itself is absent on the router-served storage trees, which run as nobody on any node — so a delete there returns exactly the envelope-only shape it always did, rather than a payload with nothing in it. admin_used is never "auto": that is a request, and this is the outcome.


fs_mkdir

Surfaces: public · local (manage)

Create a directory on a remote agent.

Input Schema

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

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

target.network_name alone creates in the network's shared drive, and storage: true in the node's storage area (router 0.4.56+) — see Addressing storage instead of a node. recursive works the same way there; mode is not applied. A mkdir on either tree is refused with resource_exhausted once the organization is at its storage limit (router 0.4.93+, and on paid plans too from 0.4.101) — creating a folder is a stored resource, where fs_delete never is.

Output Schema

path and done on the envelope are the answer, and — as on fs_delete — from router 0.4.89 an fs_mkdir key appears beside them on a call that ran on a node, naming the identity that created the directory and therefore owns it.

{
"call_id": "string",
"op": "fs_mkdir",
"done": true,
"duration_ms": "number",
"node": "string",
"path": "string",
"fs_mkdir": {
"admin_used": "boolean (which identity the mkdir ran as; router 0.4.89+)"
}
}

The fs_mkdir key itself is absent on the router-served storage trees, which run as nobody on any node, so a call there returns the envelope-only shape it always did.


fs_copy

Surfaces: public · local (manage)

Copy one file from one machine to another in a single call. Router 0.4.84+; the same operation is file with op: "copy".

Purpose

This is the answer to get this build artifact off pi5 and onto cm4. The router streams the bytes between the two agents, so:

  • the two ends need no route to each other — each needs only its own agent connection, the same precondition every other tool here has;
  • nothing is installed or stood up anywhere, and there is nothing to clean up afterwards;
  • the bytes never pass through the conversation, so size is bounded by the transfer rather than by a client's per-result budget.

Measured before it existed, the same 5 MiB pi5cm4 transfer took four calls: stand up an HTTP server on the source, know how the source resolves in the overlay, op=fetch_url from the destination, kill the server. fs_copy is one.

Input Schema

{
"source": {
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"path": "string (absolute)",
"storage": "boolean (optional)",
"admin": "\"true\" | \"false\" | \"auto\" (optional)"
},
"destination": { "…the same shape…" },
"expected_sha256": "string (optional; a precondition on the SOURCE's content)",
"mode": "string (optional octal for the destination, default \"0644\")"
}

Both ends take the same shape, so you learn it once. Each end may be a node (target.node_id, or target.network_name + target.node_name), the network's shared drive (target.network_name alone, no node), or a node's storage area (target.node_id with storage: true) — the addressing every file tool uses, described under Addressing storage instead of a node.

Parameters:

  • source / destination - Where the bytes come from and go to. An existing destination is overwritten, exactly as fs_write overwrites one, and missing parent directories are created.
  • admin - Per side, and gated per side against that end's own organization: the source may need the superuser where the destination does not. Every refusal leads with source: or destination:, because a two-ended call that says only forbidden is one you cannot act on. It applies to a node end only: sent for a router-served tree it selects no identity anywhere, and the result says so in notes[] rather than leaving you to assume it took.
  • expected_sha256 - A precondition on the source's content, the same meaning it has on op=fetch_url. The router hashes the bytes as they pass through and compares before publishing anything, so a mismatch is precondition_mismatch (HTTP 412) with the destination untouched and details.observed_sha256 / details.observed_size carrying what was actually there.
  • mode - Unix mode for the destination. A Windows destination synthesises its mode bits from no ACL, so the value applies to nothing there and the result says so in notes[].

Output Schema

{
"size": "number (bytes copied)",
"sha256": "string (one digest, over the bytes the router moved)",
"source_path": "string (in the source's own separator form)",
"destination_path": "string (in the destination's own separator form)",
"source_admin_used": "boolean (absent when that end is router-served)",
"destination_admin_used": "boolean (absent when that end is router-served)"
}

This is the payload under fs_copy. The envelope's path and node name the destination — the end this call changed — and source_path in the payload is the other half. Both paths come back resolved, so on a copy between two platforms they differ in more than the directory.

One digest, end to end. sha256 is computed by the router once as the bytes pass through, so the same number covers both legs: it is the hash of what was read from the source, and it is the digest handed to the destination's commit, where an agent on 0.4.79+ re-hashes the finished file and refuses the rename unless it matches. Compare it against sha256sum at either end.

Behaviour and limits

  • Nothing is published until the last byte has crossed. A copy that fails at any point — a deadline, a dropped node, a digest mismatch — leaves the destination exactly as it was, so the retry is simply the same call again. There is no resume position and no copy_id.
  • ⚠⚠ A copy that loses bytes on the way in now fails instead of committing (router 0.4.146). This is the one failure the digests above structurally cannot see: both are taken over the same stream, so a stream cut short agrees with itself, and only the router knows. The fix has two halves. The router now drains the source from the instant that end opens, rather than reading and writing in turn — through router 0.4.145 nothing was reading the source while the destination session was being opened, which is a round trip, so a file barely over 1 MiB lost its tail and committed, digest-verified, reported as a success. Measured on 2026-09-11: 1 048 576 bytes copied correctly, 1 048 577 did not, and the digest returned for the larger file was the digest of the first 1 048 576 bytes of it. If the source still outruns the copy, the call fails with resource_exhausted naming which end and how far it got, and nothing is written to the destination. Retry it; if it fails again, move the file with op=fetch_url from the destination, which goes node to node without passing through the router, or copy it in pieces of 1 MiB or less.
  • A copy onto a storage tree is invisible while it runs (router 0.4.94+). The in-flight file now carries the same reserved .nobgp-upload-… prefix every other upload to those trees uses, so it is hidden from fs_list and from a mounted drive's listings, and it is not counted toward stored bytes until it is published. Through 0.4.93 it was written as an ordinary .nobgp-copy-….tmp, which appeared in listings mid-copy and was metered.
  • One file, not a directory tree. List with fs_list and copy the entries you want.
  • Every failure names which end it came from, and on which node (router 0.4.88+). A copy is the one file operation with two subjects, so not_found: /tmp/x: no such file or directory names the path you already knew and withholds the only fact that would correct you. Errors the node itself reports — a missing file, a permission refusal, a directory where a file was expected — now lead with source (pi5): or destination (cm4):. The node's own wording and the error code are unchanged, so the retry a client derives does not change; what changed is that you can tell which side to look at.
  • A copy into a full storage tree is refused (router 0.4.93+, and on paid plans too from 0.4.101): the storage limit is checked against the destination only, with resource_exhausted, because a copy out of a full tree is a read and stays allowed. A node destination is unaffected — a node's own disk is neither counted nor capped.
  • A copy with either end in an organization that is over its bandwidth allowance is refused (router 0.4.106+, Free only), naming which end. Unlike the storage limit above, this one is checked on both ends: a storage cap bounds what is held, so copying out of a full tree adds nothing, while a bandwidth stop bounds what moves, and the same bytes cross the router in either direction.
  • Cross-organization copies are refused, naming which end sits where, even where you hold both ends. It is a guardrail rather than a boundary — the same caller can still fs_read and fs_write — so what it stops is one call spanning two tenants by accident.
  • A copy cannot be a sub-op of op=batch, which inherits a single target; that is refused by name rather than as an unknown op.

fs_copy or op=fetch_url?

Choose by where the source is.

SourceReach for
A node, the network's shared drive, or a node's storage areafs_copy
A URL only the destination node can reach — an internal mirror, an authenticated artifact storefile with op: "fetch_url", which the agent performs from its own network position

fs_copy is not fetch_url with a nicer URL: the fetch_url route needs the destination to reach the source, a server running on the source, and a cleanup call, and this needs none of the three.


fs_grep

Surfaces: public · local (observe)

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

Needs agent 0.4.37+

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

Input Schema

{
"target": {"node_id": "string", "network_name": "string", "node_name": "string"},
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"max_count": "number (optional)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run elevated — a JSON boolean is also accepted)"
}

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

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

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

Skipped automatically: binary files, noise directories (.git, node_modules, .venv, target, dist, …), and any file over the agent's 16 MiB per-file cap. The first two are skipped by design and are not counted anywhere; the size skip is counted in skipped_files, because it is the one that can hide a match. A path the walk could not read at all — a typo, a directory that is not on this node, a tree its identity cannot enter — is counted separately in unreadable_paths and named in unreadable[] (router 0.4.96+ with agent 0.4.96+).

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

How results arrive

The response always carries the matches. matches[] holds every hit, on every path. Sending a progressToken changes when you can have them, never whether: each match is also pushed as a notifications/progress message as the walk finds it, and streamed_as_progress: true on the response says that happened.

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

streamed_as_progress means you may have seen these already, never these were withheld. Ignore it unless you consume notifications and want to skip what you have already rendered. The doubling it costs is bounded by the same max_count that already bounds a non-streaming caller, so a streaming call is no more expensive than an ordinary one.

Through router 0.4.85 a progressToken emptied matches[]

Until router 0.4.86, sending a progressToken was read as a declaration that the caller consumes notification payloads, and the response dropped matches[] on that basis. A token says no such thing — a client may attach one to drive its own progress display without ever surfacing the payloads — so such a caller got count: 1 with matches: null: a successful-looking call carrying none of the data it asked for, with no error raised and no way to opt out.

Against a router below 0.4.86, read count rather than matches.length and be ready for an empty array. From 0.4.86 the two agree.

count is the authoritative total on every path — it is what answers "did this find anything", and it stays correct however the matches reached you. An empty result is a successful call with an empty matches[], not an error.

A zero is only a zero when skipped_files and unreadable_paths are both 0

An empty result is a successful call, so the two fields that say whether the search happened are the ones to read before believing it. They describe different failures and neither implies the other.

skipped_files — files that exist and were too big. The agent does not read a file over its 16 MiB per-file cap; it skips it and the walk carries on. Point a search at a directory holding a rotated log or a database dump and the result comes back count: 0, scanned_files: 0, no error: identical in shape to "nothing matched", and the file whose contents you were asking about was never opened. Measured on a 30 MB corpus: 0 matches; the same bytes split into three 10 MB files: 15 000. When it is nonzero, search those files individually, split them, or narrow paths. It counts the size skip only — binary files and noise directories are skipped by design and are not counted. Reported from agent 0.4.40.

unreadable_paths — places the walk never looked, and the more common wrong zero (router 0.4.96+ with agent 0.4.96+). A path that is not on this node, or one the identity the walk ran as cannot enter, used to be stepped over in silence: fs_grep over a wholly mistyped path answered count: 0, scanned_files: 0, skipped_files: 0 — a success shaped exactly like a completed search of an empty tree, with the check above passing and confirming it. Two wrong paths out of three answered scanned_files: 1, which reads as a small legitimate search. The count covers both the roots you named and directories underneath them the walk could not descend into.

unreadable[] names which of your paths failed and why, so the answer is actionable without a second call:

reasonMeansWhat to do
not_foundNo such path on this nodeFix the path — or accept that this node does not have it, which across a cohort is an ordinary per-node answer rather than an error
permission_deniedThe path is there and the identity this walk ran as cannot read itRetry with admin: "true", then read admin_used to see what you got
unreadableAnything else the walk could not open

Only the roots from your paths[] appear in it. A directory the walk could not descend inside one of them raises unreadable_paths without adding a row — that list has no bound, while this one is the size of your own request.

Both counts also reach the human-readable message, so the caller least likely to open the structured body still sees them: 0 match(es) across 0 file(s), 1 file(s) too large to search, 1 path(s) NOT SEARCHED: /tmp/typo (not_found). A clean walk says nothing at all about either, rather than reassuring you with two zeroes.

Both fields are always present in the response, so a zero is a real count and not a silence — but an agent older than the release that reports each one sends nothing and the router renders 0. Below agent 0.4.40 a skipped_files: 0, and below agent 0.4.96 an unreadable_paths: 0, therefore means nothing was skipped, or this node cannot say; check the node's info.agent_version in network_directory before trusting a zero from an old node.

Output Schema

{
"matches": [
{"path": "string", "line": "number (1-indexed)", "text": "string", "truncated": "boolean"}
],
"streamed_as_progress": "boolean (only when true; the matches were ALSO pushed as notifications)",
"scanned_files": "number",
"skipped_files": "number",
"unreadable_paths": "number (paths that were never searched; router 0.4.96+)",
"unreadable": [
{"path": "string (as you sent it)", "reason": "not_found | permission_denied | unreadable"}
],
"truncated": "boolean",
"count": "number",
"admin_used": "boolean (which identity the walk ran as)"
}

This is the payload under fs_grep; the node searched, done and duration_ms are on the envelope.

admin_used (router 0.4.83+) is the identity the walk actually ran as — true for the node's ambient identity, false for the account its owner configured. Read it beside a small count: unelevated, a walk sees only what that account can read, which is how a search comes back short and looks complete.

text is capped at 2 KiB per line. Top-level truncated means the walk hit max_count before exhausting the inputs — raise it for more. scanned_files counts the files the walker read; skipped_files counts the ones it did not, because they were over the size cap; unreadable_paths counts the places it never looked at all, with unreadable[] naming those of your own roots that failed. unreadable[] is absent on a clean walk — see A zero is only a zero.

Timing

Expect silence on a big tree and then everything at once, unless you asked for the matches as they land — a progressToken on the public endpoint, or fs_grep_subscribe — in which case each one is pushed as the walk finds it.

From agent 0.4.89 that holds at either identity. When the search runs as the node's configured user — the usual case — the agent performs it inside its privilege-dropped file worker, and through agent 0.4.88 that worker answered one reply per request: the same search streamed its matches with admin: "true" and arrived in one lump without it, so the identity a node runs as was visible in when its results appeared. The match set was always identical and no result was ever lost; only the arrival timing differed.

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

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

A large search against an agent below 0.4.38

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

On a node's own server this is always synchronous

That endpoint forwards to the router over a buffered request/response with no channel for a mid-call push, so it says so and a progressToken is ignored rather than honoured into the void. The matches are all in the response, as they are on the public endpoint; what is unavailable here is only their earlier arrival, so streamed_as_progress is never set.

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


fs_grep_subscribe

Surfaces: public · local (observe)

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

Needs agent 0.4.37+

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

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

Input Schema

{
"network_name": "string (or network_id)",
"node_names": ["string"],
"platform": "string", "os": "string", "arch": "string",
"labels": {"key": "value"},
"pattern": "string (RE2)",
"paths": ["string (absolute)"],
"ignore_case": "boolean (optional)",
"max_count": "number (optional)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; run the walk elevated — a JSON boolean is also accepted)",
"subscription_id": "string (uuid, optional)"
}

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

Supplying your own subscription_id makes a retry idempotent: it echoes the run already in flight rather than walking the fleet a second time. A slow call you retried is exactly when you least want a doubled walk. The echo compares admin alongside the pattern, so the same id at the other identity is not answered with "already walking".

Which identity the walk runs as

admin works exactly as it does on fs_grep, from router 0.4.81: omitted, each node walks as its own configured account; "true" walks as the node's ambient identity. It matters more here than on a single node — a cohort search pinned to the configured account comes back short and looks complete on every node where the interesting files are root-owned. From router 0.4.83 it is a string, and "auto" walks at whichever identity each node can offer; the response's admin_used map reports what each node was asked for.

The two refusals land in different places, because this is a fan-out:

  • Your authority to elevate is asked once, on the network's organization. Without it the whole call fails with forbidden, so a cohort is never partly elevated. Retry with admin dropped.
  • A node's own veto is that node's event. A machine whose owner set allow-admin: false, or whose unelevated identity would be root, reports its refusal and settles; every other node in the cohort keeps walking.

Below agent 0.4.89, an unelevated walk delivers its matches in one batch at the end. Whichever identity is chosen, the match set and the terminal summary are the same — but a node walking as its configured account performed the search inside a privilege-dropped worker that could only answer once, so its matches all landed together when it finished rather than arriving as it found them. From 0.4.89 they stream at either identity. On a mixed fleet expect both timings in one subscription; outstanding is still what tells you when the cohort is done.

See Execution identity for the whole contract.

Output Schema

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

All four subscribe tools return this one payload, under the shared subscription key inside the envelope — they differ in what they watch, not in what creating a subscription tells you.

nodes is the cohort as resolved at subscription time — see below. ttl_seconds is always the 600-second default: a walk is bound to the work rather than to idleness, so this tool takes no keep_seconds and sending one is invalid_args.

Reading the matches

Each match arrives as an event:

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

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

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

Each node closes with a completed event whose reason summarises its own walk — 12 match(es) across 340 file(s) in 1180ms. It names files skipped for size when there were any (, 2 file(s) too large to search, agent 0.4.40+), and from router 0.4.96 the paths that were not searched at all, naming each one and why (, 1 path(s) NOT SEARCHED: /opt/app (not_found)). Both are there for the reason fs_grep returns them: a node that reports no matches and skipped a 2 GB log did not answer your question, and neither did one that never found the directory.

The second one matters more on a cohort than on a single node. A path that exists on some machines and not others is the normal case across a fleet — different distributions, different install prefixes — and such a node used to report completed with no matches, which is indistinguishable from having searched. A fleet-wide search could be mostly not performed and read as authoritative. A cohort search that comes back empty is evidence of nothing only on the nodes where both numbers are zero; a node whose agent is older than 0.4.96 cannot report the second one, so its silence is still ambiguous.

It always ends

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

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

Not marked read-only

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

Which one to use

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

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


net_peers

Surfaces: public · local (observe)

Read the directory of peers the agent currently knows about.

Input Schema

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

All net_* tools share the same input shape — just a target pointing at the node to read from. From router 0.4.92 the three fields may equally be sent at the top level, without the target wrapper: {"node_name": "pi5"} is the whole call. See One spelling everywhere.

Output Schema

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

This is the payload under net_peers; the node it came from, done and duration_ms are on the envelope.

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

Addresses here are node-local

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

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

The marker only reaches you from agent 0.4.89. The node has computed it since 0.4.83 and the router has understood the field since 0.4.85, but the agent's copy of a peer into this reply dropped it — so through agent 0.4.88 stale was absent from every peer of every node, and a leftover entry read as directory corruption with nothing to say it was a known and expected one. Against an older agent, treat an entry the directory does not list as unmarked, not as fresh.

From router 0.4.81 there are fewer of them to explain: when a directory entry is deleted — a node deprovisioned, or an expired entry swept — the router now tells the network's nodes to drop it, where before a node's directory only ever grew and a leftover survived restarts until something overwrote that name.

Acting on that message needs agent 0.4.83, which is where the other half landed: an agent on 0.4.82 or earlier ignores the withdrawal and keeps the leftover exactly as it did before, so a network drops a deleted name only as fast as its slowest node is upgraded. On an agent that does act, the entry stops being served and stops being listed the moment the message arrives — a deleted name answers "no such host" instead of resolving to an address whose traffic goes nowhere — while the address it held stays reserved rather than being handed straight to another name, so a peer still holding the old answer cannot be pointed at the wrong machine.


net_interfaces

Surfaces: public · local (observe)

Read the agent host's network interfaces (equivalent to ip -j link / ifconfig) — and, from router 0.4.81, the node's own overlay identity.

Output Schema

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

This is the payload under net_interfaces; the node it came from, done and duration_ms are on the envelope. The same holds for the three net_* schemas below.

What is my overlay IP

The top-level overlay block is the answer, and it is reported by the node rather than derived:

  • interface - the entry it was read off: the node's overlay TUN.
  • slice - the /20 out of 100.64.0.0/10 this node self-assigned. Every handle it mints — for itself and for every peer it names — comes out of it.
  • self_ip - the address this node's own overlay name resolves to here.

The same block also stays on the interface entry it was reported on, so nothing is rewritten in flight.

addrs is not the identity

The TUN is configured with the slice base, and on macOS a point-to-point utun reports that base with a /8. So addrs on that entry is the kernel's honest answer to "is my TUN configured the way I think" and a confidently wrong answer to "what is my overlay IP". Both are reported and neither corrects the other.

self_ip is node-local exactly like every net_peers address: it names this node on this node and something else, or nothing, anywhere else. remote_id from network_directory is the identifier that is valid everywhere.

overlay is absent when the agent reported none. The node has to send it, and agent 0.4.83 is where it starts — router 0.4.81 shipped first so that no agent could answer a router that would refuse the new field, so a node on 0.4.82 or earlier answers net_interfaces exactly as before, with no overlay anywhere in the response. It is also absent on a node that has one but has nothing to say: no overlay TUN in the interface list, or neither a slice nor a self-handle yet.


net_metrics

Surfaces: public · local (observe)

Read the agent's Go runtime counters — and, from router 0.4.84 with agent 0.4.86, a host block for the machine it runs on. Triggers a brief stop-the-world pause on the agent — fine for occasional diagnostics, not a replacement for a metrics pipeline.

Output Schema

{
"uptime_secs": "number",
"goroutines": "number",
"heap_alloc_bytes": "number",
"heap_sys_bytes": "number",
"num_gc": "number",
"gc_pause_total_ms": "number",
"num_cpu": "number (logical CPUs usable by the agent process, after CPU affinity)",
"gomaxprocs": "number",
"version": "string (Go version)",
"os": "string (GOOS)",
"arch": "string (GOARCH)",
"virtualization": "string (system/role, e.g. docker/guest; omitted when nothing was reported)",
"host": {
"cpu_percent": "number",
"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)"
}
}

Everything outside host is the agent process's own runtime, not the machine's — the name has always promised more than the tool answered, which is why the host block was added rather than the tool renamed (the name sits in every client's cached tool list).

There is no accelerator block here. info.gpu is on network_directory only: an accelerator is static, so it rides registration rather than the 60-second sample this tool reports.

num_cpu is the process's, not the machine's, and it is not network_directory's info.cpu_count. This one is what the agent can actually use and honours CPU affinity, so on a container pinned to two CPUs it reads 2 while the machine has 32; cpu_count is what the machine has. They agree everywhere except a pinned container — read this one for "what would a process here get", that one for "how big is this machine".

host carries the same fields network_directory reports per node as info.health, so the two surfaces cannot drift into two spellings of one fact. Read network_directory for a whole fleet in one call; read this when you already have a node in hand.

The sample belongs to the machine that is running now, and a reboot is what invalidates it (router 0.4.112) — the same rule network_directory applies to info.health, from the same one place, so the two tools cannot disagree about the same node. Where the block is served from the node's stored sample rather than sent live by the agent, a sample taken before the machine last booted is withheld rather than published beside fields that are all current. So host is absent for about 90 seconds after a reboot, until the node samples again.

Through router 0.4.111 the key was the node's current connection instead, which suppressed the block on every reconnect as well — so a node with a flapping link reported no host block at all, measured at 0% of instants for one reconnecting every 45 seconds. A reconnect and an agent restart no longer withhold anything: neither of them is a new machine.

Before router 0.4.110 the two tools disagreed about the same node at the same instant: the directory withheld a stale sample and this tool went on serving it, which on a node being looked at because it had just restarted is the reading most likely to mislead. The rule now lives in one place and both read it.

  • at is not decoration: a node samples its host every 60 seconds, so the numbers beside it are up to a minute old by construction. A node can read healthy 55 seconds into the thing that is killing it.
  • load1 is absent on Windows, which keeps no load average — the number a collector synthesises there is not one, and a zero would be a load a real machine can have.
  • disk_free_bytes (router 0.4.144, measured by agent 0.4.119) is free space on the filesystem holding the agent's own working directory — the space a dispatched build has, not the shared drive, whose writable_roots are remote share storage and answer a different question. Absent when the node could not measure it, and on every node whose agent predates 0.4.119. Read it as free space where this agent is standing: an unelevated command with no workdir starts in the configured account's home directory, which need not be the same filesystem.
  • On a containerised node cpu_percent and mem_free_bytes are the host's, not the container's: a 512 MB container on a large box reports the box.

virtualization makes that last caveat readable in the result itself (router 0.4.85+). It is the same system/role string network_directory publishes as info.virtualization"docker/guest", "kvm/host" — derived by the router from what the node already reported, so no agent release is involved and the two surfaces cannot spell one fact two ways. Read it before the host block: when it names a container, the numbers there belong to the machine underneath. Until this release the caveat lived only in a field description pointing at another tool, so a caller reading net_metrics alone got a box's free memory with nothing in the response saying so.

⚠ It is omitted both on bare metal and on a node that never reported the underlying fields, and the two are indistinguishable here — absence means not said, never definitely not virtualized.

  • The whole block is absent when there is no usable sample to report: an agent older than 0.4.86, which does not carry one at all; on 0.4.86 an agent that has been up for less than one 60-second collection; and, from router 0.4.112, a node whose only stored sample predates the machine's current boot. From agent 0.4.87 that startup window is gone — the node takes its first sample as it comes up rather than waiting out the first interval, so the block is there from the moment the node is answering, which is exactly the window someone asks in on a node that just started or just came back. The node samples for network_directory either way, so info.health there answers for a node this tool cannot.

net_routes

Surfaces: public · local (observe)

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

The unsupported refusal names the node and what to do instead (router 0.4.87+). Through 0.4.86 it came back as the agent's own unsupported netinfo kind: ROUTES not implemented on this platform — neither the node nor an alternative, on a fan-out where knowing which node answered is the point. It now adds the node's name and says to use net_interfaces for addressing rather than routes (it also carries the node's overlay identity), and command for anything the host itself can print. Only unsupported is enriched: a timeout's remedy is to retry, not to switch tools.

Output Schema

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

net_dns

Surfaces: public · local (observe)

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

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

The unsupported refusal names the node and what to do instead (router 0.4.87+), as it does on net_routes: the node keeps its resolver configuration somewhere other than /etc/resolv.conf, so read it with command — on Windows, ipconfig /all, or Get-DnsClientServerAddress with shell: "powershell".

Output Schema

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

node_logs

Surfaces: public · local (observe)

Read a node's own agent log — the first thing to ask of a node that is misbehaving. Router 0.4.84+, and the node's own agent must be 0.4.86 or newer: the switch over the mechanisms lives on the node, so this is one of the few tools an upgrade of the fleet, not the router, turns on.

Purpose

The node picks whatever mechanism it actually has and tells you which one it used, so you do not have to know the platform and the same call works everywhere. Before this, reading an agent's log meant shelling out with a guess: the log lives in at least five shapes across an ordinary fleet — journalctl -u nobgp with no file at all, logread on OpenWrt, a file on macOS whose path is named only inside a LaunchDaemon plist, a file under C:\ProgramData, and a container whose output is held by a runtime on another machine — and a guess that misses returns nothing at all, silently, with exit 0.

The bare call is the right call:

{ "node_name": "pi5" }

That returns the newest ~200 lines at every level, oldest-first within the page. If it comes back refused because the log belongs to the superuser, retry once with admin: "true" — from agent 0.4.88 that refusal is failed_precondition naming exactly that retry, so the common flow is at worst two calls. (admin: "auto" is the cheaper retry across a mixed fleet and the value the tool's own description now points at, but it elevates only where the node reported its account as empty or as root — see the admin parameter below.)

Input Schema

{
"node_id": "string (or network_name + node_name)",
"network_name": "string",
"node_name": "string",
"lines": "number (optional; max 2000)",
"since": "string (optional; \"2h\" or an RFC3339 instant)",
"min_level": "error|warning|info|debug|trace (optional)",
"pattern": "string (optional RE2 regex)",
"cursor": "string (optional; from a previous response)",
"admin": "\"true\" | \"false\" | \"auto\" (optional)"
}

The node reference is flat here — node_id, or network_name + node_name, at the top level. This tool never had a nested target, and from router 0.4.92 the flat spelling is what every node-addressed tool takes, so there is no longer a difference to remember.

Parameters:

  • lines - How many of the newest matching lines to return. Omit for the node's default (~200). Maximum 2000, and a larger value is refused with invalid_args rather than quietly clamped — so a short answer is always distinguishable from a capped one.
  • since - A lower time bound, in either of two forms: a duration back from now ("2h", "90m", "1h30m") or an RFC3339 instant ("2026-08-19T04:00:00Z"). Days are not a duration unit — write "48h", not "2d". ⚠ A line the node could not stamp is excluded by this filter; it cannot place it in time. ⚠⚠ And a line the node mis-stamped is excluded just as silently — see below. ⚠ The response does say how much went, from router 0.4.113: see How much your filters removed.
  • min_level - A severity floor, in the CLI's own --log-level vocabulary. It means that level and above, so "warning" returns warnings and errors — which is why it is not called level. ⚠ A line the node could not tag is excluded rather than kept, so a filtered read can be shorter than the unfiltered one by more than the levels you excluded; those lines are present in a call that omits the field, where they arrive with no level. ⚠ How much shorter is answerable from router 0.4.113 — see How much your filters removed.
  • pattern - An RE2 regex matched against each line — the same spelling and engine as fs_grep. Case-insensitive is (?i) at the front; no backreferences and no lookaround.
  • cursor - Continue a previous read, backward in time. Take it from a response and nowhere else.
  • admin - The usual execution identity field. ⚠ A log is the case where elevation is usually needed — a journal unit, a root-owned mirror and a ProgramData file are not uniformly readable by the account a node's owner configured. ⚠ From router 0.4.143 the field's own description calls "auto" the value most callers want here, and that is worth reading exactly: it elevates on a node that reports its configured account as empty, or as root — the shape most of a fleet's refusals come from — and on a node reporting any other account it resolves to that account, which is the identity being refused. ⚠⚠ A node that reports no account at all is not the empty case: silence is this agent did not say, which is not a claim that there is no account, so "auto" leaves such a node unelevated and its refusal stands. "true" is what reaches the log there. So "auto" is the cheaper first choice across a mixed fleet, and "true" is what the refusal below names and what gets you the log on a node with an account. Neither is the default, on this tool as on every other.

Every filter narrows on the node. lines, since, min_level and pattern compose with AND and are applied before a byte crosses the wire, because the logs on an ordinary fleet run to megabytes — "return the log and filter it afterwards" is a call that could not be made.

Output Schema

{
"source": "string (journald | logread | syslog | file | …)",
"path": "string (absolute; absent on a command-backed source)",
"lines": [
{ "text": "string", "at": "string (RFC3339 UTC)", "level": "string" }
],
"scanned": "number (raw lines this page examined)",
"truncated": true,
"unreachable": "string (what this page does not show you, and why — four causes, see below)",
"cursor": "string (present when truncated)",
"admin_used": "boolean"
}

This is the payload under node_logs; done, duration_ms and the node are on the envelope.

Fields:

  • source - The mechanism this node actually read, in the node's own words — today journald, logread, syslog or file, and whatever a future packaging change names. It is an open vocabulary: reported so you know what you are looking at, never validated and never chosen by you. It is also how you tell a quiet node from a differently-plumbed one.
  • path - The absolute path of the file the node read — the answer to where is this node's log, which nothing else on this surface reports. Hand it straight to fs_read, fs_grep or fs_stat. Absent on a command-backed source (journald, logread), where no file is read; absent means exactly that and is never a placeholder. ⚠⚠ That file can contain NUL bytes, and both follow-ups this field invites are blind to it — see Searching the file this names.
  • lines - The newest matching lines, oldest-first within this page. Never null. at and level are present where the node could parse them and absent where it could not — a mechanism that logs everything at one severity, or a line with no stamp.
  • scanned - How many raw lines this page examined — everything the filters were applied to, matched or not, and never the lines a cursor skipped over to get here. It is what makes an empty lines[] mean something: 0 matches out of 50 000 scanned is this node did not say that, recently, while 0 matches out of 0 scanned is nothing was examined at all, and nothing else on the payload separates them. Sum it across a paged walk to say how much of the log a search actually covered. ⚠⚠ Subtract the length of lines[] from it: the difference is what this page examined and did not return, which with since, min_level or pattern set is what your filters removed — and from router 0.4.113 the one-line summary does that subtraction for you, see How much your filters removed. New in agent 0.4.95, and ⚠ absent — not zero — on a node whose agent predates it: such a node still answers the query, it just cannot say how much it looked at, so on such a node the subtraction cannot be made at all.
  • truncated - A further page follows. Always present, because false is a real answer. It is conservative: exact when the node could look one match further back, true-when-unsure if its scan budget ran out first, and provably false when you supplied since and the node reached that bound. From agent 0.4.87 it also accounts for a rotated generation of the same file, so reaching the top of the live log is not the end when the node kept the previous one beside it — see below — and from agent 0.4.94 for a journald render that came back full, which used to read as the end of the journal on the busiest units. See A cursor always moves. ⚠ false means no further page follows, which is not always you have the whole log: it is also how a walk ends at the limit of what the node's mechanism can render, or at the since bound you supplied. unreachable is what tells them apart — false with nothing there is the beginning of a log the agent is actually writing to.
  • unreachable - What this page does not show you, and where to get it. A page carrying it is a success, never an error. ⚠ It carries four causes with four different remedies, and more than one can apply at once, so read the sentences rather than the presence of the field — each one names its own cause. See What unreachable is telling you. New in agent 0.4.95, when it carried the first cause alone — before it, that one arrived as an unsupported error. The next two are agent 0.4.103, and the fourth — another mechanism on this node refused the identity you ran as — is agent 0.4.105.
  • admin_used - The identity the query actually ran as. Read it beside a short or empty lines[]: that is the field that says whether the answer is short because the node was quiet or because the query ran as an identity that cannot read the log.

Read source and path before you believe the lines

A log is not evidence about the running agent until you know they are the same process. source says which mechanism answered and path says which file it read, and those two are what make the lines attributable — nothing else in the response does, and nothing in a stale answer looks stale.

Measured on an appliance-class node: the newest line in the file was the shutdown of a previous agent, while the agent actually running had been started by hand from a terminal and was writing to a pty and into no file at all. The lines were real, recent-looking, and about a process that had already exited.

Where path is present, that is the check: hand it to fs_stat and compare its mtime against how long the node has been up, or read /proc/<pid>/fd/1 with command. Where it is absent the source is command-backed (journald, logread) and follows the service manager's own view of the unit, which is the case that does not have this problem.

Where the node can tell, it says so itself, from agent 0.4.103: that is the third cause unreachable carries — the running agent is not writing this file. Because it is a fact about the whole read rather than about one page, it is set on every page including the first, so it is visible before you have paged anywhere. On an agent below 0.4.103 nothing marks such a page, and the check above is the only way to make it.

One more reason source and path can move under you, from agent 0.4.103. Some appliance and router firmware keeps /var/log in RAM, so the init script's capture of the agent's output is erased at every reboot. Such a node writes the agent's own durable mirror as well and reads that first, so path becomes <config-dir>/nobgp.log where the same machine used to answer with a /var/log file — and the history before the last boot is there rather than gone. This is Linux only and is measured rather than assumed; the service manager's own files are still offered behind the mirror.

This is also the reason to spend the call rather than shelling out. A command script that reads the log and the process table and the file mtimes in one round trip is a reasonable thing to reach for while diagnosing a node, and it wins on round trips; what it does not get for free is which mechanism this node actually uses and whether the file it read belongs to the process it is describing. The order that works is this tool first — it names the file and says whether the node can serve a log at all — then command for process state.

Searching the file path names

⚠⚠ A log file can contain NUL bytes, and both of the follow-ups path invites are blind to one. An agent log is ordinary text until something writes a stray NUL into it — a crash dump, a truncated write, a device that logs a raw frame — and a tool that classifies the file as binary then answers as if it held nothing. ⚠ The two follow-ups classify on different evidence, so they do not fail together:

  • fs_grep reads the first 512 bytes and nothing else. A NUL in that first block makes the whole file binary to it and the file is skipped entire; a NUL further in is invisible to the check and the file is searched normally. When it does skip, the answer comes back with no matches, scanned_files: 0 and skipped_files: 0 — so it reads as nothing was examined rather than as a file I declined to open, and there is no override to turn it off.
  • A plain grep through command inspects what it reads, so a NUL anywhere in the file suppresses the matching lines; it prints binary file matches or nothing at all. There the fix is on your side: grep -a.

So a log with an early NUL is invisible to fs_grep and searchable with grep -a, and a log with a late one is the other way round. scanned_files: 0 on a path you know exists is the tell for the first.

node_logs itself is unaffected — it reads the lines through the node's own mechanism, and this is a trap only for what you do with the path next. Reported in the field's own description from router 0.4.143.

How much your filters removed

A filter that removed almost everything looks exactly like one that removed nothing — both answer with a short, clean, successful page. From router 0.4.113 the one-line summary that comes back with every call does the subtraction and says so:

node_logs: 11 lines from journald, 115 scanned, 104 not returned (active filters: since — drop it to see them)

That is scanned minus the length of lines[], and it needs no new field on either side: the node already reports scanned, and the router built the request. Three things are worth knowing before you act on the number.

  • It counts, and it never attributes. The node reports one scanned for the page, not one per filter, so a line since dropped because the node could not stamp it and a line pattern simply did not match are the same number here. The summary therefore names which filters were active and never which one did it: with one filter set the attribution is exact by elimination, and with two or three, drop them one at a time.
  • It is silent when there is nothing to say, and each silence means something different. No filter set at all: nothing was removed, so a clause there would report paging as loss. scanned absent (an agent below 0.4.95): no measurement was made, and subtracting from a zero the node never reported would manufacture a number.
  • It is also silent at a difference of exactly one on a truncated page. That one line is a matching line the node counted and then held back for the next page — a paging artefact, not a filter drop — so naming a filter beside it would blame one that removed nothing and offer drop it where the cursor is the real answer. On a truncated page, at most one line of the difference is that held-back line and the rest are genuine drops.

The case it was built for is the clockless node below: measured on a KVM-over-IP appliance, since: "2h" returned 11 lines of 115 examined ten minutes after a reboot, dropping 104 lines of the boot — and through router 0.4.112 nothing in that answer said so.

A since bound trusts the node's clock

On a node with no real-time clock, since can remove the most interesting half of a boot. Such a machine starts its clock at the epoch and keeps it there until NTP lands, so the agent stamps its whole startup window in 1969 — and a since bound drops every line of it, for the same reason it drops an unstamped line: those lines cannot be placed in the window you asked for.

Measured on a KVM-over-IP appliance ten minutes after a reboot: a "2h" bound removed 104 of the first 115 lines of the boot — profile load, TUN setup, the filesystem probe, the first registration.

From agent 0.4.103 the node says so. Where since excluded a line stamped before any noBGP build existed, unreachable carries a sentence naming that cause: those lines are not old, they were written by a machine whose clock had not been set, the startup window is the part you cannot see, and the remedy is to re-read with since omitted. Through agent 0.4.102 the same query returned a short, clean-looking answer with nothing in it to say the interesting part was gone.

And from router 0.4.113 the summary line says how many lines went, on any node whose agent reports scanned (0.4.95+) — 11 lines from journald, 115 scanned, 104 not returned (active filters: since — drop it to see them). See How much your filters removed.

So read a boot with since omitted first, on appliance-class hardware in particular, and narrow with lines or pattern instead. Supplying since is still worth it on a machine whose clock is right: it makes truncated exact rather than conservative, because the node can prove it reached the bound.

Paging runs backward in time

cursor continues into the lines older than the page it came from — the reverse of fs_list's forward next_offset walk, and reversed on purpose: a log is read newest-first and paged into the past.

It resumes by counting lines back from the newest end rather than by a byte offset, because the mechanisms share no such offset. So a live log growing between two pages can repeat a line across them; a repeat is possible, a gap is not. Dedupe on (at, text) if you are assembling pages.

A page continues across a rotation

From agent 0.4.87 a walk that reaches the top of a rotated file carries on into the generation before it. Where a node's log is the file the agent mirrors for itself, it keeps one previous generation beside the live one (nobgp.log and nobgp.log.1) — on every platform, and on Windows that mirror is the only source there is. Through agent 0.4.86 paging stopped dead at that boundary with truncated: false and no cursor: the node has nothing older, said with the older file sitting right there unread.

From agent 0.4.94 the same applies to a service manager's own log files. On macOS (launchd) and on the OpenRC and sysv Linux flavours the agent's output goes to /var/log/nobgp.err.log / .out.log (or nobgp.err / nobgp.log), which the agent copy-truncates at 100 MB keeping one previous generation — and through 0.4.93 those generations were not stitched, so on exactly the nodes whose logs are largest a page ended at the boundary with up to 100 MB unread beside it.

  • It only ever continues into an older generation of the same log, never into the next mechanism on the node's list. A node offers ordered candidates — on Linux journald, logread, the OpenRC files, the mirror, syslog — and walking from one into another would hand you an unrelated log as this node's own history.
  • The next generation is opened and read before a cursor for it is minted, so an absent or empty rotated file ends the walk honestly rather than handing you a cursor that leads nowhere.
  • A since-bounded walk that reached its bound does not continue: everything in the older generation is older still, so there is provably nothing there to find and you are not sent a round trip to be told so.

A cursor always moves

From agent 0.4.94 the cursor a page hands back is always strictly past the one it was given, and where it cannot be, the call says so instead. Two failures sat behind that, both on nodes with large logs:

  • A page could mint the cursor it was handed. Each page bounds how much it will examine, and that bound used to be measured from the newest end of the log rather than from where the cursor pointed — so once a walk had skipped its way past the budget, it examined nothing and re-minted the same cursor. A pattern that matched nothing paged forever without moving, and the older two thirds of a 100 MB file could not be reached by any query at all. The budget now starts at the cursor's position, so a page always looks at something and always advances.
  • A busy journald unit reported no more to read. The node renders the journal in bounded chunks, and a chunk that came back completely full read as the journal ended heretruncated: false, no cursor — on precisely the units with the most to say. A full chunk is now reported as capped, with a cursor, and the continued page asks the journal for enough to reach past it.

Where a mechanism genuinely cannot render as far back as a cursor points — a command-backed source keeps only the newest 8 MiB of its output, and a single log line longer than 8 MiB is one a backward file read cannot cross — the walk ends, and it is deliberately not the same cursor a second time.

From agent 0.4.95 that ending is a success, not an error. The page comes back with no lines, no cursor, truncated: false, and unreachable naming the ceiling it hit and where the older lines can still be read (nobgp service logs, at the machine). Through agent 0.4.94 the same page was unsupported — so the last call of an ordinary exhaustive search was a tool error, and a defensive caller unwound the whole operation on it. The verdict is unchanged; it now arrives in a field rather than in an error string, which is what makes does this node ever log X? a question you can page to the end of.

What unreachable is telling you

unreachable is prose, and it answers what this page does not show you, and where to get it. It carries four causes with four different remedies, more than one of which can apply to the same page, so every sentence in it names its own cause. Read it; do not treat the field's presence as one verdict.

CauseWhat it meansWhat to do
This mechanism's reachThe walk ended at the limit of what the node can render — a command-backed source keeps only its newest 8 MiB, or one log line is too long for a backward file read to crossRead the rest at the machine, with nobgp service logs
Your own since boundThe walk ended at the bound you supplied rather than at the log's beginning — older lines are there and were not read. It speaks a second time, and separately, when since dropped lines stamped before any noBGP build existed: those are not old lines but a node whose clock had not been set, and the part removed is its startup windowThe one you can lift: widen or drop since — omit it entirely for the clockless case
The running agent is not writing this fileThe lines came from a file the live agent does not write to, so they were produced by an earlier process however recent they look. Its current output goes wherever it was started fromCheck the provenance before you act on the lines — see Read source and path
An earlier mechanism refused the identity you ran as (agent 0.4.105)A node offers several log mechanisms in order. One of them served this page, and one tried before it exists and said no to the identity this call ran as — so its lines are not here. The page is what one identity can see, not everything the node recorded. It names a refusal and never a count: the refused mechanism was never read, so nothing knows how much is behind itRetry with admin: "true" — and on a node whose owner set allow-admin: false the sentence says so instead, and points at nobgp service logs at the machine, because that retry has already been refused in advance

The first two are facts about a page. The last two are facts about the whole read, so they appear on every page, the first one included — which is what makes them visible to a caller who reads the newest lines and stops. The third is the one that makes real, recent-looking lines belong to a process that has already exited; the fourth is the one that makes a page look complete when a mechanism beside it was refused.

Only the first cause reaches you from an agent below 0.4.103. The field itself is agent 0.4.95, and it carried the mechanism's reach and nothing else; the since and dead process causes are agent 0.4.103, and the refused mechanism cause is agent 0.4.105. On an older agent those pages are still what they always were — a page that stopped at your since bound is indistinguishable from one that reached the beginning of the log, lines from a file the live agent has stopped writing to arrive with nothing marking them, and a page served by one mechanism while another refused arrives looking complete. network_directory reports each node's version.

The one-line summary says the answer is incomplete on any page carrying the field, from router 0.4.110 — including page one. Until this release truncated won that line, so the third cause reached the summary only on the terminal page, which is the one page a caller who has already found what they were looking for never fetches: the lines they acted on were a dead process's and the summary never said so.

You can read which cause you have; you cannot branch on it without parsing the string, and that is deliberate rather than an omission — the remedies are for a person or a model to act on, not a switch to write.

A cursor is refused, never resumed somewhere else

A cursor names the source it was minted against — the mechanism and, for a file, which file. When that no longer holds, the call comes back invalid_args telling you to drop the cursor and start a fresh query, rather than paging you through a log the cursor never named. Three cases, all of them ordinary rather than exotic:

  • The source is gone — the node no longer offers that mechanism at all.
  • The source is still offered but will not open — a journal vacuumed between two pages, a file rotated away, a permission lost. The node can still serve its log; the newest lines will come from whatever now serves it, which is what a fresh query gets you.
  • The file rotated between two pages. The path still opens, but it no longer names the same bytes — the node fingerprints that — and a line count applied to new content would re-serve delivered lines as older history or skip unseen ones.

Each of these fails the call rather than the page, so a cursor loop should treat invalid_args here as start again from the newest lines, not as an error to retry with the same cursor.

An empty list and a refusal are different answers

  • lines: [] means the query ran on the node and matched nothing. Read it beside scanned (agent 0.4.95+): empty with a large scanned is this node did not say that, empty with scanned: 0 is nothing was examined. ⚠ Read unreachable beside both: from agent 0.4.105 an empty page whose node held lines behind an identity refusal says so there, and before that release the same page said nothing at all.
  • A node that cannot serve logs at all fails with unsupported, naming why and naming where the log can be read — rather than returning an empty list, which would read as "the node is quiet", the worst thing a diagnostic can say. Whether a node is in that state is its own answer and is not predictable from its platform: call it and read the refusal rather than deciding in advance. Containers usually can serve it, since the agent mirrors its own output to a file where no service manager captures it.
  • That refusal is about the node, never about a walk running out of reach. A page that hits the limit of what the node's mechanism can render succeeds with truncated: false and unreachable set, from agent 0.4.95.
  • A node that holds its log and refused the identity is a different answerfailed_precondition, from agent 0.4.88, and it names the one retry.
  • A timeout on a node running an agent older than 0.4.86 looks the same as a slow node, so the message says so and names net_metrics as the probe that settles it: an agent that answers net_metrics and not this is an agent that needs upgrading, and network_directory reports each node's version.
  • A node that never answered the session open is a timeout from router 0.4.91 — with the same message, naming net_metrics as the probe and command as the fallback. Through router 0.4.90 that half came back as internal carrying the router's own plumbing wording (failed to connect: connection timeout after 3s on attempt 3/3), which reads as the router broke for a node that simply did not answer, and named no next step at all — while the other door of the same tool, a read that opened and then stalled, had answered timeout with a cause since the tool shipped. Only this tool narrows the verdict; every other node-addressed call still answers internal for a connect timeout, because there the silence could be the network, the router instance or the node and nothing can say which. Retryability does not change: both codes are retryable.
  • Through agent 0.4.90 a timeout could also be the log itself, on slow hardware. The node used to find its log mechanism before accepting the session, and that search is a real walk — 2.6 s on a Pi Zero against 132–383 ms for command, fs_list and net_metrics over the same connection — so it could overrun the few seconds a session is given to open, on a node that was online and answering everything else. From agent 0.4.91 the session opens first and the log is read after, so a timeout here means a slow or absent node rather than a slow log. Nothing else moved: the refusals below arrive with the same codes and the same messages, and an argument the node rejects is still refused before it reads anything.

"This node cannot reach its log" and "this identity cannot" are different

From agent 0.4.88, a walk that opened no source because every candidate refused the identity it ran as comes back failed_precondition naming admin: "true" — the same code and the same one-retry shape as every other identity refusal on this surface, because the caller changes one field and the call works.

Through agent 0.4.87 that case answered unsupported, which is a claim about the node rather than about the identity, and its remedy sends you somewhere else entirely. Measured on a container whose owner had configured an account: admin: "auto" resolved to that account, the agent's own mirror was readable only by the superuser, every candidate came back permission-denied, and the caller was told no mechanism inside this node can reach it and pointed at docker logs on a different machine — while the very next call, with admin: "true", returned the log.

A journal that answered nothing because the identity may not read it counts as a refusal too, from agent 0.4.94. An unprivileged journalctl does not fail: it exits 0, prints -- No entries --, and mentions on the side that it is not showing messages from other users and the system — which is exactly what a system unit's lines are. Through agent 0.4.93 only a refused file was recognised, so a journald-only host — a Raspberry Pi OS service install, where there is no syslog file and no mirror because the service manager captures the agent's output — answered unsupported under admin: "false" and advised installing the agent as a service, which it already was. That case is now the same failed_precondition naming admin: "true".

A journal that answered some lines is a refusal too, from agent 0.4.105 — and this is the one that could report a clean log. Through agent 0.4.104 that recognition also required the output to hold nothing but journald's own frame lines, and an unprivileged journalctl frequently holds more: it returns the records carrying the caller's own login uid, which on a node being worked through this surface means the sudo records the agent itself produced. One such line switched the guard off, and the page built from what that identity happened to be allowed to see came back as a complete answer — truncated: false, no cursor, nothing marking it — and with min_level: "warning" it filtered down to lines: [], the exact shape a sweep reads as this node is clean. Measured on an Ubuntu 24.04 node running agent 0.4.104: the unelevated call returned 11 lines and reported a clean, complete log, while admin: "true" one minute later returned 441 lines at warning and above. The hint on the side is journald's own statement that the read is partial, so from 0.4.105 it is the whole signature: the lines that came back are discarded, the walk carries on to the node's remaining mechanisms, and where none of them serves the answer is failed_precondition naming admin: "true".

The cost is named rather than hidden. An agent that genuinely runs as that account — a user unit, or a system unit with User= — has a journal it can read in full, and 0.4.105 declines it. That costs one extra call on such a node: the elevated read serves it, because the superuser reads every journal, user journals included.

A page that served while another mechanism refused now says so (agent 0.4.105). A node tries its mechanisms in order and the first that produces a line of the agent's own wins. Where a candidate tried earlier existed and refused the identity the call ran as, that refusal used to be dropped the moment a later candidate served — so a caller received a clean-looking page from mechanism four while mechanism one held lines that identity may not read. The page is real and is still returned; what it gains is a sentence in unreachable saying a mechanism refused this identity, that its lines are not in this page, and that admin: "true" is the retry. It is a fact about the whole read, so it rides every page including the first.

Two things bound it, and both matter when you read a refusal:

  • A walk that was already elevated and still hit permission denials stays unsupported. There is no further identity to name, so naming one would send you round a loop. The distinction is the identity the call ran as, and nothing else about the request changes it.
  • A node that has refused elevation in advance says so instead of naming the retry (agent 0.4.94). Where the owner set allow-admin: false and the node's ambient identity is the superuser, admin: "true" was never going to be answered, so the refusal is unsupported — the log is here, it is unreachable from the network on this node, and it can be read at the machine with nobgp service logs. Through agent 0.4.93 it named admin: "true" anyway and that second call came back permission_denied, so the log took two calls to be told it was out of reach; network_directory reports each node's allow_admin if you would rather know before the first one.

Either refusal quotes what was attempted and why each candidate declined, so the message says which of the two you are in.

No redaction. The lines are what the node wrote; nothing here filters secrets out of them. If that matters for where the output is going, narrow with pattern and read what you get.

What a node's own settings do to it

allow-tools and allow-roots are not consulted, deliberately: those two bound the things a caller steers — an arbitrary path and an arbitrary command string — and this tool offers neither. The paths and the argv are the agent's own, fixed, and about the agent's own output, so a node serving neither domain still answers node_logs, the same way it still answers the net_* diagnostics. Gating it on command would leave a systemd node unable to read its journal on a box that serves fs.

The identity gate applies in full. admin picks between the node's two identities exactly as everywhere else (Execution identity): admin: "true" on a node whose owner set allow-admin: false is refused rather than downgraded, and "auto" on that node resolves to the configured account. A journal unit or a ProgramData file is frequently not readable by that account, and when no source is — the identity opens none of them — the answer from agent 0.4.88 is failed_precondition naming admin: "true", with the permission error quoted in what it tried. On a node that also refuses elevation the answer from agent 0.4.94 is unsupported in the first place, saying the log is unreachable from the network there and to read it at the machine; either way the refusal says which mechanisms were attempted and why each declined. And where one mechanism refused the identity while a later one served, the call succeeds and unreachable carries the refusal instead (agent 0.4.105) — so a partial read is visible on a page that would otherwise look complete. See Node Access Control.

Prefer this to command with a canned string

A canned journalctl/type line needs you to know the platform, meets the cmd.exe quoting the shell parameter exists to route around, hands back unstructured text, and returns nothing at all — silently, with exit 0 — when the mechanism you guessed is not the one that node uses.

It is the same log nobgp service logs shows

From agent 0.4.89 the on-box command resolves its source through this same list, by trying each candidate rather than deciding from the platform — so source here names the mechanism someone standing at that machine would also be reading, and the two can no longer disagree about where a node keeps its log. The two surfaces differ only in what they are for: this one filters on the node and pages backward, that one is a live tail -f.


node_config_get

Surfaces: public only

Read a node's own agent settings — the keys in its default.yml that it is willing to report, with their current values. Router 0.4.85+, and the node's own agent must be 0.4.87 or newer: the node is what answers, so an older agent never replies and the call ends in timeout saying nothing was changed. network_directory reports each node's version. Pair with node_config_set, which takes the same keys back.

It rides the control channel, not a session

This is the reason both tools exist, and it is worth reading before the schemas.

A node whose sessions are all dead — every command, fs_* and net_* call timing out — still answers these two. They travel on the node's control channel, the same link that keeps it online in network_directory, rather than on the session machinery every other node-addressed tool opens.

That matters because a config write is precisely the operation that must not depend on the subsystem it reconfigures. The case that produced these tools: one edit of a node's fs: settings on a live Windows machine left the control channel perfectly healthy — the directory still said online: true and duly picked up the new fs_backend — and every session dead. The way back was closed at both ends: the file tools refuse the agent's own configuration directory at either identity, so fs_edit could not revert it, and the only tool that could was command, which was exactly what had stopped working. Recovery needed someone at the keyboard.

So reach for these when a node is reachable in network_directory but nothing you run on it works.

The router knows nothing about the keys

Which keys exist, what each means, which apply immediately and which are refused is the node's answer, not a table held on the router. The node classifies and the router forwards its verdict verbatim — the same posture fs_backend and node_logs's source have: reported, never obeyed.

Practically: call node_config_get and read what comes back rather than guessing a key name in advance. And read node_config_set's verdicts as the node wrote them — a verdict word this router has never heard of still reaches you intact rather than being flattened into a zero value.

Input Schema

{
"node_id": "string (or network_name + node_name)",
"network_name": "string",
"node_name": "string"
}

The node reference is flat here — at the top level, like node_logs and service_publish, rather than nested under target the way the net_* and file tools take it. The bare call is the whole tool; there is nothing else to send.

Output Schema

{
"settings": [ { "key": "string", "value": "string" } ],
"count": "number"
}

This is the payload under node_config_get; done, duration_ms and the node are on the envelope.

  • settings - The keys this node is willing to report, sorted by key, never null. Values are text — the node's own rendering of the YAML scalar it holds — which is the same spelling node_config_set takes, so a read/edit/write round trip is lossless.
  • count - len(settings).

What comes back is not the whole file, and from agent 0.4.94 it is a good deal less of it. A node reports only the keys it considers operational knobs a network Admin legitimately tunes from afar — the ones that change what the node does for the network without changing whose node it is, what code it runs, or what it can reach on its own host. Today that is the diagnostic and datapath keys — log-level, debug, compress, mtu, ping-interval, idle-ttl — the two filesystem keys fs and fs-cache-ttl, the three QUIC race timers quic-race-headstart / quic-race-deadline / quic-reprobe-interval, a local profiling switch, and from agent 0.4.113 transport-family. Read the list that comes back rather than this paragraph: it is the node's answer, so a node newer than this page may report one more. Everything else the agent reads is absent, not blanked — absence is the same claim the file tools already make about that directory, where a redaction marker would advertise the shape of what it hides.

Through agent 0.4.93 it worked the other way round, and the difference is worth knowing if you have read this page before: the node reported and accepted everything except a short veto list — the owner's own settings and the local server's token — so a read carried the keys naming the node's own identity — router, insecure, quic-cert-pin, quic-endpoint, encrypt, transport — and a write could set them, because nobody had thought to veto them. Repointing a node's control plane does not widen what the node does for you, it changes who "you" are, and insecure alone disables the verification that makes the certificate pin mean anything. A list of exceptions is wrong by omission the day a setting is added; a list of what is exposed is wrong in the safe direction. So this answers what can I tune here, never what is in default.yml.

Roles

Owner or Admin, the same tier as node_config_set: the two halves of one read-modify-write are not worth splitting across tiers, and these are a node's operating configuration rather than the fleet metadata a Member reads. A Member receives forbidden.


node_config_set

Surfaces: public only

Change a node's own agent settings. Router 0.4.85+ with the node's agent on 0.4.87 or newer, as on node_config_get. set writes keys, remove deletes them, and both merge with what is already there — keys you do not name are untouched. It rides the control channel, exactly as node_config_get does and for the same reason.

Input Schema

{
"node_id": "string (or network_name + node_name)",
"network_name": "string",
"node_name": "string",
"set": { "key": "value as text" },
"remove": ["key"]
}

Parameters:

  • set - Settings to write, merged with what is on the node. ⚠ Values are the YAML scalar as text: send "8000", "true", "webdav" — a JSON number or boolean is not what this field takes. The node parses each value into the type its own key expects and refuses with a reason when it cannot. A value is capped at 4096 bytes: a config scalar is a word, a path or a number, and anything larger is a file being sent through the wrong tool.
  • remove - Keys to delete, so the node falls back to its own default for them. Merged the same way.

At most 64 keys across set and remove in one call. A key is 1–128 characters with no leading or trailing whitespace.

Unlike node_label, a bare call is not read as a read. node_config_set with neither set nor remove is invalid_args naming node_config_get, because here the read is its own tool.

Output Schema

{
"verdicts": [ { "key": "string", "applied": "live | restart_required | refused", "reason": "string (when there is something to add)" } ],
"live": "number",
"restart_required": "number",
"refused": "number",
"other": "number (omitted when zero)"
}

Read every verdict — there is no overall success. One call can apply one key live, park a second until the agent restarts, and refuse a third. A caller that stops at the first entry acts on a change that has not happened, which is why the counts are there beside the list.

appliedMeans
liveWritten and in effect now. Nothing further to do.
restart_requiredWritten but not in effect — the running agent is still on the old value until it restarts.
refusedNot written, and reason says why.

live costs a bounce, and the verdict's reason says so. A node applies a config change by reloading itself: it notices the file within about five seconds and comes back on the new value, so its sessions drop for that moment. That is a blink rather than an outage, but a caller holding a command session on the node will see it end.

A write that changes nothing costs nothing, from agent 0.4.94: setting a key to the value it already holds leaves the file byte for byte as it was, so there is no reload and no session drop. The verdict is still live — the key is in effect, which is what the word says.

  • live / restart_required / refused - Counts, always present: 0 is a real answer, and restart_required: 0 is there is nothing left to do.
  • other - Verdicts carrying an applied value none of those three counters cover — a node newer than the router. Omitted when there were none; the verdicts themselves are the authority.
  • A key the node said nothing about arrives as a caveat in notes[] on the envelope, naming the keys and telling you to read them back with node_config_get — that is the one failure this shape could otherwise hide.

A restart_required key is not in effect

Which keys are restart-only is the node's answer, and today it is one: fs, the mounted-drive backend. For that key a reload is not equivalent to a restart — the backend is chosen by a capability probe at startup and the mount it makes is held by the kernel after the process that made it is gone, which is the exact failure that produced these two tools. Everything else a node will write applies on the reload.

Two consequences the verdicts report, and both are easy to misread as an error:

  • The reload is all-or-nothing, so a call that writes fs alongside anything else parks every key in it. A reloading agent re-reads the whole file, so letting the neighbour through would apply the pending backend switch with it. Expect restart_required on keys that would otherwise have been live.
  • A later call is held behind it. While a node's file holds a restart-only value the running agent did not start with, nothing written to that node can be live — so an unrelated key set an hour later still comes back restart_required. Setting the pending key back to the value the agent started with lifts that immediately; so does a restart.

Withdrawing a pending fs change reports live, not restart_required (agent 0.4.94), with a reason saying the pending change is withdrawn rather than applied and that the hold it placed has lifted. It is genuinely live: the file now matches what the process started on, so the agent reloads within five seconds and its sessions drop for that moment, exactly as any other live key. Through agent 0.4.93 that same write came back restart_requiredwritten and NOT applied — which was wrong in the reassuring direction, since the node bounced anyway. A write of fs that changes nothing at all, on a node with nothing pending, is the ordinary live above and costs no bounce.

Do not finish the job with nobgp service restart through command

Run inside a command session, that command stops the agent as its own parent and on some platforms never brings it back — which is exactly the lockout these tools exist to remove. Either leave the key to take effect the next time that node restarts, or restart it out of band (its own service manager from the console, or a host reboot) and confirm with node_config_get afterwards.

The node decides which keys it will write

Nothing on the router holds a table of what a key means, what type it takes, or which keys are off limits. The node classifies each one and this reports its answer verbatim.

From agent 0.4.94 the writable set is an allowlist, and it is exactly the set node_config_get reports: the operational knobs listed there, and nothing else. Anything outside it comes back refused with the node's own reason — never written, while the keys around it in the same call still are. Three shapes of refusal, and the reason says which:

  • The node's veto settingsallow-admin, allow-tools, allow-roots, user, webdav-proxy-owners and the local server's mcp-token. A config surface that could widen the gates it is itself bounded by would be a privilege escalation with a friendly name. From agent 0.4.98 peer-key-pinning is refused for a sharper version of the same reason: it decides what the node does when a peer's key changes, and that check is aimed at the very control plane this surface travels over — so the party being watched must not hold the switch.
  • The node's identity and its owner's say over the machinerouter, insecure, quic-router, quic-cert-pin, quic-endpoint, encrypt, transport, plus auto-upgrade (what code runs on the box), enabled (an off switch with no remote way back on), mount (where the drive lands on the owner's filesystem) and, from agent 0.4.124, windows-firewall — what a host lets in is the owner's security posture for their own machine, the same class as allow-roots. Through agent 0.4.93 the first seven of those were writable, which is the change worth knowing about: setting them does not widen what the node does for you, it changes who "you" are. ⚠ transport-family is writable although transport is not, and the difference is what each key decides: transport picks the trust path, while transport-family picks which address of the same endpoint to dial first — same endpoint, same certificate pin, same credentials — and both of its values fall back to the other family, so it cannot put a node out of reach.
  • Everything else the agent readsmcp-port, nfs-port, interface, overlay-cidr, runtime-dir, last-mount-point and the api-socket-* pair — is refused with one generic reason saying it is not a knob tuned from here and pointing at nobgp config on the node. Deliberately the same wording for all of them: naming which is which would tell a caller more about the node than a key it cannot touch is worth.

This is not a remote way back into a node you have locked down — see Recovering a node you have locked down.

Roles

Owner or Admin. A Member receives forbidden. Neither tool is reachable from a node's local server at any tier: a node must not rewrite a peer's operating configuration.

Errors

Both tools mint no new codes.

  • forbidden — a Member.
  • target_unreachable — the node has no live control channel (it is offline). Checked before anything is sent, so an offline node costs no timeout.
  • timeout — 30 seconds without an answer. ⚠ An agent that predates this surface never answers and looks exactly like a slow node, so the message says so and states that nothing was changed. Check the node's agent version with network_directory before retrying; net_metrics answering where these do not is the probe that tells an old agent from a slow one.
  • internal — the round trip failed on the router's side and the node was never asked (router 0.4.94+): a replica still starting up, a reply subscription or a publish that failed. The message names the real cause. Through 0.4.93 every one of these was reported as the timeout above, which blamed a node that had never been reached and sent you to check an agent version that was not the problem. Both codes are retryable, so what changed is what you are told rather than what to do next.
  • invalid_args — a bare node_config_set, an over-long key or value, more than 64 keys.
  • Whatever code the node names on a whole-request failure — a malformed request, an unreadable config file. ⚠ That is a different answer from a per-key refused: a whole-request failure fails the call, while a refused verdict is a successful call (done: true) whose answer for that key is no.

Node selectors

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

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

Parameters:

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

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

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

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

Selectors are stored, not resolved

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


node_label

Surfaces: public only

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

Purpose

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

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

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

Input Schema

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

Parameters:

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

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

It takes the cohort selector, never the singular flat node reference. Name nodes with node_names (plural, inside network_name) or node_ids; the singular node_name and node_id that the per-node tools accept are refused here and always will be — so an invalid_args naming node_name is not a stale tool list, and refetching it will not help. network_name and network_id are accepted and are how a plural node_names is disambiguated; do not drop them. Router 0.4.146 says this in the tool's own description, because the refusal on its own reads like a cache problem.

Output Schema

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

Behavior

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

Example Usage

Request:

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

node_grant

Surfaces: public only

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

Purpose

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

Input Schema

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

Parameters:

  • role - Grant tier:

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

    Both grep tools sit at observe because they are searches: they read what fs_read already reads, and neither can change a peer. feedback_submit is at both tiers because it touches no peer at all — it writes one row on the router about this tool surface, and an unattended agent running against a node's own endpoint is exactly who it exists for.

    node_config_get and node_config_set are absent from both tiers. They write the file that holds a node's own veto settings, and a node must not rewrite a peer's operating configuration at any tier.

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

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

Output Schema

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

Behavior

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

Example Usage

Request:

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

node_revoke

Surfaces: public only

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

Input Schema

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

Output Schema

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

Fields:

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

Behavior

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

node_rename

Surfaces: public only

Change a node's name, and change nothing else. Router 0.4.120+.

Purpose

The node keeps everything but the name: the same node_id, its labels, its role grants, its published services and their public URLs, and its own storage area. This is the only way to change a node's name without losing all of that — leaving the network and rejoining mints a new node_id and breaks every shared service link.

Before this release the rename lived only in the web app. It is the same act at the same tier; what is new is that an assistant can perform it.

Input Schema

{
"node_id": "string (or network_name + node_name)",
"network_name": "string",
"node_name": "string",
"new_name": "string (required)"
}

Parameters:

  • The node reference is the ordinary flat one: node_id alone, or network_name + node_name. node_id is stable across renames, so prefer it when you already have it.
  • node_name is the node's current name.
  • new_name is what you want it called — deliberately not name, which would sit one word away from node_name with nothing telling the old name and the new one apart. Sending name is invalid_args naming the field.

The name rule is the one node creation uses: letters, digits and hyphens, 63 characters or less, no leading or trailing hyphen. The name is lower-cased before it is stored, so Edge-01 and edge-01 are the same request. It must be free within the network — names compare case-insensitively — and a name another live node holds comes back as already_exists.

Output Schema

{
"node_id": "string",
"name": "string",
"previous_name": "string",
"network_name": "string"
}
  • node_id - Unchanged by a rename; this is what to key on.
  • name - The name as stored, lower-cased.
  • previous_name - The name the node held before this call. It is free within the network now, and it is the name already-connected peers still resolve.

Behavior

  • Owner or Admin, the same tier the web app enforces and the same tier an organization rename and a network rename need. A Member receives forbidden ("node.rename requires a higher role in this organization"). See Roles & Permissions.
  • Renaming a node to the name it already has succeeds and changes nothing, so a retry after a dropped connection is safe.
  • Reversible by renaming back — as long as the old name is still free.
  • The new name wins it outright. From router 0.4.141 a network has one name space and a node name takes precedence in it, so if new_name was a name some member had discovered on its own LAN, that discovered entry is removed and withdrawn from the member holding it — the name means this node from the rename onwards. See One name, one answer.

What is correct at once, and what is stale

noBGP resolves the new name immediately: network_directory, and a node's name lookups through the router, both read the node's record, so they answer with the new name from this call onwards.

A peer that is already connected does not. It holds the copy it was given when that peer registered, this call pushes nothing, and a peer's directory only ever gains entries — so each peer keeps resolving the old name until it reconnects, after which both spellings work on that peer until it reconnects again.

That costs staleness and never correctness: a session is keyed on the node, not on the string, so nothing black-holes and nothing reaches the wrong machine. The response carries a notes[] entry saying so.

The node itself is not told, by design. A machine is given a name once, at registration, and noBGP owns it after that — the agent goes on calling itself whatever its own node-name says and goes on being identified by its key, so the two never have to agree. Nothing needs restarting. See Node identity.

What a rename does to the name it frees

  • provision_node re-adopts by name, so a rename changes what it re-adopts: after this call, provisioning the old name creates a new node, and provisioning the new name re-adopts this one.
  • The old name is freed for reuse, and noBGP records that this node released it. A machine that reinstalls later and still presents the old name is never handed a different node's record: if the node now holding that name is not answering, the reinstalled machine is admitted under a suffixed name (old-1, old-2) with a new node_id and an empty storage area.
  • It is admitted, not refused — so a script watching for a failed registration will not see one. Compare the name the agent ended up with, in network_directory, against the one it asked for.
  • The freed name is open to LAN discovery again, from router 0.4.141. While this node carried the name, a device of that name on a member's own network could not be discovered under it; once the name is freed, it can be.

Example Usage

Request:

{
"network_name": "production",
"node_name": "web",
"new_name": "web-edge-01"
}

Response:

{
"call_id": "8b1f…",
"op": "node_rename",
"done": true,
"node": "9c2e5a10-…",
"notes": [
"peers that are already connected still resolve \"web\" and do not learn \"web-edge-01\" until each of them reconnects; the router itself answers with \"web-edge-01\" from now on"
],
"node_rename": {
"node_id": "9c2e5a10-…",
"name": "web-edge-01",
"previous_name": "web",
"network_name": "production"
}
}

Event bus

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

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

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

The four subscribe tools share one payload key

All four return the same result type, so their payload sits under subscription rather than under each tool's own name — the one exception, with fs_delete and fs_mkdir, to the rule in The result envelope. Events are not tool results and carry no envelope: an event is the shape below, delivered inside event_tail's payload.

Event shape

Every event is a JSON object:

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

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

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

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

Limits

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

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

Isolated networks

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

Push delivery

Surfaces: local only

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

  1. resources/subscribe on nobgp://events/<subscription_id>.
  2. The server sends notifications/resources/updated when events land. There is no payload — it only says there is something to read.
  3. resources/read on the same URI returns {"subscription": "<id>", "events": [...], "progress": { ... }}. From agent 0.4.121 events is always an array, empty when nothing is pending. Below that it can be null.

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

Push delivery needs agent 0.4.121

Against a router on 0.4.83 or later, an agent below 0.4.121 reads the reply shape the router stopped sending and delivers nothing: resources/read answers {"events": null} and no notifications/resources/updated ever fires. It looks exactly like a subscription nobody is publishing to, which is why it went unnoticed for three weeks — a client waiting this way waits forever. Nothing else was affected: event_tail on the same endpoint returns the events, and a subscription that has expired was still reported as gone rather than as quiet. On such a node, upgrade the agent or poll event_tail. From 0.4.121 a reply the agent cannot read is an error it logs and retries, so the next shape change arrives as a log line rather than as silence. ⚠ That null is not an empty list. A client that loops over the field without a guard throws on such a node.

The payload carries the dispatch progress block too (agent 0.4.121) — the same dispatched / accepted / settled / outstanding counts event_tail returns, and it is how a push-path client knows a dispatch is done:

  • It is omitted for an observation subscriptionfs_subscribe and presence_subscribe have no dispatch, so there is nothing to be finished. Absent is the answer there, never a block of zeros, which a caller branching on outstanding == 0 would read as finished.
  • A move in the counts is itself a notification, even when no event arrived. A node that never acknowledged the work, or acknowledged it and then went offline, is written off without producing any event — so a dispatch can settle in complete silence, and before this a push-path client had no way to learn that except by falling back to event_tail.
  • A session that reads without subscribing still gets progress while another session is watching — but no events, because the router's queue is drained once and those events belong to the sessions that subscribed, where progress is a fact about the dispatch that costs nothing to read twice. Subscribe if you want the events. A read on a resource nobody is watching is unchanged: it drains the queue once and returns what was there.
  • A subscription the router has dropped is reported as an error on resources/read, not as an empty success.

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


fs_subscribe

Surfaces: public · local (observe)

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

Purpose

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

Input Schema

{
"watches": [
{
"path": "string (required)",
"recursive": "boolean (optional)",
"ops": ["create" | "write" | "remove" | "rename" | "chmod"]
}
],
"subscription_id": "string (optional UUID)",
"keep_seconds": "number (optional; idle lifetime, default 600, max 86400)",
"...selector fields": "see Node selectors"
}

Parameters:

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

Output Schema

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

This is the payload under subscription, the key all four subscribe tools share, inside the envelope. admin_used is absent here: a file watcher executes nothing as anybody, which is also why fs_subscribe takes no admin.

ttl_seconds is the idle lifetime as applied, which from router 0.4.91 is where you see a keep_seconds that was clamped or floored — see below.

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

Staying subscribed while you are away

keep_seconds (router 0.4.91+) sets the idle lifetime of an fs_subscribe or presence_subscribe subscription — how long it survives with no event_tail call. Omit it for the default 600 (10 minutes); the maximum is 86400 (24 hours).

{ "watches": [{ "path": "/etc/nginx", "recursive": true }], "keep_seconds": 14400 }
  • A value above the maximum is clamped, not refused, and ttl_seconds on the response is what was actually applied — the only place a clamp is visible, since the request is not echoed back. A value below the default is raised to it: the field is an extension, so sending 30 must not give you a subscription that dies sooner than sending nothing. A negative value is invalid_args.
  • Every event_tail still resets the clock, exactly as before. This changes how long a subscription tolerates silence, not how it is kept alive.
  • It buys no replay. The queue still holds the newest 256 events and drops the oldest, so a client away for twenty hours gets what fits, not what it missed — event_tail reports how much was dropped in dropped_events, but it cannot hand back what the queue no longer holds. Raise keep_seconds to keep the watchers alive; do not read it as durability for the events.
  • Only the two observation subscriptions take it. command_subscribe and fs_grep_subscribe are bound to work rather than to idleness — a dispatch closes when every node has settled and a walk closes when it finishes — so there is no idle clock to extend, and sending the field to either is invalid_args.
  • Before router 0.4.91 the ten idle minutes were the only answer, so watch this and tell me later quietly meant later, within ten minutes for the two callers most likely to go quiet: an AI client between turns, and an agent that restarted and lost its session state. Both lost every watcher they had, fleet-wide, with nothing said.

Event Payload

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

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

Watcher behaviour

Two things hold on every platform, not just macOS:

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

Before you filter on ops: the noBGP file tools write atomically, so a change they make arrives as a create and never a write. fs_write, fs_edit, fs_copy and op=fetch_url all land bytes in a sibling temporary file and rename it onto the destination, which the watcher sees as a new file appearing. An ops: ["write"] filter therefore misses every change made by these very tools — watch for create too. The temporary sibling itself is dropped on the node and does not reach you; an agent older than that fix still reports it, under a name that no longer exists by the time you read the event.

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

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

From agent 0.4.89 a node that could watch none of your paths names each one and why — in the order you declared them and in the spelling you sent, rather than the symlink-resolved form — so a refusal reads as a list of answers: /srv/app (no such file or directory); /root/private (permission denied). The three causes have three different fixes: a path that is not there, one the agent itself could not register a watch on (a permissions problem on the node, or the host's own watch limit — on Linux, fs.inotify.max_user_watches), and the node's 1024-directory watch cap already being reached. Through agent 0.4.88 all of them came back as none of the declared paths could be watched, one sentence for every cause.

A partial failure is still only a log line on the node. If at least one path registered, the subscription runs and the ones that did not are not reported to you — including a recursive tree that stopped part way at the 1024-directory cap, where you get events from the top of the tree and nothing from below it. Subscribe to a directory you are unsure about on its own if you need to know it is being watched.

A path is not re-evaluated the way the selector is

⚠⚠ A watch path must already exist when the node registers the watch, and a path that does not is never re-examined while that watch runs. The two halves of a subscription behave differently on purpose, and the difference is easy to read the wrong way round:

  • The selector keeps being evaluated, so a node enrolled or relabelled later joins the subscription on its own.
  • The path does not. A path the node answered no such file or directory for is not tried again by the watch that is running; nothing on the node is waiting for it to appear.

How you find out depends on how many paths you declared, and the two answers could not be less alike:

  • Every path missing — nothing registered, so the node has no watch to run and says so: a refused event whose reason names each declared path and what the node answered for it (/var/log/app.log (no such file or directory)). Read it from event_tail like any other event. ⚠ The subscription itself stays live, so this is a refusal to watch rather than a subscription that ended — and because nothing is running for it, the node re-attempts the whole watch the next time the router re-sends the subscription to it, which a reconnect or an agent restart does. A path created in between is picked up then.
  • Some paths missing — the watch runs on the ones that registered and the rest are silently absent, which is the case the partial-failure note above describes. Nothing reports the missing one, and its events never arrive for as long as keep_seconds says. Subscribe to a path you are unsure about on its own, so a failure is the first kind rather than the second.

To be told when something appears, watch the parent directory with recursive and let the create tell you. To watch a path you are about to make, make it first — fs_mkdir or fs_write — and subscribe after.


command_subscribe

Surfaces: public · local (manage)

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

Purpose

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

Input Schema

{
"command": "string (one command line — this or script, never both)",
"script": "string (a script body — this or command, never both)",
"shell": "string (optional)",
"workdir": "string (optional)",
"env": "object (optional)",
"max_seconds": "number (optional, default 300, max 3600)",
"stream_output": "boolean (optional)",
"admin": "\"true\" | \"false\" | \"auto\" (optional; a JSON boolean is also accepted)",
"redact": ["string (optional; one short label per secret this dispatch carries)"],
"subscription_id": "string (optional UUID)",
"...selector fields": "see Node selectors"
}

Parameters:

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

  • script - A script body, from router 0.4.83 — the same field command takes, lowered per node. See Sending a script body to a cohort.

  • shell - Optional interpreter wrapper, identical to command's. Omit (or "auto") to pass the body to each node's native shell; "powershell" dispatches it as -EncodedCommand. ⚠ It applies to the whole cohort, so a mixed-platform selector with shell: "powershell" fails on the non-Windows nodes — powershell.exe is not on their PATH and each reports its own error. Scope the selector when you set it.

  • workdir - Working directory for the run on every node. Omitted, the run inherits the agent's own working directory — usually / or the service manager's, not a user's home, which is what a single-node command session falls back to — so write absolute paths or set this. A node where the path is missing, or is not a directory, does not run the body somewhere else: from agent 0.4.85 it reports a refused event naming the path. ⚠ The node is what applies workdir and env, and from router 0.4.144 it is the only thing that does — a node still running an agent below 0.4.85 ignores both and runs the body in its own working directory, reporting success. Until router 0.4.143 they were also folded into the command line as a fallback for such an agent; that fallback is retired, since a node that reconnects takes the release its channel offers within about a minute. It matters only for a node that is not being upgraded — one with auto-upgrade: false or pinned to a channel that offers it nothing. ⚠ One path for the whole cohort, so it has to exist on every node the selector matched. Any character is accepted from router 0.4.144 — the path travels to the node as a field of its own and no shell parses it on the way, so the old refusal of a double quote, a percent sign, a newline or a null byte is gone. A Windows directory holding a percent sign works now.

  • env - Extra environment variables, merged over each agent's default environment: your value wins where a name already exists, and an empty value sets that variable empty rather than leaving the node's own. Values may contain any character from router 0.4.144, and a name only has to avoid an equals sign and a null byte — the pair travels as fields the node applies, so the old restrictions (names limited to letters, digits and underscores; values refused for a double quote, a percent sign, a newline or a null byte) no longer apply and no workaround is needed for them. ⚠ Names are case-insensitive on Windows and case-sensitive everywhere else, so two names differing only in case are one variable on part of a mixed cohort. ⚠ A handful of names are refused by the node, from agent 0.4.85 on this tool and on every earlier release of command and published terminals: the loader and interpreter-startup variables that would inject code into the run — LD_PRELOAD, LD_LIBRARY_PATH, the DYLD_* trio, BASH_ENV, ENV, ZDOTDIR, PYTHONSTARTUP, PERL5OPT, RUBYOPT, NODE_OPTIONS and CLASSPATH. Such a dispatch comes back as that node's own refused event; set what you need inside the script instead. Two variables the node sets for itself cannot be cleared by this map either — NOBGP_SUBSCRIPTION, and the agent's own directory appended to PATH.

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

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

  • admin - run the command elevated on every node in the cohort. A string from router 0.4.83"true", "false" or "auto"; a JSON boolean is still accepted. Omitted or "false" runs as each node's configured user — not uniformly root (typically the installing account; nothing at all on headless installs, where unelevated is therefore root). Caller authority is checked once, against the network's organization, before the cohort is counted: an elevated dispatch is all-or-nothing, because a partly-elevated fan-out — root on some peers, refused on others — is not a state worth reconstructing from event_tail afterwards. Below org Owner/Admin the whole call returns forbidden and nothing is dispatched. Past that gate, a node whose owner set allow-admin: false reports a refused event rather than running the work unprivileged. "auto" is the answer to a cohort whose nodes disagree about which identities they have — it takes whichever each node can offer, is authorized exactly as "true" is, and the response's admin_used map says which identity each node was asked to run at. Nodes differ: read user and allow_admin from network_directory before assuming a cohort is uniform.

  • redact - Router 0.4.85+. The same declaration command takes, and it means the same thing here: short labels for the secrets the body carries, never the secrets. See Declaring that a call carries a secret.

  • subscription_id - Optional UUID you generate. A retry with the same id and the same request echoes the existing dispatch instead of running it twice; the same id with anything about the run changed returns invalid_args, telling you to event_unsubscribe first or use a fresh id. The whole request is compared as you sent it — command or script, shell, workdir, env, admin, and from router 0.4.94 max_seconds and stream_output as well. Those last two were missing, so a retry that only moved the kill deadline or turned streaming on was answered already dispatched about a run that would end at a different moment and report differently.

Sending a script body to a cohort

script (router 0.4.83+) is the same field command takes, and this is the tool where it matters most: a cohort spans platforms by definition, and a one-liner written for POSIX fails on the Windows node as one refusal among a dozen successes — the easiest thing in a fan-out to skim past.

The body is lowered per node, not once for the cohort. Each node gets the form its own platform needs: verbatim under the login shell on Unix, a temp .cmd with CRLF endings run through %ComSpec% /d /c on Windows with shell omitted, and -EncodedCommand under shell: "powershell". That is what lets one dispatch span Raspbian, macOS, OpenWrt and Windows.

Two refusals follow from it, and both arrive as that node's own refused event while the rest of the cohort runs:

  • A node that has never reported its OS — there is nothing to lower the body for.
  • A node whose lowered line exceeds its platform's command-line limit. The encodings inflate (base64 of UTF-16LE is about 2.67×, and the Windows batch path encodes twice), so the same body can fit on Linux and not on Windows. Past that, write the body to each node with fs_write and dispatch its path.

Only a cohort where no node can be served fails the call outright, with failed_precondition, and nothing is created.

Note that OpenWrt is BusyBox ash: a bashism that passes on Debian fails there. Write POSIX sh for a mixed cohort.

Output Schema

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

This is the payload under subscription — the key all four subscribe tools share — inside the envelope. ttl_seconds is always the 600-second default here: a dispatch is bound to the work rather than to idleness, so it takes no keep_seconds and sending one is invalid_args.

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

admin_used (router 0.4.83+) maps each node id to the identity it was asked to run at — always a boolean, never "auto", because "auto" resolves per node and a single answer would be wrong on any cohort worth sending it to. A node absent from the map was refused before dispatch and ran nothing; its refused event carries the reason. fs_grep_subscribe reports it too; fs_subscribe and presence_subscribe run nothing as anybody and omit it.

Event Payload

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

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

command.signal names what ended the run (router 0.4.84+), as the signal's name and never a number — the same field the single-node command tool reports, so the two surfaces say the same thing. It is absent when the run ended on its own or the node does not report one. ⚠ It is not gated on a terminal status, deliberately: a run killed at its deadline reports cancelled with exit_code overwritten to -1, so the name is the whole answer exactly where a status gate would have dropped it.

Behavior

  • Returns not_found when no online node matched the selector.
  • It runs once and always ends — there is no repeating mode.
  • event_tail's progress block tells you where the work stands; outstanding: 0 means every node has finished.
  • event_unsubscribe kills whatever is still running.
  • A node whose owner dropped command from its allow-tools, or vetoed the requested privilege with allow-admin: false, reports refused with the reason instead of running.
  • A node that cannot serve the workdir or the env you sent refuses before it runs anything, from agent 0.4.85 — a workdir that is not a directory there, or an env name on the refused list above, lands as that node's own refused event carrying the reason while the rest of the cohort proceeds. The point of refusing rather than running is that a dispatch nobody is watching must not quietly happen in the wrong directory or with the wrong environment and report completed.
  • A node that out-talks the bus ends with overflow. Filter at the source (grep, tail -n) or redirect to a file and read it with fs_read.

presence_subscribe

Surfaces: public · local (observe)

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

Purpose

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

Input Schema

{
"subscription_id": "string (optional UUID)",
"keep_seconds": "number (optional; idle lifetime, default 600, max 86400)",
"...selector fields": "see Node selectors"
}

keep_seconds (router 0.4.91+) is the idle lifetime, and this is the tool it matters most on: waiting for a machine to come back from a reboot, a rebuild or an overnight window is exactly the case where nobody calls event_tail for hours. See Staying subscribed while you are away.

Output Schema

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

Event Payload

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

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

Wait for ready, not online, before you use a node

ready is router 0.4.84+, and it is not a debounced online. online keeps its exact meaning — the node's bridge came up — which is true again after every reconnect, and during a provision it is true for a fraction of a second before the node re-registers and drops. ready is published only when that same session has held for 5 seconds, so it is the one that says the node will still be there when your next call lands.

The number is measured rather than chosen: during a real provision_node the fleet was seen going registered → online → offline → registered → online in 1.5 s, and the doomed bridge behind that first online lived 188 ms. A session that dies inside the window publishes no ready at all; its replacement arms its own.

Keep watching online when the question is the bridge itself — a gap, a bounce, a flap count. A client that does not know the token simply sees one extra event.

There is no replay, so the subscribe comes first

A subscription's queue starts empty and holds only what arrives after it exists. event_tail's offset re-reads what that queue already holds; it does not reach back past the subscription's creation. Reboot a node and then subscribe and the ready you are waiting for has already gone by — event_tail returns empty for as long as you are willing to wait, and an empty tail is indistinguishable from a node that has not come back yet, so the mistake reads as patience.

The order is subscribe → do the disruptive thing (reboot, upgrade, provision, restart) → event_tail with wait_seconds. If you also need the state before it changes, read network_directory after subscribing, never before — that way nothing can fall between the two calls.

And do not wait with a timer. A guessed sleep, or network_directory polled in a loop, is wrong in both directions: it calls a node up before its session has held, and it burns the whole guess when the node was back in two seconds. network_directory answers is it up now; this answers tell me when that changes.

An offline is stamped when the router gave up, not when the node failed

The at on an offline event follows the keepalive window, so it lags the failure it reports — measured at about 73 seconds against the node's own kernel log for the same wedge. Read it as the moment the router noticed. The failure time is on the node, in whatever it managed to write before it went: node_logs once it is back, or its own console. Router 0.4.143 says so in the tool's description.

The lag is a property of detection and not an error to correct for — a node that fails quietly is noticed at the end of its keepalive window, and one that closes its connection cleanly is noticed at once — so never subtract a fixed figure from the timestamp to recover a failure time. network_directory's offline_at is stamped by the same observation and carries the same lag.

A node that went down before you subscribed produces nothing here. For those, network_directory's offline_at carries the moment the connection ended, and online_at — re-stamped on every reconnect — is how a directory read tells a new session from the one you just ended, which is the fallback when you did subscribe too late.

Every transition here is one a live connection was observed making, which is also the shape of the one gap: a node whose record is closed by the router's hourly repair sweep publishes no offline event, because by then there is no connection left to observe. That is a rare case — it needs the router process serving the node to have restarted while the node was already silent — but it is the reason a client that must not miss a departure should reconcile against the directory rather than treat this stream as exhaustive.


event_tail

Surfaces: public · local (observe)

Read pending events from a subscription, oldest first.

Input Schema

{
"subscription_id": "string (required)",
"max": "number (optional, default 50, cap 256)",
"wait_seconds": "number (optional, cap 30)",
"offset": "number (optional; event position to read from)"
}

Parameters:

  • max - Maximum events to return.
  • wait_seconds - Block up to this many seconds for an event instead of returning an empty result immediately. Use it when you're waiting on something specific — a command you just dispatched — rather than calling in a loop. ⚠ wait is accepted as a spelling of it from router 0.4.146. It is the name callers reach for, and because these schemas reject unknown fields it used to come back as invalid_args — after which the working move is to drop the field and call again, so the tool still answered and had simply stopped being a wait. The alias is rewritten to wait_seconds before the call runs; it is not published in the schema, and sending both is refused rather than reconciled.
  • offset - Router 0.4.84+. Event position to read from. Omit for the normal case: reading resumes where the last call stopped. See Re-reading events.

Output Schema

{
"events": [{ "...": "see Event shape" }],
"next_offset": "number (present on every response)",
"truncated": "boolean (the requested offset had aged out)",
"dropped_events": "number (how many were lost, with truncated)",
"progress": {
"dispatched": "number",
"accepted": "number",
"settled": "number",
"outstanding": "number"
}
}

Fields:

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

Re-reading events

Reading no longer destroys what it returns (router 0.4.84). Until then the queue was drained on read, so an event a response carried was gone if that response never reached you — the same defect command output had, on a second surface, and fixed with the same primitive.

The three rules are the ones the command cursor follows:

  • Omitting offset resumes exactly where the drain used to leave off, so no existing caller changes anything.
  • next_offset rides every response, empty ones included. Send it back as offset and the same events are delivered again, so a lost response costs a retry rather than the events.
  • The newest 256 events are retained. An older offset is served from the oldest event still held, with truncated: true and dropped_events saying how many were missed. An offset past the end is invalid_args — it belongs to a different subscription.

The cursor belongs to the subscription, not to you. There is one position per subscription and every reader shares it, so a second reader — or a call of your own whose response never reached you — moves it, and your next offset-less call starts later with nothing saying so. truncated cannot report that: nothing aged out, the events simply went somewhere else.

Compare next_offset against the one you last held, and subtract the number of events you were just given — that is where this read started. If it is past where you left off, the difference was delivered to another reader, and those events are still there: send your own remembered value as offset and they come back. Two limits on that recovery, both worth knowing before you rely on it (router 0.4.146 states them):

  • Only while retained. A gap larger than what is left of the 256 has genuinely aged out, and that read is the one that returns truncated with dropped_events. A truncated: false on the response in your hand describes the read you just made, not the one you are about to make.
  • The re-request moves the shared cursor too. An offset read that ends past the current position advances it for everyone, so recovering your own gap can open one for the other reader. With more than one reader on a subscription, ask for the narrowest range you need and expect the other reader to make the same comparison.

Behavior

  • Each call resets the subscription's ~10 minute idle timer. A subscription that expires stops the work on every node, so tailing is what keeps it alive.
  • Nodes that never acknowledged a dispatch are written off after a 30-second grace; a node that acknowledged gets the full deadline, so an agent restart mid-run doesn't lose the report.
  • The queue holds only the newest 256 events — treat events as coarse signals and investigate details with command or fs_read on the node that reported.

event_unsubscribe

Surfaces: public · local (observe)

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

Input Schema

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

Output Schema

{ "removed": "boolean" }

Behavior

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

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


event_publish

Surfaces: public · local (observe)

Publish an event into a subscription yourself.

Purpose

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

Input Schema

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

Output Schema

{ "delivered": "boolean" }

delivered is true when the payload was queued for this subscription. A subscription that has expired, been removed, or was never yours does not arrive here as false — that is a not_found error — so false means only that the event store refused the write, and the payload is worth retrying.

Behavior

It works for as long as the subscription does — including after every node has settled (router 0.4.88+). Writing a marker once a fan-out has finished, recording what you concluded from it, is a normal thing to do, and the event is there on the next event_tail. The subscription's own lifetime is the only limit: once it expires or is unsubscribed, this is a not_found error rather than a silent no-op.

A marker published after outstanding: 0 was lost before router 0.4.88

Through router 0.4.87 a publish into a subscription whose nodes had all settled was dropped. The call succeeded and reported delivered: false, the subscription was otherwise perfectly alive — event_tail served it, event_subscriptions listed it, event_unsubscribe removed it — and the event was simply not there afterwards. It was inheriting the past-deadline refusal that belongs to a node reporting late into finished work, which is not the same act as the owner annotating a result they are still reading. Publishing a completion marker after a fan-out finishes is the most natural use of this tool, and it was exactly the case that lost the event.

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

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


event_subscriptions

Surfaces: public · local (observe)

List your live subscriptions in a network.

Purpose

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

Input Schema

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

Output Schema

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

Behavior

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


task_stop

Surfaces: public only

Stop a running machine and keep its node. From router 0.4.149.

warning

Destructive operation. All data on the container's own disk is permanently lost. The node and its storage area stay.

Purpose

Stop a provisioned machine, and its billing, without removing the node. The node stays in the directory as an ordinary offline node, with the same node_id, name, labels, role grants and storage area. To start it again, call provision_node with the same node name: that starts a new machine on the same node, unless restartable_by_name is false.

The difference from deprovision_node is the node. Both stop the machine and destroy the container's disk. deprovision_node also removes the node from the directory. task_stop does not.

You want toUse
Stop the machine and keep the node, to start it again latertask_stop
Stop the machine and remove the nodedeprovision_node
Keep the machine running for longertask_deadline_set

Input Schema

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

Parameters:

Behavior

  • Idempotent. A task that had already stopped — at its deadline, for billing, or by an earlier task_stop or deprovision_node — is success, with already_stopped: true. Nothing is written, and the recorded reason the machine stopped stays the same. Two more cases also return already_stopped: true. When another stop (the deadline sweep or deprovision_node) closes the task while this call runs, that stop's reason is kept. When the provider reports that the container had already stopped on its own (a crash, a capacity reclaim), the call records the reason provider, not user_stop.
  • When this call does the stop (already_stopped: false), the task history records it as a stop by you, with the reason user_stop. A terminate with deprovision_node records user. So the node's task list can show which of the two ended each machine.
  • It waits, up to 35 seconds, for the node to read offline, for the reason deprovision_node waits: the stop is asynchronous, and a provision_node on the same name while the node still reads online takes a suffixed name and a new node_id. node_offline reports the outcome.
  • An account whose provisioning has been revoked can still call it, the same as deprovision_node. It stops compute and starts none.
  • A node cannot call it. It is not on the local server, and a node's grant does not reach it.
  • A stopped node is still a node. network_delete refuses a network that still has nodes in it, so to tear a network down use deprovision_node.
  • A kept node is not always started again by its name. A node that has been revoked is one case: provision_node refuses its name. restartable_by_name (from router 0.4.151) says whether provision_node on node_name starts this node again, and notes[] says why not.

Will the name start it again?

From router 0.4.151 the response answers that outright, in restartable_by_name — the one thing a caller has to know before promising "I'll start it again tomorrow", and the one thing the fields around it could not say. Keeping the node is only worth anything if provisioning its name comes back to this node, and several things break that link: the node was revoked, a newer task already owns it, another live node holds its identity key, or the name is contested. All of them read the same from the outside — a kept node with a name — so the router runs provision_node's own name rule against the kept name at the moment it answers, and reports the verdict.

restartable_by_nameWhat it meansWhat to do
trueprovision_node on node_name starts a new machine on this node — same node_id, labels, role grants and node storageProvision the same name when you want it back
falseIt does not: that name is refused, or it would get a different node with an empty storage area. notes[] carries the causeRead notes[], and do not plan on the kept node's labels, grants or storage coming back — provisioning that name now makes a new node or nothing at all
absentEither there is no node, or nothing refuses the name but the node still reads online, so the answer is not settled yetPoll network_directory until the node reads offline, then ask again

Absent never means "wait and it will be true." It means not known yet: a collision that is still resolving is reported as absent because it clears on its own, while a refusal is reported as false even while the node still reads online, because that one does not clear.

true is about the name, not about your account. provision_node can still refuse the call for reasons that have nothing to do with the node — provisioning revoked, a lapsed subscription, an exhausted compute allowance.

Output Schema

{
"task_stop": {
"task_id": "string",
"node_id": "string (optional)",
"node_name": "string (optional)",
"already_stopped": "boolean",
"stopped_at": "string (optional)",
"node_offline": "boolean (optional)",
"restartable_by_name": "boolean (optional)"
}
}
FieldMeaning
already_stoppedfalse: this call stopped the machine. true: this call stopped nothing — the task had already ended (before or during the call), or the provider had already stopped the container and the call recorded provider
stopped_atThe recorded end time, when already_stopped is true. Absent only when a concurrent stop's time could not be read back
node_id, node_nameThe node that was kept, by its current name. Absent when there is no node to keep: the container never registered, or deprovision_node removed the node
node_offlinetrue: provision the same name now to start a new machine on this node. false: the stop succeeded, but poll network_directory until the node reads offline before you provision the name. Absent: no wait ran (the task had already ended when the call read it, it has no node, or a newer task owns the node). A call that reached the provider waits, also when it returns already_stopped: true
restartable_by_nameRouter 0.4.151+. Whether provisioning node_name starts this node again. See Will the name start it again?true, false and absent are three different answers, and it is reported on every path, including already_stopped: true

Example Usage

Request:

{
"task_id": "5f0c2a8e-3b1d-4c7e-9a42-1d6f0e8b7c35"
}

Response:

{
"op": "task_stop",
"done": true,
"notes": [
"Node build-runner read offline 912ms after the stop; provision_node on this name starts a new machine on it now.",
"Node build-runner (node_id 3c1b9e2a-…) is kept offline with its labels, role grants and node storage; provision_node on this name starts it again."
],
"task_stop": {
"task_id": "5f0c2a8e-3b1d-4c7e-9a42-1d6f0e8b7c35",
"node_id": "3c1b9e2a-…",
"node_name": "build-runner",
"already_stopped": false,
"node_offline": true,
"restartable_by_name": true
}
}

task_deadline_set

Surfaces: public only

Give a running machine more time, or less.

Purpose

A provisioned machine stops at its deadline and its container's disk is destroyed then. Moving the deadline is the only way to keep a machine that is still working. Re-provisioning under the same name is not the renewal it looks like: the running machine's task still holds the name, so the call takes the next free name and starts a second, empty container beside the first — two machines billing, and the one you meant to keep still stops at its deadline. Its disk survives only for as long as its task does.

Input Schema

{
"task_id": "string (required)",
"max_hours": "integer (optional)"
}

Parameters:

  • task_id — the task whose deadline moves. It is the handle provision_node returned, and network_directory reports it per node.
  • max_hours — how many hours from now the machine may run.
max_hours counts from NOW, not from when the machine started

4 means four more hours, whatever the machine has already run — it is not "four hours of total life". Read the other way on a machine that has been up for twenty hours it would name an instant in the past, and the machine you meant to keep would be stopped within about ten minutes.

The response says the resolved instant in deadline_at. Read it; do not work it out.

max_hoursResult
omittedreset to your plan's default (24 hours) from now — what renewing a machine you still want looks like
18760that many hours from now, up to one year
0no deadline, on every plan but Free — the machine runs, and bills, until deprovision_node
smaller than the time remainingaccepted — it brings the stop forward

Shortening does not stop the machine now. The stop happens at the new deadline and is swept for within about ten minutes of it. To stop a machine immediately and keep its node, use task_stop. To stop it and remove the node, use deprovision_node.

Only a machine that is still running can be changed. A machine that has already stopped is refused (failed_precondition) naming the instant it stopped — its container and disk are gone, so there is no compute left to bound. provision_node on the same node name brings the node back and takes a fresh max_hours.

Which plan decides is the plan of the organization that is billed for the machine — the one that owns the node where there is one, and the network's otherwise. The same organization provision_node resolves its deadline against.

A plan that cannot be read is a refusal, not a removal. Where the plan of the organization that pays for the machine cannot be resolved, a machine that has a deadline is refused and keeps the one it had — the refusal names the instant that still stands. Silently dropping a bound on a call that asked to extend one is the outcome this avoids. A machine with no deadline has nothing to lose and is left alone.

Extending is starting compute, so a revoked account cannot do it

This tool is withheld from an account whose provisioning has been revoked, exactly as provision_node is — it is the only call that makes a container run longer. deprovision_node stays available to such an account, which is cheaper than any shortening this tool could offer, so nothing is left running with no way to stop it.

Money does not refuse this call, and it does not spare the machine either. There is no billing check here: an organization with no credit left, or behind on a payment, still moves a running machine's deadline. That is deliberate — refusing provision_node costs a caller a machine they do not have yet, while refusing this one destroys a disk they are still using. It is not a way around a limit: a machine stopped for the compute allowance, an empty balance or a lapsed payment is stopped whatever its deadline says — and from router 0.4.128 an empty balance is one of those, so a deadline moved on such a machine buys nothing. Clear the cause; the deadline is not what is holding that machine up.

Where to read the current deadline

network_directory reports task_deadline_at per node, and provision_node returns deadline_at for the task it just created.

Output Schema

{
"task_deadline_set": {
"task_id": "string",
"node_name": "string (optional)",
"deadline_at": "string (optional)",
"previous_deadline_at": "string (optional)",
"shortened": "boolean"
}
}
FieldMeaning
deadline_atThe instant the machine now stops. Absent means it has no deadline and runs until it is deprovisioned
previous_deadline_atWhat it would have been before this call. Absent means it had none
shortenedtrue when the new deadline is earlier than the old one. It is a field rather than something to work out from the two instants, because neither comparison is possible when either is absent
node_nameThe machine's current name. Absent while the container has not registered yet

register_node

Surfaces: public only

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

Purpose

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

Input Schema

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

Parameters:

  • network_name - Network to register the node into. If omitted, it resolves to your sole network — judged inside your personal organization if you belong to several. Name it to register into a shared org's network.
  • node_name - Name for the node. If omitted, the machine's hostname is used. ⚠ It is a request rather than a guarantee: from router 0.4.113 a machine presenting a name that a live node is answering under, or one that is contested, enrols under the next free name in the family — web-1 — instead of being refused. The machine goes on calling itself what its own configuration says, so network_directory is where the name it landed on appears.

These commands enrol a machine; they are not how you rename or move one that is already enrolled. Running one on a machine that is already a node re-registers it, and from router 0.4.116 noBGP matches it on its identity key first — so it comes back as the same node under the name noBGP already holds, whatever node_name the command carries.

Output Schema

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

Fields:

  • shell_install_command — Shell command for Linux/macOS. Pass it on verbatim and run it as an ordinary user: the installer elevates itself, so sudo is not a prerequisite. It carries the registration key.
  • windows_install_command — Windows command. Pass it on verbatim: the same text runs in Command Prompt, PowerShell 5.1 and PowerShell 7, so never ask the user which shell they are in. The installer self-elevates through UAC, so an Administrator session is not a prerequisite. The command does not contain the key: it asks for the key at a hidden prompt (noBGP registration key:). Give the user registration_key to paste there. See The Windows command asks for the key. ⚠ Run unattended, it waits at the prompt.
  • registration_key — The network's registration key by itself, for the Windows prompt. It is the same secret that shell_install_command carries. Anyone who has it can enrol a machine into the network, so do not put it in a log or a shared document.

The shell command no longer carries sudo, from router 0.4.149, and adding it back puts the key in the system log. The command it used to return was … | sudo NOBGP_KEY=<key> sh, and sudo writes the environment assignments on its own command line to the system log — so the network's full, reusable registration key was logged on every machine installed that way. install.sh elevates itself and, from agent 0.4.124, passes the key to that privileged pass in a private file rather than on a command line. Run the command as returned.

The Windows command asks for the key

From router 0.4.151 the Windows one-liner carries no key. It used to be powershell -Command "Set-Item env:NOBGP_KEY '<key>'; …", and that text is the command line of the PowerShell process it starts — which Windows process-creation auditing (Security event 4688), Sysmon and endpoint security agents record, and which stays in the history of whatever shell it was pasted into. The key is already written down by the time the installer runs, so no change to the installer could undo it.

The command now prompts for the key in the PowerShell it starts:

  • The prompt is hidden. The console shows * per character, and PowerShell does not keep Read-Host input in the command history. The key goes from the console straight into that process's environment and onto no command line.
  • Give the user registration_key alongside the command. Two things to pass on now, not one — the command is safe to paste anywhere, the key is not.
  • It is for a person at a console. Run unattended — through command, over SSH, or from a script — it waits at the prompt forever and the call reports running with no output, which is the stdin case above in another guise. For an unattended Windows install, write the key to a file and set NOBGP_KEY_FILE instead; see Keeping the key off the command line.
  • An empty answer installs without a key and falls through to browser sign-in, which is the same thing running the installer with no key has always done.

Example Usage

Request:

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

Response:

{
"shell_install_command": "curl -fsSL https://downloads.nobgp.com/agent/install.sh | NOBGP_KEY=<YOUR_REGISTRATION_KEY> NOBGP_NAME=web-server-1 sh",
"windows_install_command": "powershell -NoProfile -Command \"Set-Item env:NOBGP_NAME 'web-server-1'; [Environment]::SetEnvironmentVariable('NOBGP_KEY', (New-Object Net.NetworkCredential 'nobgp', (Read-Host -AsSecureString 'noBGP registration key')).Password); irm https://downloads.nobgp.com/agent/install.ps1 | iex\"",
"registration_key": "<YOUR_REGISTRATION_KEY>"
}

Use Cases

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

org_update

Surfaces: public only

Rename an organization.

REST/OpenAPI surface

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

Purpose

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

Input Schema

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

Parameters:

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

Output Schema

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

Fields:

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

Example Usage

Request:

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

Response:

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

Behavior

  • Requires the Owner or Admin role (members.manage scope); other callers receive forbidden.
  • Personal organizations can be renamed here too, from router 0.4.78. A personal org holds no name of its own until one is set — its label is derived from the owner's profile name, falling back to their email address. Naming it through this tool pins it: the explicit name wins over the derivation from then on, so a later profile-name edit no longer moves the label. Earlier routers returned failed_precondition for a personal org.
  • An unknown or non-UUID org_id, or an empty/over-length name, returns invalid_args. A valid-but-missing org returns not_found.

whoami

Surfaces: public · local (observe)

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

Purpose

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

Input Schema

{}

No input parameters required.

Output Schema

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

Fields:

  • user_id - The authenticated user. For a call proxied through a node's local MCP server, this is the node owner the call runs as.
  • email - Absent in node context: the caller is a node acting under its owner's authority, not the owner's login session.
  • node_context - The node the call was proxied through, present only for those calls. Such a caller is bounded to that node's own network — which is why it may address peers by node_name alone.
  • can_elevate - Whether you may send admin: true. true: elevated calls pass the router's gate (a node whose owner set allow-admin: false still refuses). false: every elevated call returns forbidden, so never send it. Absent means it depends on which organization owns the target node — read orgs[].
  • orgs[] - The organizations you belong to and your role in each, with the same can_elevate answer scoped to that organization. Match id against network_directory's org_id to find the row that governs a given node. Absent in node context, and for a caller who belongs to no organization. name is the org's display label, which for a personal organization nobody has renamed is derived from the owner's profile name, or their email address when there is none (router 0.4.78+) — never an empty string.
  • router_build - Router 0.4.86+, and always present. The build serving this call, e.g. "0.4.86+20260820". Compare it with the build your tool list came from — see below.
router_build is how you notice a stale tool list

Your tools/list is a cache, and a router newer than it may be offering tools you cannot see. Unlike a new field, which announces itself by rejecting a call and naming the fix, a tool missing from your list is simply one you never call — nothing rejects anything, so nothing tells you.

If router_build is newer than the build your list came from, refetch the tool list (in most clients, reconnecting is what does it) before concluding that a capability does not exist, and before telling a user it does not. Measured 2026-08-20: a session connected minutes before a deploy could not see three tools that deploy had added.

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

What it answers, by caller:

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

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

Example Usage

Request:

{}

Response:

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

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

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

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

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

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

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

Behavior

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

feedback_submit

Surfaces: public · local (both tiers)

Tell us where this tool surface fought you. Router 0.4.85+.

Purpose

A task that cost five calls when it should have cost one. A description that was wrong, or that was missing the one fact you needed. An error that named no next step. A parameter whose behaviour surprised you. One call, free text, nothing to follow up.

Reach for it as the friction happens, while you still have the call_id — not in a summary at the end, where the observation survives and the envelope does not.

It matters most for the caller with no human in the session — a scheduled job, an unattended agent, anything with nobody to ask at the end. A job running at 03:00 has nowhere to put the friction it hit, and that friction is the friction nobody has written down.

A human in the session is not a reason to skip it, and telling them is not a substitute (router 0.4.109 widened the guidance to say so, after it read as unattended callers only). A person can fix the problem on their own machine or file it in their own tracker, and neither reaches the people who write these descriptions. A session with someone in it is also where friction gets diagnosed rather than merely noticed — where it can be established whether the thing that fought you was the tool or the call — which is what makes those reports the ones most worth having. It is the only channel from an AI operator back to the people who write these tools, and it is read.

Any member may call it. It is at both local tiers, because it touches no peer at all.

Input Schema

{
"report": "string (required, 1–8000 characters)",
"call_id": "string (optional)",
"tool": "string (optional)"
}

Parameters:

  • report - What you were trying to do, what the surface did instead, and what it cost — calls, retries, a wrong answer you had to notice. Concrete beats polite: the exact argument you sent, the exact sentence in a description that misled you, the number of calls a one-call task took. Counted in characters, up to 8000.
  • call_id - The call_id the result envelope carried on the call you are reporting. That field tells you to quote it when reporting a problem; this is where quoting it does something, and it is what lets us find exactly what happened. Omit it for a report about the surface in general rather than about one call.
  • tool - The tool this is about, if it is about one — "fs_read", "command", "event_tail". Omit for a report spanning several.

Three fields and no more. A category enum, a severity, a want-a-reply flag are all things a caller would have to guess at, and every one of them is schema weight on a tool whose whole job is to be cheap enough to reach for.

Output Schema

{
"status": "recorded",
"used": "number",
"quota": "number",
"remaining": "number",
"resets_at": "string (RFC3339 UTC)",
"router_version": "string"
}
  • status - Always recorded on success. There is no other value: a report that could not be stored is an error, not a status.
  • used / quota / remaining - Your organization's reports in the current window including this one, the bound, and what is left. remaining is never omitted — zero is the answer that matters most.
  • resets_at - When the window's oldest report rolls off, freeing one slot. The window rolls continuously; it is not a calendar day.
  • router_version - The build that recorded the report. Quote it beside the call_id if you also file a bug.

Every field of the receipt is server-generated and nothing you sent is echoed back — not the report, not the tool name, not the call_id. Reflecting caller text would turn this store into a channel between tenants, so counts, a status, a timestamp and a version are the whole of what may come back.

Limits and shape

  • Write-only, by design. There is no tool that reads these back, and there will not be one — so this is not a way to store or retrieve anything, only a way to say something. The reports are read by people, on a staff-only surface.
  • Not deduplicated on the way in. Two identical calls are two reports, deliberately.
  • 50 reports per organization per rolling 24 hours, enforced by the database, so it is the same bound whichever replica you reach and whichever session you retry from. At the bound the call is resource_exhausted, nothing is stored, and the message names both the bound and the moment the next slot opens.
  • A caller belonging to no organization gets failed_precondition — there is nothing to attribute the report to.

Example Usage

{
"report": "fs_list on /usr/lib with recursive:true and limit:3 took 5.5s to return 3 of 23968 entries. Paging cost me 4 calls and ~22s for a listing I only needed the top of. Nothing in the description said limit bounds the response and not the walk.",
"call_id": "9e4c8f21-…",
"tool": "fs_list"
}

org_sso_setup

Surfaces: public only

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

Owner only · REST-only

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

Purpose

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

Input Schema

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

Parameters:

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

Output Schema

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

Fields:

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

Example Usage

Request:

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

Response:

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

org_sso_set_enforced

Surfaces: public only

Turn SSO login enforcement on or off for an organization.

Owner only · REST-only

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

Purpose

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

Input Schema

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

Parameters:

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

Output Schema

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

Fields:

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

Example Usage

Request:

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

Response:

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

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


Error Handling

All tools return errors in a consistent format:

{
"error": {
"code": "string",
"message": "string",
"call_id": "string",
"retryable": false,
"details": {}
}
}

call_id is on failures too, from router 0.4.83 — the same identifier the result envelope carries, minted for the call rather than for the result, so a partial result and the error beside it agree. It is the id to quote in a bug report, in a log search, or in feedback_submit: the failing call is the one a report is usually about, and an identifier present only on success is absent exactly where it is needed. Nothing may parse it.

MCP clients: the structured body arrives from router 0.4.85

On REST the shape above has always been the whole answer. On MCP it had not been reaching you. A failing tools/call came back carrying the message text and nothing else — no structured body at all, so code survived only as a prefix you would have had to parse back out of the prose, details was dropped entirely, and a genuinely retryable failure such as timeout or target_unreachable arrived indistinguishable from a permanent one. A client branching on retryable, as this page has always told you to, was branching on a field that was never sent.

From router 0.4.85 a failed tools/call carries the same {code, message, call_id, retryable, details} object in structuredContent, so both adapters answer identically. The human-readable content text is unchanged (code: message), so nothing that was reading the prose breaks.

One deliberate limit: the envelope is attached only to errors the tool surface itself raised. A failure that never reached a tool — an argument rejected against the schema, a tool name that does not exist — keeps the protocol's own error shape rather than being given a code nobody chose.

Common Error Codes

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

CodeDescriptionResolution
unauthorizedNot authenticatedSign in via OAuth
forbiddenAuthenticated but not allowedCheck account permissions
permission_deniedThe node refused — a filesystem permission failure, or one of its owner's vetoes: allow-admin, allow-tools, allow-roots, or the agent's own configuration directory. Distinct from forbidden, which is the router's verdict on youRead the message; it names which one. Not fixable by retrying — see Owner vetoes
not_foundResource doesn't existVerify resource name/ID
invalid_argsBad request parameters — including an fs_grep pattern that is not valid RE2Check input schema
target_not_foundThe node exists, in a different network of yours. From router 0.4.83 this is what you get when the node name resolves in another network you own, and the message names it: "node pi-01 not found in network home (currently in network lab)". A name that matches nothing anywhere stays not_foundRe-send with the network the message names, or take the node_id from network_directory
target_unreachableNode is offline or unreachable — or, from agent 0.4.81, the node is up but could not open the channel this call needed because its own connection to noBGP failedRetryable; noBGP already asks the node a second time. If it persists, check the agent's connection and its name resolution
already_existsResource already presentUse update or pick a new name
too_largePayload exceeds limits — including a node's own answer overrunning the 64 MiB it can send in one reply (an oversized recursive listing, glob match set or search result)Narrow the request: a smaller root, a tighter pattern, a lower max_count. Paging with limit / offset does not help against a node's own ceiling — the node builds the whole answer before the router pages it
timeoutOperation timed outRetry with a longer timeout
canceledOperation was canceledRetry if desired
rate_limitedRate limit exceededHonor Retry-After; back off
resource_exhaustedA plan resource limit was reached — creating a resource beyond the included quota; (router 0.4.93+) a write, mkdir or copy destination on a storage tree whose organization is at its storage limit; (router 0.4.103+) a provision_node whose organization has spent its included compute, run out of credit or lapsed on payment; (router 0.4.106+) a call that moves file or command bytes for an organization over its bandwidth allowance; (router 0.4.146+) a node that outran the router mid-stream, on a read, a copy or any one-shot answer; (router 0.4.147+) the same loss on a command session's output; or the streaming-slot pool is saturatedRead details.limit_type (bandwidth, compute, credit_empty — router 0.4.127+ — lapsed, organizations, …) and branch on it rather than on the message. ⚠ spend_cap is no longer among them from router 0.4.132: the spend cap bounds automatic credit refills and refuses nothing. For limit hits, upgrade the plan (see details.upgrade_url), buy credit, or remove existing resources — for the storage cap, fs_delete is never refused; for streaming-slot saturation, retry shortly
unsupportedOperation not supported on this platformCheck platform requirements
method_not_allowedWrong HTTP verb / tool modeCheck tool documentation
failed_preconditionOperation refused in the current state (e.g. enforcing SSO before an IdP is connected, leaving your own personal organization, a node whose unelevated identity would be the superuser, or a node that is not itself running privileged enough to become the account it was asked to)Change the state, then retry
precondition_mismatchexpected_sha256 guard failed (HTTP 412); the observed hash is in detailsRebase on current content and retry
not_acceptableStreaming tool called without Accept: text/event-stream (HTTP 406)Add the Accept header
internalServer-side errorRetry; contact support if persistent

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

retryable is only ever sent when it is true. A permanent failure omits the field rather than sending false, so test for the flag's presence and treat absence as do not retry — which is the safe reading either way. The same is true of details, which is absent when a code carries nothing extra.

resource_exhausted is the one code whose retryable must be read rather than assumed. Its two producers pull opposite ways: a plan cap, a lapsed subscription, an empty credit balance or a stopped bandwidth allowance is cleared by an upgrade, by buying credit or by the period rolling over, never by a retry (retryable: false), while a saturated streaming-slot pool clears on its own and answers retryable: true with a Retry-After. Router 0.4.83 is the release that made the flag say so; before it the streaming refusal claimed false while sending Retry-After: 1, and this page claimed the code was retryable in general, which is wrong for the commoner producer.

The mid-stream overrun added in router 0.4.146 is a third producer, and it carries retryable: false while its own message names a retry. That is not a contradiction to reconcile in the flag: the loss is transient and the same call may well succeed, but the message also names the shape change that makes success likely — resume at the offset it gives you, copy in pieces, narrow the request. Read the message on this one rather than branching on the flag, and do not retry the identical call in a loop.

A call addressed at an offline node names it, from router 0.4.83. Where such a call used to come back as internal quoting the node's UUID twice and its name never, it is now target_unreachable naming the node the way you addressed it, saying that nothing reached it and nothing ran, and carrying details.node_name and details.offline_at — so a client can say "offline since the 17th" without a second call, and can wait for it with presence_subscribe instead of retrying blind.

When the failure came from the node

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

The node saysCode you get now
allow-admin: false — elevated execution refusedpermission_denied
the path is outside allow-rootspermission_denied
the domain is not in allow-toolspermission_denied
the path is inside the agent's own configuration directorypermission_denied
the answer is bigger than the node can send in one replytoo_large
the agent is not running privileged enough to become the account asked forfailed_precondition
the identity an unelevated call would run as is the superuserfailed_precondition
the node holds its log and the identity this call ran as may not read it (agent 0.4.88+)failed_precondition

The failed_precondition rows are not vetoes and are worth reading differently from the rest: each says the node cannot be what was asked for, so permission_denied would be the wrong answer — it would send an org Owner off to widen a role that was never the problem. Router 0.4.42 is the release that classifies the unelevated-identity one; before it the refusal would have surfaced as internal / 500 / retryable. See Unelevated never means root for its two remedies, and node_logs for the log one, whose single retry is admin: "true".

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

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

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

Ordinary filesystem and argument failures are unchanged: a missing path is still not_found, an unreadable one permission_denied, an fs_edit whose old_string matched more than once invalid_args. One arm moved: an old_string that matched nothing is now precondition_mismatch, because the file is not what the caller read and the answer is to re-read and rebase rather than to fix the arguments — and from agent 0.4.85 its message carries the file's current hash so the retry needs no extra read.

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

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

One code on that list is not a refusal at all, from agent 0.4.81. Every entry above is the node judging your request; target_unreachable from a node is the opposite — it says the node could not open the channel this call needed, because its own connection to noBGP failed, so nothing about the request was ever judged and the operation never started. Until 0.4.81 the node had no way to say that: the failure came back as an ordinary, final error, so a machine whose resolver had broken went dark to command, the file tools and net_* at once while its control channel stayed up and the directory showed it online. It is the one code that changes what noBGP does rather than only what you are told — an unreachable answer makes it ask the node again instead of returning the first reply. The node uses it only for its own failed dial of noBGP; a command or a file operation that merely touched the network is not this, because retrying one of those could repeat work that has already had an effect. See Node Is Online but Every Command and File Call Fails.

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

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

One more of that family closed in router 0.4.94, on the net_* diagnostics. Their session-open path decided this timed out by looking for timeout or deadline anywhere in the error text, so a refusal the node had already coded for itself — whose own message happened to contain either word — was rewritten as timeout and the node's code thrown away. It is now decided by the failure itself rather than by its wording, so a node's refusal keeps the code it named; a genuine connect timeout is unchanged. node_logs answers that question the same way.

Example Error

{
"error": {
"code": "forbidden",
"message": "you are not permitted to provision nodes",
"call_id": "0b7d51e2-…"
}
}

No retryable key: this one is permanent, and the flag is sent only when it is true. A retryable failure looks like this instead:

{
"error": {
"code": "target_unreachable",
"message": "node pi-01 is offline",
"call_id": "3f1a90c4-…",
"retryable": true,
"details": { "node_name": "pi-01", "offline_at": "2026-08-17T09:12:44Z" }
}
}

Errors specific to the local server

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

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

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


Platform-specific behaviour

Windows agents

ToolBehaviour
commandcmd.exe /c, not PowerShell. .exe suffix recommended. Killed processes return exit code 1 (Unix reports 128 + the signal number). Set session.shell: "powershell" for non-trivial PowerShell scripts, and send anything multi-line as script (router 0.4.81+) — a batch body is written out and run from a file, so cmd.exe's line-length limit and quoting never apply
command quotingThe command line reaches cmd.exe verbatim, so its own quoting rules apply — dir /b "C:\Program Files\nobgp" works as typed. Agents before 0.4.38 escaped every " on the way, so quoted arguments came back with literal \" and quoted paths failed outright
command with shell: "powershell"Output is plain text. Agents before 0.4.38 interleaved PowerShell's CLIXML serialization (#< CLIXML followed by an <Objs …> blob) into the output of every call; the agent decodes that stream now, so an error's text survives as text
command / script output encodingNon-ASCII output came back mangled (cafécaf?, CJK → ???) before router 0.4.86: the body always arrived intact, but PowerShell writes its output through the console's legacy code page. From 0.4.86 both lowered Windows forms — shell: "powershell", and a script body of either kind — emit UTF-8. ⚠ A bare command with shell omitted still reaches cmd.exe untouched, so send it as script if it prints anything outside ASCII
script, shell omittedRun as a batch script from a temp .cmd. From router 0.4.86 the generated file opens with @echo off, so cmd.exe's prompt and the echo of each command line no longer arrive ahead of what the script printed — before it, one dispatch produced a different lines[] shape on Windows than on Unix
command stderr timingA completed line on stderr is forwarded as soon as it is complete. On agent 0.4.38 the CLIXML decoder — which wraps the stderr leg of every Windows command session, not only PowerShell ones — held finished lines back while the stream sat mid-line, so a command that printed a prompt or a progress line and then worked quietly returned nothing until it wrote another newline or exited. Delayed, never lost
command App Execution AliasesWindows ships zero-byte alias stubs on the PATH for python, python3, winget and others. Running one where the program is not installed prints "…was not found; run without arguments to install from the Microsoft Store" and exits 9009 — the same code cmd.exe returns for a name it could not resolve. From router 0.4.89 the response's hint names that rather than blaming quoting: install the program on the node, or turn the alias off in Settings › Apps › Advanced app settings › App execution aliases. Neither script nor shell: "powershell" changes the outcome. Matched in en-US only
command signalsSIGHUP and SIGQUIT return invalid_args. SIGINT writes Ctrl+C (0x03) to stdin — works only on processes that read stdin. Use SIGKILL for guaranteed termination
file / fs_* pathsDrive-letter form (C:\Users\... or C:/Users/...) is accepted as absolute; bare C:foo (no separator) is drive-relative and rejected. network_directory's info.fs_mount reports a Windows mount with its trailing separator (N:\) for exactly this reason — N: on its own is invalid_args
file / fs_* on the node's own driveOn a node using the webdav backend, the mapped drive belongs to the logon session that created it and is invisible to the SYSTEM service the agent runs as, so info.mount_readable reads false and paths under the mount are refused. There is no consent grant to give here — use the router-served routes, or move the node onto a winfsp mount. See A node's own mounted drive
file / fs_delete depth-guardCounts segments after / and \ separators; a drive letter is not a segment, so Windows paths bucket like their Unix analogues. C:\Windows\Temp\cache passes (depth 3); C:\Windows\System32 (depth 2) and C:\ (depth 0) are refused — both tools take force=true to override. From router 0.4.87 anything strictly inside C:\Windows\Temp is exempt from the depth rule whatever its depth, with the folder itself still guarded; the comparison folds case there because the filesystem does
file / fs_write / fs_mkdir modeOnly the read-only bit is honoured; full Unix mode bits are ignored
net_routesunsupported / HTTP 501
net_dnsunsupported / HTTP 501

macOS agents

ToolBehaviour
file, fs_*, fs_grep, fs_glob, fs_subscribe, fs_grep_subscribeA path under the agent's own mount point (/Volumes/nobgp by default) may be refused with a bare Operation not permitted, at either identity — macOS's consent layer (TCC) blocking the agent's daemon from a network volume, while the machine's own user reads the same path fine. It is not universal: a Mac whose owner has granted the daemon Full Disk Access serves those paths normally. network_directory reports info.mount_readable per node — read it rather than assuming, and see A node's own mounted drive
net_routesunsupported / HTTP 501
fs_subscribeA create followed immediately by writes can coalesce into the create alone, so an ops: ["write"] filter may miss a file created and written in one go
Walks rooted at /tmp, /etc, /varAll three are symlinks into /private. fs_list with recursive, fs_glob and fs_grep resolve the root they are handed before walking it on agent 0.4.38 and later. Earlier agents walked no further than the link and returned count: 0 / scanned_files: 0 successfully, so "nothing matched" and "we never looked" were indistinguishable — name /private/tmp explicitly when talking to one
Naming of those resultsEntries come back under the root you asked about — fs_list /tmp reports /tmp/…, not /private/tmp/… — on agent 0.4.39 and later. Agent 0.4.38 named them under the resolved path, so prefix-matching an entry against the root you sent failed; the same applies to fs_stat, which reports the link you named rather than its target

Provisioning targets

providerClasses offered
aws (default)small, medium, large, xlarge — see Compute classes

A provider name this router does not implement is invalid_args naming the ones it does, not a server error.

All four are placeable from router 0.4.144; through 0.4.143 large and xlarge were priced and accepted while the capacity behind them was still being added, so a request for one could be accepted and then fail to start. Several big machines at once may still wait on capacity — see Compute classes.


Best Practices

1. Subscribe Instead of Polling

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

2. Sessions Auto-Close

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

3. Use Descriptive Names

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

4. Handle Errors Gracefully

Check for error responses and handle them appropriately.

5. Respect Rate Limits

Batch operations where possible and implement exponential backoff.


Next Steps