Codex CLIをしばらく使っていると、「これは単なるコマンドラインツールではないのでは」と感じる場面が増えてきました。
特に codex app-server を触り始めてから、その感覚ははっきりしました。
1. Codex CLIとApp Server
最初は普通にCLIとして使っていました。
コード生成、修正、テスト補助。どれも便利です。
ただ、挙動を眺めていると、
- プロセスが常駐している
- 会話の状態を持ち続けている
- 1回の実行で終わらない
これは典型的な「コマンド」というより、対話環境です。
実は、Codex CLIを起動すると、裏で app-server というプロセスが立ち上がります。
このサーバは、標準入力(stdin)でJSONを受け取り、標準出力(stdout)にJSONを返します。
CLIは、その既製フロントエンドに過ぎない、と捉えると腑に落ちました。
2. Node.jsで作る最小構成のCodexクライアント
自作クライアントからApp Serverにアクセスすると、
- どの操作を許可するか
- どの情報をCodexに渡すか
- どこで人間が介入するか
これらを自分のルールで固定したかった、というのが正直な動機です。
最小構成では、やっていることは本当に単純です。
codex app-serverを子プロセスとして起動- stdinにJSONを1行ずつ送る(JSONL:1行1JSON)
- stdoutからJSONを1行ずつ読む
HTTPすら使いません。
「対話するプログラム」として見ると、かなり素朴です。
2.1. ステップ1:App Serverを起動する
Node.jsなら child_process.spawn が素直です。
import { spawn } from "node:child_process";
const proc = spawn("codex", ["app-server"], {
stdio: ["pipe", "pipe", "inherit"], // stderrはそのまま見えるように
});Code language: JavaScript (javascript)
私は最初、stderrを捨ててしまって原因調査に時間を溶かしました。
最小構成こそ、ログは見えるようにしておくのが楽です。
2.2. ステップ2:送受信(stdin/stdout)を組み立てる
App Serverは「stdinに1行JSONを入れると、stdoutに1行JSONが出てくる」方式です。
このため、stdoutを1行ずつ読む必要があります。
import readline from "node:readline";
const rl = readline.createInterface({ input: proc.stdout });
function send(msg) {
proc.stdin.write(JSON.stringify(msg) + "\n");
}Code language: JavaScript (javascript)
ここでの \n はとても大事です。
改行がないと、サーバ側が「まだ1メッセージが終わっていない」と待ち続けます。
2.3. ステップ3:初期ハンドシェイク(initialize → initialized)
まず最初に、初期化のやりとりをします。
この順序が崩れると、その後のメソッドが通らないことがあります。
send({
method: "initialize",
id: 0,
params: {
clientInfo: { name: "my_client", title: "My Codex Client", version: "0.1.0" },
},
});
send({ method: "initialized", params: {} });Code language: CSS (css)
2.4. ステップ4:thread/start → turn/start で会話を動かす
Codexでは、会話の単位が次の2段になっています。
- thread:会話全体(文脈の箱)
- turn:その箱の中の1回のやりとり
まず thread/start で threadId を得て、次に turn/start で入力を流します。
最小例では、ツール実行を誘発しにくい入力(翻訳など)にしておくと理解が速いです。
let threadId = null;
rl.on("line", (line) => {
const msg = JSON.parse(line);
// thread/start のレスポンスから threadId を拾う
if (msg.id === 1 && msg.result?.thread?.id && !threadId) {
threadId = msg.result.thread.id;
// 1ターン開始
send({
method: "turn/start",
id: 2,
params: {
threadId,
input: [{ type: "text", text: "次の文を英語にして: こんにちは" }],
},
});
return;
}
// ストリーミングで返るテキスト差分を表示
if (msg.method === "item/agentMessage/delta") {
const delta = msg.params?.delta ?? "";
process.stdout.write(delta);
return;
}
// ターン完了
if (msg.method === "turn/completed") {
process.stdout.write("\n");
proc.kill();
}
});
// thread開始(モデル指定)
send({ method: "thread/start", id: 1, params: { model: "gpt-5.1-codex" } });Code language: JavaScript (javascript)
この例は「一番動くところだけ」を残しています。
本気で使うなら、通知の種類をもう少し丁寧に扱った方が安全です。
2.5. ステップ5:実行方法(最小)
Node 20以降でESMを使う前提なら、例えばこんな感じです。
node minimal-codex-client.mjsCode language: CSS (css)
TypeScriptにする場合は、最初は tsx のような実行環境を使うと試行錯誤が早いです。
私は最初、ビルドまわりで脱線しました。
3. CLIより“硬い”使い方ができる
自作クライアントでは、
- 実行できる操作を制限する
- プロンプトの形式を固定する
- 必ず差分表示を挟む
といったことを、人の注意力に頼らず実装できます。
3.1. 正直に言うと、課題もあります
App Serverはまだ発展途上に見えます。
通知の種類や細かい挙動は、今後変わる可能性があります。
そのため、私は次の方針で作っています。
- 厳密に作り込みすぎない(まず動くもの)
- 受け取ったメッセージはできるだけログに残す
- 想定外の通知が来ても落ちないようにする
完璧さより、継続できる形を優先しています。
CLIに少しでも窮屈さを感じたなら、App Serverを直に触ってみる価値はあると思います。