Nessienesy.app
마법책

spec · nesy.yaml

nesy.yaml 명세

마도서 한 권이 무슨 주문을 들고 있는지 적는 단일 YAML. AI 가 이 파일을 보고 마도서를 어떻게 부를지 판단합니다.

두 가지 작성 경로

  • 코드 에디터(권장) — nesy 사이트의 등록 폼이 알아서 nesy.yaml을 만들어줘요. 메이커는 yaml을 직접 안 봐도 됩니다. 한 파일짜리 마도서에 적합.
  • git 연결 — 자기 GitHub repo 루트에 nesy.yaml 을 직접 작성. 여러 파일짜리 마도서·자기 컴퓨터에서 짜는 본격 메이커용.

1. 주문 정의 — tools

AI 가 어떤 주문을 언제 부를지 판단하는 부분. 업계 표준 MCP 형식. 1개 이상 필수, 최대 16개.

  • name영문 식별자 (snake_case 권장). 영문자로 시작, 영문/숫자/언더바만, 64자 이내.
  • description가장 중요. AI 가 이 주문을 언제 부를지 판단하는 자연어 설명. 1~4000자.
  • inputSchema(선택) 인자가 있는 주문일 때만. JSON Schema 의 object 형식. 자세히

전체 예시

# === 주문 (tools) — 필수 ===
tools:
  - name: get_rate
    description: 두 통화 간 현재 환율을 돌려줌. 환율 류 질문에 부른다.
    inputSchema:
      type: object
      properties:
        from:
          type: string
          description: ISO 4217 통화 코드. USD, KRW 같은 것.
        to:
          type: string
          description: 변환 대상 통화
      required: [from, to]

# === 사용자 설정 (선택) ===
user_settings:
  - key: default_target
    type: string
    description: 기본 변환 대상 통화. KRW 같은 ISO 4217 코드.
    default: KRW

# === 알림 (선택) ===
notifications:
  function: check_rate_alerts
  cadence: 300

# === 시각화 (선택) ===
visualization:
  function: render_widget
  cadence: 60
  height: 320

entry 파일 예시 (index.ts)

// index.ts — entry 파일
export async function run({ input, secrets }: {
  input: { from: string; to: string };
  secrets: { EXCHANGE_API_KEY: string };
}) {
  const url = `https://api.exchangerate.host/convert?from=${input.from}&to=${input.to}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${secrets.EXCHANGE_API_KEY}` },
  });
  const data = await res.json();
  return { rate: data.result };
}

run 함수는 { input, secrets } 를 받고, 결과 객체를 반환하면 끝. 마도서가 호출되면 nesy 실행환경(V8 격리 isolate, 5초·128MB) 에서 이 함수가 격리 실행돼요.

플랫폼 계약 — 코드 짤 때 참고

마도서 코드가 따라야 하는 런타임·저장소·파일·시크릿 규칙. 본문은 영어 — AI 가 이 내용을 보고 코드를 짜요.

도구가 해야 할 일·하지 말아야 할 일

마도서를 호출하는 쪽은 멀티모달 LLM (Claude 나 ChatGPT) 이에요. 호출 LLM 은 이미 이런 걸 다 할 수 있어요:

  • 이미지 보기, PDF 읽기, 오디오 듣기
  • 분류·요약·번역·받아쓰기·개체명 추출·물체/글자/악보/코드 식별
  • 자기 학습 지식으로 추론

도구는 호출 LLM 이 이미 할 수 있는 걸 다시 하면 안 돼요. 도구 호출은 시간과 돈이 더 들어요 — LLM 이 혼자 못 하는 일에만 도구를 써야 합니다:

  • ✅ 파일 생성 (PDF, 이미지, 스프레드시트, 오디오)
  • ✅ DB 쓰기, 이메일 발송, 슬랙 메시지
  • ✅ LLM 이 못 부르는 외부 API (Stripe, Notion, Linear, 메이커 본인 백엔드)
  • ✅ 상태 있는 동작 (카운터, 세션, 캐시)
  • ✅ LLM 이 모르는 최신 데이터 (실시간 환율, 사용자 캘린더, 오늘 날씨)
  • ❌ "이 이미지 분석해줘" — LLM 이 직접 함
  • ❌ "이 글 번역해줘" — LLM 이 직접 함
  • ❌ "이 문서 요약해줘" — LLM 이 직접 함

