- name
- cli-design
- displayName
- CLI Design
- description
- Design and build agent-first CLIs with HATEOAS JSON responses, context-protecting output, and self-documenting command trees. Use when creating new CLI tools, adding commands to existing CLIs such as joelclaw, or reviewing CLI design for agent-friendliness. Triggers on 'build a CLI', 'add a command', 'CLI design', 'agent-friendly output', or any task involving command-line tool creation.
- version
- 1.1.0
- author
- Joel Hooks
- tags
- ["joelclaw","cli","agentic","ux","json"]
- disable-model-invocation
- true
# Agent-First CLI Design
CLIs in this system are **agent-first, human-distant-second**. Every command returns structured JSON that an agent can parse, act on, and follow. Humans are welcome to pipe through `jq`.
## Core Principles
### 1. JSON always
Every command returns JSON. No plain text. No tables. No color codes. Agents parse JSON; they don't parse prose.
```bash
# This is the ONLY output format
joelclaw status
# → { "ok": true, "command": "joelclaw status", "result": {...}, "next_actions": [...] }
```
No `--json` flag. No `--human` flag. JSON is the default and only format.
### 2. HATEOAS — every response tells you what to do next
Every response includes `next_actions` — an array of command **templates** the agent can run next. Templates use standard POSIX/docopt placeholder syntax:
- `<placeholder>` — required argument
- `[--flag <value>]` — optional flag with value
- `[--flag]` — optional boolean flag
- No `params` field — literal command (run as-is)
- `params` present — template (agent fills placeholders)
- `params.*.value` — pre-filled from context (agent can override)
- `params.*.default` — value if omitted
- `params.*.enum` — valid choices
```json
{
"ok": true,
"command": "joelclaw send pipeline/video.download",
"result": {
"event_id": "01KHF98SKZ7RE6HC2BH8PW2HB2",
"status": "accepted"
},
"next_actions": [
{
"command": "joelclaw run <run-id>",
"description": "Check run status for this event",
"params": {
"run-id": { "value": "01KHF98SKZ7RE6HC2BH8PW2HB2", "description": "Run ID (ULID)" }
}
},
{
"command": "joelclaw logs <source> [--lines <lines>] [--grep <text>] [--follow]",
"description": "View worker logs",
"params": {
"source": { "enum": ["worker", "errors", "server"], "default": "worker" }
}
},
{
"command": "joelclaw status",
"description": "Check system health"
}
]
}
```
`next_actions` are **contextual** — they change based on what just happened. A failed command suggests different next steps than a successful one. Templates are the agent's **affordances** — they show what's parameterizable, what values are valid, and what the current context pre-fills.
### 3. Self-documenting command tree
Agents discover commands via **two paths**: the root command (JSON tree) and `--help` (Effect CLI auto-generated). Both must be useful.
**Root command (no args)** returns the full command tree as JSON:
```json
{
"ok": true,
"command": "joelclaw",
"result": {
"description": "JoelClaw — personal AI system CLI",
"health": { "server": {...}, "worker": {...} },
"commands": [
{ "name": "send", "description": "Send event to Inngest", "usage": "joelclaw send <event> -d '<json>'" },
{ "name": "status", "description": "System status", "usage": "joelclaw status" },
{ "name": "gateway", "description": "Gateway operations", "usage": "joelclaw gateway status" }
]
},
"next_actions": [...]
}
```
**`--help` output** is auto-generated by Effect CLI from `Command.withDescription()`. Every subcommand **must** have a description — agents always call `--help` and a bare command list with no descriptions is useless.
```typescript
// ❌ Agents see a blank command list
const status = Command.make("status", {}, () => ...)
// ✅ Agents see what each command does
const status = Command.make("status", {}, () => ...).pipe(
Command.withDescription("Active sessions, queue depths, Redis health")
)
```
```
COMMANDS
- status Active sessions, queue depths, Redis health
- diagnose [--hours integer] Layer-by-layer health check
- review [--hours integer] Recent session context
```
### 4. Context-protecting output
Agents have finite context windows. CLI output must not blow them up.
**Rules:**
- Terse by default — minimum viable output
- Auto-truncate large outputs (logs, lists) at a reasonable limit
- When truncated, include a file path to the full output
- Never dump raw logs, full transcripts, or unbounded lists
```json
{
"ok": true,
"command": "joelclaw logs",
"result": {
"lines": 20,
"total": 4582,
"truncated": true,
"full_output": "/var/folders/.../joelclaw-logs-abc123.log",
"entries": ["...last 20 lines..."]
},
"next_actions": [
{
"command": "joelclaw logs <source> [--lines <lines>]",
"description": "Show more log lines",
"params": {
"source": { "enum": ["worker", "errors", "server"], "default": "worker" },
"lines": { "default": 20, "description": "Number of lines" }
}
}
]
}
```
### 5. Errors suggest fixes
When something fails, the response includes a `fix` field — plain language telling the agent what to do about it.
```json
{
"ok": false,
"command": "joelclaw send pipeline/video.download",
"error": {
"message": "Inngest server not responding",
"code": "SERVER_UNREACHABLE"
},
"fix": "Start the Inngest server pod: kubectl rollout restart statefulset/inngest -n joelclaw",
"next_actions": [
{ "command": "joelclaw status", "description": "Re-check system health after fix" },
{
"command": "kubectl get pods [--namespace <ns>]",
"description": "Check pod status",
"params": { "ns": { "default": "joelclaw" } }
}
]
}
```
## Response Envelope
Every command uses this exact shape:
### Success
```typescript
{
ok: true,
command: string, // the command that was run
result: object, // command-specific payload
next_actions: Array<{
command: string, // command template (POSIX syntax) or literal
description: string, // what it does
params?: Record<string, { // presence = command is a template
description?: string, // what this param means
value?: string | number, // pre-filled from current context
default?: string | number,// value if omitted
enum?: string[], // valid choices
required?: boolean // true for <positional> args
}>
}>
}
```
### Error
```typescript
{
ok: false,
command: string,
error: {
message: string, // what went wrong
code: string // machine-readable error code
},
fix: string, // plain-language suggested fix
next_actions: Array<{
command: string, // command template or literal
description: string,
params?: Record<string, { ... }> // same schema as success
}>
}
```
### Reference implementations
- `joelclaw` — `~/Code/joelhooks/joelclaw/packages/cli/` (Effect CLI, operational surface)
- `joelclaw otel` — structured telemetry commands using the same response envelope
Use these as the current envelope source-of-truth. The former `slog` system-log CLI is retired.
## Implementation
### Framework: Effect CLI (@effect/cli)
Existing joelclaw CLI packages use `@effect/cli` with their configured runtime. Follow the target repository package manager and pinned dependencies. For a new project, use the operator’s current stack defaults; this skill does not authorize a runtime migration.
```typescript
import { Command, Options } from "@effect/cli"
import { NodeContext, NodeRuntime } from "@effect/platform-node"
const send = Command.make("send", {
event: Options.text("event"),
data: Options.optional(Options.text("data").pipe(Options.withAlias("d"))),
}, ({ event, data }) => {
// ... execute, return JSON envelope
})
const root = Command.make("joelclaw", {}, () => {
// Root: return health + command tree
}).pipe(Command.withSubcommands([send, status, logs]))
```
### Binary distribution
Build with Bun, install to `~/.bun/bin/`:
```bash
bun build src/cli.ts --compile --outfile joelclaw
cp joelclaw ~/.bun/bin/
```
### Adding a new command
1. Define the command with `Command.make`
2. Return the standard JSON envelope (ok, command, result, next_actions)
3. Include contextual `next_actions` — what makes sense AFTER this specific command
4. Handle errors with the error envelope (ok: false, error, fix, next_actions)
5. Add to the root command's subcommands
6. Add to the root command's `commands` array in the self-documenting output
7. Rebuild and install
## Streaming Protocol (NDJSON) — ADR-0058
Request-response covers the **spatial** dimension (what's the state now?). Streamed NDJSON covers the **temporal** dimension (what's happening over time?). Together they make the full system observable through one protocol.
### When to stream
Stream when the command involves **temporal operations** — watching, following, tailing. Not every command needs streaming. Point-in-time queries (`status`, `functions`, `runs`) stay as single envelopes.
Streaming is activated by command semantics (`--follow`, `watch`, `gateway stream`), never by a global `--stream` flag.
### Protocol: typed NDJSON with HATEOAS terminal
Each line is a self-contained JSON object with a `type` discriminator. The **last line is always the standard HATEOAS envelope** (`result` or `error`). Tools that don't understand streaming read the last line and get exactly what they expect.
```
{"type":"start","command":"joelclaw send video/download --follow","ts":"2026-02-19T08:25:00Z"}
{"type":"step","name":"download","status":"started","ts":"..."}
{"type":"progress","name":"download","percent":45,"ts":"..."}
{"type":"step","name":"download","status":"completed","duration_ms":3200,"ts":"..."}
{"type":"step","name":"transcribe","status":"started","ts":"..."}
{"type":"log","level":"warn","message":"Large file, chunked transcription","ts":"..."}
{"type":"step","name":"transcribe","status":"completed","duration_ms":45000,"ts":"..."}
{"type":"result","ok":true,"command":"...","result":{...},"next_actions":[...]}
```
### Stream event types
| Type | Meaning | Terminal? |
|------|---------|-----------|
| `start` | Stream begun, echoes command | No |
| `step` | Inngest step lifecycle (started/completed/failed) | No |
| `progress` | Progress update (percent, bytes, message) | No |
| `log` | Diagnostic message (info/warn/error level) | No |
| `event` | An Inngest event was emitted (fan-out visibility) | No |
| `result` | HATEOAS success envelope — always last | **Yes** |
| `error` | HATEOAS error envelope — always last | **Yes** |
### TypeScript types
```typescript
import type { NextAction } from "./response"
type StreamEvent =
| { type: "start"; command: string; ts: string }
| { type: "step"; name: string; status: "started" | "completed" | "failed"; duration_ms?: number; error?: string; ts: string }
عرض على GitHub