# 3pm-backend route `events`

Paths are relative to the backend's origin (https://fleet.edgecr4ft.net, or your own 3pm-backend), not to this site. Index: https://3pm.edgecr4ft.net/docs/backend/api.md

---

tokens:
  claude: 7901
  o200k: 4877
# GET /api/buckets/{clusterId}/events (events)

The bucket's live feed: `init`, then `node` / `telemetry` / `emit` / `call` / `artifact` — a call's state and an artifact's id and sizes only, never params, results or summaries (`getCall`, `listArtifacts`); `history`, `match` and `view` cut it to what the page draws.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- node: also open to a node's own bearer (3pma_…) when its row is granted fleet:list; that node's bucket only, and for the calls it placed itself
- query: FeedQuery (URL parameters, each optional)
- answer: text/event-stream, events init, node, telemetry, emit, call, artifact, reset — each payload as `FeedItem` below

## Details

Three optional URL parameters, applied to `init`, to the replay and to the live stream alike, cut the feed to what the page draws; without them it is sent whole. `history=0` sends `init.history` empty (the generated clients ask for it by default: pass `{ history: 1 }` to get it). `match=<text>` keeps the nodes whose hostname contains `<text>`: a plain substring, case-sensitive, at most 253 bytes — `envision` already reads as `*envision*`, and there is no wildcard or regex (`*` and `?` are matched literally: `match=envision-*` keeps no node, an empty feed rather than an error); absent or empty (`match=`), every node. `init` lists only the kept ones, a `node` event moves a node in or out of the set as its hostname changes, and the `telemetry`, `emit`, `call` and `artifact` of any other node are not sent. `view=live` cuts each `telemetry` frame, in `init.snapshots` too, to the host's gauges (CPU, per-core, load, memory, swap, uptime, sensors, batteries, `on_ac`, and the identity a `host_full` carries) and a short row per process (`id`, `name`, `instance`, `namespace`, `status`, `pid`, `cpu_percent`, `mem_bytes`, `uptime_ms`, `restarts`, `unstable_restarts`, `module`); disks, block devices, interfaces, GPUs, pressure, the kernel counters and the rest of the process record stay on `nodeHost` and `queryMetrics`. A value it does not know (`history=2`, `view=lite`) is 400 `ERR_BAD_REQUEST`. Example: `GET /api/buckets/3pmc_…/events?history=0&match=envision&view=live`.

## Types

