Skip to content

kenny Wire Protocol (v0.8)

Single source of truth. This document and the JSON files in docs/fixtures/ define the contract between kenny-server (Python) and kenny-agent (Rust). Both sides validate against the same fixtures. Do not copy schemas into CLAUDE.md — link here instead. Changes to this contract are a synchronization point: bump the version, update fixtures, then update both implementations.

Transport

  • kenny-agent opens an outbound WebSocket (WSS in production) to kenny-server at /agent/ws. The agent never listens for inbound connections.
  • All frames are UTF-8 JSON objects, one frame per WebSocket text message.
  • Claude talks to kenny-server over MCP (Streamable HTTP). That MCP layer is separate from this agent⇄server wire protocol; MCP tool calls are translated by the server into request frames on the tunnel.
  • Authentication on this tunnel is mutual and per-agent, using Ed25519 signatures layered over the (TLS) transport (ADR-0023). Each agent holds its own Ed25519 keypair (private key never leaves the device); the server stores that agent's public key. The server holds one server-wide Ed25519 keypair whose public half is pinned in the agent at install time. Right after connect the two sides run a three-message challenge-response (registerchallengeauth, below): the server proves its identity to the agent (defeating server spoofing — an attacker who terminates/MITMs TLS cannot push request frames because it cannot sign the agent's nonce), and the agent proves its identity to the server. The operator authenticates to the server (MCP endpoint + web UI) with a separate operator token (see ADR-0008), unrelated to this handshake.
  • Migration window: during rollout a server may still accept the legacy per-agent bearer register.token (symmetric) when KENNY_ALLOW_TOKEN_AUTH=1; the signature path is selected whenever register.protocol >= "0.8" and register.client_nonce is present. The token path is removed at cutover. See ADR-0023 and ADR-0014.

Frame envelope

Every frame has a type field. Known types:

type direction shape (see below)
register agent → server identifies the agent right after connect
challenge server → agent server's signed nonce (mutual-auth step 2)
auth agent → server agent's signature (mutual-auth step 3)
request server → agent invoke one capability tool
response agent → server result/error for a request (by id)
telemetry agent → server periodic pushed snapshot (no request)
log agent → server a forwarded structured log event
ping both heartbeat
pong both heartbeat reply

register (agent → server)

{
  "type": "register",
  "agent_id": "example-pc",
  "protocol": "0.8",
  "client_nonce": "<base64, 32 random bytes>",
  "meta": { "hostname": "EXAMPLE-PC", "os": "windows", "version": "0.1.0" }
}

os ∈ {windows, linux, macos}. protocol is the agent's PROTOCOL_VERSION; client_nonce is 32 fresh random bytes (base64) that the server must sign in the challenge. The server looks up agent_id and replies with a challenge (it never registers the connection until the agent's auth verifies).

token (a per-agent bearer secret) is optional and legacy: it is only honoured during the migration window (KENNY_ALLOW_TOKEN_AUTH=1) and only when protocol/ client_nonce are absent, in which case the server authenticates the token against its per-agent token store and registers immediately (no challenge/auth). On failure the server closes the socket with a non-1000 code (4401).

challenge (server → agent)

{
  "type": "challenge",
  "server_nonce": "<base64, 32 random bytes>",
  "server_sig": "<base64 Ed25519 signature over the transcript>"
}

Sent in reply to a signature-path register. server_sig is the server's Ed25519 signature, made with the server-wide private key, over the transcript (below). The agent verifies server_sig against its pinned server public key. If verification fails — or the frame is not a challenge — the agent aborts the session, sends no auth, dispatches no request, and reconnects. This is the anti-spoofing guarantee: only the holder of the server private key can answer the agent's fresh nonce.

auth (agent → server)

{
  "type": "auth",
  "agent_sig": "<base64 Ed25519 signature over the transcript>"
}

Sent only after the agent has verified server_sig. agent_sig is the agent's Ed25519 signature, made with its per-agent private key, over the same transcript. The server verifies it against the agent's stored public key; on success it registers the connection under agent_id and proceeds (pushes policy, accepts request frames). On failure it closes the socket with 4401.

Transcript (signed by both sides)

