npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@underdogai/mesh-app-sdk

v0.11.0

Published

Matrix 채팅에 붙는 앱을 선언형으로 만드는 SDK — 카드·서피스·액션·구독을 한 곳에서 정의하고, 봇 토큰 하나로 실행한다(App Service 불필요).

Readme

@underdogai/mesh-app-sdk

Matrix 채팅방에 붙는 앱을 선언형으로 만드는 SDK. 화면(카드·패널·컴포저·⌘K)과 동작(버튼·구독·호출)을 한 곳에서 정의하고, 봇 계정 토큰 하나로 실행한다. 홈서버 관리자 권한이나 Application Service 등록 파일이 필요 없다.

npm i @underdogai/mesh-app-sdk zod

5줄 요약

  • 앱이 보내는 건 전부 데이터(Matrix 이벤트)다 — 클라이언트에서 남의 코드가 실행되지 않는다.
  • 화면은 방의 상태 이벤트로 저장된다 — 한 번 올리면 그 방의 모든 멤버에게 계속 보인다.
  • 버튼·폼 제출·앱 간 호출은 전부 하나의 와이어(org.corp.ui.action)로 흐른다.
  • 핸들러는 부작용을 직접 실행하지 않고 효과(Effect)를 선언한다 — 홈서버 없이 테스트할 수 있다.
  • 실행 방식(1회 발행 / 봇 / App Service)은 앱 코드를 바꾸지 않고 고른다.

최소 예제

import { z } from "zod";
import { defineApp, defineAction, card, panel, serve } from "@underdogai/mesh-app-sdk";

const open = defineAction({
  id: "helpdesk.open",           // id 는 반드시 "<app>." 접두사 — 이게 라우팅 주소다
  scope: "ticket:open",
  values: z.object({}).passthrough(),
});

const app = defineApp({
  id: "helpdesk", app: "helpdesk", entityTypes: ["ticket"],
  actions: [open],
  handlers: {
    [open.id]: (c) => c.send(card.build("hello", [card.text("눌렀습니다")], { title: "Helpdesk" })),
  },
  sidebar: panel.sections([panel.fields("t", "Tickets", [{ label: "Open", value: "3" }])]),
  composer: {
    placeholder: "Message or /ticket…",
    commands: [{ trigger: "/ticket", label: "Open ticket", action_id: open.id }],
  },
});

await serve(app, { homeserver: "https://your-homeserver", accessToken: process.env.BOT_TOKEN });

봇을 방에 초대하고 권한 등급 50(중재자) 을 주면 화면이 뜬다. 권한을 나중에 줘도 봇을 다시 켤 필요가 없다.

네 가지 통로

| 통로 | 뜻 | |---|---| | ctx.emit(type, content, { form }) | 사실을 발행 — "event"(조용) · "message"(카드) · "state"(방 상태) | | on: { [type]: handler } | 남이 낸 이벤트를 구독해 반응 | | ctx.invoke(actionId, values) | 다른 앱의 기능을 이름으로 호출 | | handlers: { [actionId]: handler } | 내 기능이 불리면 실행 (내 것이 아니면 조용히 무시) |

고르는 자리 — 후보가 카드 밖에 있을 때 (0.9.0)

"검색하기" 버튼 · 체크박스 18개 · "사람 더 보기 · 13명" 은 서로 다른 기능이 아니라 후보가 카드 밖에 있을 때 손으로 만든 우회 UI 다. select 에 공급원 축을 선언하면 셋이 한 컨트롤이 된다.

// 방 멤버가 곧 후보 — 아바타·이름·MXID 행, 값은 MXID 배열
card.select("to", [], {
  label: "받을 사람",
  source: { kind: "room", exclude_self: true },
  multi: true, min: 1, style: "chips",
});

// 서버에 묻는 후보 — 타이핑이 멎으면 클라가 action_type: "query" 로 묻는다
card.select("issue", recentIssues, {           // 정적 사본 = 답이 오기 전과 옛 클라의 화면
  label: "이슈",
  source: { kind: "query", action_id: "issue.search", min_chars: 2 },
});

