내 에이전트 붙이기
의존성 없는 Node 에이전트가 와이어 프로토콜 v1.1 로 붙어, 직접 만든 도구를 endue OS 명령 API 로 부를 수 있게 해요.
무엇을 하나
endue OS 는 도구를 서버에 하드코딩하지 않아요. 에이전트가 describe_tools 로 알려 준 도구는 바로 명령 API 로 부를 수 있어요. 그래서 프로토콜 프레임 몇 개만 구현하면 어떤 기기·어떤 프로그램이든 Station 의 기기(type: actual Device)가 돼요.
자세한 규칙은 내 에이전트 붙이기 가이드와 와이어 프로토콜 v1.1 에 있어요.
부르는 API
프롬프트
Claude Code 같은 AI 코딩 에이전트에 그대로 붙여 넣으세요. 아래 코드가 이 프롬프트로 만든 결과예요.
endue OS 에 붙는 나만의 에이전트를 Node.js 로 만들어 줘. 의존성 없이 Node 22 (전역 WebSocket·fetch), 파일 하나(byo-agent.mjs).
등록 (공개 REST, 기본 주소 ENDUE_OS_URL 없으면 http://127.0.0.1:5173, Authorization: Bearer <토큰>;
토큰은 ENDUE_OS_TOKEN 없으면 POST {ENDUE_OS_URL}/idp/token?sub=<이름>&ttl=3600 의 access_token):
- POST /api/v1/stations {name} → POST /api/v1/stations/{id}/devices {type: "actual", kind: "linux", name}
→ data.device, data.enrollment { endpoint, token } (token 은 이 응답에서만 보인다).
와이어 프로토콜 v1.1 (WebSocket, 텍스트 프레임 하나에 JSON 하나):
- 접속: new WebSocket(`${endpoint}?token=${encodeURIComponent(token)}`)
- 열리면 ready 를 보낸다: {type:"ready", version, tools:[이름…], os, arch,
session_epoch: 프로세스마다 새 UUID, capabilities:["concurrency:4","ping_interval:20","ack"]}
- 20초마다 정확히 '{"type":"ping"}' 바이트를 보낸다 (공백·필드 없이).
- 받은 프레임: ping → {type:"pong"}; relay_config → 무시해도 됨;
tool_request {id, command, input} → 먼저 {type:"tool_ack", id}, 실행 후
{type:"tool_response", id, status:"ok"|"error", output|null, error|null, duration_ms, error_code(오류면)}.
- 모르는 type·JSON 아닌 프레임은 끊지 말고 버린다.
- 같은 id 를 다시 받으면 다시 실행하지 말고 같은 응답을 보낸다. 같은 id 에 다른 입력이면
error_code "request_id_conflict" (timeout_ms 는 비교에서 뺀다).
- 닫힘 4001 이면 다시 붙지 않는다. 그 밖에는 1초부터 두 배(최대 60초) + 0~50% 지터로 재접속, 1012 면 60초 쉰다.
- 필수 도구 두 개: describe_tools → {tools:[{name, description, input_schema, requires_confirmation}]},
workspace_info → 기기 정보 객체.
- 내 도구: say_hello {name} → {greeting}, system_load {} → {loadavg, cpus, free_mb, total_mb, uptime_s}.
확인: GET /api/v1/devices/{device_id} 의 online 이 true 가 되면 POST /api/v1/devices/{device_id}/commands
{command:"say_hello", input:{name:"endue"}} 와 {command:"system_load", input:{}} 를 보내 출력을 찍는다.
명령은 크레딧을 쓴다 — 402 INSUFFICIENT_CREDITS 면 크레딧 부족(error.details.balance)이라고 알려 줘.
ONCE=1 이면 소켓을 닫고 DELETE /devices/{device_id}, DELETE /stations/{id} 후 끝낸다.결과 코드 byo-agent.mjs
#!/usr/bin/env node
// 내 에이전트 붙이기 — endue OS 샘플.
// 의존성 없는 Node 에이전트가 와이어 프로토콜 v1.1 로 릴레이에 붙어, 자기 도구 두 개(say_hello · system_load)를
// endue OS 의 명령 API 로 부를 수 있게 한다. 같은 스크립트가 API 로 자기 도구를 불러 왕복을 확인한다.
//
// node byo-agent.mjs 붙어서 계속 돈다 (Ctrl+C 로 끝, 기기는 남는다)
// ONCE=1 node byo-agent.mjs 왕복을 확인하고 기기·Station 을 지우고 끝낸다
// ENDUE_OS_TOKEN=… ENDUE_OS_URL=… node … 운영 — endue 계정의 토큰
import os from 'node:os';
import { randomUUID } from 'node:crypto';
const BASE = (process.env.ENDUE_OS_URL ?? 'http://127.0.0.1:5173').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const token =
process.env.ENDUE_OS_TOKEN ??
(await (await fetch(`${BASE}/idp/token?sub=${process.env.ENDUE_OS_SUB ?? `sample-${Date.now().toString(36)}`}&ttl=3600`, { method: 'POST' })).json()).access_token;
async function api(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const json = res.status === 204 ? null : await res.json();
if (res.status === 402) throw new Error(`${method} ${path} → 402 ${json?.error?.code}: 크레딧이 부족해요 (잔액 ${json?.error?.details?.balance ?? '?'}). 충전한 뒤 다시 불러 주세요.`);
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${json?.error?.code}`);
return json?.data;
}
// ── 1. 기기 등록 — actual Device. 에이전트는 자기 종류를 말하지 않고, 토큰이 기기를 정한다. ──────────────
const station = await api('POST', '/stations', { name: 'byo-agent' });
const { device, enrollment } = await api('POST', `/stations/${station.station_id}/devices`, {
type: 'actual',
kind: 'linux',
name: 'my-node-agent',
labels: { made_by: 'byo-agent-sample' },
});
const endpoint = process.env.AGENT_ENDPOINT ?? enrollment.endpoint;
console.log(`device ${device.device_id} → ${endpoint}`);
// ── 2. 도구 — 이름·설명·입력 스키마. describe_tools 가 이 목록을 플랫폼에 알린다. ─────────────────
const TOOLS = {
say_hello: {
description: '이름을 받아 인사를 돌려준다.',
input_schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: 100 } } },
run: ({ name }) => ({ greeting: `안녕하세요, ${name}! — ${os.hostname()} 에서` }),
},
system_load: {
description: '이 기기의 부하·메모리·가동 시간.',
input_schema: { type: 'object', properties: {} },
run: () => ({
loadavg: os.loadavg(),
cpus: os.cpus().length,
free_mb: Math.round(os.freemem() / 2 ** 20),
total_mb: Math.round(os.totalmem() / 2 ** 20),
uptime_s: Math.round(os.uptime()),
}),
},
};
const catalog = () => ({
tools: [
...Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, input_schema: t.input_schema, requires_confirmation: false })),
],
});
const handlers = {
// 플랫폼이 핸드셰이크에서 부르는 두 도구 — 모든 에이전트가 구현한다 (플랫폼이 부르는 도구 계약).
describe_tools: () => catalog(),
workspace_info: () => ({ hostname: os.hostname(), platform: os.platform(), node: process.version, agent: 'byo-agent-sample' }),
...Object.fromEntries(Object.entries(TOOLS).map(([name, t]) => [name, t.run])),
};
// ── 3. 와이어 프로토콜 v1.1 ─────────────────────────────────────────────────────────────────
const SESSION_EPOCH = randomUUID(); // 프로세스마다 새 값 — 같은 프로세스의 재접속이면 릴레이가 진행 중이던 명령을 다시 보낸다
const PING_INTERVAL_S = 20;
const done = new Map(); // 규칙 4 — 같은 id 는 한 번만 실행한다: id → { fingerprint, response }
let backoff = 1000;
let stopped = false;
function connect() {
const url = `${endpoint}?token=${encodeURIComponent(enrollment.token)}`;
const ws = new WebSocket(url);
let pinger;
const send = (frame) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify(frame));
ws.addEventListener('open', () => {
send({
type: 'ready',
version: '0.1.0',
tools: Object.keys(handlers),
os: os.platform() === 'darwin' ? 'macos' : os.platform(),
arch: os.arch() === 'x64' ? 'x86_64' : os.arch(),
session_epoch: SESSION_EPOCH,
capabilities: ['concurrency:4', `ping_interval:${PING_INTERVAL_S}`, 'ack'],
});
// 규칙 1 — 바이트가 정확한 ping. 릴레이 런타임이 자동으로 pong 한다 (서버가 깨어나지 않는다).
pinger = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"type":"ping"}'), PING_INTERVAL_S * 1000);
backoff = 1000;
});
ws.addEventListener('message', async (ev) => {
let frame;
try {
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
} catch {
return; // 규칙 2 — 모르는 것·JSON 아닌 것은 버린다
}
if (frame.type === 'ping') return send({ type: 'pong' });
if (frame.type === 'relay_config') return; // 큰 바이트가 없는 에이전트라 블롭 업로드를 쓰지 않는다
if (frame.type !== 'tool_request') return;
const { id, command, input = {} } = frame;
const fingerprint = JSON.stringify([command, input]); // timeout_ms 는 지문에 넣지 않는다
const seen = done.get(id);
if (seen) {
return send(
seen.fingerprint === fingerprint
? seen.response
: { type: 'tool_response', id, status: 'error', output: null, error: 'request id reused with different input', error_code: 'request_id_conflict', duration_ms: 0 },
);
}
send({ type: 'tool_ack', id });
const t0 = Date.now();
let response;
try {
const handler = handlers[command];
if (!handler) throw Object.assign(new Error(`unknown tool ${command}`), { code: 'unknown_tool' });
response = { type: 'tool_response', id, status: 'ok', output: await handler(input), error: null, duration_ms: Date.now() - t0 };
} catch (e) {
response = { type: 'tool_response', id, status: 'error', output: null, error: String(e.message ?? e), error_code: e.code ?? 'tool_failed', duration_ms: Date.now() - t0 };
}
done.set(id, { fingerprint, response });
send(response);
});
ws.addEventListener('close', (ev) => {
clearInterval(pinger);
if (stopped) return;
if (ev.code === 4001) {
// 규칙 6 — 토큰이 폐기·회전됐다. 다시 붙어도 401 이니 멈춘다.
console.log('4001 agent revoked — 멈춘다');
stopped = true;
return;
}
// 규칙 7 — 지터를 넣은 지수 백오프 (1012 는 다른 프로세스에 밀린 것이라 길게 쉰다).
const wait = ev.code === 1012 ? 60_000 : backoff * (1 + Math.random() * 0.5);
backoff = Math.min(backoff * 2, 60_000);
console.log(`닫힘 ${ev.code} — ${Math.round(wait / 1000)}초 뒤 다시`);
setTimeout(connect, wait);
});
ws.addEventListener('error', () => {}); // close 가 뒤따른다
return ws;
}
let socket = connect();
// ── 4. API 로 내 도구 부르기 ──────────────────────────────────────────────────────────────
for (let i = 0; ; i++) {
const r = await api('GET', `/devices/${device.device_id}`);
if (r.online) {
console.log(`online — 도구 ${r.tool_count}개`);
break;
}
if (i > 30) throw new Error('온라인이 되지 않았다');
await sleep(1000);
}
const hello = await api('POST', `/devices/${device.device_id}/commands`, { command: 'say_hello', input: { name: 'endue' } });
console.log(`say_hello → ${hello.status}: ${hello.output?.greeting}`);
const load = await api('POST', `/devices/${device.device_id}/commands`, { command: 'system_load', input: {} });
console.log(`system_load → ${load.status}: ${JSON.stringify(load.output)}`);
if (process.env.ONCE) {
stopped = true;
socket.close(1000);
await api('DELETE', `/devices/${device.device_id}`);
await api('DELETE', `/stations/${station.station_id}`).catch(() => {});
console.log('정리했다');
} else {
console.log('붙은 채로 기다린다 — 데스크톱의 기기 창이나 명령 API 로 불러 보세요. Ctrl+C 로 끝.');
process.on('SIGINT', () => {
stopped = true;
socket.close(1000);
process.exit(0);
});
}돌려 보기
# endue-os 제품 레포에서 로컬 스택을 띄운 뒤 local/up.sh node site/samples/byo-agent.mjs # 운영 (예정) — endue 계정의 토큰으로 ENDUE_OS_URL=https://api.endueos.com ENDUE_OS_TOKEN=… node byo-agent.mjs