Netflow

rupu records where it reaches out to, not just what an agent does — every call rupu makes to a model provider or SCM, and every connection an agent's bash commands open, becomes a flow, written to a per-run ledger, enriched with ASN and org, and explored in the Control Plane.

What it is

rupu already has deep observability for what an agent does — tool calls, command runs, tool audits, coverage ledgers, findings, usage accounting. Netflow closes the other half: where an agent reaches. It has two sources. Every request rupu's own HTTP clients send on an agent's behalf — model provider APIs and SCM / issue connectors — is captured as a FlowRecord: method, host, port, query-stripped path, peer IP, status, byte counts and timings. And every TCP or UDP connection opened by a process the agent started through its bash tool — a curl, a git fetch, a pip install, a scanner — is captured from the operating system's own socket table, with the process that owned it. Both are tagged with the run and agent that produced them.

Those records land in an append-only JSONL ledger per run. At read time the control plane resolves each peer IP to an ASN and org and renders a Network explorer — activity over time, a topology from workflows to origins to the orgs they reached, per-endpoint timelines, and a sortable flow table — at run, project and global scope. A unit that ran on a remote host sends its ledgers back when it ends, so they are read at every scope too.

The whole subsystem exists to let a viewer tell "we could not observe this" apart from "it was zero". That distinction is enforced end to end: an unknown byte count is never summed as 0, an unmeasured percentile is never rendered as 0 ms, a dropped record is never silently absorbed, and every row carries a fidelity badge saying how much of it is actually known.

⚠ Scope: what netflow can and can't see. It covers provider and SCM calls over rupu's HTTP client, and the bash tool's subprocess connections where subprocess capture is available. Socket capture sees IPs, not hostnames (DNS goes through the system resolver), so a subprocess row reads as an IP and its ASN org; a UDP sendto without connect has no destination; a process that detaches from its shell before connecting (macOS) or a connection that opens and closes between two polls (Linux) can be missed; and a unit placed on a remote host records its flows in that host's ledger, which reaches the coordinator when the unit ends, not live (see Runs on remote hosts below). Netflow also cannot see rupu's own non-HTTP egress: git2 clones (often a run's largest byte volume by far), object-store bucket traffic, and the tunnel node WebSocket. Housekeeping with no run behind it — the updater, login, the control plane's fleet traffic, the ASN download — is not recorded at all. The CP restates these limits on every Network surface rather than letting a full-looking table imply completeness.
providers LLM APIs SCM github, gitlab netflow client the one HTTP door fidelity: http bash tool curl, git, pip… socket watcher ntstat · sock_diag fidelity: socket run ledger <run_id>.jsonl control plane index · explorer + ASN at read
Two capture paths, one ledger per run. rupu's own provider and SCM calls go through the instrumented HTTP client; connections from the bash tool's processes are read from the OS socket table. The control plane indexes the ledgers and enriches peers with ASN when you look.

The choke point

Capture only works if there is exactly one door. Before netflow there wasn't one — roughly thirty reqwest::Client::new() / Client::builder() sites were scattered across six crates. Netflow created the choke point and then nailed it shut: a clippy.toml lint denies reqwest::Client::new, Client::default, ClientBuilder::new, ClientBuilder::default and — the actual bypass point — ClientBuilder::build everywhere except inside the netflow crate itself, backed by a repo-level test. A regression is a build failure, not a review miss.

