V VibeCoding 365
목록으로 Codex app-server vs ChatGPT Remote

바이브코딩

Codex app-server | ChatGPT Remote와 갈리는 조건

사람용 완제품과 프로그램이 붙는 JSON-RPC 인터페이스는 원격이라는 말만 같다.

ChatGPT Remote는 사람이 모바일 앱과 데스크톱 앱으로 데스크톱 Codex 작업을 이어 보고 승인하는 완제품이다. codex app-server는 그 완제품이 내부에서 쓰는 프로토콜 인터페이스에 가깝다. 둘 다 「원격」처럼 보이지만, 조작 주체가 사람과 프로그램으로 갈린다. 이 차이를 놓치면 앱으로 끝날 일을 JSON-RPC 클라이언트로 키우거나, 반대로 제품 안 임베딩을 QR 스캔으로 오해한다.

휴대폰으로 데스크톱 Codex를 조종하고 싶을 때 누군가는 QR만 찍으라고 하고, 누군가는 프로토콜을 구현하라고 한다. 사람이 멀리서 상태를 보고 버튼을 누르면 Remote로 충분하다. 프로그램이 Codex를 호출하고 턴 중간에 개입해야 하면 app-server까지 내려간다.

이 문서는 둘 중 무엇이 고급인지 가리지 않는다. 작업이 어느 쪽 문제인지를 가른다. app-server API는 빠르게 바뀌므로 문서 문장을 베껴 타입을 손으로 적지 않고, 사용 중인 바이너리의 generate-ts 결과와 대조한다. 여기에 없는 OpenAI 제품 이름을 새로 만들지 않는다.

ChatGPT Remote 앱

사람이 폰에서 기존 작업을 이어 가고, 승인하고, diff와 테스트 결과를 보는 흐름은 Remote에 이미 있다. 데스크톱 앱에서 Remote를 켜고 QR을 폰으로 찍고, 같은 계정과 워크스페이스를 확인한다. 준비물은 최신 ChatGPT 모바일 앱, 최신 데스크톱 앱, 깨어 있고 온라인인 호스트다. 회사 워크스페이스면 관리자가 Remote 접근을 열어 두었는지도 앞선다.

폰에서 Remote 메뉴가 안 보이면 모바일과 데스크톱의 최신 버전, 같은 계정과 워크스페이스, 회사면 관리자 쪽 Remote 허용, 설정 시작점이 앱 안인지를 본다. Remote connections 문서가 이 구간의 1차 출처다.

