# 3pm-backend route `nodeAction`

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: 6329
  o200k: 3937
# POST /api/buckets/{clusterId}/nodes/{nodeId}/actions (nodeAction)

Run a custom action an app of the node declared (`io.action`), on every instance of the app or on one, optionally carrying bytes in and out; the call is answered as soon as it is placed — follow it with `getCall` or the feed's `call` events.

- 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:invoke; that node's bucket only, and for the calls it placed itself
- body: NodeActionBody (JSON)
- answer: CallView (JSON)

## Details

The body: `app` (a process name, or an id), `action` (the declared name), and optionally `instance` (one row of the app; default every instance that declares the action, at most 16), `source` (the process of the app to call, when several of one instance declare it), `params` (JSON for the handler, at most 64 KiB serialised; `null` by default) and `timeout_ms` (bounded by the declaration's; left out or 0, the action's declared timeout as the node last published it, else 30 s; 10 min at most). The answer is the `CallView` in `sent` state (`refused`, with 409 `ERR_NODE_OFFLINE` when the node has no live session, 503 `ERR_NODE_BUSY` when its session has too many frames queued); the reply lands on `getCall` as `result.results[]` — one per instance reached, `ok` with the handler's `data` (JSON), or an `err` (`failed`, `timeout`, `busy`, `gone`, `refused` — the action is declared `remote: false` —, `too_large`); a file the action leaves (a profile, a dump) is an **artifact**, a copy this backend keeps (`artifact_id` on the outcome, `artifacts[]` on the call, `artifactFile` to download, `listArtifacts` to find it again), and so is `data` past the 256 KiB the whole call keeps inline (`data_artifact_id` on the outcome). The node's agent needs the `proc:action` capability (granted by default), and the action must be declared by the app: what `nodeAppMetrics` lists under each source's `actions`. Every call is audited on the node's bus (`proc_action`) and on this backend's events (`kind: action`). A node's own bearer reaches this route with `fleet:invoke`, unless the target's `callers` (`setNodeAccess`) holds rules and none admits it, and may name the calling application in `from_app` (recorded in `placed_by` as `node:<3pmn_…>/<app>`, `-` when it names none); an account's `from_app` is ignored. Bytes: `input` (`{bytes, sha256?, ext?}`) opens a blob the caller `PUT`s and the target `GET`s, and `output: true` opens one the other way — the answer carries `input.blob` and `output.blob`, membership of the call is the only authorisation either needs, both blobs are charged to whoever placed the call, and `deadline_at` allows for the transfer as well as the timeout. A call that carries bytes names its `instance` (422 otherwise); an `input.bytes` over `--blob-max-mb` is 413 `ERR_TOO_LARGE` before anything is reserved; a node calling itself is 422. What the target writes to the output blob is not an artifact: it is relayed and never copied — no `artifact_id`, nothing in `listArtifacts` —, and `getBlob` on `output.blob` reads it until the relay forgets it, 15 min after the target finished writing once somebody has read it, 2 min if nobody has; `output.{bytes,sha256,ext}` on the call says how long it is and what digest to check it against (the target's claim: this backend never reads the bytes). `stream: true` opens a **session** instead: two `live` blobs, one each way, carrying framed messages (`KEY`/`END`/`INIT`, 1 MiB each at most) that are relayed and never kept. It names its `instance` and carries neither `input` nor `output` — its two legs are both; the answer's `input.blob` is the leg the caller writes and `output.blob` the one it reads, `input.live` is `true` and `input.bytes` absent. While it runs, `live` on the call (and on the feed's `call` events) counts `bytes_in`, `bytes_out` and `dropped_out` from memory; a session goes quiet for sixty seconds on either leg, or runs for a day, and it is over. `getBlob?as=mjpeg` is how a browser watches one. When the caller is a node and it and the target sit behind the same public address, each announced a LAN listener and neither's `direct` setting (`setNodeAccess`) refuses it, the answer carries `peer` — the target's node id, public key and private addresses — and the target's ticket names the caller the same way: the two agents may then move the blob between themselves, the blobs above staying open as the fallback, and `path` on the call says which way it went once the writer reported it.

## Types

```ts
/** `nodeAction`'s body. */
interface NodeActionBody {
  /** The app, by name or by process id. */
  app: string;
  /** One instance of the app (default: every instance that declares the action, 16 at most). */
  instance?: number;
  /** The source to call, when several of an instance declare it. */
  source?: string;
  action: string;
  /** JSON for the handler (≤ 64 KiB serialised). */
  params?: unknown;
  /** Bounded by the declaration's (30 s default, 10 min ceiling). */
  timeout_ms?: number;
  /** The bytes this call carries; the answer names the blob to `PUT` them to (`putBlob`). */
  input?: CallInputBody;
  /** Ask for an answer in bytes: the answer names the blob to `GET` (`getBlob`). */
  output?: boolean;
  /**
   * A session rather than a call: two `live` blobs, one each way, carrying framed messages
   * relayed and never kept. It names its `instance` and carries neither `input` nor `output` —
   * the session's two legs are both. The answer's `input.blob` is the leg you write and
   * `output.blob` the one you read, with `input.live` true and `input.bytes` absent; `live`
   * on the call counts what it has relayed.
   */
  stream?: boolean;
  /** The calling application, as the calling daemon named it; read only from a node's bearer. */
  from_app?: string;
}

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

/** The input of a call, as `nodeAction` takes it. */
interface CallInputBody {
  /** How many bytes will be `PUT`. Required: an input with no size is a stream. */
  bytes: number;
  /** Hex SHA-256 of those bytes, checked by the target (the relay never hashes). */
  sha256?: string;
  /** The extension the target's daemon names the file with. */
  ext?: 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[];
}

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

type NodeTarget = { id: number } | { name: 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 }[];
}
```
