diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs
new file mode 100644
index 00000000000..55707633b5c
--- /dev/null
+++ b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs
@@ -0,0 +1,118 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ deriveNetworkRunway,
+ isEarning,
+ netSpendableBaseUnits,
+ networkRunwayCaption,
+ shouldSuppressLowFundsAlert,
+} from "./agentNetworkFlow.ts";
+
+/**
+ * Covers buzz#86: net flow (income beside spend, same channel) and a
+ * runway that accounts for income without lying about a single lucky job.
+ * Pure-function tests in the `paymentsOverview` / `huddleFeeQuote` mold —
+ * no DOM, no network.
+ */
+
+const NO_INCOME = {
+ depositBaseUnits: 10_000_000n,
+ owedBaseUnits: 4_000_000n,
+ creditedBaseUnits: 0n,
+ burnRateBaseUnitsPerSec: 100,
+ incomeRateBaseUnitsPerSec: 0,
+ incomeSampleCount: 0,
+};
+
+test("net spendable nets income into the same channel, never a second pot", () => {
+ const read = {
+ ...NO_INCOME,
+ creditedBaseUnits: 2_000_000n,
+ };
+ // deposit 10 - owed 4 + credited 2 = 8
+ assert.equal(netSpendableBaseUnits(read), 8_000_000n);
+});
+
+test("net spendable floors at zero rather than going negative", () => {
+ const read = {
+ ...NO_INCOME,
+ depositBaseUnits: 1_000_000n,
+ owedBaseUnits: 1_500_000n,
+ };
+ assert.equal(netSpendableBaseUnits(read), 0n);
+});
+
+test("a depleted balance reports depleted regardless of rates", () => {
+ const state = deriveNetworkRunway({
+ ...NO_INCOME,
+ depositBaseUnits: 1_000_000n,
+ owedBaseUnits: 1_000_000n,
+ });
+ assert.deepEqual(state, { kind: "depleted" });
+ assert.match(networkRunwayCaption(state), /depleted/);
+});
+
+test("no income yet — runway is finite, driven by burn rate alone", () => {
+ const state = deriveNetworkRunway(NO_INCOME);
+ assert.equal(state.kind, "finite");
+ if (state.kind === "finite") {
+ // 6,000,000 remaining / 100 base units per sec = 60,000s
+ assert.equal(state.runwaySeconds, 60_000);
+ }
+ assert.match(networkRunwayCaption(state), /runway left/);
+});
+
+test("a single payment does not flip runway to indefinite", () => {
+ // Income currently outpaces burn, but only one sample has ever landed —
+ // must not be trusted as self-funding yet.
+ const read = {
+ ...NO_INCOME,
+ incomeRateBaseUnitsPerSec: 500,
+ incomeSampleCount: 1,
+ };
+ const state = deriveNetworkRunway(read);
+ assert.equal(state.kind, "finite");
+ assert.equal(isEarning(read), false);
+});
+
+test("sustained income that covers burn is self-funding — no depletion date", () => {
+ const read = {
+ ...NO_INCOME,
+ incomeRateBaseUnitsPerSec: 150,
+ incomeSampleCount: 5,
+ };
+ const state = deriveNetworkRunway(read);
+ assert.deepEqual(state, {
+ kind: "self-funding",
+ remainingBaseUnits: 6_000_000n,
+ });
+ assert.match(networkRunwayCaption(state), /no depletion date/);
+ assert.equal(isEarning(read), true);
+ assert.equal(shouldSuppressLowFundsAlert(read), true);
+});
+
+test("sustained income that only partially covers burn extends but does not erase runway", () => {
+ const read = {
+ ...NO_INCOME,
+ incomeRateBaseUnitsPerSec: 40,
+ incomeSampleCount: 4,
+ };
+ const state = deriveNetworkRunway(read);
+ assert.equal(state.kind, "finite");
+ if (state.kind === "finite") {
+ // net burn = 100 - 40 = 60/sec; 6,000,000 / 60 = 100,000s
+ assert.equal(state.runwaySeconds, 100_000);
+ }
+ assert.equal(isEarning(read), false);
+ assert.equal(shouldSuppressLowFundsAlert(read), false);
+});
+
+test("low-funds alert is not suppressed while income is unproven", () => {
+ const read = {
+ ...NO_INCOME,
+ incomeRateBaseUnitsPerSec: 500,
+ incomeSampleCount: 2,
+ };
+ assert.equal(shouldSuppressLowFundsAlert(read), false);
+});
diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.ts b/desktop/src/features/profile/lib/agentNetworkFlow.ts
new file mode 100644
index 00000000000..f9af995a220
--- /dev/null
+++ b/desktop/src/features/profile/lib/agentNetworkFlow.ts
@@ -0,0 +1,135 @@
+import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat";
+
+/**
+ * Net-flow domain logic for the Money tab's Network spend block (buzz#86).
+ *
+ * toon-meta#262 decision 9 puts earning on the SAME channel an agent spends
+ * from: `spendable = deposit − owed + credited`, one balance, never a
+ * second "earnings" pot. #261 decision 4 modelled money as net flow for
+ * exactly this reason — earning lands here without a UI rewrite.
+ *
+ * This module is deliberately decoupled from any live data source. The
+ * connector claim-state read this feeds from (toon-client#494's
+ * `getClaimState()`) is not yet vendored in this repo's pinned
+ * `@toon-protocol/client` (0.25.1), and the Network spend block itself
+ * (#80 — balance/allowance/refill, `onPaidWrite` live spend) has not
+ * landed, so there is no per-agent channel read to attach a UI to yet.
+ * These are the pure derivations #80 and the AgentIdentityCard earning
+ * badge / low-funds alert can wire straight into once that read exists —
+ * mirrors `paymentsOverview.ts`'s pure-derivation-first idiom.
+ *
+ * Per the Gotchas, income here is never read from a self-reported
+ * money-report event — only from `NetworkFlowRead`, the shape a connector
+ * claim-state read produces.
+ */
+
+/**
+ * A single lucky job must not flip runway to "indefinite" — that is a lie
+ * that strands someone once the job stream dries up. Require sustained
+ * income over several samples before treating an agent as self-funding.
+ */
+const MIN_INCOME_SAMPLES_TO_TRUST = 3;
+
+/** What a connector claim-state read reports for one agent's channel. */
+export type NetworkFlowRead = {
+ depositBaseUnits: bigint;
+ /** Claimed/spent against the deposit so far. */
+ owedBaseUnits: bigint;
+ /** Earned into this same channel — never a separate balance. */
+ creditedBaseUnits: bigint;
+ /** Trailing-window spend rate. */
+ burnRateBaseUnitsPerSec: number;
+ /** Trailing-window income rate. */
+ incomeRateBaseUnitsPerSec: number;
+ /** Distinct income events observed in the trailing window. */
+ incomeSampleCount: number;
+};
+
+/**
+ * `spendable = deposit − owed + credited`, floored at zero — a stale or
+ * racy read must never show a negative balance.
+ */
+export function netSpendableBaseUnits(read: NetworkFlowRead): bigint {
+ const net =
+ read.depositBaseUnits - read.owedBaseUnits + read.creditedBaseUnits;
+ return net > 0n ? net : 0n;
+}
+
+/** Whether `read`'s income has enough evidence behind it to be trusted. */
+function hasTrustedIncome(read: NetworkFlowRead): boolean {
+ return read.incomeSampleCount >= MIN_INCOME_SAMPLES_TO_TRUST;
+}
+
+/** The runway half of the Network spend block. */
+export type NetworkRunwayState =
+ | { kind: "depleted" }
+ /** Trusted income covers or exceeds burn — no depletion date to show. */
+ | { kind: "self-funding"; remainingBaseUnits: bigint }
+ | { kind: "finite"; remainingBaseUnits: bigint; runwaySeconds: number };
+
+/**
+ * Derive runway from a net-flow read. Untrusted income (too few samples)
+ * is excluded from the burn-rate offset entirely, so an agent's runway
+ * degrades to "burn rate alone" — the honest, conservative default —
+ * until income has proven itself sustained.
+ */
+export function deriveNetworkRunway(read: NetworkFlowRead): NetworkRunwayState {
+ const remainingBaseUnits = netSpendableBaseUnits(read);
+ if (remainingBaseUnits <= 0n) return { kind: "depleted" };
+
+ const trustedIncomeRate = hasTrustedIncome(read)
+ ? read.incomeRateBaseUnitsPerSec
+ : 0;
+ const netBurnRateBaseUnitsPerSec =
+ read.burnRateBaseUnitsPerSec - trustedIncomeRate;
+
+ if (netBurnRateBaseUnitsPerSec <= 0) {
+ return { kind: "self-funding", remainingBaseUnits };
+ }
+
+ const runwaySeconds = Number(remainingBaseUnits) / netBurnRateBaseUnitsPerSec;
+ return { kind: "finite", remainingBaseUnits, runwaySeconds };
+}
+
+function formatRunwayDuration(seconds: number): string {
+ if (seconds < 60) return "under a minute";
+ const minutes = seconds / 60;
+ if (minutes < 60) return `${Math.round(minutes)} min`;
+ const hours = minutes / 60;
+ if (hours < 24) return `${Math.round(hours)} hr`;
+ const days = Math.round(hours / 24);
+ return `${days} day${days === 1 ? "" : "s"}`;
+}
+
+/** The caption the Network spend block shows under the runway row. */
+export function networkRunwayCaption(state: NetworkRunwayState): string {
+ switch (state.kind) {
+ case "depleted":
+ return "Balance is depleted — writes will fail until it is topped up.";
+ case "self-funding":
+ return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — income is covering spend, so there's no depletion date.`;
+ case "finite":
+ return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — about ${formatRunwayDuration(state.runwaySeconds)} of runway left.`;
+ }
+}
+
+/**
+ * Whether an agent pays for itself — the `AgentIdentityCard` earning badge
+ * predicate. Requires the same trusted-income bar as runway, so the fleet
+ * glance never claims self-funding off one job.
+ */
+export function isEarning(read: NetworkFlowRead): boolean {
+ return (
+ hasTrustedIncome(read) &&
+ read.incomeRateBaseUnitsPerSec >= read.burnRateBaseUnitsPerSec
+ );
+}
+
+/**
+ * A rescue prompt for a self-funding agent is noise that teaches people to
+ * ignore the alert — suppress it only once `deriveNetworkRunway` has
+ * actually concluded the agent is self-funding.
+ */
+export function shouldSuppressLowFundsAlert(read: NetworkFlowRead): boolean {
+ return deriveNetworkRunway(read).kind === "self-funding";
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx
index 8e33408ff68..14aad69a80c 100644
--- a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx
@@ -175,7 +175,8 @@ function NetworkSpendPlaceholder() {
Balance, runway, and refill will land here — never summed with model - usage above. + usage above. Any income this agent earns nets into this same balance; + there's no separate earnings account to check.