Building a CLI for AI Agents: The kd Tool

A non-interactive daemon with JSON output, packaged as a single binary with no prompts and direct function calls.

7 min read | KEIBIDROP Series

The Problem

Interactive CLIs are designed for humans; they use prompts, menus, spinners, colored output, and readline for editing. AI agents (Claude Code, Cursor, etc.) need a different set of properties:

KEIBIDROP already had an interactive CLI (keibidrop-cli) and a desktop GUI. Neither worked for agents. We needed a third interface.

Design Goals

kd show fingerprint prints JSON and exits. Every response is {"ok":true,"data":{...}} or {"ok":false,"error":"..."}. The daemon calls kd.CreateRoom() and kd.AddFile() directly, with no abstraction layers in between. State lives in the daemon process across commands. FUSE mode is recommended so agents can read/write files via normal file I/O.

Architecture: Daemon + Unix Socket

kd start runs a foreground daemon that initializes KEIBIDROP and listens on a Unix socket (default: /tmp/kd.sock). All other commands are thin clients that connect to the socket, send a JSON request, and print the JSON response.

The daemon dispatches commands directly to Go functions. There is no HTTP server, no REST API, no protocol layer beyond minimal JSON framing:

// cmd/kd/main.go

func dispatch(kd *common.KeibiDrop, req Request, ...) Response {
    switch req.Command {
    case "show":
        return cmdShow(kd, req.Args)
    case "register":
        kd.AddPeerFingerprint(req.Args[0])
        return okResponse(map[string]any{"registered": req.Args[0]})
    case "create":
        return cmdCreateOrJoin(kd, "create")  // calls kd.CreateRoom()
    case "join":
        return cmdCreateOrJoin(kd, "join")    // calls kd.JoinRoom()
    case "add":
        kd.AddFile(req.Args[0])
        return okResponse(map[string]any{"added": req.Args[0]})
    case "list":
        return cmdList(kd)  // reads kd.SyncTracker.LocalFiles/RemoteFiles
    case "status":
        return cmdStatus(kd)  // includes mount_path, save_path
    case "disconnect":
        // ...
    case "stop":
        // ...
    }
}

Each command maps to one or two function calls on the KEIBIDROP instance. No abstraction layers, no middleware, no request validation beyond basic argument counts.

Why Not HTTP/REST?

Unix sockets are simpler for local-only communication. No port conflicts (socket files, not TCP ports). No TLS needed for local IPC. No CORS, no auth headers. File permissions control access. This is the same pattern Docker uses with /var/run/docker.sock.

FUSE Mode for Agents

The recommended agent workflow uses FUSE. After connecting, the agent treats the mount path as a regular directory:

# Start daemon with FUSE
KD_SAVE_PATH=./saved KD_MOUNT_PATH=./mount ./kd start

# Connect to peer
./kd register <peer-fp>
./kd create

# After connecting, use the mount as a normal folder
ls ./mount/                   # see peer's shared files
cat ./mount/readme.txt        # read a remote file
cp ./myfile.pdf ./mount/      # share a file with peer
The mount_path is a live, bidirectional view of shared files. Read remote files and write local files directly to/from this folder. No need for kd add or kd pull; just use normal file I/O. This is why FUSE mode is recommended for agents: fewer API calls, standard tools, no learning curve.

Real Session Transcript

A complete session with actual JSON output from a real test run:

$ KD_SAVE_PATH=./saved KD_NO_FUSE=1 KD_SOCKET=/tmp/kd.sock ./kd start
{"ok":true,"data":{"fingerprint":"Ea5btbne8xIJ5BRu...","fuse":false,
  "ip":"2a02:2f00:...","socket":"/tmp/kd.sock"}}

$ ./kd show fingerprint
{"ok":true,"data":{"fingerprint":"Ea5btbne8xIJ5BRu..."}}

$ ./kd register "a3qR2sdQ_sCR-8Xhi..."
{"ok":true,"data":{"registered":"a3qR2sdQ_sCR-8Xhi..."}}

$ ./kd create
{"ok":true,"data":{"peer_ip":"2a02:2f00:...","status":"connected"}}

$ ./kd status
{"ok":true,"data":{"connection_status":"healthy","fingerprint":"Ea5b...",
  "peer_fingerprint":"a3qR...","local_files":0,"remote_files":1,...}}

$ ./kd list
{"ok":true,"data":{"files":[{"name":"/rmg.img","size":318767104,
  "path":"","source":"remote"}]}}

$ ./kd pull "/rmg.img" "./saved/rmg.img"
{"ok":true,"data":{"pulled":"/rmg.img","to":"./saved/rmg.img"}}

$ ./kd disconnect
{"ok":true,"data":{"new_fingerprint":"-y-dYaTF...","status":"disconnected"}}

$ ./kd stop
{"ok":true,"data":{"status":"stopped"}}

Running Two Instances

For testing on a single machine, use different ports and sockets:

# Alice
KD_SAVE_PATH=./SaveAlice KD_NO_FUSE=1 \
  KD_INBOUND_PORT=26001 KD_OUTBOUND_PORT=26002 \
  KD_SOCKET=/tmp/kd-alice.sock ./kd start

# Bob
KD_SAVE_PATH=./SaveBob KD_NO_FUSE=1 \
  KD_INBOUND_PORT=26003 KD_OUTBOUND_PORT=26004 \
  KD_SOCKET=/tmp/kd-bob.sock ./kd start

# Connect them
KD_SOCKET=/tmp/kd-alice.sock ./kd register <bob-fp>
KD_SOCKET=/tmp/kd-bob.sock ./kd register <alice-fp>
KD_SOCKET=/tmp/kd-alice.sock ./kd create &
KD_SOCKET=/tmp/kd-bob.sock ./kd join

What We Learned

Agents prefer FUSE, because standard file I/O means fewer API calls; an agent that knows ls and cat can use KEIBIDROP without reading documentation.

JSON output must be a single line. Agents parse output with jq or json.loads(), and multi-line JSON or mixed text/JSON output breaks parsing.

Blocking commands need background execution. kd create blocks until the peer joins, so agents must run it with & or a timeout.

The entire CLI is one Go file: cmd/kd/main.go, 520 lines. Daemon, client, protocol, dispatch, help text. No framework, no external dependencies beyond the KEIBIDROP core library.

The full agent integration guide with all commands, environment variables, and JSON output examples is at docs/kd-agent-guide.md.