agcli 0.7.0

A tiny, no-bloat foundation crate for building agentic CLIs in Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
---
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 (joelclaw, slog), 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]
---

# 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 do not 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 is 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
// x Agents see a blank command list
const status = Command.make("status", {}, () => ...)

// checkmark Agents see what each command does
const status = Command.make("status", {}, () => ...).pipe(
  Command.withDescription("Active sessions, queue depths, Redis health")
)
```

```text
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
  timestamp: number,            // Unix epoch seconds
  schema_version?: string,      // optional version tag for envelope schema
  result: object,               // command-specific payload
  next_actions: Array<{
    command: string,            // command template (POSIX syntax) or literal
    description: string,        // what it does
    params?: Record<string, {
      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,
  timestamp: number,            // Unix epoch seconds
  schema_version?: string,      // optional version tag for envelope schema
  error: {
    message: string,            // what went wrong
    code: string,               // machine-readable error code
    retryable: boolean          // true if the agent should retry the command
  },
  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)
- `slog` - system log CLI (same envelope patterns)
- `agcli` - Rust crate implementing all 5 principles as reusable primitives ([GitHub]https://github.com/matthiasdebernardini/agcli, [crates.io]https://crates.io/crates/agcli)

Use these as the current envelope source-of-truth.

## Implementation

### Framework: Effect CLI (`@effect/cli`)

All CLIs use `@effect/cli` with Bun. This is non-negotiable - consistency across the system matters more than framework preference.

```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]))
```

### Rust Implementation (agcli)

The `agcli` crate implements all 5 principles as reusable Rust primitives. It provides the envelope types, HATEOAS next_actions with automatic `params` population, NDJSON streaming with terminal event enforcement, and context-safe truncation helpers.

```toml
[dependencies]
agcli = "0.5.0"
serde_json = "1"
```

```rust
use agcli::{AgentCli, Command, CommandOutput, NextAction, ActionParam};
use serde_json::json;

let cli = AgentCli::new("mycli", "My agent-native CLI")
    .version("1.0.0")
    .command(
        Command::new("deploy", "Deploy to environment")
            .usage("mycli deploy <env> [--tag=<tag>]")
            .handler(|req, _ctx| {
                let env = req.arg(0).unwrap_or("staging");
                Ok(CommandOutput::new(json!({ "deployed": env }))
                    .next_action(
                        NextAction::new("mycli status", "Check deployment status"),
                    ))
            }),
    );

let run = cli.run_env();
println!("{}", run.to_json());
std::process::exit(run.exit_code());
```

Build with Cargo:

```bash
cargo build --release
cp target/release/mycli ~/.local/bin/
```

Note: The Redis/infrastructure streaming sections above are joelclaw-specific. agcli provides generic `NdjsonEmitter` primitives that work with any `Write` sink.

### Binary distribution (TypeScript)

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 is the state now?). Streamed NDJSON covers the temporal dimension (what is 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 do not understand streaming read the last line and get exactly what they expect.

```text
{"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 }
  | { type: "progress"; name: string; percent?: number; message?: string; ts: string }
  | { type: "log"; level: "info" | "warn" | "error"; message: string; ts: string }
  | { type: "event"; name: string; data: unknown; ts: string }
  | { type: "result"; ok: true; command: string; timestamp: number; schema_version?: string; result: unknown; next_actions: NextAction[] }
  | { type: "error"; ok: false; command: string; timestamp: number; schema_version?: string; error: { message: string; code: string; retryable: boolean }; fix: string; next_actions: NextAction[] }
```

### Emitting stream events

Use the `emit()` helper - one JSON line per call, flushed immediately:

```typescript
import { emit, emitResult, emitError } from "../stream"

// Progress events
emit({ type: "start", command: "joelclaw send video/download --follow", ts: new Date().toISOString() })
emit({ type: "step", name: "download", status: "started", ts: new Date().toISOString() })
emit({ type: "step", name: "download", status: "completed", duration_ms: 3200, ts: new Date().toISOString() })

// Terminal - always last
emitResult("send --follow", { videoId: "abc123" }, [
  { command: "joelclaw run abc123", description: "Inspect the completed run" },
])
```

### Redis subscription pattern

Streaming commands subscribe to the same Redis pub/sub channels the gateway extension uses. `pushGatewayEvent()` middleware is the emission point - the CLI is just another subscriber.

```typescript
import { streamFromRedis } from "../stream"

// Subscribe to a channel, transform events, emit NDJSON
await streamFromRedis({
  channel: `joelclaw:notify:gateway`,
  command: "joelclaw gateway stream",
  transform: (event) => ({
    type: "event" as const,
    name: event.type,
    data: event.data,
    ts: new Date().toISOString(),
  }),
  // Optional: end condition
  until: (event) => event.type === "loop.complete",
})
```

### Composable with Unix tools

NDJSON is pipe-native. Agents and humans can filter streams:

```bash
# Only step events
joelclaw watch | jq --unbuffered 'select(.type == "step")'

# Only failures
joelclaw send video/download --follow | jq --unbuffered 'select(.type == "error" or (.type == "step" and .status == "failed"))'

# Count steps
joelclaw send pipeline/run --follow | jq --unbuffered 'select(.type == "step" and .status == "completed")' | wc -l
```

### Agent consumption pattern

Agents consuming streams read lines as they arrive and can make decisions mid-execution:

1. Start the stream: `joelclaw send video/download --follow`
2. Read lines incrementally
3. React to early signals (cancel if error, escalate if slow, log progress)
4. The terminal `result`/`error` line contains `next_actions` for what to do after

This eliminates the polling tax - no wasted tool calls checking "is it done yet?"

### Cleanup

Streaming commands hold a Redis connection. They must:
- Handle SIGINT/SIGTERM gracefully (disconnect Redis, emit terminal event)
- Use `connectTimeout` and `commandTimeout` to prevent hangs
- Clean up the subscription on stream end (success, error, or signal)

## Anti-Patterns

| Do not | Do |
|--------|----|
| Plain text output | JSON envelope |
| Tables with ANSI colors | JSON arrays |
| `--json` flag to opt into JSON | JSON is the only format |
| Dump 10,000 lines | Truncate + file pointer |
| `Error: something went wrong` | `{ ok: false, error: {...}, fix: "..." }` |
| Undiscoverable commands | Root returns full command tree |
| Static help text | HATEOAS next_actions |
| `console.log("Success!")` | `{ ok: true, result: {...} }` |
| Exit code as the only error signal | Error in JSON + exit code |
| Require the agent to read `--help` | Root command self-documents |
| Subcommand with no `withDescription` | Every command gets a description for `--help` |
| Poll in a loop for temporal data | Stream NDJSON via Redis sub (ADR-0058) |
| Plain text in streaming commands | Every line is a typed JSON object |
| Hold Redis connections without cleanup | SIGINT handler + connection timeout |

## Naming Conventions

- Commands are nouns or verbs, lowercase, no hyphens: `send`, `status`, `logs`, `gateway`
- Subcommands follow naturally: `joelclaw search "query"`, `joelclaw loop start`
- Flags use `--kebab-case`: `--max-quality`, `--follow`
- Short flags for common options: `-d` for `--data`, `-f` for `--follow`
- Event names use `domain/action`: `pipeline/video.download`, `content/summarize`

## Checklist for New Commands

- [ ] Returns JSON envelope (`ok`, `command`, `timestamp`, `result`, `next_actions`)
- [ ] `Command.withDescription()` set (shows in `--help`)
- [ ] Error responses include `fix` field and `error.retryable` boolean
- [ ] `schema_version` set when envelope schema is versioned
- [ ] Root command lists this command in its tree
- [ ] Output is context-safe (truncated if potentially large)
- [ ] `next_actions` are contextual to what just happened
- [ ] `next_actions` with variable parts use template syntax (`<required>`, `[--flag=<value>]`) + `params`
- [ ] Context-specific values pre-filled via `params.*.value`
- [ ] No plain text output anywhere
- [ ] No ANSI colors or formatting
- [ ] Works when piped (no TTY detection)
- [ ] Builds and installs