【app-server】
Codex CLIは「基盤」としても使える

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に渡すか
  • どこで人間が介入するか

これらを自分のルールで固定したかった、というのが正直な動機です。

最小構成では、やっていることは本当に単純です。

  1. codex app-server を子プロセスとして起動
  2. stdinにJSONを1行ずつ送る(JSONL:1行1JSON)
  3. 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を直に触ってみる価値はあると思います。