메이커가 "이미지 분석 / 텍스트 요약 / 번역" 같은 함수를 만들겠다고 하면 부드럽게 방향 틀어주세요: 호출 AI (Claude / ChatGPT) 가 그 정도는 자체 능력으로 처리하고, 거기서 도구를 쓰면 비용·지연만 두 배. 그다음 "그 분석 결과로 뭘 하고 싶은지" 를 물어보고, 그 다음 단계를 도구로 만들어요.

도구 설계의 정석: 입력을 이미 추출된 결과 로 (예: page_chords: [{ page: 1, measures: [...] }]) 받기. 원본 이미지를 받으면 안 됨. 추출은 호출 LLM 이, 그 결과로 하는 일은 도구가.

시크릿 — YAML 이 아니라 UI 에서 등록

시크릿은 manifest 에서 선언하지 않아요. 메이커가 nesy.app 마도서 편집 폼에서 (이름 + 값 + 선택사항인 설명) 직접 등록하면, 플랫폼이 tool_secrets 테이블에 암호화해서 저장 — 마도서별로 격리.

run() 안에서는 메이커가 정한 이름 그대로 secrets 매개변수에 도착해요:

ts
export async function run({ input, secrets }) { const res = await fetch("https://api.stripe.com/v1/charges", { headers: { Authorization: `Bearer ${secrets.STRIPE_API_KEY}` }, }); }

함수 적을 때는 그냥 `secrets.WHATEVER_NAME` 을 참조하고, 나중에 메이커한테 "폼에 이 이름들 등록해두세요" 라고 알려주세요. YAML 에 secrets: 블록 넣지 마요 — 파서가 무시합니다.

시크릿이 진짜 필요한 때

호출 LLM 이 직접 못 부르는 외부 시스템 에 마도서가 말 걸어야 할 때만 필요:

  • Stripe, Notion, Linear, Slack, GitHub API (인증 필요)
  • 메이커 본인의 DB·백엔드
  • 특화 서비스 (SMS 용 Twilio, 메일 용 Resend, 파일 저장소 R2 / S3)

다른 LLM 부르려고 시크릿 만들지 마요 (Anthropic, OpenAI, Google Gemini). 호출 LLM 이 이미 그 자리에 있어요 — LLM 한테 일 시키고 결과를 도구 인자로 넘기도록.

api.anthropic.com, api.openai.com, vision/chat/embedding 엔드포인트 fetch 하고 싶어지면 멈춰요. manifest 를 다시 짜서 LLM 이 분석 결과를 넘기게 하거나, 그 함수 자체를 빼요.

규칙

  • 소스 코드에 API 키 하드코딩 금지 — 시크릿 vault 에만.
  • 시크릿 값 로그 금지console.log(secrets) 같은 거 하면 서버 로그에 노출됨.
  • 이름 규칙: ASCII 영문/숫자/언더바, 첫 글자는 영문자나 언더바 (예: STRIPE_API_KEY, NOTION_TOKEN). 폼이 검증해요.

환경 변수 안 쓰고 vault 쓰는 이유

(마도서 × 시크릿 이름) 은 저장 시 암호화, 호출 시점에만 V8 isolate 안으로 복호화, 호출 LLM 한테는 절대 echo 안 됨. 메이커가 코드 재배포 없이 UI 에서 회전·폐기 가능.

user_settings — user_settings: 블록

사용자별 환경설정 — AI 가 대화 중 읽거나 바꿀 수 있는 값 (카테고리 목록, 통화, 타임존, 기본값) 이 필요하면 nesy.yamluser_settings: 섹션을 적어요. 플랫폼이 읽기·쓰기 함수를 자동 합성해줘서 메이커는 코드를 한 줄도 안 짜요.

yaml
user_settings: - key: categories type: list_string # string | integer | number | boolean | list_string | enum 중 하나 default: [식비, 교통, 기타] description: 가계부 카테고리 목록 - key: currency type: enum options: [KRW, USD, JPY] # type=enum 이면 필수 default: KRW - key: monthly_budget type: integer default: 500000

플랫폼이 자동 노출하는 것

호출 AI 의 도구 목록에 합성 함수 두 개가 자동 등장 — 메이커가 안 만들어도:

  • get_user_settings() — 합쳐진 객체 반환 (manifest default + 사용자가 바꾼 값 덮어쓴 거)
  • update_user_settings(key, value) — 타입 검증 후 한 항목 갱신, 새 합쳐진 객체 반환

호출 AI 가 사용자가 *"가계부 카테고리에 '여행' 추가해줘"* 나 *"내 기본 통화를 JPY 로 바꿔"* 같은 말 할 때 알아서 부름. 메이커는 이런 흐름에 코드 한 줄도 안 짜요.

