May 14, 2026 Technical Release

Narwhal Cloud Agent Gateway — Integration Guide

"This document is for developers who want to implement their own Agent and integrate with the Narwhal Cloud platform via the AgentGateway gRPC protocol."

Source Code: https://github.com/narwhal-cloud/runman-agent

This document is for developers who want to implement their own Agent and integrate with the Narwhal Cloud platform via the AgentGateway gRPC protocol.

1. Overview

┌─────────────────────────┐   gRPC bidirectional stream   ┌──────────────────────────┐
│  Agent (your impl)      │ ══════════════════════════════ │  Narwhal Cloud Platform  │
│  running on host        │   AgentEnvelope  →            │  gRPC Server             │
│                         │   ← PlatformEnvelope          │                          │
└─────────────────────────┘                               └──────────────────────────┘
  • The Agent dials the platform gRPC endpoint; the platform acts as the server.
  • Once connected, both sides communicate over a single persistent bidirectional stream — no new handshake per message.
  • The Agent is responsible for the full lifecycle of every VM on its host (create, start/stop, delete, reinstall, etc.).
  • The platform never dials the Agent; all control commands flow downstream through the stream.

Protocol file: proto/agent/agent.proto

2. Connection & Authentication

2.1 gRPC Endpoint

Obtain an agent_token and the gRPC address from the platform. The Agent connects using standard gRPC dial. TLS is recommended.

2.2 Authentication

Pass the token via gRPC metadata on every Connect call:

key:   authorization
value: Bearer <agent_token>

Go example:

import "google.golang.org/grpc/metadata"

ctx := metadata.AppendToOutgoingContext(context.Background(),
    "authorization", "Bearer "+agentToken,
)
stream, err := client.Connect(ctx)

2.3 Opening the Stream

stream, err := agentClient.Connect(ctx)
// stream is grpc.BidiStreamingClient[AgentEnvelope, PlatformEnvelope]
// Upstream:   stream.Send(&AgentEnvelope{...})
// Downstream: stream.Recv() returns *PlatformEnvelope

Send the first Heartbeat immediately after the stream opens, then every 30 seconds.

3. Heartbeat

The Heartbeat is the primary mechanism for the Agent to report host state. The platform relies on it for reconciliation and anomaly detection.

3.1 Frequency

Send every 30 seconds. Must not exceed 60 seconds between heartbeats.

3.2 Heartbeat Fields

FieldTypeRequiredDescription
timestampint64Unix epoch seconds
cpu_pctfloatHost CPU utilization, 0–100
ram_used_mbint64Used RAM (MB)
disk_used_gbint64Used disk (GB)
net_in_bpsint64Real-time inbound rate (bytes/s)
net_out_bpsint64Real-time outbound rate (bytes/s)
load1/5/15floatSystem load averages
uptimeint64System uptime (seconds)
cpusint32Logical CPU count
ram_total_mbint64Total RAM (MB)
disk_total_gbint64Total disk (GB)
bandwidth_mbpsint32Total bandwidth (Mbps)
virt_typestringVirtualization backend: podman / cloudhv
vmsVMSummary[]Status summary of all VMs on this host
entry_hoststringon changeHost IPv4 address or DDNS hostname
entry_ipv6stringon changeHost IPv6 address (optional)
os_imagesOSImageInfo[]on changeList of available OS images

entry_host, entry_ipv6, and os_images only need to be populated when their values change. Leave them empty otherwise; the platform retains the last reported value.

3.3 VMSummary Fields

FieldTypeDescription
vm_idstringVM UUID (assigned by the platform)
statusVMStatusCurrent runtime status
cpu_pctfloatVM CPU utilization, 0–100
ram_used_mbint64VM used RAM (MB)
traffic_in_bytesint64Cumulative inbound traffic (bytes, since creation)
traffic_out_bytesint64Cumulative outbound traffic (bytes, since creation)
ipsstring[]VM IP addresses (internal and public)
monthly_traffic_inint64Current month inbound traffic (bytes)
monthly_traffic_outint64Current month outbound traffic (bytes)

