노트 앱 뜯어보기

이 저장소의 첫 내장 앱 tiffy.notes를 예로, 매니페스트부터 도구 하나, 테스트, 사서가 부르는 자리까지 끝까지 따라가기.

노트 앱(tiffy.notes)은 지금 상태에서 말한 첫 내장(builtIn) 앱입니다. 실제로 설치되고 실행되는 앱이 어떤 모양인지 보는 데 가장 좋은 예입니다. 이 문서는 그 앱을 위에서 아래로 뜯어봅니다.

매니페스트

노트 앱의 tiffy-app.json입니다.

{
  "manifestVersion": 1,
  "id": "tiffy.notes",
  "name": { "ko": "노트", "en": "Notes" },
  "description": { "ko": "사서와 비서가 사용자의 노트를 찾고, 만들고, 고치는 기본 도구", "en": "Built-in tools for searching, creating, and editing the user's notes" },
  "version": "0.1.0",
  "sdk": "^1.0.0",
  "category": "productivity",
  "icon": "icon.svg",
  "developer": { "name": "Tiffy", "url": "https://usetiffy.com", "support": "[email protected]" },
  "license": "MIT",
  "builtIn": true,
  "runsIn": "worker",
  "helpSlug": "using/notes",
  "pricing": { "monthlyMicro": 0 },
  "multiInstance": false,
  "scopes": ["notes.read", "notes.write"],
  "tools": [
    { "key": "notes.search", "title": { "ko": "노트 검색", "en": "Search notes" }, "input": "schemas/search.in.json", "output": "schemas/search.out.json", "approval": "none", "irreversible": false },
    { "key": "notes.get", "title": { "ko": "노트 읽기", "en": "Get note" }, "input": "schemas/get.in.json", "output": "schemas/get.out.json", "approval": "none", "irreversible": false },
    { "key": "notes.create", "title": { "ko": "노트 만들기", "en": "Create note" }, "input": "schemas/create.in.json", "output": "schemas/create.out.json", "approval": "none", "irreversible": false },
    { "key": "notes.update", "title": { "ko": "노트 고치기", "en": "Update note" }, "input": "schemas/update.in.json", "output": "schemas/update.out.json", "approval": "none", "irreversible": false },
    { "key": "notes.append", "title": { "ko": "노트에 덧붙이기", "en": "Append to note" }, "input": "schemas/append.in.json", "output": "schemas/append.out.json", "approval": "none", "irreversible": false },
    { "key": "memory.remember", "title": { "ko": "기억하기", "en": "Remember" }, "input": "schemas/memory-remember.in.json", "output": "schemas/memory-remember.out.json", "approval": "none", "irreversible": false },
    { "key": "memory.forget", "title": { "ko": "잊기", "en": "Forget" }, "input": "schemas/memory-forget.in.json", "output": "schemas/memory-forget.out.json", "approval": "none", "irreversible": false }
  ],
  "triggers": [],
  "presets": [],
  "lifecycle": { "entry": "src/index.ts" },
  "i18n": "i18n/"
}

기억 도구 둘은 에이전트가 오래 남을 사실을 적고 지우는 데 씁니다. memory.remember({ scope: "user" | "agent", text })는 기억 노트(self/memory/user.md 또는 에이전트별 self/memory/<roleKey>.md)에 한 줄을 더하고, 비밀처럼 보이는 문자열은 secret_like로 거절합니다. memory.forget({ scope, text })는 그 문장이 담긴 줄을 지우며, 못 찾으면 오류가 아니라 removed: false로 끝납니다. 어느 에이전트가 불렀는지는 플랫폼이 알아내므로 앱은 scope만 고릅니다.

눈여겨볼 점

  • builtIn: true와 runsIn: "worker". 플랫폼의 내장 앱 registry에 등록돼 있어야 워커가 이 번들을 찾습니다.
  • scopes가 notes.read, notes.write 둘뿐입니다. 도구 다섯 개 중 notes.attach는 매니페스트에도, src/index.ts에도 없습니다. ctx.notes.attach가 아직 준비 중이기 때문입니다(ctx API 참고).
  • 도구가 다섯 개인데 notes.getByPath라는 도구 키는 없습니다. ctx.notes.getByPath는 플랫폼 ctx의 method고, notes.get 도구가 내부적으로 그것을 불러 흡수합니다(바로 아래 참고).
  • 모든 도구가 approval: "none", irreversible: false입니다. 노트 편집은 되돌릴 수 있으니 승인 없이 씁니다.

도구 구현 하나: notes.get

노트 앱의 src/tools/get.ts 전체입니다.

import type { AppContext } from "@tiffy/app-sdk";
import { err } from "@tiffy/app-sdk";

type Input = { id?: string; path?: string };

/** notes.get: id 또는 path로 노트 하나를 읽는다. */
export const get = async (ctx: AppContext, rawInput: unknown) => {
  const input = (rawInput ?? {}) as Input;
  if (input.id) return ctx.notes.get(input.id);
  if (input.path) return ctx.notes.getByPath(input.path);
  return err("bad_request", "id 또는 path 중 하나가 필요하다", { retryable: false });
};

