# 3pm-backend route `nodeCall`

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: 4069
  o200k: 2593
# POST /api/buckets/{clusterId}/nodes/{nodeId}/calls (nodeCall)

Send one generic command to the node's agent — `{"op": "restart", "target": {"name": "api"}}`, `stop` or `ping` (an action goes through `nodeAction`); answered as soon as it is placed — follow it with `getCall`.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- body: NodeCallBody (JSON)
- answer: CallView (JSON)

## Details

The body is the `AgentRequest` as the link carries it: `{"op": "ping"}`, `{"op": "restart", "target": {"name": "api"}}` or `{"target": {"id": 3}}`, `{"op": "stop", "target": …}`. Any other op, `action` included, is a 400: an action goes through `nodeAction`, which checks its name, its params and its target. Never `start`, never `all`. The host must allow the capability the op needs (`proc:restart`, `proc:stop`; `ping` needs none), else the reply is `err {forbidden}`. At-most-once: a call in flight when the link drops is never replayed — it ends `lost`, and the caller places a new one.

## Types

```ts
/** `nodeCall`'s body: one of the link's generic `AgentRequest`s (an action goes through `nodeAction`). */
type NodeCallBody =
  | { op: "ping" }
  | { op: "restart"; target: NodeTarget }
  | { op: "stop"; target: NodeTarget };

/** One remote command, from `sent` to its end. */
interface CallView {
  call_id: string;
  node_id: NodeId;
  /** The `AgentRequest` sent. */
  request: CallRequest;
  state: CallState;
  /** Who placed it: `user:<id>`, or `node:<3pmn_…>/<app>` (`-` when the application was not named). */
  placed_by: string;
  /**
   * Once `replied`: for an action, `{ ok: true, res: "triggered", results: ActionOutcomeView[] }`;
   * for the rest, the agent's `AgentResponse` as is (`{ res: "pong", … }`, `{ res: "ok", affected }`, `{ res: "err", code, message }`).
   * `ok` says the action ran (`res === "triggered"`), never whether every instance succeeded — each outcome carries its own `ok`;
   * for a shell (`res: "exited"`, `code?`, `signal?`, `recording?` — the artifact the node uploaded its recording as, for an `e2e` session), that it exited 0.
   * A refusal (`res: "err"`) keeps the agent's own `code`, finer than `err.code`: `e2e_required` (the node serves no session in clear; `forbidden` in `err`), `key_not_authorized` (the caller's `cli.key` is not in the node's own `authorized_keys`, the message names the fingerprint; `forbidden` in `err`) and `e2e_broken` (the encrypted channel failed; `failed` in `err`).
   * `err` is set when the agent refused the call as a whole — `busy` (it already runs its most commands: retry), `timeout`, `forbidden`, `failed` — the same error the placing node's `call_result` carries.
   */
  result?:
    | { ok: boolean; res: "triggered"; results: ActionOutcomeView[] }
    | { ok: boolean; res: string; err?: { code: ActionErrCode; message: string }; [k: string]: unknown };
  sent_at: EpochMs;
  deadline_at?: EpochMs;
  acked_at?: EpochMs;
  replied_at?: EpochMs;
  /**
   * The blob to `PUT` the call's bytes to, when it carries any — the same thing `request.input` holds.
   * `bytes` is absent on a stream (`live: true`): a session has no length to declare.
   */
  input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
  /**
   * The blob the target writes its answer to, when one was asked for; `GET` it once the call is replied.
   * `bytes`, `sha256` and `ext` are absent until an outcome has claimed it: check the bytes you pull against `sha256`.
   */
  output?: { blob: string; bytes?: number; sha256?: string; ext?: string };
  /**
   * A stream's counters while it is relayed, and frozen at their last value once it has settled;
   * absent for a call that carries no stream, and gone once the relay has forgotten its blobs.
   */
  live?: LiveView;
  /** The files the call's actions left, once replied. */
  artifacts: ArtifactView[];
  /** The other end, when the two nodes are LAN neighbours: in the answer that placed the call only, for the caller's agent to move the bytes itself. */
  peer?: Peer;
  /** Which way the call's blob went, once its writer said: `direct` (agent to agent) or `relay`; absent, the relay. */
  path?: "direct" | "relay" | (string & {});
}

type NodeTarget = { id: number } | { name: string };

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

/** The `AgentRequest` a call sent: a generic command, or the action `nodeAction` built. */
type CallRequest =
  | NodeCallBody
  | {
      op: "action"; target: NodeTarget; instance?: number; source?: string; action: string;
      params?: unknown; timeout_ms?: number;
      /** The input blob and what it holds, as the target's agent reads it. */
      input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
      /** The output blob the target writes its answer to. */
      output?: string;
      /** A session: `input` and `output` are its two `live` legs. */
      stream?: boolean;
    }
  | {
      /** `nodeShell`: `input` is the keyboard leg, `output` the screen leg (both `pipe`). */
      op: "shell"; term: string; cols: number; rows: number; input: string; output: string;
      /** Who placed it (`placed_by`), and that node's hostname. */
      from: string; from_hostname?: string;
      /** The legs are sealed end to end between the caller and the node: the backend relays bytes it cannot read. */
      e2e?: boolean;
      /** The node records the session's screen itself (an `e2e` session; `record_shells` on). */
      record?: boolean;
    }
  | {
      /** `nodeForward`: `host:port`, or (`accepted`) a connection a listen accepted, `host` empty and `port` 0. */
      op: "forward"; host: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean; accepted?: ForwardAccepted;
    }
  | {
      /** `nodeListen`: `bind` empty is the node's loopback, `port` 0 one it picks. */
      op: "listen"; bind: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean;
    };

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

/** What one instance answered to an action. */
interface ActionOutcomeView {
  proc_id: number;
  name: string;
  instance: number;
  source: string;
  action: string;
  duration_ms: number;
  ok: boolean;
  err?: { code: ActionErrCode; message: string };
  /** The handler's JSON (≤ 256 KiB); bigger, `data_artifact_id` names an artifact holding it. */
  data?: unknown;
  data_artifact_id?: string;
  /** The file the handler left, as an artifact. */
  artifact_id?: string;
  /** The output the handler pushed to the call's blob: where it is, how long, and its digest (the target's claim, never checked here: check it when you pull the blob). */
  output?: { blob: string; bytes: number; ext: string; sha256: string };
}

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

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

/** 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;
}

/** A file an action left: what `listArtifacts` lists and a call carries. */
interface ArtifactView {
  artifact_id: string;
  node_id: NodeId;
  call_id: string;
  app: string;
  instance: number;
  source: string;
  action: string;
  /** `cpuprofile`, `heapprofile`, `json`, …: what the file is; `artifactFile` serves gzip of it. */
  ext: string;
  bytes: number;
  /** The size of the stored file; equal to `bytes` when `encoding` is `identity`. */
  gz_bytes: number;
  sha256: string;
  /** `gzip` (what the agent sent) or `identity` (a call's output kept raw, by a backend that still copied them). */
  encoding: "gzip" | "identity" | (string & {});
  /** Who placed the call it came out of; empty once that call's row is past retention. */
  placed_by: string;
  /** A profile's hot frames, when it parsed. */
  summary?: ProfileSummary;
  created_at: EpochMs;
}

/**
 * The other end of a call whose two nodes are LAN neighbours: its node id, its Ed25519 public key
 * (base64url, the only key its TLS handshake may present) and the private addresses of its listener.
 */
interface Peer {
  node_id: NodeId;
  pubkey: string;
  addrs: string[];
}

/** A connection a listen accepted, as its `accepted` message numbered it. */
interface ForwardAccepted {
  /** The listen's call id. */
  listen: string;
  conn: number;
}

/** The hot frames of a sampling profile (`threepm_core::profile_summary`). */
interface ProfileSummary {
  kind: "cpu" | "heap";
  /** Microseconds (cpu) or bytes (heap) over every frame. */
  total: number;
  samples: number;
  duration_ms?: number;
  top: { function: string; location: string; self_value: number; percent: number }[];
}
```