규칙

  • tools:get_user_settings / update_user_settings 적지 마요 — 플랫폼이 합성해줌. 적으면 중복돼서 LLM 이 헷갈려요.
  • run() 안에서 구현하지 마요 — 런타임이 메이커 코드에 닿기 전에 가로챔.
  • run()다른 함수 안에서 설정을 읽어야 하면 (예: 사용자 카테고리 목록으로 지출 필터링) data.get("__settings") 로 가져와요.
  • 키는 snake_case, __ 로 시작 불가 (예약).
  • 지원 타입: string, integer, number, boolean, list_string, enum (options: 배열 필수).

일반 도구 vs. user_settings, 어떤 걸 써야?

메이커가 "환경설정" 비슷한 거 (사용자가 한 번 정하고 잊어버리는 값) 를 얘기하면 항상 user_settings: 를 써요. get_settings / update_settings / set_currency 같은 걸 일반 도구로 만들면 안 됨.

값이 행동의 일부 면 (예: 지출 기록 시 amount) 그건 일반 함수 입력으로 — user_setting 아님.

사용자별 저장소 — data API

(사용자, 마도서) 쌍마다 격리된 key-value 네임스페이스. run() 안에서 사용:

ts
await data.set("expenses:2026-05-09:abc", { amount: 70000, category: "식비" }); const item = await data.get("expenses:2026-05-09:abc"); // 없으면 null const rows = await data.list("expenses:2026-05-09:"); // [{ key, value, updated_at }] await data.delete("expenses:2026-05-09:abc");

규칙

  • 네임스페이스 격리: 각 (사용자, 마도서) 는 자기만의 키 공간. 한 사용자가 다른 사용자 데이터 못 읽고, 한 마도서가 다른 마도서 데이터 못 읽음.
  • 크기 제한: row 당 64KB (JSON 직렬화 후). 더 큰 덩어리는 파일 채널 사용.
  • 예약 키: __ 로 시작하는 키는 플랫폼 예약 (설정, 파일 티켓 등) — 쓰지 마요. 단 하나 읽기 가능한 예외: data.get("__settings") 는 합쳐진 사용자 설정 반환.
  • 직렬화 가능한 값만: Date, Map, Set 안 됨 — 일반 JSON 으로 변환. 타임스탬프는 ISO 문자열, set 은 배열로.
  • list 는 value 도 같이 줌: data.list(prefix) 반환은 [{ key, value, updated_at }]. list 후 각 key 마다 `data.get()` 부르는 N+1 패턴 절대 금지 — 시각화·브리핑이 눈에 띄게 느려져요. list 결과의 r.value 를 그대로 쓰면 됩니다.
  • 네트워크 왕복: 모든 data.* 호출은 Supabase 로 감. run() 전체가 5초 예산 — 가능하면 묶어요.

이름 짓는 패턴

: 를 계층 키 구분자로:

expenses:2026-05-09:abc-uuid
expenses:2026-05-09:def-uuid
expenses:2026-05-10:ghi-uuid

그러면 data.list("expenses:2026-05-") 한 번에 2026년 5월치 다 잡아요. 날짜 범위 조회 가장 싼 방법.

자주 쓰는 모양:

  • <엔티티>:<그룹>:<id> — 그룹 (날짜, 프로젝트, 카테고리) 별
  • <엔티티>:<id> — id 로 평면 조회
  • <엔티티>:by-<필드>:<값>:<id> — 보조 인덱스

data API 가 아닌 것

  • ❌ 관계형 DB 아님. JOIN·트랜잭션·SQL 없음.
  • ❌ 값으로 검색 불가 — 키 prefix 로만.
  • ❌ append-only 아님 — set() 은 그 키의 값을 통째 덮어씀. 배열에 추가하려면 get → push → set 다시. data.append() 없음.

전문 검색·복잡한 관계·트랜잭션 일관성 필요하면 마도서 모양이 잘못된 거예요. 다른 문제를 고르세요.

런타임 — V8 격리, WinterCG 부분집합

메이커 코드는 V8 isolate 안에서 실행돼요 (isolated-vm 기반). 노출된 API 는 WinterCG Minimum Common API 를 따라서, Cloudflare Workers / Deno / Vercel Edge 에서 돌던 코드면 여기서도 돌아갑니다.

