#2167·kanata

Feature request: add a `msg` subcommand to kanata binary to act as its own TCP client

Author: XhstormRCreated Aug 25, 2026Updated Sep 8, 2026
LabelsenhancementPRs welcome

Is your feature request related to a problem? Please describe.

The TCP server has no first-party client, so every integration has to re-implement the same transport layer before it can send a single command.

My use case is application-aware layer switching on macOS: a small daemon watches NSWorkspace.didActivateApplicationNotification and sends ChangeLayer on every app switch. Conceptually that is "send one JSON object". In practice it required ~200 lines of transport code, because a correct client has to deal with all of the following:

  1. Framing. The protocol is newline-terminated JSON over a byte stream (docs/config.adoc: "Each message is a single line of JSON terminated by a newline"), so the client needs its own line-splitting layer on top of TCP. On Apple platforms that means implementing NWProtocolFramerImplementation; in other languages, a hand-rolled buffer-and-split loop.

  2. Reconnection. kanata may restart, the socket is not available before the server starts, and src/tcp_server.rs deliberately drops the connection when a client sends something it cannot deserialize (ServerResponse::Error + connections.lock().remove(&addr)). A long-lived client needs idempotent reconnect/backoff logic.

  3. A heterogeneous response contract. On the same socket, three different things can arrive, and which one you get depends on the command:

    • ChangeLayer and SetMouseno response at all.
    • ActOnFakeKey → a ServerMessage::Error only on failure, nothing on success, so a client can only conclude "it worked" by waiting out a timeout.
    • Reload/ReloadNext/ReloadPrev/ReloadNum/ReloadFileServerResponse ({"status":"Ok"} / {"status":"Error","msg":...}), plus a later ServerMessage::ReloadResult when wait: true.
    • RequestLayerNames/RequestFakeKeyNames/RequestCurrentLayerName/RequestCurrentLayerInfo/Hello → a ServerMessage variant.
    • Unsolicited events (LayerChange, ConfigFileReload, MessagePush, HoldActivated, TapActivated) are pushed at any time and interleave with query responses, so a client cannot simply "read the next line" after a request — it must filter by variant.
  4. Silent failure on a bad layer name. Kanata::change_layer walks layer_info and returns without doing anything if no name matches (src/kanata/mod.rs), and the TCP handler sends no response for ChangeLayer. So a wrong or stale layer name is indistinguishable from success. To get any error signal at all, my daemon has to send RequestLayerNames first, cache the set, and validate locally before every send.

The only integration path the documentation offers today is echo '{"ChangeLayer":{"new":"nav"}}' | nc localhost 7070. That is fine as a protocol illustration but not usable as an interface: it cannot correlate a response with a request (point 3), its exit status reflects nc's socket, not kanata's outcome, and nc is not present on a default Windows install while kanata is cross-platform.

The result is that the same 150–250 lines of client code get rewritten, per language, in every downstream project, and each copy silently rots as protocol details change.

Describe the solution you'd like.

A send-cmd subcommand in the kanata binary itself, i.e. kanata ships its own client — the same pattern as sing-box api ... and paneru send-cmd ...:

$ kanata send-cmd --help
Send a command to a running kanata instance via its TCP server

Usage: kanata send-cmd [OPTIONS] <COMMAND>

Commands:
  change-layer         Switch to a layer                  -> ChangeLayer
  list-layers          List all layer names               -> RequestLayerNames
  current-layer        Print the active layer name        -> RequestCurrentLayerName
  current-layer-info   Print active layer name + cfg text -> RequestCurrentLayerInfo
  list-fake-keys       List virtual key names             -> RequestFakeKeyNames
  act-on-fake-key      Press/Release/Tap/Toggle a vkey    -> ActOnFakeKey
  set-mouse            Move the cursor to x,y             -> SetMouse
  reload               Reload the current config          -> Reload
  reload-next          Load the next config               -> ReloadNext
  reload-prev          Load the previous config           -> ReloadPrev
  reload-num           Load config at index               -> ReloadNum
  reload-file          Load a config by path              -> ReloadFile
  hello                Print server version/capabilities  -> Hello
  raw                  Send a raw JSON ClientMessage      -> (escape hatch)

Options:
  -p, --port <PORT or IP:PORT>  Same syntax/parsing as the server's --port.
                                Required, since the server itself has no default port;
                                could fall back to a $KANATA_PORT env var.
      --timeout-ms <MS>         Connect/read timeout [default: 5000]
      --json                    Print the raw server message instead of plain text
  -h, --help                    Print help

Examples:

bash
kanata send-cmd -p 9999 change-layer nav
kanata send-cmd -p 9999 list-layers            # base\nnav\nnum
kanata send-cmd -p 9999 list-layers --json     # {"LayerNames":{"names":["base","nav","num"]}}
kanata send-cmd -p 9999 current-layer
kanata send-cmd -p 9999 act-on-fake-key email-sig tap
kanata send-cmd -p 9999 reload --wait --timeout-ms 5000
kanata send-cmd -p 9999 raw '{"ChangeLayer":{"new":"nav"}}'

