自分の UI と、Codex の実行をつなぐ
App Server は、Codex をアプリへ深く組み込むためのインターフェースです。クライアントが会話の開始やユーザー入力を送り、サーバーがエージェントの進捗や承認要求を返します。たとえば、チャット画面の横に「実行中のコマンド」と「承認ボタン」を表示するために使えます。
初期化を済ませ、1 ターンを送信し、ストリーミングと最終状態を受け取る Node.js クライアントを動かす。さらに、承認・再開・中断をどこに組み込むか説明できる。
この教材では、Node.js が子プロセスとして codex app-server を起動します。画面上のデモはブラウザ内だけで動くシミュレーションです。公開サイトから手元の Codex を操作することはありません。
| やりたいこと | まず検討する手段 | 扱う範囲 |
|---|---|---|
| 会話履歴・承認・進捗を独自 UI に統合 | App Server | 接続と状態を持つ双方向クライアント |
| ジョブや CI をプログラムから自動化 | Codex SDK | 公式資料が案内する自動化の入口 |
| CLI から単発の処理を実行 | codex exec | コマンドとしての非対話実行 |
| Codex に外部ツールやデータを提供 | MCP | エージェントが使う機能の拡張 |
App Server と MCP は、ともに JSON-RPC を使いますが、目的とメソッドは異なります。App Server の要求を、そのまま MCP サーバーに送ることはできません。
一次情報:App Server — 概要・Protocol / Codex SDK / Non-interactive mode / MCP
Thread → Turn → Item で考える
Thread は会話のまとまり、Turn は 1 回のユーザー入力と、それに続くエージェントの作業、Item はその中の個々の入出力です。1 つの返答を「文字列 1 本」として扱うより、途中経過を UI に表示しやすくなります。
| 識別子 | 何を特定するか | 例 |
|---|---|---|
id | JSON-RPC の要求と応答 | 3 |
threadId | どの会話か | thr_demo |
turnId | どの入力に対する作業か | turn_1 |
itemId | どのメッセージ・コマンド等か | msg_1 |
この表の値は説明用です。実接続では応答から受け取った値を使います。id: 3 の応答が返っても、ターンが終わったとは限りません。作業の終了は turn/completed の通知で判断します。
1 メッセージずつ、通信を進める
stdio 接続では、JSON オブジェクト 1 個を 1 行として送り、末尾に改行を付けます。一般的な JSON-RPC 2.0 の形式に基づきますが、通信上は "jsonrpc":"2.0" を省略します。複数行に整形した JSON をそのまま送ると、JSONL の区切りを壊します。
method + id は要求。応答は同じ id に result または error を付けて返します。
method があり、id がないメッセージは通知。通知に応答を返す必要はありません。
通信シミュレーター
外部通信・モデル呼び出しなし。メッセージは学習用に抜粋しています。実際には追加の通知が届き、応答と通知の到着順が前後することもあります。
受付と完了の間を観察する
- 「初期化を飛ばすと?」を押し、エラーの理由を確かめる。
- 最初から順に進め、
turn/startの応答で止める。 - まだ文章が完成していないことを確認し、
turn/completedまで進める。
一次情報:Protocol / Message schema / Initialization
実習用フォルダーを用意する
ターミナル操作と JavaScript の async/await が分かれば進められます。実習コードは Node.js 標準モジュールのみで動きます。Node.js 22 以上を推奨し、教材作成時は Node.js 26.8.2 と Codex CLI 0.154.0 で確認しています。
--live は実際のモデルに入力を送り、利用枠または API 料金を消費します。mkdir -p ~/codex-app-server-lab
cd ~/codex-app-server-lab
node --version次の ZIP をダウンロードして展開し、client.mjs、rpc.mjs、mock-server.mjs をこのフォルダーへ置きます。ZIP 内の README にも実行コマンドがあります。
# まだ Codex CLI がない場合
npm install -g @openai/codex
# インストール済みなら、まずバージョンとオプションを確認
codex --version
codex app-server --help
# 実モデルを使う場合はログイン状態を確認
codex login status
# 未認証の場合のみ実行し、表示される案内に従う
codex loginインストールされる最新版と本教材の検証版は一致しない場合があります。差異があるときは、手元のバイナリからスキーマを生成して確かめます。既存の認証情報を HTML やソースコードに貼り付ける必要はありません。
codex app-server generate-ts --out ./schemas-ts
codex app-server generate-json-schema --out ./schemas-json公式 Web ドキュメントは更新され、手元の環境と差異が生じる場合があります。一方、生成物は実行した Codex の版に対応します。フィールド名・必須項目・列挙値に迷ったら、生成された v2/TurnStartParams.json などを読むと、実装とのずれを減らせます。
一次情報:Codex CLI / Authentication / スキーマ生成
モックで動かし、本物に接続する
ネットワークなしで 1 ターンを完走する
node client.mjsおおむね次の結果が表示されます。モックは、通知を取りこぼさないか確かめるため、turn/start の応答より先に完了通知を送ります。
[ready] initialize → initialized
[thread] thr_demo
接続できました。これはモック応答です。
[turn] turn_1: completed
[final] 接続できました。これはモック応答です。本物への接続でも、最初の操作は同じです。initialize の応答を待ち、initialized を通知してから、他のメソッドを使います。初期化は接続ごとに 1 回です。
await rpc.request('initialize', {
clientInfo: {
name: 'app_server_handson',
title: 'App Server Hands-on',
version: '1.0.0'
}
});
rpc.notify('initialized');
// この後で thread/start などを送る初期化だけを確認する
node client.mjs --handshake[ready] initialize → initialized が表示され、プロセスが終了すれば成功です。接続・JSONL・初期化までを確かめた状態であり、認証やモデル利用の成功までは示しません。
実サーバーは既存の Codex 設定を読み込みます。起動時の管理設定取得など、モデル推論以外の通信が発生する場合があります。
実習には、機密ファイルを含まない練習用フォルダーを使ってください。
一次情報:Getting started / Initialization
会話を作り、最初の入力を送る
thread/start は会話の入れ物を作ります。ここでは cwd を練習用フォルダーに固定し、ファイル操作を読み取り専用に制限します。approvalPolicy: 'never' は「承認を求めない」という意味で、サンドボックスを解除する設定ではありません。
const { thread } = await rpc.request('thread/start', {
cwd: process.cwd(),
sandbox: 'read-only',
approvalPolicy: 'never'
});
const { turn } = await rpc.request('turn/start', {
threadId: thread.id,
input: [{
type: 'text',
text: 'ツールを使わず、日本語で「接続できました」とだけ返してください。',
text_elements: []
}]
});
// turn.id を保存。ここでは作業完了を判断しない。モデル名は固定せず、Codex の既存設定を使います。明示的に選ぶ場合は model/list で利用候補を確認し、実際に使う ID を指定します。プランや組織の設定によって利用可能なモデルは異なります。
短い返答を受け取る
node client.mjs --live[thread]に続く ID を控える。- テキストが届き、
[turn] ...: completedが出るまで待つ。 [final]の確定メッセージを確認する。モデルの出力や分割数は実行ごとに変わります。
利用枠を消費したくない場合は、この実習をスキップして LAB 5 に進めます。モックで検証できるのはクライアントの処理であり、実サービスの認証や応答ではありません。
client.mjs 全文を読む
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { RpcClient } from './rpc.mjs';
const args = process.argv.slice(2);
const live = args.includes('--live');
const handshake = args.includes('--handshake');
if (live && handshake) throw new Error('--live と --handshake は同時に使えません');
const resumeAt = args.indexOf('--resume');
const resumeId = resumeAt >= 0 ? args[resumeAt + 1] : null;
if (resumeAt >= 0 && (!resumeId || resumeId.startsWith('--'))) throw new Error('--resume に threadId が必要です');
const proc = live || handshake
? spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'inherit'] })
: spawn(process.execPath, [fileURLToPath(new URL('./mock-server.mjs', import.meta.url))], {
stdio: ['pipe', 'pipe', 'inherit'],
env: { ...process.env, GUIDE_MOCK_APPROVAL: args.includes('--approval') ? '1' : '0' }
});
const completed = new Map();
const messages = new Map();
const key = (threadId, turnId) => JSON.stringify([threadId, turnId]);
let rpc;
let deadline;
try {
rpc = new RpcClient(proc, {
onServerRequest: m => {
if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(m.method)) {
console.log(`[approval] ${m.method} → decline(教材の既定値)`);
return { decision: 'decline' };
}
// 未対応要求を許可したことにしない。RpcClient が -32601 を返す。
},
onNotification: m => {
const p = m.params;
if (m.method === 'item/agentMessage/delta') {
const k = JSON.stringify([p.threadId, p.turnId, p.itemId]);
messages.set(k, (messages.get(k) ?? '') + p.delta);
process.stdout.write(p.delta);
}
if (m.method === 'item/completed' && p.item?.type === 'agentMessage') {
const k = JSON.stringify([p.threadId, p.turnId, p.item.id]);
messages.set(k, p.item.text); // 確定内容で置換。delta に追記しない。
}
if (m.method === 'turn/completed') completed.set(key(p.threadId, p.turn.id), p.turn);
}
});
await rpc.request('initialize', { clientInfo: { name: 'app_server_handson', title: 'App Server Hands-on', version: '1.0.0' } });
rpc.notify('initialized');
console.log('[ready] initialize → initialized');
if (!handshake) {
const params = { cwd: process.cwd(), sandbox: 'read-only', approvalPolicy: 'never' };
const result = resumeId
? await rpc.request('thread/resume', { ...params, threadId: resumeId })
: await rpc.request('thread/start', params);
const threadId = result.thread.id;
console.log(`[thread] ${threadId}`);
const { turn } = await rpc.request('turn/start', {
threadId,
input: [{ type: 'text', text: 'ツールを使わず、日本語で「接続できました」とだけ返してください。', text_elements: [] }]
});
const until = Date.now() + 120000;
while (!completed.has(key(threadId, turn.id))) {
if (rpc.closed) throw new Error('完了通知の前に接続が終了しました');
if (Date.now() > until) {
// タイムアウトは完了ではない。停止を要求し、成功と表示しない。
await rpc.request('turn/interrupt', { threadId, turnId: turn.id }).catch(() => {});
throw new Error('turn timeout: 停止要求を送信しました。状態を確認してください');
}
await new Promise(resolve => { deadline = setTimeout(resolve, 20); });
}
const final = completed.get(key(threadId, turn.id));
console.log(`\n[turn] ${final.id}: ${final.status}`);
console.log('[final]', [...messages.values()].join('\n'));
if (final.status !== 'completed') throw new Error(`turn ${final.status}: ${final.error?.message ?? ''}`);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
clearTimeout(deadline);
rpc?.close();
if (!rpc) proc.kill();
}
一次情報:API overview — thread/start・turn/start・model/list / Sandbox と approvals
届いた JSON を、3 方向に振り分ける
接続中は常に受信を待ち続けます。サーバーはクライアントに要求を送ることもあるため、id だけを見て「自分の要求への応答」と判断してはいけません。まず method の有無を確認します。
const hasId = Object.hasOwn(message, 'id');
if (message.method && hasId) {
// サーバーからの要求。ユーザーの判断などを返す。
} else if (message.method) {
// 通知。画面やターン状態を更新する。
} else if (hasId) {
// 応答。pending.get(message.id) を解決する。
}if (message.id) では ID が 0 の要求を見落とします。また、リクエストと応答の到着順が同じとは限らないため、待機中の要求は Map で管理します。要求送信前から受信処理を登録しておくことも重要です。
delta は追記、completed は確定
| 通知 | UI の更新 | 注意点 |
|---|---|---|
item/started | メッセージや作業の表示枠を用意 | テキスト以外の Item もある |
item/agentMessage/delta | 該当 Item に文字列を追記 | 会話・ターン・Item の ID で分離する |
item/completed | 確定した Item で表示を置換 | 確定テキストの再追記は二重表示になる |
turn/completed | ターンの処理中表示を終了 | status を確認。成功とは限らない |
教材では threadId + turnId + itemId をキーに本文を管理し、threadId + turnId をキーに完了通知を保存します。これにより、開始応答を処理する前に届いた完了通知も拾えます。画面へ表示するときは textContent などを使い、受信文字列をそのまま HTML として挿入しません。
turn/completed は終了を表す通知です。status が completed、failed、interrupted のどれかを見て、成功・失敗・中断を分けて表示します。rpc.mjs 全文を読む
import readline from 'node:readline';
// 受信順に依存せず、応答・通知・サーバー要求を振り分ける。
export class RpcClient {
constructor(proc, { onNotification = () => {}, onServerRequest, timeoutMs = 15000 } = {}) {
this.proc = proc;
this.pending = new Map();
this.nextId = 1;
this.timeoutMs = timeoutMs;
this.closed = false;
this.rl = readline.createInterface({ input: proc.stdout });
this.rl.on('line', line => {
try {
const m = JSON.parse(line);
const hasId = Object.hasOwn(m, 'id');
if (m.method && hasId) {
// 両方向の id は別物。同じ値でも pending の応答として扱わない。
Promise.resolve().then(() => onServerRequest?.(m)).then(result => {
this.send(result === undefined
? { id: m.id, error: { code: -32601, message: 'Unsupported server request' } }
: { id: m.id, result });
}).catch(() => {
if (!this.closed) this.send({ id: m.id, error: { code: -32603, message: 'Client handler failed' } });
});
} else if (m.method) {
onNotification(m);
} else if (hasId) {
const p = this.pending.get(m.id);
if (!p) return;
clearTimeout(p.timer);
this.pending.delete(m.id);
m.error ? p.reject(new Error(`${m.error.code}: ${m.error.message}`)) : p.resolve(m.result);
}
} catch (error) { this.fail(error); }
});
proc.on('error', error => this.fail(error));
proc.on('exit', (code, signal) => this.fail(new Error(`server exit: ${code ?? signal}`)));
proc.stdin.on('error', error => this.fail(error));
proc.stdout.on('error', error => this.fail(error));
}
send(message) {
if (this.closed) throw new Error('connection closed');
this.proc.stdin.write(JSON.stringify(message) + '\n');
}
request(method, params = {}) {
if (this.closed) return Promise.reject(new Error('connection closed'));
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`timeout: ${method}; 結果不明のため自動再送しません`));
}, this.timeoutMs);
this.pending.set(id, { resolve, reject, timer });
try { this.send({ id, method, params }); }
catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
});
}
notify(method, params = {}) { this.send({ method, params }); }
fail(error) {
this.closed = true;
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); }
this.pending.clear();
}
close() {
this.fail(new Error('client closed'));
this.rl.close();
this.proc.stdin.end();
this.proc.kill();
}
}
モックの実装と、その限界
モックは初期化、開始・再開、テキスト通知、承認の一部だけを再現します。実サーバーの完全なスキーマや実行環境を代替するものではありません。
import readline from 'node:readline';
const send = m => process.stdout.write(JSON.stringify(m) + '\n');
let initialized = false;
let acknowledged = false;
let approvalId;
let turn = 0;
const finish = () => {
const ids = { threadId: 'thr_demo', turnId: `turn_${turn}`, itemId: `msg_${turn}` };
send({ method: 'item/started', params: { ...ids, item: { id: ids.itemId, type: 'agentMessage', text: '' } } });
for (const delta of ['接続できました。', 'これはモック応答です。']) {
send({ method: 'item/agentMessage/delta', params: { ...ids, delta } });
}
send({ method: 'item/completed', params: { ...ids, item: { id: ids.itemId, type: 'agentMessage', text: '接続できました。これはモック応答です。' } } });
send({ method: 'turn/completed', params: { threadId: ids.threadId, turn: { id: ids.turnId, status: 'completed', items: [], error: null } } });
};
readline.createInterface({ input: process.stdin }).on('line', line => {
const m = JSON.parse(line);
if (!m.method) {
if (m.id === approvalId) {
send({ method: 'serverRequest/resolved', params: { threadId: 'thr_demo', requestId: m.id } });
finish();
}
return;
}
if (m.method === 'initialize') {
if (initialized) return send({ id: m.id, error: { code: -32600, message: 'Already initialized' } });
initialized = true;
return send({ id: m.id, result: { userAgent: 'guide-mock/1.0', platformFamily: 'unix', platformOs: 'mock' } });
}
if (m.method === 'initialized') { acknowledged = initialized; return; }
if (!acknowledged) return send({ id: m.id, error: { code: -32600, message: 'Not initialized' } });
if (m.method === 'thread/start' || m.method === 'thread/resume') {
return send({ id: m.id, result: { thread: { id: 'thr_demo' } } });
}
if (m.method === 'turn/start') {
turn++;
send({ method: 'turn/started', params: { threadId: 'thr_demo', turn: { id: `turn_${turn}`, status: 'inProgress', items: [], error: null } } });
if (process.env.GUIDE_MOCK_APPROVAL === '1') {
approvalId = m.id; // 同じ id の逆向き要求でも衝突しないことを試す。
send({ method: 'item/commandExecution/requestApproval', id: approvalId, params: { threadId: 'thr_demo', turnId: `turn_${turn}`, itemId: 'cmd_demo', command: 'echo demo', cwd: '/tmp', reason: '教材の模擬承認' } });
} else finish(); // 応答より先に完了通知を送り、取りこぼしを試す。
send({ id: m.id, result: { turn: { id: `turn_${turn}`, status: 'inProgress', items: [], error: null } } });
return;
}
send({ id: m.id, error: { code: -32601, message: 'Method not found' } });
});
承認では、サーバーが質問する側になる
設定によって、コマンド実行やファイル変更に承認が必要になります。このときサーバーから method + id を持つ要求が届きます。クライアントは対象操作をユーザーに示し、サーバーが付けた ID をそのまま使って応答します。
{
"id": 81,
"method": "item/commandExecution/requestApproval",
"params": {
"threadId": "thr_demo", "turnId": "turn_1",
"itemId": "cmd_demo", "command": "echo demo",
"cwd": "/tmp", "reason": "教材の模擬承認"
}
}{ "id": 81, "result": { "decision": "decline" } }accept は承認、decline は拒否です。acceptForSession など追加の選択肢もありますが、権限の範囲を広げる判断を UI が勝手に行ってはいけません。availableDecisions が届く場合は、その選択肢と手元のスキーマに従います。未対応のサーバー要求に空の成功応答を返すことも避けます。
逆向きの要求を受け取る
node client.mjs --approval[approval] ... → decline を確認します。モックは要求と逆向き要求に同じ ID を使います。受信処理が method を先に判定していれば、取り違えずに応答できます。
このモックでは拒否した後も模擬テキストを返します。実サーバーで承認を拒否した後のエージェントの行動や終了状態は、作業内容によって変わります。
UI に組み込むときの表示項目
対象の会話・ターン・Item に加え、コマンド、作業ディレクトリ、理由を確認できるようにします。ファイル変更なら変更内容を示します。ネットワーク承認の networkApprovalContext がある場合は、宛先ホスト・プロトコル等を示し、通常のコマンド承認と区別します。別クライアント等で解決された要求は serverRequest/resolved で画面から閉じます。
「続きを送る」と「今の作業に追加する」を分ける
| 意図 | メソッド | 必要な考え方 |
|---|---|---|
| 保存した会話を再開 | thread/resume | 返された Thread に次の Turn を送る |
| 履歴を読むだけ | thread/read | 読む操作だけでは会話を再開しない |
| 進行中の入力へ補足 | turn/steer | 対象の expectedTurnId が一致する必要がある |
| 進行中の作業を中断 | turn/interrupt | 応答後も turn/completed を待つ |
| 履歴から別会話に分岐 | thread/fork | 新しい Thread ID を持つ会話を作る |
同じ Thread にもう 1 ターン送る
モックで引数と呼び出し経路を試す場合:
node client.mjs --resume thr_demo実際の保存と再開を確認する場合は、LAB 4 で控えた ID に置き換えて実行します。この操作も実モデルを呼び出します。
node client.mjs --live --resume <控えたthreadId>実接続では同じ Thread ID と、新しい Turn ID が表示されることを確認します。モックの履歴は保存されず、毎回固定 ID を返すため、永続化の検証にはなりません。
進行中のターンを操作する
以下はクライアント内で使う追加実装の例です。threadId と turnId には対象の実 ID を渡し、ターンが進行中のときだけ実行します。
// 今のターンへ補足。新しい turn/started は発生しない。
await rpc.request('turn/steer', {
threadId,
expectedTurnId: turnId,
input: [{ type: 'text', text: '説明を3項目にまとめてください。', text_elements: [] }]
});
// 停止要求。応答の {} を作業完了と解釈しない。
await rpc.request('turn/interrupt', { threadId, turnId });
// 受信を続け、対象ターンの turn/completed と status を確認する。中断は、すでに実行された副作用を巻き戻す操作ではありません。また、接続が切れたこととターンが終わったことは別です。再接続時は初期化をやり直し、保存済み履歴・状態を確認してから画面を復元します。ターン作成を無条件に再送すると、作業が重複する可能性があります。
一次情報:thread/resume・thread/read・thread/fork / Steer an active turn / Interrupt a turn
止まった場所から、原因を絞る
| 症状 | 最初に確認すること | 対処 |
|---|---|---|
spawn codex ENOENT | 同じターミナルで codex --version が動くか | CLI のインストールと PATH を確認 |
Not initialized | 接続後に初期化を済ませたか | initialize の応答 → initialized → 通常の要求 |
Already initialized | 同じ接続へ initialize を 2 回送っていないか | 接続単位で初期化状態を管理 |
| JSON の解析に失敗 | stdout にデバッグ文字列を混ぜていないか | JSONL は stdout、診断ログは stderr に分離 |
| 開始したまま止まる | サーバーからの承認要求を無視していないか | method + id のメッセージを処理 |
| 同じ文章が 2 回表示 | delta と確定全文を両方追記していないか | item/completed は確定値で置換 |
| 認証・モデル・利用枠のエラー | 接続成功と実行成功を混同していないか | ログイン状態、model/list、エラー情報を確認 |
| フィールドが拒否される | CLI の版とドキュメントが一致するか | スキーマを生成。実験 API の opt-in も確認 |
WebSocket の -32001 | 要求が集中していないか | 指数バックオフとジッターで再試行を調整 |
通信方式を増やす前に
stdio は子プロセスと組み合わせやすい入口です。WebSocket は 1 テキストフレームに 1 メッセージを載せます。Unix socket の接続も単なる JSON の直書きではなく、WebSocket の HTTP Upgrade ハンドシェイクを使います。
リモート接続に進む場合は、公式資料の認証フラグと TLS の手順を確認します。平文の ws:// は localhost または SSH 転送で使い、ネットワークへ公開する際は認証を設定します。本教材の HTML にトークンを埋め込んだり、ローカルの App Server をそのまま公開したりする構成は採りません。
一次情報:Protocol — transport・認証・過負荷 / Experimental API opt-in / 実験機能とサポート範囲
実装の判断ができるか確かめる
各問で答えを選ぶと、理由を表示します。理解が曖昧なら対応する章へ戻り、通信をもう一度追ってください。
Q1. turn/start の result が返った。画面は?
Q2. method と id: 0 を持つメッセージが届いた。
Q3. delta の後に item/completed の全文が届いた。
Q4. 実行中の同じターンに補足を入れたい。
発展課題:最小クライアントから自分の UI へ
- 複数の Item を個別の行に表示し、進行中・完了・失敗を区別する。
- 承認要求をキューで管理し、対象と理由を表示してから判断を返す。
- 切断時は「状態未確認」と表示し、再接続後に履歴と照合する。
- UI から停止要求を送り、対象ターンの終了通知まで観察する。
完成条件は、途中経過が見えること、拒否や失敗も表示できること、同じ作業を勝手に重複実行しないことです。
一次情報と、教材の検証範囲
仕様の説明は OpenAI の公式ドキュメントをもとに構成し、コード・図・シミュレーターは学習用に作成しました。公式サンプルの省略形と、手元のスキーマで必要な構造を区別しています。確認日:。
- Codex App Server 公式ガイド — 通信、初期化、Thread / Turn / Item、承認、認証、transport。
- openai/codex — App Server 実装 — 実装側の仕様を調べる入口。
- Codex CLI / Authentication — 導入と認証。
- Codex SDK / Non-interactive mode / MCP — 用途別の選択肢。
- StellarArc design-system — カラー、タイポグラフィ、文書レイアウト、inline SVG 規定。
検証済み:Codex CLI 0.154.0 のスキーマ生成、ローカル App Server の初期化、モックのターン処理・承認、受信処理のテスト、表示と操作のブラウザ検証。
実モデルの呼び出し:教材作成時は未実施です。LAB 4 と実接続の LAB 6 は、利用者が自身の認証・利用枠で実施する手順として掲載しています。
表現レビュー:Gemini 3.8 Flash High によるレビュー後、仕様・識別子・コードへの影響を確認して表現を調整しています。
ブラウザの実習チェックは localStorage に保存します。外部への送信は行いません。Web フォントの読み込みには Google Fonts を使い、通信できない場合はローカル書体へ切り替わります。