```ts
/**
 * What the feed (`events`, `follow`) leaves out; each field absent = the
 * feed whole on that account. The generated clients send `history: 0`
 * unless told otherwise.
 */
interface FeedQuery {
  /** `0` (the clients' default): `init.history` empty. `1`: sent. */
  history?: 0 | 1 | "0" | "1" | boolean;
  /**
   * Only the nodes whose hostname contains this text: a plain substring,
   * case-sensitive — `"envision"` already reads as `*envision*`. No
   * wildcard and no regex: `*` and `?` are matched literally, so
   * `"envision-*"` keeps no node (an empty feed, not an error). Absent or
   * `""`: every node.
   */
  match?: string;
  /** `"live"`: each telemetry frame cut to the host's gauges and a short row per process. */
  view?: "live" | "full";
}

/** One item of `Client.follow()`: the event's kind and its payload. */
type FeedItem =
  | ["init", BucketDetail]
  | ["node", NodeView]
  | ["telemetry", Telemetry]
  | ["emit", EmitData]
  | ["call", CallEvent]
  | ["artifact", ArtifactEvent]
  | ["reset", null];

/** `GET /api/buckets/{id}`, and the feed's `init`. */
interface BucketDetail {
  bucket: BucketView;
  /** `host:port` the pasted lines point at. */
  advertise: string;
  nodes: NodeView[];
  /** The last telemetry frame of each node the hub has heard from. */
  snapshots: Telemetry[];
  /** Per node, per process: the recent samples. */
  history: NodeHistory[];
}

interface NodeView {
  node_id: NodeId;
  /** What to display: enrollment first, presence for an active node. */
  state: DisplayState;
  /** The database state alone. */
  enrollment: NodeState;
  hostname: string;
  os: string;
  arch: string;
  agent_version: string;
  /** Which token admitted the host. */
  token_id?: string;
  /**
   * What the host owner lets be asked of this node, exactly as its last
   * `hello` declared it (`link.toml` `caps`). Empty means the node
   * accepts nothing — or, for a node that has not linked since it
   * enrolled, that it has not said yet.
   */
  caps: Capability[];
  /** What this node may ask of the bucket with its own bearer; empty by default. */
  scopes: NodeScope[];
  /** Who may place a call on this node: empty, every node holding `fleet:invoke`; with rules, what a rule admits. */
  callers: CallerRule[];
  /** What the node lets go agent to agent with a LAN neighbour: nothing, a call's files (the default), or its live legs too. */
  direct: DirectMode;
  enrolled_at: EpochMs;
  approved_at?: EpochMs;
  revoked_at?: EpochMs;
  last_seen_at?: EpochMs;
  last_auth_error?: AuthError;
  /** Bytes received over the node's link per second, averaged over the last 5s. */
  rx_bps: number;
  /** Bytes received since the backend started. */
  rx_bytes: number;
  /** Where the node's last link came from, as this backend saw it (behind `--trust-proxy`, the forwarded client); absent until the node linked since the backend started. */
  peer_ip?: string;
  /** The private addresses of the node's interfaces (RFC 1918, CGNAT, IPv6 ULA; container bridges left out), from its last complete host snapshot. */
  private_ips?: string[];
  /** The account the node's daemon runs as, from its last complete host snapshot; absent until it sent one. */
  user?: string;
  /** The accounts with an open login session on the node, from its last complete host snapshot. Sent to the bucket's owner and to a node holding `fleet:read`; absent otherwise, which is not the same as nobody being logged in. */
  sessions?: string[];
}

/** One `telemetry` event. */
interface Telemetry {
  node_id: NodeId;
  seq: number;
  ts: EpochMs;
  /** State patches of the node the backend refused so far: a gap (answered by a resync) or a section over a bound. */
  dropped: number;
  snap: TelemetrySnapshot;
}

/** One `emit` event: which node, and what happened on it. */
interface EmitData {
  node_id: NodeId;
  ev: AgentEvent;
}

/**
 * A call on the feed (`call` events): its state and who answered how,
 * never what was asked or answered — the feed needs no token, so the
 * params, the handlers' data and the error messages stay behind
 * `getCall`.
 */
interface CallEvent {
  call_id: string;
  node_id: NodeId;
  /** The request's `op`: `action`, `ping`, `restart`, `stop`. */
  op: string;
  /** The action's name, for an `action`. */
  action?: string;
  /** The target: an app name, or a process id as text. */
  app?: string;
  instance?: number;
  state: CallState;
  /** Who placed it (`user:<id>`, `node:<3pmn_…>/<app>`): what a node's feed is filtered on. */
  placed_by: string;
  /** The size the caller declared for the call's input, when it carries one; absent for a stream. */
  input_bytes?: number;
  /** For a stream: what it has relayed so far, counted in memory. */
  live?: LiveView;
  sent_at: EpochMs;
  deadline_at?: EpochMs;
  acked_at?: EpochMs;
  replied_at?: EpochMs;
  /** Once replied: the answer's tag (`triggered`, `pong`, `ok`, `err`, `unknown`). */
  res?: string;
  /** For `res: "err"`: its code (`forbidden`, `not_found`, `unknown`…). */
  err_code?: string;
  /** For an action: one line per instance reached. */
  results?: CallOutcomeEvent[];
  /** Which way the call's blob went (`direct`, `relay`), once its writer said. */
  path?: string;
}

/** A file an action left, on the feed (`artifact` events): no summary — `listArtifacts` has it. */
interface ArtifactEvent {
  artifact_id: string;
  node_id: NodeId;
  call_id: string;
  app: string;
  instance: number;
  source: string;
  action: string;
  ext: string;
  bytes: number;
  gz_bytes: number;
  /** `gzip` or `identity`, as `artifactFile` serves it. */
  encoding: "gzip" | "identity" | (string & {});
  /** Who placed the call it came out of: what a node's feed is filtered on. */
  placed_by: string;
  created_at: EpochMs;
}

interface BucketView {
  cluster_id: ClusterId;
  name: string;
  created_at: EpochMs;
  /** Does a host that links with a reusable token wait for `approveNode`? `false`: a valid token of the bucket is enough (`setBucketSettings`). For the owner only — absent from what a node's bearer reads. */
  approval?: boolean;
  /** Does a node ask of the bucket only what it was granted (`setNodeAccess`)? `false`: every active node may place a call on every other, with no scope granted node by node (`setBucketSettings`). For the owner only, like `approval`. */
  require_access_rights?: boolean;
  /** Is the screen of each `3pm fleet ssh` / `fleet attach` session kept as an asciicast artifact of its call (`setBucketSettings`)? On by default. For the owner only, like `approval`. */
  record_shells?: boolean;
}

interface NodeHistory {
  node_id: NodeId;
  procs: ProcHistory[];
}

/** `3pmn_…` — a node. */
type NodeId = string;

/** `NodeView.state`: the enrollment state, or the presence of an active node. */
type DisplayState = NodeState | "online" | "stale" | "offline";

/** Enrollment state in the database, presence aside. */
type NodeState = "active" | "pending" | "revoked" | "unknown";

/** What the host owner lets the backend ask of a node, as its `hello` declared it. */
type Capability = "metrics:read" | "proc:restart" | "proc:stop" | "unknown";

/**
 * What a node may ask of its bucket with its own bearer, granted by the
 * owner (`setNodeAccess`): `fleet:list` the fleet as a list, `fleet:read`
 * its metrics, hosts, trees and action catalogue, `fleet:invoke` placing
 * calls. Nothing by default.
 */
type NodeScope = "fleet:list" | "fleet:read" | "fleet:invoke";

/**
 * One line of a node's `callers`: who may place a call on it once the
 * list holds a rule (an empty list accepts every node holding
 * `fleet:invoke`). `"*"` is the wildcard, and a missing `from_app` /
 * `app` / `action` means `"*"` — except an action of the `3pm:` prefix,
 * which a wildcard never reaches.
 *
 * `from_app` is an operational guard rail, not a boundary: the calling
 * application's name is declared by the calling daemon and the backend
 * cannot check it. The trust boundary is the node.
 */
interface CallerRule {
  /** The calling node: a `3pmn_…` id, or `"*"`. */
  from: string;
  from_app?: string;
  app?: string;
  action?: string;
}

/** `off`, `files` (a call's files go direct between LAN neighbours) or `all` (the default: its live legs too). */
type DirectMode = "off" | "files" | "all" | (string & {});

/** Milliseconds since the Unix epoch. */
type EpochMs = number;

/** The last time a node was turned away at `/api/v1/link`. */
interface AuthError {
  /** `ERR_NODE_PENDING`, `ERR_NODE_REVOKED`, … */
  code: string;
  at: EpochMs;
}

interface TelemetrySnapshot {
  /** Volatile host gauges, identity blanked. */
  host_tick?: HostMetrics;
  /** The whole host snapshot: first frame, identity change, and periodically. */
  host_full?: HostMetrics;
  procs: RedactedRecord[];
  /** Absent while the daemon is down. */
  daemon?: DaemonInfo;
}

type AgentEvent =
  | {
      ev: "process";
      id: number;
      name: string;
      kind: ProcEventKind;
      exit_code?: number;
      exit_signal?: string;
    }
  | { ev: "daemon_shutdown"; reason: ShutdownReason }
  | { ev: "unknown" };

type CallState = "sent" | "acked" | "replied" | "unacked" | "refused" | "lost" | (string & {});

/** What a `live` session has relayed, counted in memory — never stored. */
interface LiveView {
  /** Bytes the caller has sent into the session (the input blob). */
  bytes_in: number;
  /** Bytes the target has sent back (the output blob). */
  bytes_out: number;
  /** Whole messages the window threw away with a reader still behind them, both ways together. */
  dropped_out: number;
  /** When the session's blobs were opened. */
  since: EpochMs;
  /** The answer leg goes agent to agent between LAN neighbours (both nodes `direct: "all"`): there is no preview to watch. A leg gone direct counts what its writer reports every 5 s. */
  direct?: boolean;
}

/** One instance's answer on the feed: no data, no message. */
interface CallOutcomeEvent {
  proc_id: number;
  name: string;
  instance: number;
  source: string;
  action: string;
  duration_ms: number;
  ok: boolean;
  err_code?: ActionErrCode;
  artifact_id?: string;
  data_artifact_id?: string;
}

/** `3pmc_…` — a bucket. */
type ClusterId = string;

interface ProcHistory {
  id: number;
  name: string;
  points: Point[];
}

/**
 * Host gauges and counters. Wide and platform-dependent — see
 * `HostMetrics` in `threepm-protocol`; the fields below are the ones
 * every platform fills.
 */
interface HostMetrics {
  hostname: string;
  os_name: string;
  os_version: string;
  kernel_version: string;
  arch: string;
  uptime_s: number;
  cpu_brand: string;
  logical_cpus: number;
  global_cpu_percent: number;
  per_core_percent: number[];
  load_avg: [number, number, number];
  mem_total: number;
  mem_used: number;
  mem_available: number;
  swap_total: number;
  swap_used: number;
  processes_count: number;
  /** 3pm's own footprint, measured by the host agent; absent from an older daemon. */
  "3pmDaemon"?: ThreepmDaemon;
  /** The heaviest processes of the host, managed by 3pm or not: the ten largest by CPU, by memory and by network rate, merged and sorted by pid — sort by the column you want and take your own top. Live only (no series is kept). Absent from a daemon with the option off, or predating the table. */
  top_processes?: HostTopProcess[];
  [field: string]: unknown;
}

/** One managed process, as the agent redacts it. */
interface RedactedRecord {
  id: number;
  name: string;
  instance: number;
  namespace?: string | null;
  status: ProcStatus;
  pid?: number | null;
  /** Absent during the warm-up window after a spawn. */
  cpu_percent?: number | null;
  mem_bytes: number;
  uptime_ms?: number | null;
  restarts: number;
  unstable_restarts: number;
  listen_ports: number[];
  exit_code?: number | null;
  exit_signal?: string | null;
  created_at_epoch_ms: number;
  next_cron_epoch_ms?: number | null;
  waiting_for: string[];
  module?: string | null;
  interpreter?: string | null;
  watch: boolean;
  /** Environment variable **names** — never values. */
  env_keys: string[];
  /**
   * Live processes of the instance's tree, the spawned pid included:
   * 1 for an app running alone, absent from a daemon predating process
   * trees. The tree itself is `nodeProcessTrees`.
   */
  proc_count?: number;
  /**
   * The tree's network traffic, short keys: absent on a node with no
   * source, before the daemon's first 5 s window, and from an older
   * agent. Its history: `process.net.received` / `process.net.sent`.
   */
  net?: RedactedNet;
}

interface DaemonInfo {
  version: string;
  protocol: number;
  pid: number;
}

type ProcEventKind =
  | "start"
  | "online"
  | "exit"
  | "restart"
  | "stop"
  | "delete"
  | "errored"
  | "unknown";

type ShutdownReason = "kill" | "signal" | "home_removed" | "unknown";

type ActionErrCode = "failed" | "timeout" | "busy" | "gone" | "refused" | "too_large" | (string & {});

/** One sample of one instance: cpu and RSS of its whole process tree, as 3pm reports them. */
interface Point {
  /** Epoch seconds. */
  t_s: number;
  cpu: number;
  rss_kb: number;
}

/** What 3pm itself costs the node (`HostMetrics.3pmDaemon`). */
interface ThreepmDaemon {
  /** When the agent assembled this part, Unix ms. */
  at_epoch_ms?: number;
  /** One row per 3pm process: the daemon, then the host agent. */
  processes: ThreepmProcess[];
  /** The daemon's counters; absent for a tick the daemon did not answer. */
  runtime?: ThreepmRuntime;
}

/** One row of `HostMetrics.top_processes`. */
interface HostTopProcess {
  pid: number;
  /** The executable's name: never its path, never its arguments. */
  name: string;
  /** The account it runs as; absent when the platform does not say. */
  user?: string;
  /** Share of one core over the last window: 100 is one core busy. */
  cpu_percent: number;
  /** Resident set; shared pages are counted in every process holding them. */
  mem_bytes: number;
  /** Physical footprint (macOS): what Activity Monitor calls Memory, with the compressed pages and graphics memory the RSS misses. Never on Linux; absent on macOS for a process the source did not list. When a table has it, its memory top was ranked on it, not on `mem_bytes`. */
  footprint_bytes?: number;
  /** Bytes per second over the last window. Absent, not zero, when the node has no per-process source or the source cannot see this process. */
  net_rx_bytes_per_s?: number;
  net_tx_bytes_per_s?: number;
}

type ProcStatus =
  | "launching"
  | "online"
  | "stopping"
  | "stopped"
  | "waiting_restart"
  | "errored"
  | "unknown";

/** Network traffic as the link carries it. */
interface RedactedNet {
  /** Bytes per second received / sent over the daemon's last window, rounded to whole bytes. */
  r: number;
  t: number;
  /** Bytes received / sent since the daemon started measuring; reset by a restart or a daemon update. */
  rb: number;
  tb: number;
  /** The window lost some notifications: a lower bound. Absent when false. */
  pa?: boolean;
}

/** One 3pm process as its host agent sees it from the OS. */
interface ThreepmProcess {
  /** `daemon` or `host-agent` (the set may grow). */
  role: string;
  pid: number;
  cpu_percent: number;
  mem_bytes: number;
  uptime_ms?: number;
  /** Linux only for the daemon row on the OS side; filled from the daemon elsewhere. */
  open_fds?: number;
  /** Linux only. */
  threads?: number;
}

/** The daemon's runtime counters, the health subset of `3pm internals`. */
interface ThreepmRuntime {
  version: string;
  boot_id: number;
  uptime_ms: number;
  apps_total: number;
  online: number;
  launching: number;
  stopping: number;
  stopped: number;
  waiting_restart: number;
  errored: number;
  restarts_total: number;
  unstable_restarts_total: number;
  timers_armed: number;
  bus_subscribers: number;
  log_sinks: number;
  log_rotations_total: number;
  log_bytes_written_total: number;
  daemon_log_bytes: number;
  nofile_soft: number;
  nofile_hard: number;
  handoffs: number;
  host_agent_restarts: number;
}
```