Attribution is bound when a client is constructed, not discovered at request time. Task-locals and tracing spans both lose context across a tokio::spawn, which would produce flows with silently missing attribution — worse than none. Provider and SCM clients are already built per run, so binding the context there costs no new plumbing. Process-global clients with no run behind them (the control plane's host registry, the ASN refresh, the updater, login) are wired to a null sink and record nothing: a flow with no run has no ledger to land in, and pretending otherwise would scatter unattributable rows across every view.

Capture must never break a request. That is non-negotiable. The ledger sink writes through a bounded channel to a background task; on overflow it drops the record and increments a counter the UI surfaces as "N flows dropped" — visible loss, not silent loss. Sink and write failures log once and continue, ASN lookup degrades to "unknown" and is never fallible in a way a caller must handle, and a failure to open the ledger degrades to transcript-only capture rather than failing the run.

Bash subprocess connections

An agent's bash tool spawns ordinary processes, and those don't go through rupu's HTTP client. Rather than put a proxy in their path or require root, netflow reads the socket accounting the kernel already keeps — the same data nettop and ss show — and attributes each socket to the tool call whose processes opened it. Nothing sits in the command's network path: its environment, privileges and performance are unchanged, and raw-socket tools keep working.

PlatformHow sockets are observedHow they're attributed
macOS The kernel's network-statistics control (com.apple.network.statistics, the source behind nettop), usable as a normal user. Every TCP and UDP socket's addresses, owning process and byte counts as it opens, updates and closes. By ancestry: a socket belongs to a call when its owning process descends from that call's shell.
Linux sock_diag netlink: a poll of every TCP/UDP socket (every subprocess_poll_ms, default 50 ms) plus the kernel's socket-close notifications with final byte counts. By cgroup: each bash call's shell moves itself into a per-call cgroup before running anything, so every descendant — even a detached one — is attributed. This needs cgroup v2 and either a systemd user manager or a writable, delegated cgroup; without them, capture is reported as unavailable on the run, with the reason.

Each connection becomes one flow at socket fidelity: transport, remote IP and port, local address, the process name and pid, open and close times, and bytes in and out where the OS reports them. It carries the tool-call id of the bash call that caused it, so the CP's flow table links straight to that call in the run's transcript. There is no URL, method or status — those live inside the encrypted stream, and the views show a dash rather than inventing them.

Capture never touches the command, and loss is visible. Every capture operation is best-effort: if a backend can't start on this machine, the run records why once ("subprocess capture unavailable: …") and the CP shows it, and a dropped kernel event becomes a note on the run rather than a silent gap. Subprocess capture is on by default; set [netflow].subprocess_capture = false, or RUPU_NETFLOW_SUBPROCESS=0 to force it off for one process whatever the config says (only the value 0 does). The backend is chosen once per process.

What a flow records

One FlowRecord is one outbound request. Fields typed as optional are genuinely unknown when absent — they are never defaulted to zero:

FieldWhat it holds
idULID for this flow. Minted by the caller for streamed bodies so a later completion line can find it.
tsUTC timestamp, taken when the record is emitted.
ctxAttribution: run_id, step_id, agent, workspace_id, tool_call_id (each optional) and origin. Which run a flow belongs to is answered by the ledger file it landed in.
ctx.originWhat opened the connection — {"kind":"provider","name":"anthropic"}, {"kind":"scm","name":"github"}, {"kind":"subprocess","name":"curl"}.
ctx.tool_call_idThe bash tool call that caused a socket flow. Absent on HTTP flows.
fidelitycoarse | socket | http | full — how much of this record is actually known. See below.
methodHTTP method.
schemehttps / http.
hostEndpoint hostname.
portEndpoint port. host:port is the endpoint identity used by the rollup and the graph.
pathRequest path, query-stripped — never contains a ?.
peer_ipThe peer actually connected to. Absent at coarse fidelity. This is the field ASN enrichment keys on.
processThe process that owned the socket — pid and name. Socket flows only.
local_addrThe local ip:port, for correlating with other logs. Socket flows only.
directionoutbound | inbound. Reserved in the schema; no capture backend fills it in yet.
resolved_ipsEvery address DNS returned for the host — more than the one peer actually used.
http_versionNegotiated HTTP version, when known.
statusResponse status code, when a response was received.
outcomeok | http_error | transport_error | timeout.
errorError text for a failed flow. URLs and query strings are kept out of it.
bytes_outRequest bytes, when observable.
bytes_inResponse bytes. Declared (from Content-Length) at emit time; observed once a completion line finalizes it.
body_completefalse while a streamed body is still draining; flipped by the completion line folded in at read time.
ttfb_msTime to first byte.
duration_msTotal duration. Absent for a flow still in flight.

Read back through the control plane, each flow gains one more field — asn — resolved at render time rather than stamped at write time (see ASN enrichment below).

Deliberate omissions

Fidelity — the honesty field

Every flow declares how much of itself is real, and every CP view renders the badge. The subsystem never claims coverage it does not have.

FidelitySourceWhat is true
http The instrumented client Exact request and response metadata, including peer IP and byte counts.
coarse A connector whose HTTP stack rupu does not own (the GitHub client) Host, outcome and timing are real; byte counts and peer IP were not observable. Such a flow gets no ASN, and its bytes render as a dash, never 0 B.
socket The OS socket table (bash subprocesses) Process, remote IP and port, timing, and bytes where the OS exposes them. No URL, method or status — more than coarse, less than http.
full Frame-level capture from the isolated runtime Reserved — not emitted today. When that backend lands, the same views begin showing full with no schema change.

Streaming bodies

Every provider chat path is a streamed response, where Content-Length doesn't exist. Netflow does not estimate. The middleware emits the record at response-header time with an unknown bytes_in and body_complete: false; the stream-consuming loop — which already counts bytes — later calls complete(flow_id, bytes_in, duration_ms) to finalize it. The caller minted the flow id and attached it to the request, so it knows exactly which record to close: explicit, no ambient magic.

The ledger

Every run, workflow step, fan-out unit, sub-agent and session turn gets its own append-only ledger:

PathWhen
<project>/.rupu/netflow/<run_id>.jsonl When the project has a .rupu/netflow/ directory — rupu init creates it. Rooted at the project root, so invoking rupu from a nested directory doesn't fragment it.
~/.rupu/netflow/<run_id>.jsonl Otherwise. A repo that was never initialised never gets a ledger written inside it. Honors $RUPU_HOME.

The ledger is strictly append-only — a record written at header time is never rewritten. Each line is one of six envelopes:

LineMeaning
flowA full FlowRecord.
completeFinalizes a streamed body written earlier — carries the flow id, observed bytes_in, and duration_ms. Folded into its flow at read time.
socket_completeFinalizes a socket flow when the socket closes: id, duration_ms, and the final bytes_in, bytes_out, outcome and error where known.
captureWhat subprocess capture could do on this run — state: active with its backend, or state: unavailable with a reason — plus an optional loss note.
droppedRecords visible loss when the writer's buffer overflowed, with a count and a timestamp.
recoveredA count of flows a dropped line counted that are back in the ledger (see below), and when; subtracted from the dropped total.

Reads tolerate damage: a missing file is an empty ledger, not an error, and a malformed line is skipped so a torn write at the tail can't lose the whole history. Flows observed during a run are also written into that run's transcript as net_flow events, which the CLI's transcript views render inline.

When a run ends, every flow its transcript holds and its ledger lacks (the writer could not open the ledger, or dropped the line) is appended to the ledger, followed by any complete / socket_complete line the ledger already had for it and a recovered line. A run that crashed or was killed never reaches its end: its own view, and its agentiflow's, still merge the transcripts of the run and of every run it launched, while project and global views show only what reached the ledger.

Runs on remote hosts

A unit placed on a remote host (host:, distribute:, the fallback-host retry) and a run placed there whole write their ledgers on that host. The host also tees every ledger line of the run, and of every sub-agent and child beneath it, into one stream in the run's directory on the host (runs/<id>/netflow.jsonl), which starts with a begin line even when the run made no network calls — so "no flows" is never confused with "not collected". When the run ends, the coordinator collects the stream and lands it: split per ledger id into its own ~/.rupu/netflow/<id>.jsonl, byte for byte, with a landing record, ~/.rupu/runs/<id>/netflow.landing.json, that says what arrived: complete, partial (with the reason) or unavailable (a host too old to stream, or a stream that failed or was lost). A complete record is never downgraded by a later landing.

HostHow the stream arrives
localNothing to collect: the unit's ledgers are already in the coordinator's own netflow directories.
SSHPulled with the run's other ledgers (usage.jsonl, coverage.jsonl) in one ssh round trip once the run is terminal on the host; a copy pulled before that is partial.
TunnelThe node sends it with the run's other mirrored files, but only to a control plane that advertises mirror.netflow.
BucketThe worker uploads it before it writes the run's finished marker.
HTTP (placed units)The coordinator fetches GET /api/runs/:id/netflow/stream from a peer whose features list run.netflow_stream; a read after a poll error or at the wall timeout is partial.

Nothing lands while the unit is still running: until it ends, its flows show only through the transcript merge above. Once landed, the ledgers are read like any other at run, project, global and agentiflow scope, and a copy that is partial, unavailable or not collected at all is listed as incomplete at every scope, naming the host and the reason; the workflow run also gets a step warning for it. A missing or short copy never fails the unit and is never shown as whole. rupu netflow prune treats landed ledgers like any other ledger.

CLI

rupu netflow show run_01J...                     # one run's flows
rupu --format json netflow show run_01J...       # full records, plus dropped_total
rupu netflow prune --older-than 14d --dry-run    # preview
rupu netflow prune --older-than 14d              # delete

rupu netflow show <run-id> prints the run's flows (time, method, host, path, status), merging its ledger with the net_flow events in its transcript, and reports any dropped records. It finds the run in the global run store or in any registered project.

Ledgers are kept until you prune them; nothing deletes them automatically. rupu netflow prune deletes ledgers older than --older-than (default 30d) from the current project's .rupu/netflow/ and the global ~/.rupu/netflow/. It goes by file modification time, never touches a file modified in the last hour, and rejects a zero or negative cutoff. A ledger whose run is still live is never deleted, however long the run has been idle: an unfinished workflow run (paused and awaiting-approval runs included, unless it is running/pending with a dead recorded runner pid) keeps its own, its steps', its fan-out units' and its sub-agents' ledgers, and a standalone rupu run whose process is alive keeps its own and its sub-agents'. Those rows report skipped_live. --dry-run previews, and it supports --format json|csv.

The ledger directory ignores itself. It holds every host, IP, path and timing rupu contacted during a run, so .rupu/netflow/ gets its own .gitignore containing a bare * — written when the directory is created, so the protection travels with the directory rather than depending on a project-level entry that older projects never got. An existing .gitignore there is never clobbered.

In the Control Plane

Netflow follows the same placement rule as findings: a tab on a run, a tab on a project, and a global page — never on a workflow definition. A flow belongs to a run, not to a workflow. All three render the same Network explorer; the scope only narrows the data.

SurfaceScope
Run → Network This run's ledger plus those of its fan-out units and sub-agents, folded into the run's totals. Opens on the run's own time span, and shows the run's subprocess-capture state.
Project → Network Every ledger in the project's .rupu/netflow/, plus any of the project's runs whose ledger fell back to the global directory. Opens on the shorter of the last hour and the project's latest run with flows.
Network (global) Every registered project and the global directory. Opens on the shorter of the last hour and the latest run with flows.

Ledgers landed from a remote host (Runs on remote hosts above) are read at every scope like any other; a copy that is partial or missing is listed as incomplete, with the host and the reason.

The explorer

Why not a world map. A geographic map was considered and rejected: nearly every endpoint resolves to a CDN edge, so a country pin shows a point of presence rather than who was actually contacted — visually striking, forensically false. A topology answers the real question without pretending to know something it doesn't; ASN/org is the honest form of "where did this go."

Honesty on demand

A coverage popover on every Network surface carries the one authored sentence that says what netflow covers at that scope and what it cannot see, the fidelity legend, and the dropped-record accounting — and, at run scope, whether subprocess capture was active or why it wasn't. Every scope-limit sentence in the CP, including the empty states, draws from that one source, so the wording can't drift into claims the code doesn't back. Three states are called out rather than glossed: dropped records ("N flows dropped — the capture buffer overflowed", shown even on an empty table, where silent incompleteness does the most damage), unavailable ASN enrichment (so a blank Network column reads as "couldn't look it up", not "no ASN"), and an empty table, which restates the scope limit so "no rows" never implies "no network activity".

Loading large histories

rupu cp serve keeps a netflow index of every ledger, warmed when the server starts. It notices changed files with a cheap stat instead of re-opening them, reads only the lines appended since last time, and keeps a small summary of every file (time span, counts, origins) in memory always, plus compact flow rows up to a memory budget. Over the budget, the rows of the files with the oldest flows are dropped and re-read from disk when a view needs them — a wide window gets slower, but the answer never changes. Each section of a Network page loads on its own, so the KPIs and topology appear before a long flow list finishes, and long tables are virtualized.

Behind those surfaces sit these read-only endpoints:

# flows at run, project and global scope (?from= / ?to= to window the run's)
GET /api/runs/:id/netflow
GET /api/projects/:id/netflow
GET /api/netflow

# the explorer's aggregates; scope is run:<id>, project:<id>, or absent for global
GET /api/netflow/explorer?scope=project:ws_01JB7…

# the topology graph
GET /api/netflow/graph

# the index: its size, budget and evictions
GET /api/netflow/index

# the latest run with flows (no scope = global)
GET /api/netflow/latest?scope=project:ws_01JB7…

Every read but the graph takes workflow, origin, org and host (keys to keep) and not_workflow, not_origin, not_org and not_host (keys to leave out), each a comma-separated list.

ASN enrichment

A peer IP on its own is nearly unreadable. Netflow resolves it to an autonomous system number and org — so a flow reads AS13335 Cloudflare rather than a bare address — and that is the enrichment the flow table's Network column shows.

The operator never runs a command for this. The dataset is acquired and refreshed automatically from a combined IPv4+IPv6 prefix→ASN table, compacted on ingest into a sorted binary range table at ~/.rupu/netflow/asn.db (v4 and v6 held separately, binary-searched; unrouted rows skipped). It isn't bundled with the binary — compacted it's meaningful bloat on a binary that already embeds the CP's web assets. Two triggers keep it current:

Enrichment is resolved at read time, never stamped onto the record. Three consequences fall out of that, and they're the reason for the design:

Refresh is best-effort and fails safe: on any failure the existing table is left untouched, and a download that parses to an empty table (a gzipped error page, say) is refused rather than allowed to destroy working enrichment. The parsed table is cached in-process and keyed on the file's mtime, so three Network tabs opening at once don't reparse hundreds of thousands of ranges three times. The download itself has no run behind it, so — like the rest of rupu's housekeeping — it is not recorded as a flow.

Configuration

Netflow capture needs no configuration — HTTP capture is on wherever an agent runs, and subprocess capture is on wherever the OS allows it. The [netflow] section tunes subprocess capture, the control plane's index, and the ASN table.

KeyTypeDefaultWhat it does
subprocess_capture bool true Observe the sockets the bash tool's processes open. RUPU_NETFLOW_SUBPROCESS=0 forces it off regardless.
subprocess_poll_ms integer (ms) 50 Linux socket-table poll interval. Lower catches shorter connections at slightly more CPU. macOS is event-driven and ignores it.
subprocess_linger_ms integer (ms) 3000 How long a finished bash call stays attributable, so sockets that close just after the shell exits still count (capped at one day).
cp_index_budget_mb integer (MiB) 256 Memory budget for the flow rows cp serve's index keeps resident. Check usage with GET /api/netflow/index. A change made through the control plane applies on the next request; a hand edit needs a restart.
asn_auto_refresh bool true Acquire and refresh the ASN table automatically. Set false for an air-gapped install — flows are still captured and every other view works; the Network column just reports enrichment unavailable.
asn_refresh_interval_days integer (days) 7 Refresh cadence. BGP prefixes move slowly, so weekly is ample. A table older than this counts as stale; 0 means always refresh.
asn_source_url string https://iptoasn.com/data/ip2asn-combined.tsv.gz Source for the combined IPv4+IPv6 prefix→ASN table. Point it at an internal mirror if outbound access to the public dataset isn't available.

Unknown keys in this section are rejected, and any key you leave out takes its default:

# ~/.rupu/config.toml — every [netflow] key, at its default
[netflow]
subprocess_capture = true
subprocess_poll_ms = 50
subprocess_linger_ms = 3000
cp_index_budget_mb = 256
asn_auto_refresh = true
asn_refresh_interval_days = 7
asn_source_url = "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
# air-gapped: keep capture, skip the download entirely
[netflow]
asn_auto_refresh = false

Telemetry only

Netflow is pure telemetry. A flow carries no severity, no triage state, and no coupling to the findings subsystem. Anomalous egress does not become a finding, and nothing in netflow decides what "anomalous" means.

That's a deliberate decision, not an omission. Judgement baked into a recording layer is judgement you can't revise without rewriting history — and a record that carries a verdict stops being evidence. Keeping the data inert means the flows stay exactly as trustworthy as their fidelity field claims, and nothing more. Should anomaly surfacing be wanted later, it lands as an additive detector sitting on top of this data, reading the same ledger and emitting findings of its own — not as a change to what a flow is.

The same restraint applies to enforcement: netflow observes egress, it does not block it. Deny-by-default egress policy needs the isolated-runtime substrate that the full-fidelity backend would bring, and is out of scope until then.

Still coming

Everything above ships today. What follows genuinely does not, and the page says so rather than implying otherwise:

Coming laterWhat it adds
Full-fidelity capture Frame-level capture inside a microVM — the only mechanism that behaves identically on macOS and Linux. Attribution becomes definitional rather than correlated (the VM is the run), and it adds what socket capture can't give: hostnames and URLs for subprocess traffic, every packet of a UDP conversation, and DNS lookups that resolve and never connect. It feeds the existing record schema, ledger format and API contract unchanged — the views simply start reporting full.
Non-HTTP egress git2 clones, object-store bucket traffic and the tunnel-node WebSocket are invisible to an HTTP-layer capture. They arrive with the frame-level backend, not before.
Remote hosts' flows, live A unit on a remote host sends its ledgers back when it ends (Runs on remote hosts above), not while it runs; a copy that is short or missing is listed as incomplete rather than shown as whole.
Lifting coarse to http The GitHub connector brings its own HTTP stack, so its flows are coarse: host, outcome and timing only.
Egress enforcement Netflow records; it does not block. Deny-by-default policy needs the isolated runtime as its substrate.