질의를 받는 핸들러는 부작용 없이 후보만 돌려준다:

handlers: {
  "issue.search": async (ctx) => {
    if (ctx.action_type === "query") {
      const hits = await api.searchIssues(String(ctx.values.q ?? ""));
      ctx.result({ kind: "options", name: String(ctx.values.name), options: hits.map((i) => ({
        value: i.key, label: i.title, hint: i.key,
      })) });
      return;                                   // 🔴 여기서 아무것도 만들지 않는다
    }
    await api.assign(ctx.values.issue);         // 같은 액션의 진짜 제출
  },
},

🔴 options 사본은 source 가 있어도 계약이다. 이 키를 모르는 클라이언트는 source 를 벗기고 options 만 그린다 — 비워 보내면 그쪽 화면에는 고를 것이 없는 select 가 선다. 방 멤버처럼 서버가 채울 수 없는 후보라면 카드 fallback 이 그 사실을 말해야 한다(빌더가 경고를 남긴다).

할 일 내기 — ctx.task (0.10.0)

대화가 아니라 처리할 일을 사람의 인박스에 올린다. 태스크는 받는 사람마다 그 사람의 용도 방(사람 × 용도마다 하나, 대화가 막힌 전용 방)으로 가고, 처리 카드에서 끝낸다. 용도는 앱이 미리 선언한다 — 방 이름이 된다.

const app = defineApp({
  id: "finance", app: "finance", entityTypes: ["invoice"],
  tasks: { "finance.approval": { name: "재무 승인" } },   // 용도 id 는 "<app>." 접두사
  // ...
  handlers: {
    [requestApproval.id]: (c) => {
      const taskId = c.task.create({
        purpose: "finance.approval",
        to: ["@kim:hs", "@lee:hs", "@park:hs"],          // 각자의 방으로 팬아웃 — 같은 task_id
        title: "INV-1023 결재",
        card: card.build("inv-1023-approve", [/* 버튼·폼 */]),  // 처리 카드 — 제출은 평소의 액션 경로
        completion: { kind: "quorum", min: 2 },          // 기본 { kind: "first" } — 먼저 처리하면 나머지 취소
        due: Date.parse("2026-09-30T18:00:00+09:00"),    // epoch ms 정수
        requested_by: { user: c.sender },
        tabs: ["files", "activity"],                     // 상세에 세울 탭 — 순서·모양은 렌더러가 정한다
        attachments: [{ name: "INV-1023.pdf", mxc, mime: "application/pdf" }],  // 파일·미디어 탭 — 봇이 먼저 올린 mxc
      });
      c.result({ kind: "ok", message: "결재를 올렸어요" });
    },
  },
});

// 나중에(같은 앱의 다른 핸들러·구독에서) — 그 task_id 의 모든 복제본에 간다.
c.task.update({ task_id: taskId, purpose: "finance.approval", status: "cancelled", note: "전표가 취소됐어요" });
  • 잘못된 입력은 발행 전에 throw 한다 — 선언 안 된 용도, 빈 to, MXID 가 아닌 받는 사람, 받는 사람 수보다 큰 정족수, 소수 due(Matrix canonical JSON 은 부동소수를 받지 않는다). 잘못된 태스크 하나가 N 명에게 복제되기 전에 멈춘다.
  • 상세 화면(받는 쪽 클라)은 탭을 작업 · 개요 · 파일 · 미디어 · 활동 순서로 세우고 작업(처리 카드)을 먼저 연다. tabs 를 안 주면 작업·개요·활동 + 첨부가 있으면 파일(미디어 첨부가 있으면 미디어)이다. 앱이 짓는 건 처리 카드뿐이고, 개요는 task.create 필드로, 활동은 ctx.task.update 이력(상태·note)으로 클라가 조립한다 — 활동에 남길 말은 note 에 적는다.
  • 처리 카드는 머리가 보여 주는 제목·요청자·마감·진행을 반복하지 않는다. 작업 탭은 머리 바로 아래에 처리 카드를 틀·제목 없이 펼치고, 맨 끝의 card.actions 줄을 하단 고정 바로 올린다(렌더러의 "태스크 서피스" 표시형 — 앱이 고르는 게 아니다). 카드에는 결정에 필요한 것(금액·증빙·입력·버튼)만 담는다.
  • attachments 의 출처는 mxc:// 하나다(https 는 throw) — 태스크는 N 명에게 복제되는 정본이라 외부 출처를 싣지 않는다. tabs 를 직접 고르면서 files·media 를 둘 다 빼면 throw(첨부가 어디에도 안 보인다).
  • ctx.emit("org.corp.task.…") 는 여전히 막힌다 — 태스크는 이 헬퍼로만 나간다.

태스크를 집행하는 쪽 — 호스트

serve() 는 기본으로 집행한다 — 봇 토큰 하나로 받는 사람 × 용도마다 태스크 방을 만들거나 다시 쓰고, 방마다 task.create → 그걸 m.reference 하는 처리 카드 순서로 보낸다. 두 번째 태스크부터는 방이 늘지 않는다. ctx.task.update 는 그 task_id 의 복제본마다 그 방의 task.create 를 가리켜 간다.

await serve(app, { homeserver, accessToken });                    // 끝 — ctx.task 가 그대로 동작한다
await serve(app, { homeserver, accessToken, onTask: myExecutor }); // 집행을 통째로 바꾸고 싶을 때만

다른 호스트(AS 등)는 같은 집행기를 쓴다 — createTaskHost({ client, self }) 의 execute(app, effect) 를 applyEffects({ onTask }) 에 넘긴다. client 는 TaskHostClient(방 만들기·초대·멤버십·참여 방·생성 이벤트· 이벤트 쪽 읽기) — createBotClient() 가 그대로 만족한다.

태스크 방이 어떤 방인가(#714 설계 §4):

  • room type org.corp.task + 용도 표식(m.room.create content 의 org.corp.task 키 — { v, purpose, assignee }). 이름은 defineApp({ tasks }) 의 name. is_direct 는 쓰지 않는다 — 받는 쪽 클라는 DM 으로 줍지 않고 스스로 수락한다.
  • 사람은 말을 못 하고 남을 초대하지 못한다(events_default·invite 100). 처리 카드의 제출(org.corp.ui.action)만 열려 있다.
  • 호스트는 자기가 만든 방만 태스크 방으로 인정한다 — 표식은 누구나 흉내 낼 수 있어서다. 색인은 메모리에 있고, 재시작하면 방의 생성 이벤트와 task.create 로 되살린다(따로 저장소가 없다).
  • 🔴 태스크 방에서 도는 핸들러의 방 상태 효과는 건너뛴다(ctx.workflow·emit(…, { form: "state" }) — 경고만). 우편함 방에 태스크마다 상태가 쌓이지 않게 하는 규칙이다. 태스크의 진행은 ctx.task.update 로 낸다.
  • 태스크 방에서 사람의 PL 은 0 이지만, 배정이 곧 권한이다(#714 §6-7) — 발신자가 그 방의 받는 사람이면 기본 게이트 (defaultBotGate · AS evaluateAction)가 파괴적(destructive) 액션의 PL 요구를 면제한다(scope·ACL·에이전트 상한은 그대로). 커스텀 게이트는 입력의 taskAssignee 로 같은 판단을 할 수 있다(taskAssignmentGrants).

완료 규칙 — 앱은 "끝냈다"만 말한다 (STEP 5)

처리 카드 핸들러에서 c.task.update({ task_id, purpose, status: "done" }) 를 내면 호스트가 그 제출이 온 복제본 (= 누른 받는 사람)과 태스크의 completion 으로 나머지를 정한다(decideTaskUpdate — 순수):

| 규칙 | 호스트가 보내는 것 | |---|---| | 선점 first | 처리한 사람의 복제본 done, 나머지 cancelled — 둘 다 by: { user: 처리한 사람 } | | 정족수 quorum | 모든 복제본에 progress = round(100·k/min)(서로 다른 사람 k 명) · by · min 에 닿으면 done. 정족수 태스크의 progress 는 호스트 몫이다(제출 맥락의 앱 값은 버린다) |

  • 같은 사람이 두 번 눌러도 한 번으로 센다. 이미 닫힌 태스크·이미 센 사람의 제출은 핸들러 전에 결과(denied)로 막는다 (TaskHost.guard — "이미 끝난 할 일이에요." / "이미 처리했어요 — 한 사람은 한 번만 셉니다."). 닫힌 태스크는 다시 열지 않는다.
  • done 이 아닌 제출(반려 → cancelled)과 제출 맥락이 없는 갱신(요청 방의 철회, 구독 핸들러)은 규칙과 무관하게 모든 복제본에 간다.
  • task.update.by(0.10.0 · optional Ref)는 호스트만 채운다 — 받는 쪽 활동 탭의 주어가 되고, 재시작한 호스트는 복제본의 update 이력에서 이 값으로 "누가 이미 셌나"를 되살린다(tallyFromHistory).
  • 다른 호스트는 제출을 처리할 때 execute(app, effect, { roomId, actor }) 로 맥락을 넘기고, 디스패치 전에 guard(...) 를 부른다.
  • 레퍼런스 앱: packages/domain/app-payables(지급 승인 = 정족수 · 폼 · 증빙 체크리스트 · 영상 첨부 / 지급 실행 = 선점).

mesh-bot 마이그레이션 — 우회 UI 4개를 걷어내기

지금 봇들이 손으로 조립한 우회 UI 넷은 서로 다른 기능이 없어서가 아니라 계약에 축이 없어서 생겼다. 축이 생겼으니 넷 다 지운다. 필요한 단계는 이렇게 갈린다:

| 필요한 것 | SDK | |---|---| | 제약 선언(required·min/max·pattern…) + ctx.result | 0.7.0 | | 참조 값 ref + card.activity | 0.8.0 | | select 의 source/style/searchable + priority·long·summary | 0.9.0 |

🔴 0.9.0 은 빌더 API 가 한 군데 바뀐다 — card.actions(...) 가 블록 하나가 아니라 Block[] 을 돌려준다. 액션 줄 안의 ⋯ 는 옛 클라가 actions 블록을 통째로 떨구게 만들어서, 같은 ⋯ 를 최상위 사본으로 병기해야 하기 때문이다(card.activity 와 같은 규약). 호출부는 펼쳐 준다:

- blocks: [card.fields(items), card.actions([...])]
+ blocks: [card.fields(items), ...card.actions([...])]

① "처리하지 못했어요" 오류 카드 → ctx.result

// before — 방 전원의 타임라인에 실패 카드가 쌓인다
{ "card_id": "report-err-1", "title": "보고를 처리하지 못했어요",
  "context": { "state": "submitted" },
  "blocks": [{ "type": "text", "text": "기한을 선택해 주세요." }] }
// after — 누른 사람의 그 필드 아래로. 타임라인엔 아무것도 안 쌓인다
// ① 누르기 전에 보여 준다 ("today" 는 카드를 **보는 날** 기준으로 렌더러가 푼다 — 며칠 뒤에도 눌린다)
const due = card.input("due", { input: "date", label: "기한", required: true,
                                min: "today", error: "기한은 오늘 이후로 선택해 주세요" });

// ② 서버 판정은 새 카드가 아니라 그 컨트롤로
ctx.result({ kind: "reject", errors: { due: "오늘 이후여야 해요" } });
ctx.result({ kind: "denied", reason: "현재 수신자만 처리할 수 있어요" });
ctx.result({ kind: "ok", message: "확인 처리됨" });

권한 거절도 같은 채널이다. 버튼에 visible_for: [수신자] + disabled_reason 을 달면 누르기 전에 안다.

② 입력창 + "검색하기" 버튼 → select 의 source: { kind: "query" }

// before — 세 블록이 한 컨트롤을 흉내 낸다. 타이핑해도 안 줄고 버튼을 눌러야 온다
"blocks": [
  { "type": "input", "name": "q", "label": "받을 사람 찾기", "placeholder": "이름이나 이메일" },
  { "type": "actions", "elements": [{ "type": "button", "action_id": "search", "text": "검색하기" }] },
  { "type": "checkbox", "name": "p1", "label": "KANG MINJI" }
]
// after — 한 컨트롤. 타이핑이 곧 필터이고, 왕복은 source:query 일 때만 돈다
{ "type": "select", "name": "issue", "label": "이슈",
  "source": { "kind": "query", "action_id": "issue.search", "min_chars": 2 },
  "options": [ { "value": "T-01", "label": "테스트-01" } ] }   // 🔴 사본은 계약이다

핸들러는 ctx.action_type === "query" 일 때 부작용 없이 ctx.result({ kind: "options", … }) 로만 답한다 (위 "고르는 자리" 절의 예제).

③ 체크박스 18개 + "사람 더 보기 · 13명" → source: { kind: "room" } + multi + style: "chips"

// before — 값이 표시명 문자열이라 동명이인·개명에 깨지고, 나도 후보고, 얼굴이 없다
"blocks": [
  { "type": "checkbox", "name": "to_1", "label": "KANG MINJI" },
  { "type": "checkbox", "name": "to_2", "label": "[email protected]" },
  { "type": "collapsible", "title": "사람 더 보기 · 13명", "blocks": [ /* … */ ] }
]
// after — 값은 MXID 배열. 아바타·이름·MXID 는 방 상태가 나르고, 넘치는 후보는 상자 안에서 스크롤한다
card.select("to", [], {
  label: "받을 사람",
  source: { kind: "room", exclude_self: true },
  multi: true, min: 1, style: "chips",
});

⚠️ 방 멤버는 서버가 미리 채울 수 없어 options 사본이 비게 된다(빌더가 경고한다) — 그 경우 카드 fallback 이 "옛 클라에서는 고를 수 없다"는 사실을 말해야 한다.

④ collapsible 안의 처리 이력 마크다운 → card.activity(items)

// before — 접힌 상태에서 정보가 0이고, 펼쳐도 사람·시각에 구조가 없다
{ "type": "collapsible", "title": "처리 이력", "blocks": [
  { "type": "text", "text": "- 18:10 현호손 보고 올림\n- 18:12 heetae 에게 전달" } ] }
// after — 주어가 참조라 아바타·이름으로 서고, maxVisible 을 넘는 나머지는 렌더러가 접는다
...card.activity([                                    // 🔴 items 는 최신순(맨 앞이 가장 최근)
  { who: { user: "@heetae:theunderdog.ai" }, what: "1차 리마인드", at: "2026-09-20T18:15+09:00" },
  { who: { user: "@hyunho:theunderdog.ai" }, what: "전달", at: "2026-09-20T18:12+09:00",
    to: { user: "@heetae:theunderdog.ai" } },
], { label: "처리 이력", maxVisible: 3 })

card.activity 는 옛 클라용 collapsible 사본을 자동으로 병기한다(그래서 여기도 Block[] 이라 펼친다). 새 렌더러는 legacy_for: "activity" 표식을 보고 사본을 안 그린다 — 클라 배포가 퍼지면 { legacy: false }.

LLM 으로 카드 만들기 (0.11.0)

카드는 블록을 조합하는 빌더라, LLM 에게 스키마를 주면 질문마다 새 형식의 카드를 받아 올릴 수 있다.

import { cardToolDefinition, parseCardToolInput, explainCardError } from "@underdogai/mesh-app-sdk";

const tool = cardToolDefinition();            // { name, description, input_schema } — Anthropic tool use (toOpenAiTool 로 변환)
const r = parseCardToolInput(toolUse.input);  // 엄격 zod 검증 + card_id·v 채움, 서버 몫 키(state·context …)는 벗긴다
if (!r.ok) /* r.error 를 tool_result(is_error) 로 LLM 에 돌려주고 다시 받는다 */;
  • 스키마는 안내이고 진실은 zod 다 — LLM 이 스키마를 지켰어도 반드시 parseCardToolInput 을 지난다.
  • 카드 형식은 바뀌지 않았다. 옛 클라이언트도 그대로 그린다. 자세한 블록 카탈로그·좋은 카드 원칙은 저장소 docs/custom-event-ui.md §15, 돌아가는 예제는 examples/card-llm-bot/.

실행 방식

await publish(app, { homeserver, accessToken, roomId });  // 1회 발행, 프로세스 없음
await serve(app, { homeserver, accessToken });            // 봇 상시 실행 (대부분 이걸로 충분)

되돌릴 수 없는 액션(destructive: true)은 기본적으로 누른 사람의 권한 등급 50 이상을 요구한다. 자체 정책은 serve(app, { gate }) 로 대체할 수 있고, 초대 수락 범위는 acceptInvite 로 좁힐 수 있다.

앱이 컴포저를 선언하면 봇이 참여한 모든 방의 입력창 어휘가 바뀐다. 일부 방에서만 쓰려면 shouldPublishSurface 로 좁힌다 — 거절한 방에 이미 올라가 있던 서피스는 함께 회수된다.

await serve(app, {
  homeserver, accessToken,
  shouldPublishSurface: ({ roomId }) => roomId === CONTRACT_ROOM_ID,
});

DM 은 기본으로 받지 않는다 (0.11.0)

봇에게 온 DM 초대(is_direct)는 기본으로 거절한다 — 사람과의 협업은 채널에서 멘션으로 한다. 거절과 함께 시작할 때 프로필에 "이 계정은 DM 을 받지 않는다"를 선언한다(org.corp.accepts_dm: false). 그러면 Mesh 클라이언트가 이 계정의 "DM 보내기"를 숨기고 DM 생성을 거부한다. 둘은 같이 간다 — 거절만 하고 선언을 안 하면 클라이언트가 버튼을 띄워 대답 없는 방이 생긴다. 에이전트 전용 장치가 아니라 계정 속성이다 — 사람도 설정에서 같은 필드를 켠다.

await serve(app, { homeserver, accessToken });  // 기본: DM 초대 거절 + accepts_dm=false 선언

await serve(app, {
  homeserver, accessToken,
  acceptDirectInvites: true,   // DM 을 받는 봇 — 초대를 받고, 선언도 "받음"(필드 삭제)으로 맞춘다
});

declareAcceptsDm 으로 선언만 따로 정할 수도 있다(명시값이 이긴다). 이미 그 값이면 쓰지 않고, 홈서버가 확장 프로필(MSC4133)을 모르면 경고만 남기고 계속 돈다.

0.10 → 0.11 마이그레이션

  • DM 으로 쓰는 봇 → acceptDirectInvites: true 를 더한다. 안 하면 DM 초대를 더는 받지 않는다.
  • 그 외 봇 → 아무것도 안 해도 된다. 거절과 선언이 자동이다.
  • acceptInvite 콜백은 이제 isDirect 도 받는다(DM 초대는 acceptDirectInvites 가 먼저 거르므로, 켰을 때만 true 가 온다).
  • BotClient 를 직접 구현했다면 setProfileField 는 선택 메서드다 — 없으면 선언만 건너뛴다.

⚠️ Matrix 에는 DM 차단 표준이 없다. 이 약속은 Mesh 클라이언트와 이 SDK 가 지키는 것이고, 홈서버가 강제하지 않는다.

예외 — 서버 관리자. Matrix 서버 관리자는 accepts_dm=false 와 상관없이 DM 을 열 수 있다. 봇은 초대한 사람이 관리자인지 확인할 수 없으므로, Mesh 의 관리자 클라이언트는 초대 대신 서버 관리 API(POST /_synapse/admin/v1/join)로 봇을 DM 방에 직접 들인다(서버가 관리자만 허락한다). 봇은 그 방에 이미 들어와 있고 기본으로 머문다. 나가게 하려면 acceptAdminDirect: false.

문서

전체 가이드 — 설치부터 화면 지도, 권한 모델, 보안 수칙까지: https://chatops.theunderdog.ai/sdk-guide

라이선스

MIT