Behaviour I would ask for, since these are the parts a shell script cannot do itself:

  • Meaningful exit codes. 0 on success; non-zero and a message on stderr for connect failure, timeout, {"status":"Error"}, or a ServerMessage::Error. This is the single most valuable part for scripting.
  • Response correlation. Read lines until the response variant that belongs to the request arrives (or the deadline passes), discarding unrelated pushed events.
  • Client-side validation for change-layer. Query RequestLayerNames first and exit non-zero with the available names listed if the requested layer does not exist. This turns today's silent no-op (point 4 above) into a real error without changing the wire protocol or server behaviour at all.
  • raw escape hatch so new ClientMessage variants are reachable before a dedicated subcommand exists, and so the subcommand set never becomes a bottleneck.

Notes on fitting this into the existing CLI:

  • src/main_lib/args.rs is currently a flag-only clap::Parser. To keep this a non-breaking change, send-cmd should be an optional subcommand (#[command(subcommand)] cmd: Option<Command>, with args_conflicts_with_subcommands), so bare kanata -c foo.kbd keeps behaving exactly as it does today.
  • It can be gated on the existing tcp_server feature (already in default), so no new feature flag and no new release artifact.
  • The implementation should be small: tcp_protocol::{ClientMessage, ServerMessage, ServerResponse} is already a workspace crate, and example_tcp_client/src/main.rs already contains most of the connect/read/parse logic. This is largely clap wiring plus moving that example into the binary, where it stays version-locked to the server.
  • docs/config.adoc "Client Commands" could then show kanata send-cmd ... alongside the nc one-liners, and the protocol tables become the reference rather than the API.

Describe alternatives you've considered.

  • nc / socat one-liners (what the docs recommend today). Rejected: no request/response correlation, exit status does not reflect kanata's outcome, and no nc on a default Windows install.
  • Build and ship example_tcp_client. Requires a Rust toolchain on the user's machine, produces a second binary to distribute and keep in sync, and the example is explicitly a demo rather than a supported interface. Every downstream project ends up vendoring its own fork of it.
  • Write a client per language (what I actually did). ~200 lines of Swift for framing, reconnect, encode/decode, and local layer-name validation, none of which is specific to my use case. kanata-tray, qanata, and other community projects each carry an equivalent copy. This works, but it duplicates protocol knowledge outside the repo where it cannot be updated with the protocol.
  • A separate kanata-cmd / kanata-client binary. Solves the same problem but adds a release artifact per platform and introduces client/server version skew. Putting the client in the same binary avoids both by construction.
  • Leaving long-lived subscribers out of scope. A client that wants pushed LayerChange / MessagePush / HoldActivated events still needs a persistent socket, and send-cmd is deliberately not proposed as a replacement for that. (A kanata listen-style streaming mode would be a natural follow-up, but I am not asking for it here.)
  • Known cost, stated plainly: driving send-cmd from an event-driven daemon means one process spawn per event (~10–20 ms on macOS) instead of one persistent socket. For app-switch-triggered layer changes that is not perceptible, and it removes ~150 lines of transport code from my side. Anyone for whom the spawn cost matters can still use the raw protocol — this feature adds an option, it does not take one away.

Additional context

Prior art — the same shape in comparable daemons.

paneru (Unix socket, same "send a command to my daemon" role):

$ paneru send-cmd --help
Sends a command via a Unix socket to the running `paneru` daemon

Usage: paneru send-cmd [CMD]...

Arguments:
  [CMD]...

$ paneru send-cmd window shrink
$ paneru send-cmd window focus unmanaged
$ paneru send-cmd window togglefloatlayer

sing-box (structured subcommand group over its API, with connection flags):

$ sing-box api --help
API service client

Usage:
  api [command]

Available Commands:
  connection     Manage connections
  group          Manage outbound groups
  logs           Print the service logs
  mode           Print the current clash mode
  outbounds      List outbounds
  status         Print the service status
  version        Print the API service version

Flags:
      --secret string   API service secret (default: $BOX_API_SECRET)
      --url string      API service URL (default: $BOX_API_URL)

$ sing-box api --url 127.0.0.1:9091 --secret 11 mode list
$ sing-box api --url 127.0.0.1:9091 --secret 11 connection list

The transport differs (paneru uses a Unix socket, kanata uses TCP), but that is irrelevant to the CLI shape: in both projects the daemon binary doubles as the client, and integrators write one shell line instead of a socket client.

Source references for the claims above (kanata main @ 1.12.1-prerelease-1):

  • docs/config.adoc, "TCP Protocol Overview" / "Client Commands" / "Server Messages" — newline-framed JSON, nc examples, full message tables.
  • tcp_protocol/src/lib.rsClientMessage (13 variants), ServerMessage (12 variants), ServerResponse ({"status":...}).
  • src/tcp_server.rsChangeLayer and SetMouse send no response; ActOnFakeKey writes a ServerMessage::Error on failure only; handle_reload_with_wait sends ServerResponse then optionally ReloadResult; an undeserializable message triggers ServerResponse::Error and disconnects the client.
  • src/kanata/mod.rs, Kanata::change_layer — returns silently when no layer matches.
  • src/main_lib/args.rs — flag-only clap::Parser, no subcommands today.
  • Cargo.tomltcp_server is in default features.