여기서 배울 것 세 가지입니다.

  1. 도구 하나가 ctx 호출 하나로 끝날 수 있습니다. notes.get은 입력을 보고 ctx.notes.get이나 ctx.notes.getByPath 중 하나를 그대로 돌려줍니다. 둘 다 이미 Result<T> 모양이라 감쌀 필요가 없습니다.
  2. ctx.notes.get은 소유자 확인을 하고, ctx.notes.getByPath는 하지 않습니다(ctx API 참고). 노트는 space 단위로 공유되는 자료이기 때문입니다. 도구 코드는 이 차이를 신경 쓰지 않고 그대로 흘려보냅니다.
  3. 입력 검증 실패는 err()로, ctx 실패는 그대로 전파합니다. id도 path도 없으면 앱이 직접 bad_request를 만들지만, 노트가 없거나 권한이 없는 경우는 ctx.notes.*가 이미 올바른 에러 코드(not_found 등)로 돌려주므로 손대지 않습니다.

조금 더 복잡한 예로 notes.append(src/tools/append.ts)는 먼저 ctx.notes.get으로 현재 본문을 읽고, ctx.notes.update가 conflict를 돌려주면 응답이 준 최신 currentBody/currentVersion으로 딱 한 번만 재시도합니다. 에이전트가 expectedVersion을 몰라도 되게 하기 위해서입니다.

테스트: 모의 ctx로 재시도 경로만 겨냥하기

노트 앱의 test/append-retry.test.ts에서, 실제 DB나 createMockContext 없이 ctx.notes만 vi.fn()으로 흉내 내 재시도 로직만 검증합니다.

import { describe, expect, it, vi } from "vitest";
import type { AppContext } from "@tiffy/app-sdk";
import { ok, err } from "@tiffy/app-sdk";
import { append } from "../src/tools/append";

describe("notes.append retry", () => {
  it("retries exactly once with the conflict's currentBody/currentVersion, then succeeds", async () => {
    const update = vi
      .fn()
      .mockResolvedValueOnce(err("conflict", "버전 충돌", { currentVersion: 3, currentBody: "다른 사람이 먼저 고친 본문" }))
      .mockResolvedValueOnce(ok({ id: "n1", path: "wiki/x.md", title: "x", body: "다른 사람이 먼저 고친 본문\n\n덧붙일 문단", version: 4, updatedAt: "now" }));
    const get = vi.fn().mockResolvedValue(ok({ id: "n1", path: "wiki/x.md", title: "x", body: "본문", version: 2, updatedAt: "now" }));
    const ctx = { notes: { get, update } } as unknown as AppContext;

    const result = await append(ctx, { id: "n1", text: "덧붙일 문단" });

    expect(update).toHaveBeenCalledTimes(2);
    expect(update).toHaveBeenNthCalledWith(1, "n1", { body: "본문\n\n덧붙일 문단", expectedVersion: 2 });
    expect(update).toHaveBeenNthCalledWith(2, "n1", { body: "다른 사람이 먼저 고친 본문\n\n덧붙일 문단", expectedVersion: 3 });
    expect(result.ok).toBe(true);
  });
});

ctx를 통째로 흉내 내는 대신 도구가 실제로 쓰는 두 메서드(notes.get, notes.update)만 골라 만든 것이 요점입니다. 재시도 조건(첫 시도가 conflict일 때만, 딱 한 번만)만 확인하면 되는 테스트라 ctx API가 소개하는 createMockContext보다 가볍습니다. notes.search처럼 입력 검증만 있는 도구는 createMockContext의 notesStore를 미리 채워 두고 부르는 편이 더 맞습니다.

사서가 이 앱을 쓰는 자리

이 도구들을 실제로 부르는 것은 사서(librarian) 프로필입니다. 사서 프로필이 allowedTools: ["notes.*"]를 선언해 두었고, 대화 요청이 워커에 오면 이 값이 플랫폼의 도구 브리지로 전달됩니다.

agentProfile?.tools ?? profile.allowedTools  // 사서: ["notes.*"], 비서: [] (= 설치된 전부)
        ↓
collectBridgeTools(env, spaceId, allowedToolKeys)
        ↓ 영역에 설치된 앱마다 manifest.tools를 훑어 패턴에 맞는 것만
notes.search, notes.get, notes.create, notes.update, notes.append
        ↓ 점을 없앤 이름으로 Claude Agent SDK의 in-process MCP 도구로 등록
notes_search, notes_get, notes_create, notes_update, notes_append

모델이 notes_search 같은 도구를 부르면 플랫폼이 노트 앱의 src/index.ts를 자식 프로세스로 스폰해 tool:notes.search를 invoke하고, 결과를 다시 모델에 돌려줍니다. 지금은 Claude 백엔드만 이렇게 도구를 붙입니다. Codex 백엔드는 아직 도구를 전혀 받지 않으므로, 같은 사서 프로필이라도 Codex로 돌면 노트 도구를 못 씁니다.

영역이 새로 생기면 플랫폼이 tiffy.notes를 자동으로 enabled 상태로 설치해 두므로, 개발자가 따로 설치할 필요는 없습니다. 앱을 새로 만들 때 이 전체 경로를 그대로 따라가려면 시작하기의 "내장 앱으로 등록하기" 절을 보세요.