같은 바이너리, 다른 동작
Claude Agent SDK는 Claude Code CLI를 subprocess로 spawn하여 동작한다. SDK가 별도의 경량 런타임을 갖고 있는 게 아니라, 우리가 터미널에서 쓰는 그 claude 바이너리를 그대로 호출하는 구조다.
그런데 SDK를 통해 호출된 Claude Code는 일반 interactive 세션과 확연히 다르게 동작한다:
- CLAUDE.md, skills, hooks 등 프로젝트 설정을 기본적으로 로드하지 않는다
- Interactive REPL 대신 JSON 기반 프로토콜로 통신한다
같은 바이너리인데 어떻게 이런 차이가 발생하는 걸까? 특수한 argument 하나로 제어하는 걸까?
실제 SDK 소스 코드(claude-agent-sdk v0.1.55)를 열어 확인했다.
노트
이 글은 Python Agent SDK v0.1.55 기준이다. SDK는 빠르게 변화하고 있으므로 이후 버전에서는 세부 구현이 달라질 수 있다.
Transport 레이어의 환경변수 주입
SDK의 transport 레이어(SubprocessCLITransport)에서 subprocess를 spawn할 때, 현재 프로세스의 환경변수를 기반으로 SDK 전용 환경변수를 구성한다:
inherited_env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
process_env = {
**inherited_env,
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
**self._options.env,
"CLAUDE_AGENT_SDK_VERSION": __version__,
}몇 가지 포인트:
CLAUDECODE키를 명시적으로 필터링한다 (이유는 후술)CLAUDE_CODE_ENTRYPOINT를"sdk-py"로 세팅하여 CLI가 SDK 모드임을 인식하게 한다- 사용자 지정 env(
self._options.env)가 그 뒤에 spread되므로, entrypoint를 포함한 대부분의 값을 override할 수 있다 CLAUDE_AGENT_SDK_VERSION은 맨 마지막에 세팅되어 항상 SDK 버전이 보장된다
Stream JSON 모드
SDK는 --print(-p) 모드를 사용하지 않는다. 대신 stream-json 모드로 CLI를 호출한다:
def _build_command(self) -> list[str]:
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
# ... (각종 옵션 처리) ...
cmd.extend(["--input-format", "stream-json"])
return cmd이 모드에서는 stdin/stdout을 통해 JSON 메시지를 주고받는 양방향 제어 프로토콜이 동작한다. Interactive REPL과는 완전히 다른 통신 경로다.
프로세스 시작 후 SDK는 initialize 요청을 보내고, 이후 user 메시지를 JSON으로 전송한다:
# Initialize control protocol
await query.initialize()
# String prompt → JSON user message로 변환하여 전송
user_message = {
"type": "user",
"session_id": "",
"message": {"role": "user", "content": prompt},
}
await chosen_transport.write(json.dumps(user_message) + "\n")세션 파일 자체는 생성된다. close() 메서드에는 subprocess가 세션 파일을 flush할 시간을 확보하기 위한 grace period가 구현되어 있다:
# The subprocess needs time to flush its session file after receiving
# EOF on stdin. Without this grace period, SIGTERM can interrupt the
# write and cause the last assistant message to be lost (see #625).setting_sources - 프로젝트 설정의 opt-in 로딩
SDK를 통해 호출된 Claude Code가 CLAUDE.md나 skills를 로드하지 않는 핵심 메커니즘은 setting_sources다.
SDK 옵션에서 setting_sources를 명시하지 않으면, CLI는 프로젝트 설정을 로드하지 않는다. 이 옵션이 지정된 경우에만 --setting-sources CLI 인자로 전달된다:
if self._options.setting_sources:
cmd.extend(["--setting-sources", ",".join(self._options.setting_sources)])즉, CLAUDE.md나 skills를 활용하고 싶다면 명시적으로 opt-in해야 한다:
from claude_agent_sdk import ClaudeCode
claude = ClaudeCode()
result = await claude.query(
prompt="...",
options=ClaudeAgentOptions(
setting_sources=["project"], # 프로젝트 설정 로드 opt-in
),
)가능한 값은 "user", "project", "local" 세 가지다.
정리
SDK가 CLI subprocess의 동작을 제어하는 메커니즘을 정리하면:
| 메커니즘 | 코드상 위치 | 효과 |
|---|---|---|
CLAUDECODE 필터링 | 환경변수 구성 | 부모 Claude Code 세션 감지 방지 |
CLAUDE_CODE_ENTRYPOINT="sdk-py" | 환경변수 주입 | CLI가 SDK 모드임을 인식 |
--output-format stream-json | CLI 인자 | JSON 양방향 프로토콜로 통신 |
--input-format stream-json | CLI 인자 | stdin으로 JSON 메시지 수신 |
--setting-sources 미지정 | CLI 인자 (부재) | CLAUDE.md, skills 등 로드하지 않음 |
CLI 바이너리 자체는 동일하다. 환경변수와 CLI 인자의 조합이 동작 범위를 결정하는 구조다.
여담: CLAUDECODE 필터링이 필요했던 이유
환경변수 구성 코드에서 CLAUDECODE 키를 필터링하는 이유가 있다.
Claude Code는 실행 시 CLAUDECODE=1 환경변수를 세팅한다. 이 값이 자식 프로세스에 상속되면, CLI는 “이미 Claude Code 세션 안에 있다”고 판단하여 nested session 에러를 발생시킨다.
초기 SDK 코드는 os.environ을 그대로 spread했기 때문에, Claude Code 세션 안에서 SDK를 호출하면 이 에러가 발생했다. 당시 사용자들은 수동으로 환경변수를 비워서 우회해야 했다:
# 과거 우회 방법
options = ClaudeAgentOptions(
env={"CLAUDECODE": ""},
)현재 버전(v0.1.55)에서는 SDK가 inherited_env 구성 시 CLAUDECODE를 자동으로 제외하므로 이 문제가 해결되었다:
inherited_env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}환경변수 상속이 양날의 검임을 보여주는 사례다 - 유연하지만, 의도치 않은 간섭이 발생할 수 있다.