3.4 VMStatus Enum

ValueMeaning
VM_STATUS_CREATINGBeing provisioned (platform waiting for Agent report)
VM_STATUS_RUNNINGRunning
VM_STATUS_STOPPEDStopped / powered off
VM_STATUS_ERRORError state

4. Command Execution Flow

Every platform command follows a request-acknowledgement pattern:

Platform                          Agent
   │                                │
   │── PlatformEnvelope ──────────►│  command with unique command_id
   │   command_id = "cmd-uuid-123" │
   │   payload = CmdStartVM{...}   │
   │                                │
   │                           execute
   │                                │
   │◄─ AgentEnvelope ───────────────│  report result
   │   payload = CommandResult{     │
   │     command_id = "cmd-uuid-123"│
   │     success = true             │
   │   }                            │

Rules:

  1. Every PlatformEnvelope command must be acknowledged with a CommandResult, including failures.
  2. CommandResult.command_id must exactly match the corresponding command’s command_id.
  3. Exception: Ping requires no CommandResult.
  4. Commands may execute concurrently; acknowledgements need not be in order.

4.1 CommandResult Structure

message CommandResult {
  string command_id = 1; // echoes PlatformEnvelope.command_id
  bool   success    = 2; // whether the command succeeded
  string error      = 3; // error message when success=false
  bytes  data       = 4; // JSON-encoded extra data (command-specific)
}

4.2 AgentEnvelope Upstream Message Types

oneof fieldWhen to send
heartbeatEvery 30 seconds
cmd_resultAfter executing any command
port_fwd_listIn response to CmdGetPortForwards
console_outputWhen the VM TTY produces output
console_eventWhen a console session changes state

5. VM Lifecycle Commands

5.1 CmdCreateVM — Create a VM

On receiving this command, the Agent should:

  1. Provision and start a VM with the specified resources.
  2. Report VMStatus = VM_STATUS_RUNNING in the next heartbeat.
  3. Acknowledge with CommandResult{success: true}.
FieldTypeDescription
vm_idstringVM UUID — use as the local identifier
cpuint32vCPU count
ram_mbint64RAM (MB)
disk_gbint64Disk (GB)
bandwidth_mbpsint32Bandwidth limit (Mbps); 0 = unlimited
os_imagestringOS image ID, must match an entry from Heartbeat.os_images
root_passwordstringInitial root password (plaintext)

5.2 CmdStartVM — Start a VM

Power on a stopped VM. Report RUNNING in the next heartbeat on success.

5.3 CmdStopVM — Stop a VM

FieldDescription
vm_idVM UUID
forcetrue = hard power cut; false = graceful shutdown (ACPI signal)

5.4 CmdRestartVM — Restart a VM

Perform a graceful reboot. For a force restart, the platform sends CmdStopVM{force:true} followed by CmdStartVM.

5.5 CmdDeleteVM — Destroy a VM

Irreversible. Release all associated resources (disk image, IPs, port-forward rules, etc.).
The platform completes billing settlement before issuing this command.

5.6 CmdReinstallVM — Reinstall OS

The existing disk is wiped. Recommended sequence: stop VM → destroy old instance → reprovision with new image → start.

FieldDescription
os_imageNew OS image ID
root_passwordNew root password
cpu/ram_mb/disk_gb/bandwidth_mbpsResource spec (may change with plan upgrade/downgrade)

5.7 CmdResetPassword — Reset Root Password

The Agent must write the new password into the VM (e.g. via cloud-init, chroot + chpasswd).
The password is transmitted as plaintext — enable TLS at the transport layer.

6. Port Forwarding Commands

The Agent must maintain port-forwarding rules at the host network layer (e.g. iptables/nftables) to forward traffic from host ports to the corresponding VM’s internal ports.

