# Symphony 명세를 Go와 Claude Code로 구현해보기

- URL: https://soohan.dev/projects/personal/cymphony
- Category: projects/personal
- Published: 2026-08-03
- Tags: go, claude-code, agent-orchestration, notion, telegram
- Author: Soohan Park

> OpenAI가 명세만 공개한 코딩 에이전트 오케스트레이터 Symphony를, 참조 구현과는 다른 언어·다른 에이전트·다른 트래커로 직접 구현해봤다. Codex 자리에 로컬 claude CLI를 끼우고 노션을 작업 큐로 썼다.

---

OpenAI가 2월에 [Symphony](https://github.com/openai/symphony)를 공개했습니다. 코딩 에이전트를
옆에서 지켜보는 대신 작업 자체를 관리하자는 아이디어로, Linear 보드를 감시하다가 태스크가
올라오면 에이전트를 띄워 구현하고 PR까지 올린 뒤 사람이 승인하면 머지합니다.

흥미로웠던 건 리드미가 제시하는 순서였습니다. 첫 번째 옵션이 "직접 만들어봐라"입니다. 좋아하는
코딩 에이전트에게 `SPEC.md`를 던져주고 원하는 언어로 구현하라고 시키라는 것이고, Elixir로 된
참조 구현은 그다음 옵션입니다. 명세를 공개하고 구현은 각자 알아서 하라는 배포 방식 자체가
낯설어서 한번 해보고 싶어졌습니다.

그래서 그 첫 번째 옵션을 해본 결과가 [cymphony](https://github.com/soohanpark/cymphony)입니다.
Go로 쓴 단일 바이너리고, Codex 자리에는 로컬 `claude` CLI가, Linear 자리에는 노션이, UI 자리에는
텔레그램이 들어갑니다. macOS에서 launchd로 상시 돌아갑니다.

## 무엇을 두고 무엇을 갈아끼웠나

명세를 읽으면서 가장 먼저 한 일은 여기서 무엇이 Codex 전용이고 무엇이 아닌지를 가르는
것이었습니다. Configuration, Orchestrator core, Workspace lifecycle, Tracker abstraction,
Observability는 특정 벤더에 묶여 있지 않았습니다. 폴링해서 작업을 집어오고, 격리된 워크스페이스를
만들고, 상태를 전이시키고, 로그를 남기는 일은 그 자리에 무슨 에이전트가 오든 똑같습니다.

그래서 실제로 갈아끼운 자리는 둘뿐이었습니다. 에이전트를 실행하는 자리와 작업을 읽어오는
자리입니다. 나머지는 명세의 layer 구분을 그대로 패키지 경계로 옮겼습니다.

대신 경계 규칙 하나를 처음부터 못 박아뒀습니다. `orchestrator`는 외부 SDK를 직접 import하지
않는다는 것입니다. 노션이든 텔레그램이든 `claude`든, 오케스트레이터는 항상 `Tracker`,
`Notifier`, `CodingAgent` 인터페이스만 통과합니다. 명세가 layer를 나눠둔 이유를 나중에야
체감했는데, 그 얘기는 뒤에서 하겠습니다.

## worker 자리에 Claude Code 끼우기

여기가 명세를 그대로 따를 수 없던 지점입니다. Symphony는 Codex app-server에 붙은 worker
프로세스를 하나 띄워두고 여러 턴을 스트림으로 주고받습니다. `claude` CLI에는 그런 상주 모드가
없습니다.

대신 세션 ID가 있습니다. 첫 턴은 `--session-id`로 UUID를 직접 지정하고, 이후 턴은 `--resume`으로
같은 UUID를 부릅니다. 대화 기록은 Claude Code가 알아서 남기기 때문에 cymphony는 UUID만 기억하면
됩니다.

```bash
claude -p "<prompt>" --session-id <uuid> \
       --output-format stream-json --verbose \
       --permission-mode acceptEdits \
       --append-system-prompt "<WORKFLOW.md 내용>"
```

UUID를 cymphony가 먼저 발급하는 게 중요했습니다. 프로세스를 띄우기 전에 노션 property에 적어둘 수
있어야, 중간에 무엇이 죽더라도 다음 폴링에서 그 세션을 이어받을 수 있습니다. `claude`가 UUID를
정하게 두면 프로세스가 끝나야만 알 수 있고, 그사이에 죽으면 스레드를 통째로 잃습니다.

stdout은 `--output-format stream-json`으로 받아 줄 단위로 파싱합니다. 마지막 `result` 줄에서
토큰 사용량과 종료 사유, 최종 텍스트를 꺼냅니다. JSON 파싱에 실패한 줄도 버리지 않고 파일에
그대로 남깁니다. 턴 로그는 나중에 무엇이 잘못됐는지 볼 유일한 기록이라, 이해하지 못한 줄일수록
원본을 손대지 않는 편이 나았습니다.

턴마다 프로세스가 새로 뜬다는 차이 때문에 명세의 worker-level 개념 몇 가지는 자리를 옮겨야
했습니다. worker가 멈췄는지 보는 stall timeout은 턴 단위 시간 제한이 됐고, 재시도 큐는 노션
`Status`가 대신합니다. 상주하는 무언가가 없으니 상태를 둘 곳이 노션밖에 없었습니다.

## 노션 페이지 하나가 작업 단위

Tracker를 Linear에서 노션으로 바꾸는 건 어댑터 하나 쓰는 일이라고 생각했는데, 실제로는 각 개념을
노션의 무엇에 대응시킬지 정하는 일이 대부분이었습니다. 결국 페이지 하나를 스레드 하나로 잡았고,
거기서 나머지가 따라 나왔습니다. 페이지 본문이 첫 턴 프롬프트가 되고, `Status` select가 상태
머신이 되고, `Unique ID`가 텔레그램에서 부를 이름이 됩니다.

<Img src="https://nptgbqzcpg7dbphu.public.blob.vercel-storage.com/images/projects/personal/cymphony/notion-board.png"
     width="1600" height="516"
     alt="cymphony 작업 큐로 쓰는 노션 데이터베이스. ID, Type, Assignee, Priority, Title, Status, Repo 칼럼에 태스크 다섯 건이 들어 있다"
     caption="그림 1. 데모용으로 꾸민 화면이 아니라 실제로 굴리던 보드입니다. dogfood 라벨이 붙은 두 건은 중간에 취소했고, 맨 아래 코드 태스크는 Blocked로 남아 있습니다." />

`Assignee`가 `agent`인 것만 집어오게 한 건 같은 보드를 제가 직접 쓰기 위해서입니다. 제 할 일과
에이전트에게 넘길 일을 굳이 다른 도구로 나눌 이유가 없었습니다.

`Status`를 진짜 상태 머신으로 쓴 게 결과적으로 편했습니다. 지금 무슨 일이 벌어지는지를 보려고
로그를 뒤질 일이 없고, 사용자가 개입하는 지점도 전부 이 칼럼 하나로 표현됩니다. SQLite는 같은
상태를 미러링하면서 턴별 종료 코드와 토큰, 이벤트 처리 여부만 따로 기록합니다. 중복 처리를 막는
용도지 진실의 출처는 아닙니다.

## 만들면서 마주친 것들

가장 먼저 부딪힌 건 승인 프롬프트였습니다. `claude`는 도구를 호출하기 전에 사용자에게 물어보는데,
cymphony는 사람이 앞에 없는 상태로 돌기 때문에 프롬프트가 뜨는 순간 아무도 대답하지 않습니다.
에러가 나는 것도 아니고 시간 제한까지 조용히 멈춰 있다가 실패합니다. 그래서 모드를 고르는 일이
사실상 "어떤 도구를 물어보지 않고 통과시킬 것인가"를 정하는 일이 됐습니다. `acceptEdits`를
기본으로 두고 필요한 도구만 화이트리스트에 명시하는 쪽으로 정리했는데, 여기서 `Bash(git *)` 같은
glob은 인식되지 않아 도구 이름을 그대로 적어야 한다는 것도 돌려보고 알았습니다.

`--resume`이 이전 대화를 못 찾는 경우도 있었습니다. 이때 `claude`는 결과의 에러 목록에 대화를 찾을
수 없다는 문구를 담고 그냥 끝나기 때문에, 종료 코드만 보면 정상과 구분되지 않습니다. 그 문구를
감지해 세션 유실로 표시하고, chat 태스크는 새 UUID를 발급해 첫 턴부터 다시 시작하게 했습니다.
코드 태스크에는 아직 이 복구를 붙이지 않았습니다. 워크스페이스와 브랜치 상태까지 같이 따져야
해서 미뤄뒀습니다.

## 코드가 아닌 요청도 받고 싶어져서

한동안 돌려보니 보드에 코드 작업이 아닌 걸 적고 싶어지는 순간이 있었습니다. 라이브러리를 비교해달라거나, 레포의 최근 변경을 요약해 달라거나 하는 것들입니다. 그런데 적어두면 전부 Blocked로 떨어졌습니다.

원인은 엔진에 있던 조건 하나였습니다. 첫 턴이 끝나면 git 변경이 있는지 보고, 없으면 곧장 Blocked로
보냅니다. 변경이 있으면 무조건 PR을 만들고요. 명세의 흐름을 그대로 옮기다 보니 작업은 곧 코드
변경이라는 전제가 파이프라인 전체에 박혀 있었습니다. 참고로 이 판정도 그 자체로 한 번 손봐야
했습니다. 에이전트가 커밋까지 마치면 워킹 트리는 깨끗하기 때문에, 변경 유무를 보려면 upstream을
찾아 그 뒤로 커밋이 쌓였는지까지 봐야 했습니다.

그래서 `Type` select를 하나 추가하고 chat 경로를 갈랐습니다. chat 태스크는 `Todo → In Progress → Done`으로만 움직이고 브랜치도 PR도 만들지 않습니다. 주입하는 규약 문서도 다릅니다. 커밋과 푸시를
금지하는 대신 웹 검색과 페이지 조회를 미리 승인해뒀습니다. 실시간 정보가 필요한 질문에 모델의
기억으로 답하지 말고 직접 찾아보라는 뜻입니다.

답을 어디에 쓸지는 길이로 정합니다. 짧으면 노션 댓글로 달고, 길거나 코드 펜스나 표가 섞여 있으면
페이지 하위에 sub-page를 만들어 마크다운을 노션 블록으로 변환해 넣습니다.

<Img src="https://nptgbqzcpg7dbphu.public.blob.vercel-storage.com/images/projects/personal/cymphony/chat-result.png"
     width="1369" height="1600"
     alt="chat 태스크의 답변이 노션 sub-page로 게시된 화면. 아부다비의 실시간 날씨를 표로 정리하고 시간대별 일정을 제안하고 있다"
     caption="그림 2. 그림 1의 TASK-2가 남긴 결과 페이지입니다. 제목의 turn 6은 같은 세션에서 여섯 번째 턴이라는 뜻입니다." />

후속 지시는 노션에서 그대로 이어집니다. 댓글을 남기고 `Status`를 `Todo`로 되돌리면 다음 폴링에서
`--resume`으로 같은 세션을 이어받습니다. 별도 폴러를 붙이지 않고 이미 있는 상태 머신을 재사용한
셈인데, 위 결과가 여섯 번째 턴까지 간 것도 그 흐름을 몇 번 반복한 결과입니다.

여기서 재밌었던 건 갈아끼운 자리가 대체로 버텼다는 점입니다. `Tracker`와 `CodingAgent` 인터페이스는
손대지 않았고 노션 스키마도 칼럼 하나만 늘었습니다. 정작 깨진 건 명세를 옮기면서 제가 하드코딩한
전제였습니다. 명세가 layer를 나눠둔 부분은 그대로 버텼고, 제가 편의상 눌러 담은 부분이 나중에
값을 청구한 것에 가깝습니다.

## 느낀 점

코드까지 전부 구현해서 공개하는 게 보통인데, 개념만 내놓고 각자 구현해보라고 하는 방식이 의외로 흥미로웠습니다.

만들고 나니 [openclaw](https://github.com/openclaw/openclaw)나
[paperclip](https://github.com/paperclipai/paperclip) 같은 서비스가 어떤 과정으로 돌아가는지도
어렴풋이 감이 왔습니다. 맡길 일을 어딘가에 적어두면 에이전트가 그걸 집어가고 결과가 다시 그
자리로 돌아오는 흐름이라는 점에서는 크게 다르지 않을 거라고 생각합니다.

다만 지금 구현한 파이프라인은 선형적이고 단방향입니다. 상태를 되돌려 흐름을 한 번 더 태울 수는
있지만 그뿐이라, 실무에 그대로 쓰기에는 단조롭다는 느낌이 있었습니다. 도입하는 곳마다 자기 업무에
맞게 파이프라인을 설계하는 일이 따로 필요해질 것 같고, 그게 하나의 직종이 될지도 모르겠습니다.

그래도 이 구조를 고도화한다면 간단한 운영 업무나 자잘한 일들은 상당 부분 반자동화할 수 있을 것
같습니다. 사람은 문제를 정의하고 감독하면서 티켓만 관리하면 되니까요. 기업에서 AI가 지금보다 더
일상화되면 이런 형태의 프로세스를 채택하는 곳이 늘어나지 않을까 싶습니다.