사용 가능한 전역

  • 표준 JS: Object, Array, String, Number, Boolean, Date, RegExp, Error, Map, Set, WeakMap, WeakSet, Promise, Symbol, JSON, Math, Proxy, Reflect, ArrayBuffer, typed array 들
  • URL, URLSearchParams, TextEncoder, TextDecoder (utf-8 만), atob, btoa
  • fetch — 단순화된 응답: { ok, status, statusText, headers, text(), json() }
  • crypto.randomUUID(), crypto.getRandomValues(typedArray)
  • crypto.subtle.digest(algo, data), crypto.subtle.importKey(...), crypto.subtle.sign(algo, key, data), crypto.subtle.verify(algo, key, sig, data) — SHA-256, HMAC, 웹훅 서명 검증, JWT 사인, AWS Sig v4 용
  • setTimeout, clearTimeout, setInterval, clearInterval — 5초 예산 안에서
  • console.log / info / warn / error — nesy 서버 로그로 전달
  • performance.now()

사용 불가

  • crypto.subtle.encrypt / decrypt / generateKey / exportKey / deriveKey / deriveBits / wrapKey / unwrapKey — digest·importKey·sign·verify 만 연결됨
  • setImmediate (Node 전용) — setTimeout(fn, 0) 으로 양보
  • Blob, File, FormData, ReadableStream, WritableStreamUint8Array 직접 조작
  • AbortController, AbortSignalfetch 는 호스트 쪽에서 이미 4.5초 캡 걸려있음
  • ❌ Node 전용: fs, child_process, net, os, path, require, process, Buffer ( Uint8Array 쓰기)
  • ❌ 브라우저 DOM: document, window, localStorage, alert
  • ❌ 네이티브 바인딩: sharp, canvas, ffmpeg, C++ 호출하는 거 못 불러옴
  • globalThis.NESY_* 같은 플랫폼 전역 없음 — 플랫폼이 주는 핸들은 input, secrets, data 셋뿐 (run() 매개변수로 들어옴)
  • ❌ 파일시스템 없음 (fs 도 없고 temp 디렉토리·디스크 영구 저장도 없음) — 바이트 I/O 는 파일 채널로

예산

  • 호출당 5초 (wall-clock)
  • 128MB 메모리
  • fetch 는 호스트 쪽에서 4.5초 캡 (응답 처리 + 후속 코드용으로 약 500ms 남김)
  • 실패 시 throw new Error("사용자가 읽을 메시지") — 이 텍스트가 호출 AI 까지 전달돼요
  • 문자열·{ text: "..." }·JSON 직렬화 가능한 객체 중 하나 반환

파일 채널 — 선언형 type: file 입력 + return { files } 출력

업로드·다운로드 인프라는 플랫폼이 투명하게 처리해요. 메이커는 도메인 로직만 적어요. `uploads.create()` / `uploads.consume()` / `uploads.publishResult()` 부르지 마요 — 옛 API 폐기 예정이고, 호출 LLM 과 토큰 핑퐁 무한루프를 일으켜요.

기본 패턴 — 이미 추출된 인자

LLM 이 채팅에서 파일에서 필요한 걸 (예: text, amount, page_chords: [...]) 뽑아 구조화된 데이터로 넘길 수 있으면 그렇게 해요. 파일 채널로 바이트 통째 넘기는 것보다 빠르고 싸요. LLM 이 추출, 도구는 행동.

파일 채널은 원본 바이트가 진짜로 필요할 때만 써요 — 원본 사진을 PDF 위에 인쇄, EXIF 추출, 파일 해싱, 오디오 핑거프린트, 원본 바이트를 아카이브로 묶기.

파일 받기 — manifest 에 type: file 선언

yaml
tools: - name: build_score_pdf description: 악보 이미지를 PDF 한 장으로 묶어 줍니다. inputSchema: type: object required: [title, sheets] properties: title: type: string description: 곡 제목. sheets: type: file label: 악보 이미지 hint: 페이지 순서대로 업로드해 주세요. mime: ["image/*"] maxItems: 50 maxSize: 200MB

index.ts 에서 그 필드는 파일 객체 배열로 이미 채워져서 도착해요:

ts
export async function run({ input }) { const sheets = input.args.sheets as Array<{ filename: string; mime: string; size: number; bytes: Uint8Array; }>; // sheets[0].bytes 가 원본 이미지. 끝 — 업로드 코드 필요 없어요. }

플랫폼이 알아서 해주는 거 (base hook)

  • LLM 스키마에서 type: file 필드를 숨겨요 — LLM 이 영영 못 봐서, "파일을 도구 인자로 설명" 하려는 토큰 핑퐁이 사라짐.
  • 사용자가 어떤 비파일 인자 조합으로 도구를 처음 부르면, 플랫폼이 (사용자, 마도서, sha256(파일 외 인자)) 키로 업로드 티켓을 발급하고 인박스 URL 을 LLM 한테 반환. 메이커 코드는 아직 실행 안 됨.
  • 사용자가 nesy.app/inbox 에서 업로드한 뒤 같은 명령을 다시 외침. LLM 이 같은 인자로 재호출 → 같은 키 → 같은 티켓 → 플랫폼이 바이트 붙여서 그제야 메이커 코드를 input.args.sheets 채워서 실행.
  • 호출 성공하면 티켓 소비 (재시도용 30분 유예 창).

파일 반환 — return { files: [...] }

ts
return { message: "PDF 만들었어요!", files: [ { filename: "악보.pdf", mime: "application/pdf", bytes: pdfBytes }, ], };

플랫폼이 각 파일을 자동 공개하고, (사용자 × 마도서 × 30분 슬라이딩 창) 단위로 단일 다운로드 URL 에 묶고, 메이커 출력을 이렇게 다시 씀:

json
{ "message": "PDF 만들었어요!", "download": { "bundleToken": "...", "url": "https://nesy.app/r/<bundleToken>", "files": [{ "filename": "악보.pdf", "mime": "application/pdf", "sizeBytes": 12345 }] } }

LLM 은 사용자에게 download.url 을 안내해요 (사용자가 /r/<bundleToken> 페이지에서 파일을 하나씩 받음). 30분 안에 같은 마도서를 다시 부르면 새 파일이 같은 묶음 에 추가 — URL 한 개, 여러 개 아님.

한도

  • 파일 입력 (필드당): 최대 100 파일 / 총 1GB. 기본 5 / 50MB. 필드마다 maxItems / maxSize 설정 ("200MB" 같은 문자열 또는 바이트 숫자).
  • 출력 파일: 파일당 1GB. 묶음은 마지막 파일 추가 후 24시간 (슬라이딩 창).
  • 파일 바이트는 `Uint8Array` — 텍스트라고 확실하지 않으면 디코딩하지 마요.

2. 알림 — notifications

정해진 주기로 마도서를 깨워 사용자에게 푸시 알림을 보냄. 환율 알리미, 일일 요약 같은 거. 본문은 영어로 작성 — AI 가 이 글을 보고 코드를 만들어요.

알림 — notifications: 블록

nesy.app 이 정해진 주기로 부르는 백그라운드 함수를 선언해요. 함수가 돌려준 객체는 옵트인한 사용자 모두에게 Web Push 알림으로 발송돼요. 환율 알림, 일일 요약, 예약 리마인더, "X 일 일어나면 알려줘" — 사용자가 먼저 묻지 않아도 되는 거에 써요.

yaml
notifications: function: check_rate_alerts # 필수. snake_case. tools[] 에 넣지 마요. cadence: 300 # 선택. 초. 60-86400. 기본 300 (5분).

함수가 반환할 것

run() 안에서 input.tool === "check_rate_alerts" 일 때 이 모양으로 반환:

ts
return { notifications: [ { id: "rate-USD-KRW-2026-05-14", // 필수. 중복 제거 키. 같은 id 절대 두 번 안 보냄. title: "USD → KRW 가 1,400 도달!", // 필수. OS 알림에 표시. body: "설정한 알림 발동.", // 선택. 알림 본문. url: "/tools/rate-alerts", // 선택. 사용자가 누르면 열리는 URL. }, ], };

notifications 가 비었거나 없으면 아무것도 안 보냄. 플랫폼이 (사용자, 마도서, id) 로 중복 제거 — 함수가 다음 틱에 같은 id 를 또 반환하면 조용히 버려요.

크론 루프 동작

60초마다 nesy.app 백그라운드 워커가 만기된 일정을 스캔. 각 일정에 대해 그 마도서의 알림에 구독한 사용자들 (/account/notifications 에서 켠 사람들) 순회, input = {} 으로 함수 호출 (사용자별), 새 {id, title, body, url} 모두를 인박스 테이블에 영구 저장, 등록된 모든 기기로 Web Push 발사.

함수는 사용자별, 격리 실행. 각 호출마다 자기 data 네임스페이스, 자기 secrets (메이커의 vault, 모든 사용자 공통), 표준 5초 타임아웃. 크론 루프가 throw 를 잡아서 로그 — 한 사용자 실패가 다른 사용자 진행을 막지 않아요.

사용자별 상태 읽기

거의 항상 "누구를 위해 확인하는 건지" 를 알아야 해요. data API 로 사용자가 미리 저장해둔 설정·임계값을 읽으세요:

ts
if (input.tool === "check_rate_alerts") { const threshold = await data.get("threshold:USD-KRW"); if (!threshold) return { notifications: [] }; const rate = await fetch("https://api.example.com/rate?from=USD&to=KRW").then((r) => r.json()); if (rate.value < threshold) return { notifications: [] }; return { notifications: [{ id: `rate-USD-KRW-${new Date().toISOString().slice(0, 10)}`, title: `USD → KRW 가 ${rate.value} 도달`, body: `설정한 임계값은 ${threshold} 였어요`, url: "/tools/rate-alerts", }], }; }

사용자가 아직 알림 설정 안 해뒀으면 빈 배열 반환 — "할 일 없다" 고 throw 하지 마요.

cadence 고르기

사용 사례적절한 cadence
일일 요약86400 (1일)
한 시간에 한 번 점검3600
빠른 리마인더 / 모니터링300 (5분)
공격적 폴링60 (최소값)

크론이 60초마다 도는 거라 60초 미만은 파서가 거부. 사용자 기대를 만족하는 가장 cadence 를 골라요 — 발동 한 번이 메이커 자원을 먹어요 (함수가 구독한 사용자 모두에게 실행됨).

안정적인 id 고르기

id 는 중복 제거 키 — 이벤트 기반으로 결정적이게, "감지된 순간" 기반으로 만들지 말 것:

  • "daily-summary-2026-05-14" — 하루 종일 같은 id; 한 번만 발사
  • "rate-USD-KRW-crossed-1400" — 임계값 교차마다 한 번
  • crypto.randomUUID() — 매 틱마다 발사 (중복 제거 안 됨)
  • String(Date.now()) — 같은 문제; 절대 이전 id 와 안 같음

사용자가 알림 해제·재활성화하면 그 상태는 저장된 상태로 확인 (id 회전이 아니라).

이 함수를 tools: 에 넣지 마요

notifications.function시스템 호출, 호출 LLM 이 부를 게 아님. tools: 에 넣지 마요 — 파서는 강제하지 않지만 거기 넣으면 어느 Claude / ChatGPT 사용자든 직접 알림 점검을 트리거해서 자기 인박스 오염시킬 수 있어요.

사용자가 보는 것

발사된 알림은 두 군데 도착:

1. OS 알림 배너 (사용자가 푸시 허용했고 브라우저가 닫혔거나 백그라운드일 때) 2. `/account/notifications` 인박스 — 영수증이 영구 저장, 사용자가 나중에 다시 봄

둘 다 같은 sent_notifications 테이블에서 가져옴; 푸시가 전달 못 됐어도 (브라우저가 서비스 워커 제거 등) 인박스에는 나옵니다.

3. 시각화 — visualization

사용자의 서재에서 위젯으로 표시될 HTML 을 마도서가 반환. 본문은 영어로 작성 — AI 가 이 글을 보고 코드를 만들어요.

시각화 — visualization: 블록

원본 HTML 을 반환하는 함수를 선언해요. nesy.app 이 사용자의 /library 페이지 시각화 모달에서 격리된 iframe 안에 그려줘요 — 사용자가 자기 마도서를 카드로 펼쳐보는 개인 서재. 대시보드, 차트, 라이브 카운터, 미니 캘린더 — 글로 읽는 것보다 보는 게 나은 거에 써요.

yaml
visualization: function: render_widget # 필수. snake_case. tools[] 에 넣지 마요. cadence: 60 # 선택. 초. 10-3600. 기본 60. height: 320 # 선택. iframe 높이 (px). 60-2000. 기본 320. tools_allowed: # 선택. 양방향 호출 허용 함수 (아래 양방향 호출 섹션). - add_entry inputSchema: # 선택. 위젯 상호작용 활성화 (아래 참조). type: object properties: date: type: string

함수가 반환할 것

run() 안에서 input.tool === "render_widget" 일 때:

ts
return { html: "<!DOCTYPE html><html>...</html>" };

그게 다. 플랫폼이 그 문자열을 격리된 iframe (sandbox="allow-scripts") 안에 넣고 /widgets/<slug> 로 서빙해요. 스타일·스크립트는 인라인 — 별도의 에셋 파이프라인 없음.

자동 새로고침

사용자 보드에서 위젯 팝업이 열려 있는 동안 iframe 이 cadence 초마다 자동 리로드. 변하는 데이터에 써요 (실시간 환율, 최신 카운터, 현재 날씨). html 작게 — 매 틱마다 문서 통째로 다시 가져와요.

위젯이 자체적으로 새로고침 안 해도 되면 (정적 참조, 천천히 변하는 데이터) cadence 를 3600 처럼 길게, 사용자가 다시 열도록 맡겨요.

위젯 상호작용 — inputSchema + postMessage

기본적으로 위젯은 입력 없음 — 매 새로고침마다 input = {}. iframe 안 사용자 컨트롤 (날짜 선택기, 드롭다운, 토글) 에 위젯이 반응하려면 inputSchema 선언하고 iframe 이 상태를 부모에 보내야 해요.

1. 위젯이 이해할 입력 선언:

yaml
visualization: function: render_widget inputSchema: type: object properties: date: type: string # 사용자가 고른 ISO 날짜 view: type: string # "week" | "month"

string, integer, number, boolean 만 인정 — array / object / file 은 URL 쿼리 파라미터로 못 실어. 선언 안 한 property 는 조용히 버려져요.

2. iframe HTML 안에서, 사용자가 바꾸면 상태를 부모에게 보내기:

html
<input type="date" id="d" value="2026-05-14"> <script> document.getElementById("d").addEventListener("change", (e) => { parent.postMessage( { type: "widget-state-change", state: { date: e.target.value } }, "*", ); }); </script>

3. nesy.app 보드 페이지가 그 메시지를 받아서, 각 키를 inputSchema 로 검증하고, 새 쿼리스트링으로 iframe 리로드. 함수는 다음 렌더 때 input.date, input.view 등으로 값을 받아요.

상태는 자동 새로고침 사이에도 유지 — 사용자가 고른 날짜가 60초마다 리셋되지 않아요.

양방향 호출 — tools_allowed + nesy.invoke

위젯이 그냥 보여주기만 한다면 inputSchema 만으로 충분해요. 하지만 위젯 안 버튼이 마도서 함수를 호출해서 (예: "+1 잔" 누르면 add_entry 실행 → 카운트 바로 갱신) 결과를 받아 즉시 화면 갱신하려면 양방향이 필요해요. 새로고침 기다리거나 채팅으로 돌아갈 필요 없이 위젯 안에서 바로.

1. tools_allowed 에 위젯이 부를 수 있는 함수 화이트리스트:

yaml
visualization: function: render_widget tools_allowed: - add_entry - get_today_count

tools_allowed 가 비어있거나 미지정이면 정적 위젯 — 위젯이 invoke 시도해도 부모가 401 회신 (토큰 자체가 발급 안 됨). tools[].name 안에 있는 이름만 적을 수 있어요 (manifest 검증 단계에서 cross-check).

2. 위젯 HTML 안에 helper 두고 호출:

html
<button id="add">+1</button> <p id="cnt">오늘 0잔</p> <script> // 양방향 호출 helper — call_id 로 동시 호출 매칭 function rid() { return Math.random().toString(36).slice(2); } function invoke(fn, args) { return new Promise(function (resolve, reject) { var id = rid(); function onMsg(e) { var d = e.data; if (!d || d.type !== "nesy.invoke.result" || d.call_id !== id) return; window.removeEventListener("message", onMsg); if (d.ok) resolve(d.data); else reject(new Error(d.error)); } window.addEventListener("message", onMsg); parent.postMessage( { type: "nesy.invoke", call_id: id, fn: fn, args: args }, "*", ); }); } document.getElementById("add").onclick = async function () { try { var r = await invoke("add_entry", { delta: 1 }); document.getElementById("cnt").textContent = "오늘 " + r.count + "잔"; } catch (e) { alert("실패: " + e.message); } }; </script>

call_id 는 위젯이 만드는 고유 문자열 — 동시에 여러 호출 보낼 때 결과 매칭에 필수.

호출 흐름:

1. 위젯이 parent.postMessage({type:"nesy.invoke", call_id, fn, args}, "*") 보냄. 2. 부모(서재 카드) 가 자기 widget session 토큰을 Authorization: Bearer 헤더에 박아 /api/widgets/<slug>/invoke 호출. 3. 서버가 토큰 검증 → fntools_allowed 안인지 확인 → 마도서 함수 실행 → 결과 회신. 4. 부모가 {type:"nesy.invoke.result", call_id, ok:true, data} 또는 {type:"nesy.invoke.result", call_id, ok:false, error} 를 위젯에 회신.

보안 약속:

  • 토큰은 부모 메모리에만 — 위젯 HTML / URL / postMessage 어디에도 노출 X. 위젯이 직접 fetch 로 백엔드 부르려고 시도해도 sandbox 격리 때문에 실패해요.
  • tools_allowed 에 적힌 이름만 위젯이 호출 가능. 다른 함수 부르면 403.
  • 토큰 발급 시 tools_allowed 스냅샷이 토큰 안에 박혀요. 토큰 발급 후 메이커가 tools_allowed 줄였어도 서버가 manifest 를 다시 읽어 재검증 (defense-in-depth) — 줄어든 함수는 거부.
  • 사용자+마도서 단위로 분당 120 호출 제한. 폭주하는 위젯 코드 작성 주의.
  • 토큰 TTL 30분 — 모달 오래 켜놓으면 만료. 다시 열면 새 토큰 발급.

언제 양방향 쓸까:

  • 위젯 안에서 사용자가 변경 동작 (추가/삭제/토글) 하는 경우.
  • 표시 즉시 갱신이 필요한 경우 (cadence 새로고침 기다리기 싫을 때).
  • 위젯 안 polling (예: 진행률·실시간 상태).

언제 안 쓸까:

  • 위젯이 그냥 차트·요약·달력 표시만 — inputSchema 만으로 충분.
  • 사용자가 채팅에서 호출해도 되는 동작 — 위젯 버튼 굳이 X.

샌드박스 origin 주의

iframe 이 sandbox="allow-scripts" (allow-same-origin 없음) 라서 origin 이 "null" 문자열이에요. 부모는 메시지를 origin 이 아니라 contentWindow 동일성으로 매칭해요. 따라서:

  • 메시지 보낼 때 origin "*" — 샌드박스 모델 안에서 정확하고 안전.
  • 위젯 안에서 document.cookie, localStorage, 플랫폼 API 호출 시도하지 마요. 위젯은 그냥 HTML; 키는 메이커의 run() 함수에 있어요.

합치면 — 클릭 가능한 미니 캘린더

ts
if (input.tool === "render_widget") { const date = (input.date as string) ?? new Date().toISOString().slice(0, 10); const entries = await data.list(`entry:${date}:`); return { html: ` <!DOCTYPE html> <html><head><style>body{font-family:sans-serif;padding:12px}</style></head> <body> <input type="date" id="d" value="${date}"> <p>${date} 에 ${entries.length} 개 기록</p> <script> document.getElementById("d").addEventListener("change", (e) => { parent.postMessage( { type: "widget-state-change", state: { date: e.target.value } }, "*", ); }); </script> </body></html>`, }; }

사용자가 보드 열고, 날짜 고르고, iframe 이 {date} 를 부모에게 보내고, 부모가 ?date=2026-05-20&t=<tick> 으로 iframe 리로드, 함수가 input.date 로 다시 실행, 갱신된 HTML 반환. 별도 API 엔드포인트 왕복 없음.

이 함수를 tools: 에 넣지 마요

visualization.function시스템 호출 — 호출 LLM 이 부를 게 아님. tools: 에 넣지 마요 — 파서는 강제 안 하지만 거기 넣으면 어느 Claude / ChatGPT 사용자든 렌더러를 직접 호출해서 HTML 문자열에 토큰만 낭비.

시각화를 건너뛸 때

  • 도구가 쓰기 동작만 (보여줄 상태 없음) — 건너뛰기.
  • 사용자가 채팅에서만 부르지 절대 "열어볼" 일 없는 도구 — 건너뛰기.
  • 출력이 숫자 하나뿐 — 호출 LLM 이 "1,400 이에요" 라고 채팅에 말하면 끝. 건너뛰기.

상태가 쌓이고 사용자가 타이핑 없이 훑어보고 싶을 때 시각화가 빛납니다.

검증 정책

  • tools 가 빠지거나 비어 있으면 거부. 최소 1개, 최대 16개.
  • name 은 영문자로 시작, 영문/숫자/언더바(_)만 가능, 64자 이내. 중복 거부.
  • description 은 1~4000자 필수.
  • inputSchema 는 JSON Schema object 형식만. 속성 16개·중첩 한 단계까지.
  • 과거 키 spells: 가 보이면 안내하며 거부 — tools: 로 바꿔주세요.
  • language: py 또는 entry 가 .py로 끝나면 거부. “Python 은 곧 지원” 안내.
  • 형식 오류는 어떤 줄·어떤 항목인지 메시지로 알려줘요.

다른 플랫폼이랑 호환되나요?

  • Claude Desktop · Cursor (MCP) — tools 부분이 그대로 호환. 우리 어댑터가 MCP 프로토콜로 노출.
  • ChatGPT (GPT Actions) — 우리 어댑터가 OpenAPI 3.0 으로 자동 변환. 메이커는 신경 쓸 필요 없어요.
  • OpenAI Function Calling parameters 라는 다른 키 이름을 쓰지만 의미는 같음. 직접 변환할 일 있으면 inputSchemaparameters.