# 콜사인 (Callsign) > 콜사인은 내 모든 기기를 위한 개인 컨트롤 플레인입니다. 폰·폴드·맥 데스크톱·볼륨키 무전으로 맥미니나 PC에서 도는 내 에이전트(Claude Code)에게 일을 맡기고, 에이전트는 맥·윈도우·라즈베리파이·폰을 콜사인(기기 이름)으로 불러 화면을 보고, 누르고, 명령을 돌립니다. 셀프 호스팅이고 기기는 밖으로만 연결하며, 중간에 저희 클라우드가 없습니다. AI 엔지니어링 회사 Ferrorium(https://ferrorium.com/)이 만듭니다. 부르면, 어느 기기에서든 일한다. (Every device, one call away.) 최종 업데이트: 2026-11-03 ## 가치 (만든 사람의 실제 사용 순서 — 2026-09-02~11-03, 갈래 2,596개·메시지 41,220건, 기기 호출 최근 9일 32,021건) 1. 어디서든 "내" 클로드에게 묻는다 — 메시지의 46%. 폰·볼륨키 무전으로 내 맥의 파일·스킬·계정을 가진 Claude Code 에 질문·조사 2. 자리를 떠나도 개발이 굴러간다 — 메시지의 26%(갈래 622개). 버그 수정·시험·배포·PR 머지를 폰에서 지시·승인 3. 에이전트가 내 기기를 직접 보고 누른다 — 9일 32,021번(하루 약 3,600번). 화면 52%·입력 20%·셸 8%·파일 8%, 맥 61%·윈도우 34% 4. 소통을 대신 챙긴다 — 메시지의 8%. 슬랙·메일·카톡 요약·답장 초안·발송 전 확인 5. 자는 동안 돌고 아침에 결과가 와 있다 — 예약이 연 갈래 252개(하루 3번 브리핑·감시) 6. 사업·생활 잡무를 끝까지 — 메시지의 10%. 공모전·견적·주문·결제·발행, 토큰 장부로 사용량 확인 (갈래 제목 낱말로 나눈 근삿값) ## 설계 가치 - 확장성: 새 기기는 8자리 설정 코드로, 새 능력은 `묶음.동작` 클러스터 하나로 더한다. 규약은 더하기만(옛 기기 안 깨짐). 클러스터 30여 종, 붙여 본 기기 11대 - 규모: 라벨·selector 로 고르고 `-A` 로 맞는 기기 전부에 동시에. lease·cordon·drain, 원하는 상태 `apply`/`diff` - 이식성: macOS·Linux(x86_64·arm64 정적)·Windows·라즈베리파이·폰, 실행 파일 2.5~3.5MB 하나. 빌드 없는 기기는 파이썬 40줄 - 최소 권한: 기기 열쇠는 그 이름에만, 설정 코드 10분·한 번, 권한 view·operate·manage·administer, 기기별 deny, 열린 포트 0 - 기록: 모든 호출 감사 기록(누가·어느 기기·무엇·ms·성공), 토큰 장부, 이벤트 스트림(SSE) - 끊김에 강함: 재접속 시 놓친 출력 채움, 못 보낸 말 우편함 재시도(client_id 로 한 번만), 서버 줄 세우기, 끊긴 기기는 즉시 502 - 에이전트용: 모든 출력 JSON 봉투(ok·data·meta)·종료 코드, 명령 표 하나 = 도움말·`csn schema`·MCP 도구(110개) - 소유: 내 기계·내 구독, 외부 반출 0, SHA-256 확인, 공개 규약이라 잠김 없음 ## 설치 (빌드된 실행 파일만, SHA-256 검증) - macOS·Linux·Raspberry Pi: `curl -fsSL https://callsign.ferrorium.com/install.sh | sh` - Windows: `irm https://callsign.ferrorium.com/install.ps1 | iex` - 기기를 허브에 붙이기: `curl -fsSL https://callsign.ferrorium.com/install.sh | sh -s -- --join https://<허브> <설정 코드>` - 파일 목록·합계: https://callsign.ferrorium.com/dl/ ## 통신 규약 (OS 무관) - WebSocket 위 JSON 텍스트 한 줄. 동사 read·write·invoke·subscribe, 기기별 일은 클러스터 이름(shell.run·screen.shot…) - 전문: https://callsign.ferrorium.com/protocol/ (원문 https://callsign.ferrorium.com/protocol.md) ## 가격 (얼리 액세스) - 1인 설치 대행: 49만~99만 원(1회) - 팀 도입: 300만 원~ - 기업(사내망·온프레미스): 문의 - 부가세 별도, Claude 구독료 미포함 ## 요건 - Claude Code 구독(기존 구독 그대로 사용) - 항상 켜 둘 수 있는 맥 또는 윈도우 PC - 지시하는 앱은 안드로이드·맥만(아이폰은 노드로만 붙음) ## 문의 문의는 메일로 받습니다: wks0968@gmail.com (48시간 안에 메일로 회신). 양식: https://callsign.ferrorium.com/#join ## 더 읽을 것 - 전체 본문(규약 포함, 한 파일): https://callsign.ferrorium.com/llms-full.txt - 통신 규약: https://callsign.ferrorium.com/protocol.md --- # 부록: 통신 규약 전문 (https://callsign.ferrorium.com/protocol.md) --- title: 콜사인 통신 규약 — callsign-wire/1 summary: 어떤 OS·언어로든 허브에 기기를 붙이고 부를 수 있게 하는 JSON-over-WebSocket/HTTPS 규약 audience: [human, agent] applies_to: [macos, linux, windows, android, ios, raspberry-pi, any] requires: [WebSocket(RFC 6455), JSON(RFC 8259), HTTPS] commands: - csn fabric commission <이름> - callsign-agent join <허브> <코드> - csn api GET nodes updated: 2026-11-03 --- # 콜사인 통신 규약 — `callsign-wire/1` **TL;DR** - 전송은 **WebSocket 위 UTF-8 JSON 텍스트 프레임** 하나뿐이다. 바이너리 프레임·전용 직렬화·OS API 가 없다 — WebSocket 과 JSON 을 다루는 언어라면 어디서든(마이크로컨트롤러 포함) 노드를 새로 짤 수 있다. - 노드는 **밖으로 걸기만 한다**(허브 → 노드 인바운드 포트 0개). 방화벽·NAT·통신사 망 뒤에서도 붙는다. - 메시지는 다섯 가지: 노드 → 허브 `hello`·`beat`·`attr`·`endpoints`·`result`, 허브 → 노드 요청 `{id, verb, args}` 하나. - 동사는 Matter 에서 빌린 넷: `read`·`write`·`invoke`·`subscribe`. 기기마다 다른 일은 `invoke` 의 **클러스터 이름**(`shell.run`·`screen.shot`…)으로만 갈린다. 규약을 지키는 실제 구현: 노드 `agent-rs/src/main.rs`(`session`)·`agent-rs/src/proto.rs`, 허브 `server/src/adapters/http_fleet.rs`(`line`)·`server/src/domain/fleet.rs`. 이 문서와 코드가 어긋나면 **코드가 맞다** — 문서를 고쳐라. ## 1. 왜 이 모양인가 | 결정 | 이유 | | --- | --- | | WebSocket + JSON | 모든 OS·언어에 표준 라이브러리가 있다. 맥·윈도우·리눅스·안드로이드·iOS·ESP32 가 같은 글을 읽는다 | | 노드가 바깥으로 건다 | 기기에 포트를 열지 않는다. 공유기·LTE·회사망 뒤에서도 그대로 | | 열쇠는 `Authorization` 머리글로만 | 주소(`?token=`)에 실으면 프록시·접근 기록에 남는다 | | 기기마다 열쇠 하나, 이름에 묶음 | 열쇠 하나가 새도 그 기기만 끊으면 된다. 남의 이름으로는 못 붙는다 | | 동사 넷 + 클러스터 이름 | 새 기기가 새 동사를 만들 필요가 없다. 모르는 클러스터는 "모른다"고 답하면 된다 | | 텍스트 한 줄 = 메시지 하나 | 조각 맞추기·길이 머리가 없다. 디버깅은 `websocat` 하나로 된다 | ## 2. 주소와 인증 ``` 노드 줄: wss://<허브>/api/fleet/ws (테일넷·LAN 안이면 ws://<허브>:8788/api/fleet/ws) 부르는 쪽: https://<허브>/api/fleet/* (HTTP JSON) 머리글: Authorization: Bearer <열쇠> ``` 열쇠는 세 가지다. | 열쇠 | 누가 쥐나 | 여는 것 | | --- | --- | --- | | 기기 열쇠 | 노드 하나 | 그 노드 이름으로 `/api/fleet/ws` 에 붙기만. 다른 이름으로 `hello` 하면 허브가 끊는다 | | 조작 열쇠 | CLI·에이전트(`csn`) | `/api/fleet/call`·`nodes`·`events` 등 부르기 | | 마스터 토큰 | 허브 주인 | 전부. 단, 터널을 거친 줄로는 노드 행세를 못 한다 | ### 2.1 설정 코드로 들이기(마스터 토큰을 기기에 복사하지 않는다) ``` 허브 주인: csn fabric commission newpi → 8자리 코드(10분·한 번) 새 기기: POST https://<허브>/api/fleet/join {"code":"12345678","node":"newpi","os":"linux"} 허브 답: 200 {"node":"newpi","token":"<기기 열쇠>"} ``` 기기는 받은 열쇠를 자기 설정(`~/.callsign-agent/agent.json`, 권한 0600)에 적고 그 열쇠로 WebSocket 을 연다. `callsign-agent join <허브> <코드>` 가 이 일을 한다(`agent-rs/src/join.rs`). 회수는 허브에서 그 줄을 지우면 끝 — 이 기기만 끊긴다. ## 3. 노드 줄(WebSocket) ### 3.1 순서 ``` 노드 허브 │── (TLS + Upgrade, Authorization) ──▶ │ 열쇠 검사. 모르면 401 │── hello ───────────────────────────▶ │ 첫 프레임은 반드시 hello. 아니면 {"error":…} 후 닫음 │── beat (10초마다) + WS Ping ────────▶ │ 30초(3번) 못 들으면 죽은 노드 │── attr (값이 바뀔 때만) ─────────────▶ │ │◀────────────────────── {id,verb,args} │ 일 시키기(여러 개가 동시에 올 수 있다) │── {m:"result",id,ok,res|error,ms} ──▶ │ id 로 짝을 맞춘다. 순서는 상관없다 ``` - 모든 프레임은 **텍스트 프레임 하나에 JSON 객체 하나**다. 메시지·프레임 상한은 64 MiB(파일 조각이 base64 로 실린다). - 노드는 요청을 **동시에** 처리해도 된다. 허브는 `id` 로만 짝을 맞춘다. - 노드가 30초 동안 프레임(Pong 포함)을 하나도 못 받으면 줄이 죽은 것으로 보고 다시 건다(와이파이를 갈아타면 TCP 는 15분을 버틴다). - 같은 이름이 새 줄로 붙으면 허브는 옛 줄을 닫는다. 다시 붙을 때 `hello` 부터 새로 한다. ### 3.2 `hello` — 노드 → 허브, 첫 프레임 ```json { "node": "pi", "os": "linux", "caps": ["shell", "files", "gpio"], "clusters": ["shell.run", "file.get", "file.put", "gpio.read", "gpio.write", "sys.info"], "taints": [], "endpoints": [{"id": 1, "type": "ir-bridge", "clusters": ["onoff"]}] } ``` | 키 | 필수 | 뜻 | | --- | --- | --- | | `node` | ✓ | 콜사인(노드 이름). 영문·숫자·`-`·`_` 64자까지. `.`·`/` 는 쓰지 않는다(CLI 주소 `node/끝점.클러스터.속성` 을 가른다) | | `os` | ✓ | `macos`·`windows`·`linux`·`android`·`ios` 또는 새 이름(소문자). 허브는 값을 해석하지 않고 고르기(`--os`)에만 쓴다 | | `caps` | | 능력 라벨(`screen`·`shell`·`hid`·`gpu`·`matter`·`adb`…). 허브의 `-r ` 고르기에 쓰인다 | | `clusters` | | 알아듣는 `invoke` 클러스터. 비면 거르지 않는다(옛 노드) | | `taints` | | 지금 일을 받지 않을 사정(`low-battery`·`hot`·`busy`…) | | `endpoints` | | 품은 장치(끝점 1 이상). 없으면 끝점 0(노드 자신)만 | ### 3.3 노드 → 허브 | `m` | 모양 | 언제 | | --- | --- | --- | | `beat` | `{"m":"beat","taints":["low-battery"]}` | 10초마다. taint 를 늘 통째로 보낸다 | | `attr` | `{"m":"attr","attrs":{"battery":81,"idle_min":3},"endpoint":0}` | 속성이 바뀔 때. `endpoint` 없으면 0 | | `endpoints` | `{"m":"endpoints","endpoints":[…]}` | 품은 장치 목록이 바뀔 때(다시 `hello` 하지 않는다) | | `result` | `{"m":"result","id":17,"ok":true,"res":{…},"ms":12.3}` | 요청 하나의 답 | | `result`(실패) | `{"m":"result","id":17,"ok":false,"error":"…"}` | 모르는 클러스터·거부·실패. 사람이 읽을 글로 | 모르는 `m` 은 허브가 버린다 — 새 메시지를 더해도 옛 허브가 죽지 않는다. ### 3.4 허브 → 노드 ```json {"id": 17, "verb": "invoke", "args": {"cluster": "shell.run", "cmd": "uptime", "timeout": 30}} ``` | 키 | 뜻 | | --- | --- | | `id` | 허브가 매기는 수. 답에 그대로 돌려준다 | | `verb` | `read`·`write`·`invoke`·`subscribe` | | `args` | 동사별 인자. `endpoint`(수)가 있으면 그 끝점에, 없으면 노드 자신에 | | `error` | `id` 없이 이것만 오면 허브의 알림(예: `"이 열쇠는 pi 전용입니다"`). 답하지 않는다 | ## 4. 동사 | 동사 | `args` | 답 `res` | | --- | --- | --- | | `read` | `{"attr":"battery"}` 또는 `{}` | `{"battery":81}` 또는 속성 전체 | | `write` | `{"attr":"screen_off_min","value":5}` | 쓴 뒤 값. 보기 전용 노드는 거부 | | `invoke` | `{"cluster":"<이름>", …}` | 클러스터마다 다르다(§5) | | `subscribe` | `{"attr":"temp.value","min_s":1,"max_s":10}` | `{"subscribed":{…}}`. 이후 값은 `attr` 로 온다 | ## 5. 클러스터(`invoke`) — 모든 OS 공통 이름 노드는 할 수 있는 것만 `hello.clusters` 에 싣는다. 이름은 같고 OS 마다 속만 다르다 (`screen.shot` 은 맥에선 ScreenCaptureKit, 윈도우에선 GDI, 리눅스에선 `grim`, 폰에선 adb/WDA). | 묶음 | 클러스터 | 대표 인자 → 답 | | --- | --- | --- | | 셸 | `shell.run` | `cmd`, `timeout`, `shell`(`sh`·`bash`·`zsh`·`powershell`), `cwd`, `bg` → `{code,out,err}` | | 화면 | `screen.shot`·`screen.state`·`window.list` | `region`, `scale` → `{png\|jpg(base64), bytes, screen}` | | 입력 | `input.click`·`input.move`·`input.type`·`input.key`·`input.scroll`·`input.batch` | `x`,`y` / `text` / `key` / `steps[]` | | 파일 | `file.get`·`file.put`·`file.list`·`file.stat` | `path`, `data`(base64), `offset` | | 클립보드 | `clip.get`·`clip.set` | `text` | | 시스템 | `sys.info`·`sys.health`·`proc.list`·`proc.kill`·`power.lock`·`net.wol`·`log.tail` | | | 작업 | `job.list`·`job.log` | `shell.run bg` 로 떼어 둔 일 | | 장치 | `gpio.read`·`gpio.write`·`gpu.info`·`matter.cmd` | | | 시뮬레이터 | `sim.*`(iOS)·`emu.*`(안드로이드) | | | 권한 | `perm.check`·`perm.request` | 맥 TCC 등 | | 에이전트 | `agent.update`·`agent.restart`·`agent.rekey` | 자기 갱신·열쇠 교체 | 새 클러스터는 `묶음.동작` 꼴로 이름 짓고, 모르는 이름이 오면 **아는 목록을 붙여** 실패로 답한다. 노드 설정의 `deny`(클러스터 글롭)에 걸린 것은 "모른다"가 아니라 "거부한다"고 답한다. ## 6. 부르는 쪽(HTTP) — `POST /api/fleet/call` CLI(`csn`)·에이전트·스크립트는 노드 줄을 직접 열지 않고 허브에 HTTP 로 부탁한다. ```http POST /api/fleet/call Authorization: Bearer <조작 열쇠> Content-Type: application/json {"requires":["gpu"], "os":"linux", "tolerate":["hot"], "selector":{"room":"lab"}, "verb":"invoke", "args":{"cluster":"shell.run","cmd":"nvidia-smi"}, "wait_s":30, "timeout_s":90, "all":false} ``` | 키 | 뜻 | | --- | --- | | `node` | 이름을 찍으면 그 노드만 | | `requires`·`os`·`tolerate`·`selector` | 고르기(쿠버네티스의 nodeSelector·toleration·label selector) | | `wait_s` | 맞는 노드가 없을 때 기다릴 초(Pending). 0 이면 바로 409 | | `timeout_s` | 노드 답 상한(기본 90 — 클라우드플레어가 100초에 끊는다) | | `all` | 맞는 노드 **모두**에게 | 답은 노드의 `result` 를 그대로 싣는다. 줄이 없던 것(`noline`, 다른 노드로 넘겨도 안전)과 보낸 뒤 끊긴 것(`dropped`, 돌았을 수 있음)을 구분해 알린다. 그 밖의 노선: `GET /api/fleet/nodes`(목록)·`status`·`events`(SSE)·`audit`·`cordon`·`claim`·`join`. ## 7. 새 OS 에 노드를 짜는 최소 구현 아래 다섯 가지만 하면 `csn ls` 에 뜨고 `csn sh <노드> -- …` 가 돈다. 파이썬 40줄이면 된다. 1. `wss://<허브>/api/fleet/ws` 에 `Authorization: Bearer <기기 열쇠>` 로 붙는다. 2. `hello` 를 보낸다(`clusters: ["shell.run"]`). 3. 10초마다 `{"m":"beat","taints":[]}` 와 WebSocket Ping 을 보낸다. 4. `{id, verb:"invoke", args:{cluster:"shell.run", cmd}}` 를 받으면 돌리고 `{"m":"result","id":…,"ok":true,"res":{"code":0,"out":"…","err":""}}` 로 답한다. 5. 줄이 끊기면 1~5초 뒤 처음부터 다시 붙는다. ```python import asyncio, json, subprocess, websockets # pip install websockets HUB, KEY, NODE = "wss://hub.example.com/api/fleet/ws", "<기기 열쇠>", "mybox" async def main(): while True: try: async with websockets.connect(HUB, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws: await ws.send(json.dumps({"node": NODE, "os": "linux", "caps": ["shell"], "clusters": ["shell.run"], "taints": []})) async def beat(): while True: await ws.send(json.dumps({"m": "beat", "taints": []})); await asyncio.sleep(10) asyncio.create_task(beat()) async for raw in ws: r = json.loads(raw) if "id" not in r: continue a = r.get("args") or {} if r.get("verb") == "invoke" and a.get("cluster") == "shell.run": p = subprocess.run(a["cmd"], shell=True, capture_output=True, text=True, timeout=a.get("timeout", 120)) out = {"m": "result", "id": r["id"], "ok": True, "res": {"code": p.returncode, "out": p.stdout, "err": p.stderr}} else: out = {"m": "result", "id": r["id"], "ok": False, "error": f"{NODE} 는 {a.get('cluster')} 를 모릅니다. 아는 것: shell.run"} await ws.send(json.dumps(out)) except Exception: await asyncio.sleep(3) asyncio.run(main()) ``` ## 8. 호환 규칙 - **더하기만 한다.** 키를 새로 더할 수는 있어도 있던 키의 뜻을 바꾸거나 빼지 않는다. 받는 쪽은 모르는 키를 무시한다. - 빈 배열·없는 키는 같은 뜻이다(`endpoints` 가 없으면 끝점 0 만). - 버전 번호를 주고받지 않는다 — 능력은 `caps`·`clusters` 로 알린다. 뜻이 깨지는 변경이 필요하면 노선을 새로 연다(`/api/fleet/ws2`).