← 샘플

내 에이전트 붙이기

의존성 없는 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