Both signatures cover the same byte string, constructed identically on both sides (0x00 is a single NUL separator byte; nonces are the raw 32 bytes, not their base64):

transcript = "kenny-mutual-auth-v1"   (20 ASCII bytes, domain-separation label)
           || 0x00
           || agent_id                (UTF-8 bytes)
           || 0x00
           || client_nonce            (32 raw bytes, from register)
           || 0x00
           || server_nonce            (32 raw bytes, from challenge)

Binding both nonces and agent_id into both signatures prevents replay and reflection. Ed25519 public keys, private seeds, signatures, and nonces are exchanged as standard base64 (with padding). Deterministic golden vectors live in docs/fixtures/vectors/mutual_auth.json; both implementations verify against them so the transcript stays byte-identical across Rust and Python.

Enrollment (first contact)

An agent generates its keypair locally on first run; its public key reaches the server once via a one-time enrollment token carried by the installer (over TLS): POST /api/agents/{id}/enroll with { "public_key": "<base64>" }, authorized by the enrollment token. The server records the public key bound to agent_id (the token is single-use). Thereafter only signatures authenticate. The installer also carries the pinned server public key. See ADR-0023.

request (server → agent)

{
  "type": "request",
  "id": "9f1c0e2a-...",
  "tool": "powershell_exec",
  "args": { "script": "Get-Process | Select -First 5", "timeout_s": 30 }
}

id is a server-generated UUID. tool is one of the names in the tool catalog below. args matches the per-tool schema.

response (agent → server)

Success:

{ "type": "response", "id": "9f1c0e2a-...", "ok": true,
  "result": { "stdout": "...", "stderr": "", "exit_code": 0 } }

Error:

{ "type": "response", "id": "9f1c0e2a-...", "ok": false,
  "error": { "code": "timeout", "message": "tool exceeded 30s" } }

