A Coding Agent Gets a Scriptable JSONL Interface and a Real CLI Binary
The Coding Agent has been published to npm. You can experience the final product directly. GitHub address: github.com Welcome to Star, npm address @di-code/coding-agent - npm. Install and use it directly with
npm install -g @di-code/coding-agent
This article is the twelfth in the "Developing a Coding Agent from Scratch" series. In the previous two articles, we completed CLI argument parsing and print mode:
Argument array -> runCli() -> Agent.prompt() -> Final text -> stdout
Print mode is suitable for direct human reading, but scripts and other programs often need more information. For example:
- When did the Agent start working?
- What content is the model generating?
- Were any tools executed?
- Did this request end in success, cancellation, or failure?
- What is the final transcript?
If only the final text is output, all this process information is lost. Therefore, this article adds a second output mode: JSONL mode.
JSONL (JSON Lines) is not a giant JSON array, but "one independent JSON object per line":
{"version":1,"event":{"type":"agent_start"}}
{"version":1,"event":{"type":"turn_start"}}
{"version":1,"event":{"type":"agent_end","messages":[...]}}
After completion, the program must also become a real Node.js program that can be launched from the command line: reading process.argv, connecting stdout/stderr to the real terminal, and providing the di-code command through the package's bin field.
This article still uses the Faux Provider. It only returns the fixed Faux response., does not access the network, does not read API Keys, and does not implement the read tool, session storage, or a real Provider in advance.
Differences Between JSON Mode and Print Mode
Both modes call the same Agent, but their observation goals differ:
| Mode | What it consumes | stdout content |
|---|---|---|
Final AssistantMessage |
Final text block | |
| JSON | Every AgentEvent |
One versioned JSON record per line |
You can think of the Agent as a race:
- Print mode only cares about the final score;
- JSON mode records the complete process, including the starting signal, each lap, and the end of the race.
Therefore, JSON mode cannot be implemented as "calling runPrintMode() and then wrapping the text into JSON." That would lose events like agent_start, message_update, and agent_end.
Why Every Line Needs a Version
The simplest JSON output might be:
{"event":{"type":"agent_start"}}
But in the future, we might add fields, change event structures, or add new event types. When a consumer gets a line, it needs to know which protocol version it follows. Therefore, this project uses a fixed:
export const JSON_EVENT_VERSION = 1 as const;
export interface JsonEventRecord {
readonly version: typeof JSON_EVENT_VERSION;
readonly event: AgentEvent;
}
The shape of the output object is:
{
version: 1,
event: AgentEvent,
}
Putting the version on every line, rather than just the first line, has two benefits:
- Consumers can independently parse any line without relying on previous state.
- When logs are truncated, sharded, or read from the middle, the protocol version can still be identified.
This is a small but important public interface design. JSONL is not temporary debug text; it is a communication contract between the CLI and scripts.
Data Flow for a JSON Command
Below is the complete path for --mode json hello:
sequenceDiagram
participant OS as Operating System
participant Entry as entry.ts
participant Main as runMain
participant CLI as runCli
participant Agent as Agent
participant Json as runJsonMode
OS->>Entry: process.argv / stdout / stderr
Entry->>Main: Arguments and I/O writer
Main->>CLI: run callback
CLI-->>Main: { kind: 'run', mode: 'json' }
Main->>Agent: Create Faux Provider + Agent
Main->>Json: prompt + Agent + writer
Json->>Agent: subscribe(listener)
Json->>Agent: prompt("hello")
Agent-->>Json: AgentEvent 1, 2, ...
Json-->>OS: One line of JSON per event
Agent-->>Json: AssistantMessage
Json-->>OS: Return exit code 0 or 1
Json->>Agent: finally unsubscribe()
Two timing points are important here: the subscription must happen before calling prompt(), otherwise the initial events might already be emitted; unsubscription must be placed in finally, otherwise the success, failure, and rejection paths will have different cleanup behaviors.
Step 1: Implement JSON Output Mode
Create the file:
di-code/packages/coding-agent/src/modes/json.ts
First, import the Agent events, assistant message, and the I/O defined in the previous article:
import type { AgentEvent, AgentListener } from "@di-code/agent";
import type { AssistantMessage } from "@di-code/ai";
import type { PrintIo } from "./print.ts";
The shape of AgentListener is a function that receives an AgentEvent and can return a Promise. The JSON writer itself is synchronous, so here it just needs to wrap the event and pass it to stdout.
Then define the protocol version and the minimal runner interface:
export const JSON_EVENT_VERSION = 1 as const;
export interface JsonEventRecord {
readonly version: typeof JSON_EVENT_VERSION;
readonly event: AgentEvent;
}
export interface JsonRunner {
prompt(text: string): Promise<AssistantMessage>;
subscribe(listener: AgentListener): () => void;
}
JsonRunner is smaller than the full Agent, but it has subscribe() compared to PromptRunner. This is because JSON mode needs to observe process events and cannot just wait for the final message.
Converting Unknown Exceptions to Error
Like print mode, an external Promise might reject a string, number, or plain object. Unify it to Error first:
function toError(cause: unknown): Error {
return cause instanceof Error ? cause : new Error(String(cause));
}
Implementing runJsonMode
Continue in the same file by adding:
export async function runJsonMode(prompt: string, runner: JsonRunner, io: PrintIo): Promise<number> {
const unsubscribe = runner.subscribe((event) => {
const record: JsonEventRecord = { version: JSON_EVENT_VERSION, event };
io.stdout(`${JSON.stringify(record)}\n`);
});
try {
const assistant = await runner.prompt(prompt);
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
io.stderr(`${assistant.errorMessage}\n`);
return 1;
}
return 0;
} catch (cause) {
io.stderr(`${toError(cause).message}\n`);
return 1;
} finally {
unsubscribe();
}
}
Let's examine this code section by section:
subscribe()is called beforetryand the unsubscribe function is saved immediately. This ensures the first event emitted byprompt()is not lost.- Each time the listener receives an event, it creates
{ version: 1, event }, converts it to a single line of text usingJSON.stringify(), and appends a newline character. - When
prompt()returns a failure message, the already produced JSONL remains on stdout; the error description is written separately to stderr, and the exit code returns1. - When
prompt()rejects, it also only writes to stderr, without polluting the JSONL with an exception stack trace. finallyexecutesunsubscribe()regardless of success, structured failure, or exception.
Why Not Collect All Events First
One of the values of JSONL is real-time capability. Consumers can read the first line, the second line, while the Agent is still running, without waiting for the entire request to finish. Writing event by event also reduces memory usage and preserves the order of event occurrence.
Why Failure Events Still Remain on stdout
A failure might also have produced useful lifecycle events, for example:
agent_start
turn_start
message_start
message_end(error)
agent_end
These events are valid AgentEvents and should be recorded as usual. stderr only supplements human-readable diagnostic text; it must not clear or overwrite already output JSONL for the sake of error reporting.
Step 2: Write Unit Tests for JSON Mode
Create:
di-code/packages/coding-agent/test/json.test.ts
Here, a fake JsonRunner is used to specifically test the output projection, without repeating tests for the Agent Loop.
Testing That Each Event Occupies Its Own Line
The test runner can manually trigger subscribers within prompt():
function createRunner(options: { message?: AssistantMessage; reject?: Error; events?: AgentEvent[] }) {
let listener: AgentListener | undefined;
const unsubscribe = vi.fn();
const runner: JsonRunner = {
subscribe(next) {
listener = next;
return unsubscribe;
},
async prompt() {
for (const event of options.events ?? []) {
await listener?.(event);
}
if (options.reject) {
throw options.reject;
}
return options.message ?? assistant("stop");
},
};
return { runner, unsubscribe };
}
Then verify that each line can be parsed independently:
const io = createIo();
const { runner } = createRunner({
events: [{ type: "agent_start" }, { type: "turn_start" }],
});
expect(await runJsonMode("hello", runner, io)).toBe(0);
const records = io.stdout.mock.calls.map(
([line]) => JSON.parse(line.trim()) as { version: number; event: AgentEvent },
);
expect(records).toHaveLength(2);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toEqual(["agent_start", "turn_start"]);
expect(io.stderr).not.toHaveBeenCalled();
Using JSON.parse() here is more meaningful than directly checking strings, because it proves that consumers can actually read the output.
Testing Failure Messages and Rejection
Tests for failure messages must assert both stdout and stderr:
const io = createIo();
const { runner } = createRunner({
message: assistant("error", "model failed"),
events: [{ type: "agent_start" }],
});
expect(await runJsonMode("fail", runner, io)).toBe(1);
expect(io.stdout).toHaveBeenCalledTimes(1);
expect(io.stderr).toHaveBeenCalledWith("model failed\n");
Then verify that Promise rejection cleans up the subscription:
const io = createIo();
const { runner, unsubscribe } = createRunner({ reject: new Error("listener failed") });
expect(await runJsonMode("reject", runner, io)).toBe(1);
expect(io.stderr).toHaveBeenCalledWith("listener failed\n");
expect(unsubscribe).toHaveBeenCalledTimes(1);
Run:
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run json.test.ts
In the GREEN phase, you should see 1 test file, 3 tests all passing.
Step 3: Let runMain Dispatch JSON
In the previous article, runMain() returned a placeholder error for JSON. Now open:
di-code/packages/coding-agent/src/main.ts
Add the import:
import { runJsonMode } from "./modes/json.ts";
Then fix the run callback to "create the runtime once, then choose the output layer by mode":
run: async (command) => {
const faux = createFauxProvider({ responses: options.fauxResponses, now: options.now });
const agent = new Agent({ provider: faux.provider, model: faux.model, now: options.now });
if (command.mode === "json") {
return runJsonMode(command.prompt, agent, options);
}
return runPrintMode(command.prompt, agent, options);
},
Here, parseCliArgs() must not be duplicated, and JSON mode must not create another Agent instance itself. Both modes should share the same AgentSession boundary, only changing "how to observe and output the result."
Update main Integration Tests
Open:
di-code/packages/coding-agent/test/main.test.ts
Change the test in 6b that said "JSON not yet implemented" to a success assertion:
it("runs a faux prompt through versioned JSON mode", async () => {
const io = createIo();
const exitCode = await runMain(["--mode", "json", "hello"], {
...io,
version: "0.0.0",
fauxResponses: [{ type: "success", content: [{ type: "text", text: "done" }] }],
});
expect(exitCode).toBe(0);
expect(io.stderr).not.toHaveBeenCalled();
const records = io.stdout.mock.calls.map(
([line]) => JSON.parse(line.trim()) as { version: number; event: { type: string } },
);
expect(records.length).toBeGreaterThan(0);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toContain("agent_start");
expect(records.map((record) => record.event.type)).toContain("agent_end");
});
Run regression tests:
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run main.test.ts
npm test --workspace packages/coding-agent -- --run print.test.ts
npm test --workspace packages/coding-agent -- --run cli.test.ts
Expected results are 3/3, 4/4, and 8/8 respectively. This proves the JSON integration did not break existing print and argument behaviors.
Step 4: Create the Real Node Entry Point
Up to now, runMain() is still a testable function. When a user runs the program directly, a very thin entry point is needed to connect Node's global objects.
Create:
di-code/packages/coding-agent/src/entry.ts
Write:
#!/usr/bin/env node
import packageMetadata from "../package.json" with { type: "json" };
import { runMain } from "./main.ts";
const exitCode = await runMain(process.argv.slice(2), {
version: packageMetadata.version,
fauxResponses: [{ type: "success", content: [{ type: "text", text: "Faux response." }] }],
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
});
process.exitCode = exitCode;
Understand it line by line:
- The shebang allows Unix environments to execute the generated file as a script; Windows still relies on Node/npm to start it.
process.argv.slice(2)removes Node and the script path, passing only user arguments torunMain().- The package JSON provides the current version, avoiding hardcoded file reading in the CLI parser.
fauxResponsesis the fixed response for the current no-network vertical slice; it is not user input, nor a substitute for a future real Provider.- The writer connects the mode layer's
PrintIoto the real stdout/stderr. process.exitCodesets the exit status but does not immediately interrupt asynchronous cleanup.
The entry file should remain very thin. Argument syntax belongs to cli.ts, mode behavior belongs to modes/, runtime composition belongs to main.ts; entry.ts is only responsible for wiring.
Step 5: Configure Package Commands and bin
In the root di-code/package.json, add:
"dev": "node --experimental-strip-types packages/coding-agent/src/entry.ts"
--experimental-strip-types allows the current Node version to directly run the source code after TypeScript type erasure, suitable for the development entry of this learning project. It does not replace the formal build.
In di-code/packages/coding-agent/package.json, add:
"bin": {
"di-code": "./dist/entry.js"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "node --experimental-strip-types src/entry.ts",
"test": "vitest run --passWithNoTests"
}
The meaning of bin is: after installing or linking this package, the command di-code points to the build artifact dist/entry.js. Do not point to src/entry.ts, because publishing and subprocess testing use the built JavaScript.
Why dev Doesn't Auto-build
If dev executed a build first, then started the program, the build output might mix into the stdout being consumed by the machine, and it adds an extra step to every run. Building should be done by a separate npm run build; the development smoke test then runs the source entry.
When using npm to execute JSON commands, it is recommended to add --silent:
npm run --silent dev -- --mode json hello
A plain npm run dev might print a script banner from npm itself. That is not application output, but it would cause line-by-line JSON consumers to see non-JSON text. Running node dist/entry.js directly or using the installed di-code bin can also avoid this problem.
Step 6: Test the Entry Point with a Real Subprocess
Directly calling runMain() only proves the function composition is correct, but cannot prove:
- Whether
process.argv.slice(2)is correct; - Whether the package JSON can be loaded by the entry point;
- Whether
process.exitCodeis passed to the operating system; - Whether stdout/stderr are truly separated;
- Whether the build artifact
dist/entry.jscan be started.
Therefore, create:
di-code/packages/coding-agent/test/cli-process.test.ts
The test uses Node's spawn() to start an independent process:
const entryPath = resolve(process.cwd(), "dist/entry.js");
async function runCli(args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [entryPath, ...args], {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => resolveResult({ code, stdout, stderr }));
});
}
Then cover five external behaviors:
it("prints help without runtime diagnostics", async () => {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Usage: di-code");
expect(result.stderr).toBe("");
});
it("prints the package version", async () => {
const result = await runCli(["--version"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("0.0.0\n");
expect(result.stderr).toBe("");
});
it("runs the deterministic print path", async () => {
const result = await runCli(["--print", "hello"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("Faux response.\n");
expect(result.stderr).toBe("");
});
it("writes versioned JSON events", async () => {
const result = await runCli(["--mode", "json", "hello"]);
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
const records = result.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { version: number; event: { type: string } });
expect(records.length).toBeGreaterThan(0);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toContain("agent_start");
expect(records.map((record) => record.event.type)).toContain("agent_end");
});
it("keeps usage errors off stdout", async () => {
const result = await runCli(["--unknown"]);
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain('Unknown option "--unknown".');
});
This type of test is closer to the user's actual experience than unit tests, because it verifies the complete process boundary, not just the return value of a function.
You must build before running:
Set-Location D:\pi\di-code
npm run build
npm test --workspace packages/coding-agent -- --run cli-process.test.ts
Expected subprocess test results are 1 file, 5 tests all passing.
Final Verification and Smoke Test
After completing the above steps, run:
Set-Location D:\pi\di-code
npm run build
npm test --workspace packages/coding-agent
npm run check
npm run dev -- --help
npm run dev -- --version
npm run dev -- --print hello
npm run --silent dev -- --mode json hello
Expected results:
- coding-agent collects 5 test files, 23 tests, all passing.
- Root
npm run checkand rootnpm run buildsucceed. - help outputs Usage, exit code is
0, no credentials required. - version outputs
0.0.0. - print stdout is only
Faux response.. - JSON stdout has every non-empty line independently
JSON.parse-able, withversion: 1; it containsagent_startandagent_end. - stderr is empty for a normal JSON run.
When checking JSON output, you can observe the line count in PowerShell like this:
$jsonLines = npm run --silent dev -- --mode json hello
$jsonLines | ForEach-Object { $_ | ConvertFrom-Json }
If any line is not JSON, ConvertFrom-Json will immediately report an error, which is more reliable than visually inspecting a long string.
Summary
This article completed the CLI's first complete product link:
runJsonMode()subscribes to AgentEvent, wrapping each event as{ version: 1, event }.- Each JSON record occupies its own line, allowing consumers to read and parse line by line.
- Structured failures still retain the already produced JSONL, diagnostics only write to stderr, exit code is
1. - Unsubscription in
finallyavoids leaving a listener on success, failure, and rejection paths. runMain()selects print or JSON output on the same Agent, without duplicating CLI parsing logic.entry.tsconnectsprocess.argv, real stdout/stderr, and exit code to the application.binpoints todist/entry.js, and subprocess tests verify the real command-line boundary.
At this point, Task 6's arguments, print, JSONL, and development entry point have been connected into a deterministic link. It still cannot read files, but it already possesses a CLI foundation that can be consumed by scripts, observed through events, and does not depend on a real network.
The next article will enter Task 7: implementing a read tool constrained by the working directory, and completing the end-to-end flow of "model requests read -> local execution -> tool result returned -> model final answer."
Open source address: github.com