remoteControl/* 계열 메서드는 ChatGPT 앱을 대체하는 자체 클라이언트를 만드는 경우에만 의미가 있다. 실험적 범주로 보는 편이 안전하다. 원격 제어를 끈다고 이미 등록된 기기 권한까지 자동으로 사라지는 것은 아니므로, 등록된 컨트롤러 목록과 revoke 흐름을 따로 관리한다.

QR은 데스크톱 호스트와 폰 앱을 같은 계정으로 묶는 pairing이다. 호스트가 꺼져 있거나 오프라인이면 폰의 Remote 화면은 이어 볼 대상이 없다. 회사 워크스페이스가 Remote를 닫아 두면 메뉴가 안 보인다. 앱 버전이 어긋나도 같다. 설정 시작점이 웹이 아니라 앱 안인지를 Remote connections 문서가 가른다. 이 흐름은 JSON-RPC 클라이언트를 짜지 않는다. 사람이 승인 버튼을 누르는 완제품이다.

준비물 세 가지가 동시에 맞아야 한다. 최신 ChatGPT 모바일 앱, 최신 데스크톱 앱, 깨어 있고 온라인인 호스트다. 같은 계정과 같은 워크스페이스인지도 확인한다. 회사면 관리자가 Remote 접근을 열어 두었는지를 앱 메뉴보다 먼저 본다. 메뉴가 안 보이면 버전, 계정, 워크스페이스, 관리자 허용, 설정 시작점 순으로 가른다. 이 점검이 끝나기 전에 app-server listen을 공개 주소에 올리는 구성은 공식 문서가 금지하는 쪽에 가깝다.

핵심 포인트: 사람 한 명이 폰에서 상태를 보고 승인하는 목표면 Remote가 프로토콜 학습보다 짧다.
Remote 완제품과 app-server 인터페이스
그림 1. Remote는 사람용 완제품이고, app-server는 그 자리에 자기 코드를 앉힐 때 쓰는 인터페이스다.

app-server JSON-RPC

app-server를 직접 다룬다는 말은 ChatGPT 앱 자리에 자기 프로그램을 앉히겠다는 뜻이다. 사내 포털이나 CI 화면에서 스트리밍 출력과 승인을 코드로 받아야 할 때 등장한다.

HTTP 서버처럼 생각하면 처음부터 틀린다. codex app-server는 데몬을 띄워 리버스 프록시 뒤에 두는 웹 서버가 아니다. 기본 전송은 stdio이고, 줄 단위 JSON의 양방향 JSON-RPC 2.0에 가깝다. 자기 앱이 자식 프로세스로 띄우고 stdin과 stdout으로 대화한다고 보는 편이 맞다.

전송플래그상태
stdio--stdio (기본)안정
unix socket--listen unix://...로컬 제어용
websocket--listen ws://...실험적, 미지원
off--listen off로컬 전송 비노출

공식 문서는 원격 연결에 SSH를 쓰라고 하고, 공유나 공개 네트워크에 app-server 전송을 직접 노출하지 말라고 못 박는다. 바깥에서 접근해야 하면 VPN이나 메시 네트워킹으로 묶고, 원격 개발 환경이 필요하면 데스크톱 앱의 SSH 호스트 연결이 안전하다.

MCP와 app-server는 둘 다 양방향 JSON-RPC라는 점은 비슷하다. 역할은 다르다. MCP는 모델에게 도구를 건네는 규격이고, app-server는 스레드, 승인, 샌드박스, 실행 흐름까지 포함해 Codex 에이전트 전체를 구동하는 인터페이스에 가깝다.

API 표면은 빠르게 움직인다. deprecated와 실험 메서드, 개발 중인 플러그인 계열이 혼재한다. README를 읽고 타입을 손으로 적기 시작하면 버전이 한 번만 바뀌어도 어긋난다.

구분ChatGPT Remotecodex app-server
정체사람이 쓰는 완제품프로그램이 붙는 인터페이스
조작 주체사람호출하는 코드
설정 비용앱 설정과 QRJSON-RPC 클라이언트
승인앱 버튼승인 핸들러 구현
전송OpenAI 측 원격 제어 흐름기본 stdio, 선택적 로컬 리스너
맞는 일이동 중 감독과 승인자동화, 임베딩, 커스텀 UI

app-server가 필요한 경우는 넷이다. Codex를 제품 안에 넣을 때. 사내 개발 포털, CI 대시보드, 이슈 화면에서 실패를 고치는 버튼을 누르면 그 자리에서 스트리밍과 승인이 필요하다. 실행 중간에 프로그램이 개입해야 할 때. 진행 중인 턴에 메시지를 주입하거나 끊어야 하면 앱 UI만으로는 안 된다. 승인 정책을 코드로 결정할 때. 특정 디렉터리만 자동 승인하거나, 무응답 5분이면 거부하는 규칙을 코드로 적는다. 다른 프레임워크와 섞을 때. 예로 AI SDK 계층에서 Codex를 제공자로 감싸려면 프로토콜 수준 접근이 필요하다. 넷 중 하나도 아니면 완제품이 낫다.

SDK를 쓰면 일상 사용에서 세부가 많이 가려진다. 모든 기능이 SDK에 바로 노출되는 것은 아니어서, 실행 중 개입 같은 제어가 필요하면 프로토콜 이해가 다시 필요하다.

제품 임베딩, 중간 개입, 코드로 정하는 승인, 프레임워크 연동 넷 중 하나도 없으면 Remote 완제품이 낫다.

자식 프로세스로 codex app-server를 띄우면 stdin이 요청 줄이고 stdout이 응답과 알림 줄이다. 리버스 프록시 뒤에 포트를 여는 구성이 아니다. unix socket은 같은 기계의 다른 프로세스가 붙을 때 쓴다. websocket listen은 실험적이고 미지원이라 운영 경로가 아니다. 공개 네트워크에 이 전송을 올리면 SSH나 VPN이 아니라 app-server 자체가 노출된다. 공식 문서가 원격에 SSH를 쓰라고 못 박는 지점이다. deprecated 메서드와 실험 메서드, 개발 중인 플러그인 계열이 README에 같이 있으면, 손 타입이 어느 범주인지 가리지 못한다. generate-ts가 사용 중인 바이너리의 표면만 남긴다.

thread turn item

API 전체는 이 세 계층 위에서 움직인다. Thread는 사용자와 Codex 사이의 대화 하나다. 여러 턴을 담고, 저장되며, resume이나 fork의 대상이다. Turn은 대화의 한 차례다. 보통 사용자 입력으로 시작해 에이전트 메시지나 작업 종료로 닫힌다. Item은 턴 안의 개별 요소다. 사용자 메시지, 에이전트 메시지, 셸 실행, 파일 편집, 추론 조각이 여기 들어간다.

메서드 이름이 thread/start, turn/start, item/completed 형태로 보이는 이유가 여기 있다. thread/start에서 cwd, 승인 정책, 샌드박스, 퍼스낼리티가 걸린다. cwd는 신뢰된 프로젝트 상태 같은 부수 효과를 만들 수 있다. 레거시 sandbox와 실험적 permissions를 한 요청에 같이 보내면 거부될 수 있다.

흐름은 initialize, initialized, thread/start, turn/start, item 시작과 델타와 완료, turn/completed 순이다. 델타 알림을 꺼 두고 최종 메시지만 기다리면 턴이 빈 것처럼 보인다. thread/shellCommand와 process/spawn은 설계에 따라 별도 실행 창구가 될 수 있다. 최종 사용자에게 노출되는 제품이면 기본으로 감추고, 관리자 흐름에서만 연다.

Thread 하나가 여러 Turn을 담는다. Turn 하나가 여러 Item을 담는다. 사용자 입력이 Turn을 열고, 에이전트 메시지와 셸과 파일 편집이 Item으로 쌓인 뒤, 작업 종료가 Turn을 닫는다. resume은 그 Thread를 다시 연다. fork는 한 지점에서 새 Thread를 만든다. item/completed를 구독하지 않으면 셸 출력이 UI에 안 보이고, 턴만 끝난 것처럼 보인다. cwd와 승인 정책은 Thread를 열 때 걸리므로, Turn마다 바꾸려면 새 Thread가 필요할 때가 많다.

thread turn item 계층
그림 2. thread는 대화, turn은 한 차례, item은 그 안의 메시지와 실행 조각이다.

initialize

연결 하나당 정확히 한 번, 다른 메서드보다 먼저 initialize를 보낸다. initialized 알림까지 보낸 뒤에야 나머지 요청이 유효하다. 이 단계를 빼먹으면 "Not initialized"가 나고, 두 번 호출하면 "Already initialized"가 온다. 통합 초기에 제일 자주 보는 실패다.

initialize 요청 본문의 모양은 다음 블록이다.

{
  "method": "initialize",
  "id": 0,
  "params": {
    "clientInfo": {
      "name": "my_app",
      "title": "My Application",
      "version": "0.1.0"
    }
  }
}

id는 JSON-RPC 요청 식별자다. 알림에는 id가 없고, 응답은 이 번호로 짝을 맞춘다. clientInfo.name은 로그와 운영 태그에서 어느 통합인지 가리는 데 쓰인다. title과 version은 사람이 읽는 표기다. 이 객체를 빼거나 순서를 뒤집고 thread/start를 먼저 보내면 "Not initialized"다.

한 줄은 JSON 객체 하나다. 클라이언트가 stdin에 initialize 요청 줄을 쓰고, stdout에서 그 id의 응답을 받은 뒤, initialized 알림 줄을 기다린다. 알림은 id가 없으므로 응답 대기 루프에 넣으면 그 줄에서 영원히 막힌다. initialized 뒤에야 thread/start가 유효하다. thread/start 응답이 오면 turn/start를 보낸다. 그다음 stdout에는 item 시작, 델타, item/completed, turn/completed가 알림으로 섞인다. HTTP 상태 코드는 이 전송에 없다. 거절은 JSON-RPC 오류 객체다. 큐가 포화되면 코드 -32001이 그 객체에 실린다.

SDK는 initialize와 thread/start의 세부를 가린다. 진행 중인 턴에 메시지를 주입하거나 끊는 제어가 필요하면 그 가림을 걷고 generate-ts 산출물의 메서드 이름을 본다. 손 타입으로 메서드를 지어 내면 바이너리가 한 번만 바뀌어도 컴파일이 통과한 채로 런타임이 깨진다.

멀티유저 서비스처럼 app-server를 서버로 띄워 여러 사용자가 붙게 만드는 방향은 공식 권장과 거리가 있다. 기본 전송이 stdio이고, 공개 네트워크 노출을 피하라는 지침이 분명하다. 사용자별 격리와 상위 서버 계층을 따로 두는 편이 안전하다.

한 프로세스의 stdin을 여러 사용자가 나눠 쓰면 cwd와 승인 큐가 섞인다. 사용자별 자식 프로세스, 또는 상위 서버가 사용자마다 app-server를 띄우는 쪽이 격리에 가깝다. initialize의 clientInfo.name이 로그에서 어느 통합인지 가려도, 같은 프로세스면 큐 포화 -32001이 누구 것인지만 남을 수 있다.

generate-ts

타입과 스키마를 app-server 버전에 맞춰 생성해 고정한다. 산출물을 CI에 넣고 diff가 생기면 리뷰 없이 넘어가지 않게 막는다. 바이너리 버전도 latest 대신 고정한다. 생성 명령의 예는 다음 블록이다.

codex app-server generate-ts --out ./src/generated
codex app-server generate-json-schema --out ./schema

첫 줄은 TypeScript 타입을 ./src/generated에 쓴다. 둘째 줄은 JSON Schema를 ./schema에 쓴다. README 문장을 베껴 인터페이스를 손으로 적으면, 바이너리가 한 번만 바뀌어도 컴파일이 통과한 채로 런타임이 깨진다. 사용 중인 바이너리의 generate-ts 결과가 대조 기준이다.

app-server는 경계가 있는 큐를 쓰고, 포화되면 -32001 같은 과부하 에러로 새 요청을 거절할 수 있다. 즉시 재시도 루프가 아니라 지수 백오프와 지터가 기본이다. 이 에러가 자주 보이면 요청을 너무 빠르게 밀고 있다는 뜻이다.

운영 쪽에서는 구조화 로그, turn/completed의 토큰 사용량, 어떤 통합에서 난 턴인지 구분하는 태그가 같이 간다. resume 직후 복원되는 사용량 이벤트가 있으면 UI에 같이 반영한다. 문서만 읽고 넘기기 쉬운 부분인데, 이 셋이 없으면 프로토콜 통합이 운영에서 빨리 흐려진다.

CI에 넣은 generate-ts 산출물은 바이너리를 올린 날 diff로 터진다. 그 diff를 리뷰 없이 머지하면, 컴파일이 통과한 클라이언트가 런타임에 없는 필드를 보낸다. json-schema 산출물은 다른 언어 클라이언트가 같은 표면을 따르게 한다. latest 태그를 따라가면 생성물과 프로세스가 어긋난다. 버전을 고정한 뒤에만 스키마를 다시 뽑는다. -32001은 큐 포화다. 같은 연결에 턴을 겹쳐 밀면 stdin 버퍼가 찬다. 지수 백오프는 그 연결을 쉬게 한다. 즉시 재시도는 포화를 유지한다.

generate-ts는 사용 중인 바이너리가 받는 필드 이름만 남긴다. README의 deprecated 메서드와 실험 메서드, 개발 중인 플러그인 계열을 손으로 고르면 어느 범주인지 가리지 못한다. json-schema는 TypeScript가 아닌 클라이언트가 같은 initialize와 thread/start 표면을 따르게 한다. CI가 ./src/generated와 ./schema diff를 리뷰 없이 머지하면, 컴파일이 통과한 채 런타임에 없는 필드를 보내게 된다. 바이너리 버전도 latest 대신 고정한다. 고정한 뒤에만 스키마를 다시 뽑는다.

승인으로 턴이 멈추는 이유

에이전트가 샌드박스 밖 명령을 실행하거나 워크스페이스 밖 파일을 건드리려 하면, app-server는 클라이언트에게 역방향 승인 요청을 보낸다. 승인 핸들러를 구현하지 않으면 턴이 멈춘 것처럼 보인다. 설계 때 자동 승인 범위, 사람 승인 대기 시간, 로그 포맷을 먼저 정한다.

무인 자동화라면 승인 없는 범위로 좁히는 편이 낫고, 사람 승인 UI라면 타임아웃 후 거부를 기본값으로 둔다. 「승인 UI는 나중에」로 시작하면 실제로는 가장 먼저 막힌다. 턴이 시작됐는데 아무 반응이 없는 경우 대부분이 여기다. 그다음은 initialize 순서를 잘못 처리했거나, 델타 알림을 꺼 두고 최종 메시지만 기다리는 경우다.

Remote 앱의 승인 버튼은 이 역방향 요청을 사람이 받게 만든 화면이다. app-server를 직접 붙이면 그 버튼을 코드가 대신한다. 특정 디렉터리만 자동 승인하거나, 무응답 5분이면 거부하는 규칙은 핸들러 안에 적는다. 프롬프트에 「알아서 승인」만 적으면 턴이 멈춘다.

역방향 요청은 turn/start의 응답이 아니다. 에이전트가 샌드박스 밖 명령이나 워크스페이스 밖 파일을 건드리려는 순간에, 서버가 클라이언트에게 결정을 묻는다. 핸들러가 stdout의 그 줄을 읽고 승인 또는 거부를 stdin에 쓰지 않으면, 그 요청은 큐에 남고 턴은 멈춘 것처럼 보인다. 무인 자동화는 승인 없는 범위로 cwd와 샌드박스를 좁힌다. 사람 UI는 타임아웃 후 거부를 기본값으로 둔다. thread/shellCommand와 process/spawn은 설계에 따라 별도 실행 창구가 된다. 최종 사용자 제품이면 기본으로 감추고 관리자 흐름에서만 연다. remoteControl/*는 ChatGPT 앱을 대체하는 자체 클라이언트를 만들 때만 의미가 있다. 원격 제어를 끈다고 이미 등록된 기기 권한이 자동으로 사라지지는 않는다. 컨트롤러 목록과 revoke를 따로 관리한다.

원격 접근 세 갈래

원격이라는 말을 보고 app-server 전송을 밖으로 직접 열 생각부터 하면 안 된다. 선택지는 세 가지다. 사람이 조종하면 ChatGPT Remote다. 원격 개발 머신에서 Codex를 돌리면 데스크톱 앱의 SSH 호스트 연결이다. 네트워크 밖 머신에 안전하게 접근하면 VPN 또는 메시 네트워킹이다.

Remote SSH VPN 선택
그림 3. 사람 원격은 Remote, 원격 개발 머신은 SSH 호스트, 네트워크 계층은 VPN이다. app-server를 공개 주소로 열지 않는다.

websocket --listen은 실험적이고 미지원이다. unix socket은 같은 기계 안의 로컬 제어용이다. 공개 주소에 리스너를 올리는 구성은 공식 문서가 금지하는 쪽에 가깝다. openai/codex 저장소와 issue 12329가 전송과 원격 제어 논의의 출발점이다. ai-sdk-provider-codex-app-server는 프레임워크 연동의 한 예이지, 공개 네트워크 서버가 아니다.

Remote 앱은 ChatGPT가 이미 연 세션 위에서 사람이 승인한다. SSH 호스트는 개발 머신의 셸이 원격이고, Codex 프로세스는 그 안에서 로컬이다. VPN은 네트워크 계층만 가깝게 만들고, app-server 전송은 그대로 stdio다. 세 갈래를 한 주소로 합치면 승인과 cwd와 로그가 한 소켓에 쌓인다. issue 12329가 다루는 원격 제어도, 공개 websocket을 제품 기본으로 올리는 답이 아니다. 로컬 unix와 stdio를 유지한 채 바깥은 SSH나 VPN으로 들어가는 쪽이 문서와 맞다.

JSON-RPC 요청과 알림

줄 단위 JSON-RPC는 HTTP와 실패 표현이 다르다. 요청 한 줄에는 method, id, params가 있다. 응답 한 줄은 그 id를 그대로 돌려준다. 알림 한 줄에는 id가 없다. 클라이언트가 응답을 기다리면 그 줄에서 영원히 막힌다. initialize는 요청이다. initialized는 알림이다. item 델타와 item/completed, turn/completed도 알림으로 오는 경우가 많다. 요청만 짝 맞추고 알림 스트림을 버리면, 턴이 시작된 뒤 화면이 빈다.

stdin에 쓰는 쪽은 클라이언트다. stdout에서 읽는 쪽도 클라이언트다. 서버는 자식 프로세스다. 포트 번호와 TLS 종료는 기본 전송에 없다. --listen unix://...는 같은 기계의 소켓이다. --listen ws://...는 실험적이고 미지원이다. --listen off는 로컬 전송을 끈다. 공식 문서가 원격에 SSH를 쓰라고 하는 이유는, 이 전송을 공개 주소에 올리지 말라는 뜻이다. openai/codex issue 12329가 전송과 원격 제어 논의의 출발점이다.

id는 클라이언트가 유일하게 고르면 된다. 응답이 늦게 섞여 와도 id로 짝을 맞춘다. 알림을 요청으로 오해하고 id를 기대하면 타임아웃이 난다. "Not initialized"는 initialize 전에 thread/start를 보낸 상태다. "Already initialized"는 같은 연결에서 initialize를 두 번 보낸 상태다. 연결을 새로 열면 다시 한 번만 보낸다. clientInfo.name은 로그와 운영 태그에서 어느 통합인지 가린다. title과 version은 사람이 읽는 표기다.

cwd와 샌드박스

thread/start의 cwd는 그 스레드가 파일을 열고 셸을 실행하는 작업 디렉터리다. 신뢰된 프로젝트 경로를 넘기면 그 트리 안의 파일 상태, 설치 산출물, 로컬 설정이 부수 효과가 된다. 홈 디렉터리나 공유 볼륨을 cwd로 두면 승인 범위가 넓어진다. 레거시 sandbox 필드와 실험적 permissions를 한 요청에 같이 보내면 거절될 수 있다. 문서가 공존을 보장하지 않는 조합이다. 둘 중 사용 중인 바이너리가 받는 쪽만 고른다. generate-ts 결과가 그 필드의 이름을 가른다.

샌드박스 안 읽기는 승인이 없어도 진행되는 경우가 많다. 샌드박스 밖 명령과 워크스페이스 밖 파일은 역방향 승인 요청을 만든다. 핸들러가 없으면 그 요청이 큐에 남고 턴은 멈춘다. thread/shellCommand와 process/spawn은 설계에 따라 별도 실행 창구가 된다. 최종 사용자 제품이면 기본으로 감추고 관리자 흐름에서만 연다. 퍼스낼리티도 thread/start에 걸린다. 턴마다 바꾸려 하면 스레드를 새로 여는 쪽이 문서와 맞을 때가 많다.

MCP와 SDK의 자리

MCP는 모델에게 도구 목록을 건네는 규격이다. 도구 하나가 외부 API를 호출하면, 그 호출의 승인 정책은 MCP 서버와 호스트의 몫이다. app-server는 스레드, 턴, 아이템, 승인, 샌드박스, 실행 흐름까지 Codex 에이전트 전체를 구동한다. 둘 다 양방향 JSON-RPC라는 점만 비슷하다. MCP를 붙였다고 app-server 클라이언트가 생긴 것은 아니다.

SDK는 일상 사용에서 initialize와 thread/start의 세부를 가린다. 모든 메서드가 SDK에 바로 노출되는 것은 아니다. 진행 중인 턴에 메시지를 주입하거나 끊는 제어가 필요하면 프로토콜 문서와 generate-ts 산출물이 다시 기준이다. ai-sdk-provider-codex-app-server는 프레임워크 연동의 한 예이지, 공개 네트워크에 올리는 서버가 아니다.

멀티유저 서비스처럼 한 app-server 프로세스에 여러 사용자를 붙이는 방향은 공식 권장과 거리가 있다. 기본 전송이 stdio이고 공개 네트워크 노출을 피하라는 지침이 분명하다. 사용자별 격리와 상위 서버 계층을 따로 둔다. 한 사용자가 다른 사용자의 cwd와 승인 큐를 보면 안 된다.

델타 알림과 빈 턴

흐름은 initialize, initialized, thread/start, turn/start, item 시작, 델타, item 완료, turn/completed 순이다. 델타는 에이전트 메시지와 셸 출력의 조각이다. 최종 메시지만 기다리면 긴 턴 동안 UI가 멈춘 것처럼 보인다. 델타를 꺼 두고 완료만 구독하면, 완료 전에 클라이언트가 타임아웃을 내고 빈 턴으로 기록한다.

turn/completed에는 토큰 사용량이 붙는 경우가 있다. resume 직후 복원되는 사용량 이벤트가 있으면 UI에 같이 반영한다. 구조화 로그에 thread id, turn id, client name이 없으면 과부하 -32001이 어느 호출자인지 안 보인다.

큐가 포화되면 새 요청을 -32001로 거절할 수 있다. 즉시 재시도는 큐를 더 채운다. 지수 백오프와 지터가 기본이다. stdio는 HTTP 커넥션 풀이 아니다. 클라이언트가 요청을 밀어 넣는 속도가 자식의 처리 속도를 넘으면 stdin 버퍼가 찬다. 이 에러가 자주 보이면 요청을 너무 빠르게 밀고 있다는 뜻이다.

resume과 fork

Thread는 저장된다. resume은 같은 대화를 이어 간다. fork는 한 지점에서 새 스레드를 연다. 원격 앱의 「이어 보기」는 resume에 가깝다. 프로그램이 실패 지점부터 다른 지시를 넣으면 fork에 가깝다. cwd와 승인 정책은 thread/start 때 걸리므로, resume이 그 설정을 그대로 가져가는지는 generate-ts와 바이너리 문서를 본다. 손 타입으로 가정하지 않는다.

타입이 바뀌면 CI의 generate-ts diff가 리뷰 없이 넘어가지 않게 막는다. 바이너리 버전도 latest 대신 고정한다. README 문장을 베껴 인터페이스를 손으로 적으면, 바이너리가 한 번만 바뀌어도 컴파일이 통과한 채로 런타임이 깨진다. codex app-server generate-ts --out ./src/generated와 codex app-server generate-json-schema --out ./schema가 그 고정 명령이다.

역방향 승인 요청의 순서

에이전트가 샌드박스 밖 명령을 실행하거나 워크스페이스 밖 파일을 건드리려 하면, app-server는 클라이언트에게 역방향 승인 요청을 보낸다. 이 요청은 클라이언트가 먼저 보낸 turn/start의 응답이 아니다. 서버가 클라이언트에게 결정을 묻는 반대 방향이다. Remote 앱의 승인 버튼은 사람이 그 요청을 받게 만든 화면이다. app-server를 직접 붙이면 코드가 그 버튼을 대신한다.

핸들러가 없으면 요청이 큐에 남고 턴은 멈춘 것처럼 보인다. 설계 때 자동 승인 범위, 사람 승인 대기 시간, 로그 포맷을 먼저 정한다. 무인 자동화라면 승인 없는 범위로 좁힌다. 사람 승인 UI라면 타임아웃 후 거부를 기본값으로 둔다. 특정 디렉터리만 자동 승인하거나 무응답 5분이면 거부하는 규칙은 핸들러 안에 적는다. 프롬프트에 「알아서 승인」만 적으면 턴이 멈춘다. 「승인 UI는 나중에」로 시작하면 실제로는 가장 먼저 막힌다.

턴이 시작됐는데 아무 반응이 없는 경우 대부분이 여기다. 그다음은 initialize 순서를 잘못 처리했거나, 델타 알림을 꺼 두고 최종 메시지만 기다리는 경우다. Item은 턴 안의 개별 요소다. 사용자 메시지, 에이전트 메시지, 셸 실행, 파일 편집, 추론 조각이 여기 들어간다. 메서드 이름이 thread/start, turn/start, item/completed 형태로 보이는 이유가 여기 있다.

thread/shellCommand와 process/spawn은 설계에 따라 별도 실행 창구가 된다. 최종 사용자에게 노출되는 제품이면 기본으로 감추고, 관리자 흐름에서만 연다. cwd는 thread/start에 걸린다. 신뢰된 프로젝트 상태 같은 부수 효과를 만들 수 있다.

QR 페어링과 등록 기기

사람이 폰에서 상태를 보고 승인하면 ChatGPT Remote다. 데스크톱 앱에서 Remote를 켜고 QR을 폰으로 찍고, 같은 계정과 워크스페이스를 확인한다. 준비물은 최신 ChatGPT 모바일 앱, 최신 데스크톱 앱, 깨어 있고 온라인인 호스트다. 회사 워크스페이스면 관리자가 Remote 접근을 열어 두었는지도 앞선다. 폰에서 Remote 메뉴가 안 보이면 모바일과 데스크톱의 최신 버전, 같은 계정과 워크스페이스, 회사면 관리자 쪽 Remote 허용, 설정 시작점이 앱 안인지를 본다. Remote connections 문서가 이 구간의 1차 출처다.

원격 제어를 끈다고 이미 등록된 기기 권한까지 자동으로 사라지는 것은 아니다. 등록된 컨트롤러 목록과 revoke 흐름을 따로 관리한다. remoteControl 계열 메서드는 ChatGPT 앱을 대체하는 자체 클라이언트를 만드는 경우에만 의미가 있다. 실험적 범주로 보는 편이 안전하다.

원격 개발 머신은 데스크톱 앱의 SSH 호스트 연결이다. 네트워크 밖 머신은 VPN 또는 메시 네트워킹이다. app-server 전송을 공개 주소로 여는 선택지는 공식 문서가 금지하는 쪽에 가깝다. websocket --listen은 실험적이고 미지원이다. unix socket은 같은 기계 안의 로컬 제어용이다. --stdio가 기본이고 안정이다. --listen off는 로컬 전송을 비노출한다.

제품 임베딩, 중간 개입, 코드로 정하는 승인, 프레임워크 연동이 하나라도 있으면 app-server다. 넷 중 하나도 아니면 완제품이 낫다. 사내 포털에서 실패를 고치는 버튼을 누르면 그 자리에서 스트리밍과 승인이 필요하다. 진행 중인 턴에 메시지를 주입하거나 끊어야 하면 앱 UI만으로는 안 된다. SDK가 세부를 가려도, 그 제어가 필요하면 generate-ts 산출물이 다시 기준이다.

마무리

앞에서 다룬 Codex 통합 갈림의 핵심만 짧게 정리한다.

  • ChatGPT Remote는 사람용 완제품이고, app-server는 프로그램이 붙는 인터페이스다.
  • 제품 임베딩, 중간 개입, 코드로 정하는 승인, 프레임워크 연동이 없으면 Remote로 끝난다.
  • 기본 전송은 stdio이며 공개 네트워크 직접 노출은 공식 문서가 금지한다.
  • initialize 한 번과 initialized 알림 전에 다른 요청을 보내지 않는다.
  • 승인 핸들러가 없으면 턴이 멈춘 것처럼 보인다.
  • 타입은 generate-ts로 버전에 고정하고, -32001은 백오프로 본다.
  • 원격이 필요하면 Remote, SSH 호스트, VPN 셋 중 하나다.

「완제품으로 끝나는 일을 프로토콜 문제로 키우지 않는다」 app-server API는 빠르게 바뀌므로 손 타입보다 생성 스키마가 기준이다.

출처와 링크

조사 기준: 2026년 8월. app-server API는 매우 빠르게 변한다. 문서 문장을 베껴 타입을 손으로 적으면 버전과 어긋난다. 사용 중인 바이너리의 generate-ts 결과가 대조 기준이다.

FAQ

자주 묻는 질문

이미 Remote가 되는데 app-server를 배워야 하는가?

사람 한 명이 폰과 데스크톱 앱으로 작업을 이어 보기만 하면 Remote로 충분하다. 제품 화면에 Codex를 넣고 승인 규칙을 코드로 정하거나 턴 중간에 개입해야 하면 app-server가 필요하다. 고급 여부가 아니라 조작 주체의 차이다.

app-server를 웹 서버처럼 띄워 여러 사용자가 붙게 할 수 있는가?

그 방향은 공식 권장과 거리가 있다. 기본 전송이 stdio이고 공개 네트워크 직접 노출을 피하라는 지침이 있다. 다중 사용자라면 사용자별 격리와 상위 서버 계층을 따로 둔다.

MCP와 app-server는 같은 JSON-RPC인가?

전송 모양이 비슷해도 역할이 다르다. MCP는 모델에게 도구를 건네는 규격이다. app-server는 스레드와 승인, 샌드박스, 실행 흐름까지 포함한 에이전트 구동 인터페이스에 가깝다.

SDK만 쓰면 프로토콜 세부를 몰라도 되는가?

일상 호출은 많이 가려진다. 모든 메서드가 SDK에 바로 나오지는 않아서, 실행 중 메시지 주입 같은 제어가 필요하면 다시 프로토콜 문서와 generate-ts 결과가 필요하다.

턴이 시작됐는데 출력이 없으면 어디를 보는가?

승인 요청을 받아 놓고 응답하지 않은 경우가 가장 흔하다. 그다음은 initialize와 initialized 순서 오류, 델타를 끄고 최종 페이로드만 기다린 경우다. 이벤트 로그에 역방향 승인 프레임이 있는지가 갈림길이다.

모바일 앱에 Remote 메뉴가 없으면 무엇부터 대조하는가?

모바일과 데스크톱이 최신인지, 같은 계정과 워크스페이스인지, 회사면 관리자가 Remote를 허용했는지를 본다. 설정 시작점이 웹이 아니라 앱 안인 경우도 많다.