@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 zod5줄 요약
- 앱이 보내는 건 전부 데이터(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.createcontent 의org.corp.task키 —{ v, purpose, assignee }). 이름은defineApp({ tasks })의name.is_direct는 쓰지 않는다 — 받는 쪽 클라는 DM 으로 줍지 않고 스스로 수락한다. - 사람은 말을 못 하고 남을 초대하지 못한다(
events_default·invite100). 처리 카드의 제출(org.corp.ui.action)만 열려 있다. - 호스트는 자기가 만든 방만 태스크 방으로 인정한다 — 표식은 누구나 흉내 낼 수 있어서다. 색인은 메모리에 있고,
재시작하면 방의 생성 이벤트와
task.create로 되살린다(따로 저장소가 없다). - 🔴 태스크 방에서 도는 핸들러의 방 상태 효과는 건너뛴다(
ctx.workflow·emit(…, { form: "state" })— 경고만). 우편함 방에 태스크마다 상태가 쌓이지 않게 하는 규칙이다. 태스크의 진행은ctx.task.update로 낸다. - 태스크 방에서 사람의 PL 은 0 이지만, 배정이 곧 권한이다(#714 §6-7) — 발신자가 그 방의 받는 사람이면 기본 게이트
(
defaultBotGate· ASevaluateAction)가 파괴적(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