6.1 CmdSetPortForward — Add a Rule

FieldDescription
vm_idVM UUID
protocolPROTOCOL_TCP or PROTOCOL_UDP
host_portHost (external) port; recommended range 1024–65535
guest_portVM internal port
descriptionLabel e.g. “SSH”, “HTTP”

6.2 CmdDelPortForward — Remove a Rule

Uniquely identifies the rule by the {vm_id, protocol, host_port} triple.

6.3 CmdGetPortForwards — List Rules

Note: The response does not use CommandResult.data. Send the result via AgentEnvelope.port_fwd_list, and also send a CommandResult to acknowledge the command.

7. Console TTY

The console feature lets users interact directly with a VM’s TTY via the platform’s WebSocket interface.

7.1 Session Flow

Platform                                    Agent
   │                                          │
   │── CmdConsoleOpen ─────────────────────►│
   │   vm_id = "vm-uuid"                    │
   │   session_id = "sess-uuid"             │  ← unique session identifier
   │   cols = 220, rows = 50               │
   │                                          │
   │                              attach VM TTY (e.g. podman exec -it)
   │                                          │
   │◄─ ConsoleEvent{CONNECTED} ──────────────│  TTY attached
   │◄─── CommandResult{success:true} ────────│  command ack
   │                                          │
   │── CmdConsoleInput ──────────────────────►│
   │   session_id = "sess-uuid"             │
   │   data = <stdin bytes>                 │
   │                                          │
   │◄─ ConsoleOutput ────────────────────────│
   │   session_id = "sess-uuid"             │
   │   data = <stdout bytes>                │
   │                                          │
   │── CmdConsoleResize ─────────────────────►│
   │   cols = 240, rows = 60               │  terminal resize
   │                                          │
   │── CmdConsoleClose ──────────────────────►│
   │   session_id = "sess-uuid"             │
   │                                          │
   │◄─ ConsoleEvent{DISCONNECTED} ───────────│
   │◄─── CommandResult{success:true} ────────│

7.2 Message Reference

DirectionMessageDescription
DownstreamCmdConsoleOpenRequest Agent to open a TTY session
DownstreamCmdConsoleInputForward raw keyboard input (including control chars) to VM stdin
DownstreamCmdConsoleResizeResize terminal (ioctl TIOCSWINSZ)
DownstreamCmdConsoleCloseClose session; Agent cleans up TTY resources
UpstreamConsoleOutputRaw TTY output bytes from VM
UpstreamConsoleEventSession lifecycle events (CONNECTED / DISCONNECTED / ERROR)

7.3 Error Handling

  • If the VM does not exist or the TTY cannot be attached, report ConsoleEvent{type: ERROR, reason: "..."} and CommandResult{success: false, error: "..."}.
  • If the TTY disconnects mid-session (VM reboot, crash, etc.), proactively report ConsoleEvent{type: DISCONNECTED, reason: "..."} without waiting for CmdConsoleClose.

7.4 session_id

session_id is generated by the platform. All console frames (input, output, events, resize) for the same session carry the same session_id. A single host may have multiple concurrent console sessions across different VMs; the Agent must use session_id to route frames correctly.

8. Keep-alive

The platform periodically sends Ping messages to prevent proxies (Nginx, CDN, etc.) from closing idle connections.

The Agent does not need to send any response to Ping, including CommandResult.

9. Reconnection

9.1 Reconnect Strategy

On any stream error, reconnect immediately using exponential backoff.

9.2 Command Idempotency

The platform does not replay unacknowledged commands after reconnection. Commands missed during a disconnect will not be retransmitted. The Agent should reconnect quickly and restore the heartbeat so the platform can sense the node is back online.

That said, each command implementation should be idempotent to handle the rare case of a duplicate delivery.

10. Code Generation

Generate code using protoc and the appropriate plugins for your language.


Protocol version: v1.0 — refer to the version header in the protocol definition for updates. Narwhal Cloud © 2026