error.code ∈ {timeout, not_found, exec_failed, unsupported, bad_args, internal, disabled, blocked}. unsupported is returned by an agent that lacks the capability on its platform (e.g. winget_list on a Linux dev build). disabled is returned when the agent is online but the person at the endpoint has switched remote control off locally (via the agent's tray menu): the agent then refuses every mutating tool (powershell_exec, winget_install|uninstall|update, net_dns_flush, net_adapter_reset, agent_update) while telemetry and read-only diagnostics keep working. Remote control is on by default and the choice persists across restarts. See ADR-0011.

blocked is returned by the agent's deterministic, always-on safety guard: a compiled-in policy that refuses individually dangerous calls (e.g. a powershell_exec script that deletes volume shadow copies, clears event logs, or disables Defender; an fs_read of the SAM hive; an agent_update from a non-allowlisted host) regardless of operator approval or kill-switch state. Unlike disabled, the guard cannot be turned off remotely and is not a substitute for the operator confirm-gate (ADR-0009) or the local kill-switch (ADR-0011); it is a last-line, defense-in-depth refusal sitting below them. The message names the matched rule. See ADR-0020.

The guard's built-in rules ship as a shared deny-rule catalog (docs/policy/deny_rules.json): the agent embeds it at build time and the server loads the same file for an optional best-effort mirror that can refuse a call before forwarding (earlier feedback). The agent remains the authoritative enforcement point. Operators may add — but never remove — deny rules on top of the built-ins; those extra rules are delivered to the agent via the policy frame below. See ADR-0021.

policy (server → agent)

After a successful register (and again whenever the operator changes the list), the server pushes the operator's append-only extra deny rules to the agent. These are additive to the agent's compiled-in built-ins (which can never be weakened or removed by this frame). An empty rules array clears the operator additions but leaves the built-ins intact.

{
  "type": "policy",
  "rules": [
    { "id": "op_block_choco", "applies_to": "powershell",
      "pattern": "(?i)\\bchoco\\b", "reason": "operator: block chocolatey" }
  ]
}

Each rule has id (stable identifier), applies_to ∈ {powershell, self_protection, path}, a pattern (regex in the portable subset common to Rust regex and Python re — no backreferences/lookaround), and a human-readable reason. The agent recompiles its rule set on each policy frame; a rule whose pattern fails to compile is skipped (logged), never fatal. The same {id, applies_to, pattern, reason} shape is used by the shared catalog.

telemetry (agent → server, pushed)

The agent pushes a snapshot on a timer (default every 900 s; the server may send the interval in a future register ack — not in v0.1). A snapshot is a map of section name → section payload. Every section payload carries status and summary plus section-specific fields, so the server can aggregate fleet health without domain logic.

{
  "type": "telemetry",
  "agent_id": "example-pc",
  "collected_at": "2026-06-04T18:00:00Z",
  "snapshot": {
    "disk": {
      "status": "warn",
      "summary": "C: 91% full",
      "volumes": [
        { "mount": "C:", "total_bytes": 511000000000, "free_bytes": 46000000000, "percent_used": 91 }
      ],
      "top_dirs": [
        { "path": "C:\\Users\\testuser\\Videos", "bytes": 120000000000 }
      ]
    },
    "defender": {
      "status": "crit",
      "summary": "Real-time protection OFF",
      "enabled": false,
      "realtime_protection": false,
      "last_scan": "2026-05-01T03:00:00Z",
      "last_scan_type": "quick",
      "last_signature_update": "2026-05-20T06:00:00Z",
      "threats_found": 0,
      "action_needed": true
    }
  }
}

A telemetry_collect request (see tool catalog) returns the same snapshot shape inside response.result, optionally restricted to args.sections.

log (agent → server, pushed)

The agent forwards its own structured log events (from tracing) to the server so operator-visible events survive when the agent runs as a Windows service and its stderr is discarded. The agent emits one frame per event for events at or above a configurable level (KENNY_LOG_FORWARD_LEVEL, default info); the agent still writes a fuller record to a local rotating file. Forwarding is best-effort: while the agent is disconnected, events accumulate in a bounded buffer and the oldest are dropped under pressure — log frames are never retried like a request.

{
  "type": "log",
  "agent_id": "example-pc",
  "at": "2026-06-04T18:00:01Z",
  "level": "warn",
  "target": "kenny_agent::tunnel",
  "message": "tunnel error; backing off",
  "fields": { "error": "connection reset", "backoff_secs": 4 }
}

level ∈ {error, warn, info, debug, trace}. at is an RFC 3339 / ISO 8601 timestamp. target is the emitting module path. fields is an optional object of structured key/values captured from the event (absent when the event has none). The server persists these alongside its own log records and the tool-call audit (see ADR-0017); they are never forwarded to an agent.

ping / pong

{ "type": "ping" }
{ "type": "pong" }

Either side may send ping; the peer replies pong. The server marks an agent offline if no frame (any type) arrives within 3 missed intervals.

Tool catalog

The server exposes each tool as an MCP tool (after select_agent); the agent implements a handler with the same name. Argument keys are exact.

tool args result (sketch)
powershell_exec {script, timeout_s} {stdout, stderr, exit_code}
fs_list {path} {entries:[{name,is_dir,bytes}]}
fs_search {root, pattern} {matches:[path]}
fs_read {path} {content, truncated}
fs_disk_usage {} {volumes:[...]}
winget_list {} {packages:[{id,name,version,available}]}
winget_install {id} {ok, log}
winget_uninstall {id} {ok, log}
winget_update {id?} {ok, log}
diag_processes {} {processes:[{pid,name,cpu,mem_bytes}]}
diag_services {filter?} {services:[{name,display,status,start}]}
diag_eventlog {log, count} {events:[{time,level,source,message}]}
diag_autostart {} {entries:[{name,command,location}]}
net_config {} {interfaces:[...], dns:[...]}
net_dns_flush {} {ok}
net_adapter_reset {name} {ok}
screen_capture {} {image_b64, format:"png"}
remotehelp_status {} {installed, version, internet_ok, interactive_session}
remotehelp_start {} {launched, pid, note}
remotehelp_stop {} {stopped}
telemetry_collect {sections?} snapshot map (see telemetry frame)
agent_update {version, url, sha256} {ok, staged_version}

agent_update is a server-triggered self-update (state-changing): the agent downloads the new binary from url (served by the server's download endpoint), verifies it against sha256, stages it, and restarts itself (as a Windows service) into the new version. The agent answers {ok, staged_version} before restarting, so the connection drops and the agent reconnects on the new version (compare register.meta.version). On a non-Windows/dev build the agent returns error.code = "unsupported".

The remotehelp_* tools orchestrate Windows Quick Assist as a remote-help concierge: kenny prepares and brokers a session but does not carry the screen or input itself (Quick Assist brings its own Microsoft relay, NAT traversal, and encryption). remotehelp_status is read-only — it reports whether Quick Assist is installed (installed, version from Get-AppxPackage), whether the internet is reachable (internet_ok), and whether an interactive user session is present to host the app (interactive_session). remotehelp_start and remotehelp_stop are mutating: start launches Quick Assist on the interactive user desktop and answers {launched, pid, note} (the note reminds the operator that a human helper must supply the Quick Assist code and the person at the PC must accept); stop terminates Quick Assist ({stopped}) so no session lingers. Because the agent runs as a session-0 service with no desktop, start launches the app via the user-session tray helper over a local named pipe, restricted to an allow-list of remote-help executables — same delivery mechanism as screen_capture (ADR-0018). On a non-Windows/dev build start/stop return error.code = "unsupported" and status reports everything not-available. See ADR-0022.

Server-only MCP tools (not forwarded to a single agent)

tool args purpose
list_agents {} known agents + online state + overall health
select_agent {id} set the active agent for subsequent forwarded tools
fleet_overview {} per-agent rolled-up health for the dashboard
agent_health {id} per-section status/summary for one agent
agent_snapshot {id, section?} latest stored snapshot (or one section) for an agent

Telemetry sections

Each section payload must include status ∈ {ok, warn, crit} and a short summary string. Raw fields are section-specific (see docs/fixtures/telemetry_*).

Mandatory: disk, peripherals, network, routing, processes, services, defender, win_update. Hardware health: disk_smart, battery, memory, thermals (optional). Security & crypto: firewall, encryption, av_thirdparty, defender_quarantine. Update & stability: reboot_pending, os_support, reliability, app_updates. Operations & daily: uptime, time_sync, printers, wifi_quality, autostart.

Health thresholds (e.g. disk used > 80% ⇒ warn and ≥ 95% ⇒ crit; Defender real-time protection off ⇒ crit; Defender scan older than 14 days ⇒ warn) are evaluated server-side in kenny-server/kenny_server/health_rules.py. The agent SHOULD set a reasonable status per section, but the server's rules are authoritative for fleet aggregation. These thresholds are illustrative of the data-driven rules in health_rules.py, which is the source of truth for exact boundaries.

Versioning

PROTOCOL_VERSION = "0.8". Both implementations expose this constant; from v0.8 the agent puts it on the wire in register.protocol to select the mutual-auth handshake. Bump on any breaking change to a frame or tool schema.

  • 0.8 — mutual agent⇄server authentication via per-agent Ed25519 signatures: added the challenge (server → agent) and auth (agent → server) frames and the register.protocol / register.client_nonce fields; register.token becomes optional (legacy, migration-window only). Breaking handshake change. See ADR-0023.
  • 0.7 — added the remotehelp_status, remotehelp_start, and remotehelp_stop tools (orchestrate Windows Quick Assist as a remote-help concierge); additive tools, no frame changes. See ADR-0022.
  • 0.6 — added the policy frame (server → agent) delivering the operator's append-only extra deny rules for the safety guard; additive frame, no tool changes. See ADR-0021.
  • 0.5 — added the blocked error code for the agent's deterministic, always-on safety guard; additive to the error-code set, no frame or tool-schema changes. See ADR-0020.
  • 0.4 — added the log frame (agent → server) for forwarded structured log events; additive frame, no tool changes. See ADR-0017.
  • 0.3 — renamed every capability tool from dotted (powershell.exec) to underscore (powershell_exec) identifiers so names are valid Anthropic tool names (^[a-zA-Z0-9_-]{1,128}$); breaking tool-schema change, no frame changes.
  • 0.2 — added the agent_update tool (server-triggered self-update); no frame changes.
  • 0.1 — initial contract.