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.
}
Reference implementations
joelclaw
—
~/Code/joelhooks/joelclaw/packages/cli/
(Effect CLI, operational surface)
slog
— system log CLI (same envelope patterns)
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.
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/
:
bun build src/cli.ts
--compile
--outfile
joelclaw
cp
joelclaw ~/.bun/bin/
Adding a new command
Define the command with
Command.make
Return the standard JSON envelope (ok, command, result, next_actions)
Include contextual
next_actions
— what makes sense AFTER this specific command
Handle errors with the error envelope (ok: false, error, fix, next_actions)
Add to the root command's subcommands
Add to the root command's
commands
array in the self-documenting output
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
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
;
result
:
unknown
;
next_actions
:
NextAction
[
]
}
|
{
type
:
"error"
;
ok
:
false
;
command
:
string
;
error
:
{
message
:
string
;
code
:
string
}
;
fix
:
string
;
next_actions
:
NextAction
[
]
}
Emitting stream events
Use the
emit()
helper — one JSON line per call, flushed immediately:
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.
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: