12.5. 플러그인 개발 가이드¶
이 문서는 Backend.AI GO의 UI 내부에 직접 렌더링되는 App Plugin을 만드는 개발자를 위한 가이드입니다. 매니페스트 스키마, 네 가지 UI 슬롯, 권한 모델, 플러그인이 호스트의 React 인스턴스를 공유할 수 있게 하는 의존성 해석 계약, CSS 파이프라인, Plugin SDK를 다룹니다.
최종 사용자로서 플러그인을 설치, 활성화, 구성하는 방법을 찾고 있다면 대신 플러그인 관리 문서를 참고하세요. 이 문서는 그 가이드의 개발자용 짝입니다.
미리보기 기능
App Plugin 시스템은 현재 미리보기 단계입니다. 여기서 설명하는 런타임(모듈 로딩, 라이프사이클, 오류 격리, SDK)은 구현되어 있고 테스트로 검증됩니다. 공개 레지스트리를 통한 플러그인 배포는 아직 제공되지 않으며, 플러그인은 .zip 아카이브나 로컬 디렉토리에서 설치합니다.
App Plugin이란 (그리고 아닌 것)¶
App Plugin은 다음과 같습니다:
plugin.json매니페스트와 번들된 JavaScript 진입점 파일(선택적으로 CSS 파일)로 구성되며, 호스트가 동적으로 가져와 자신의 React 트리 내부에 렌더링합니다.- 권한 시스템으로 SDK 접근이 제한됩니다: 플러그인은 자신이 선언한 권한이 포함하는 SDK 메서드만 호출할 수 있습니다. 권한 목록은 SDK 표면을 제한할 뿐이라서, 보장하지 않는 범위는 아래 신뢰 모델을 참고하세요.
- 애플리케이션에 기본 제공으로 번들되거나, 사용자가 App Plugins 페이지에서
.zip아카이브 또는 로컬 디렉토리로부터 설치합니다.
App Plugin이 아닌 것:
- Claude Code 플러그인 번들(
.claude-plugin)이 아닙니다. 이것들은 별도의 Extensions 페이지에서 가져와 압축을 풀며, 스킬, 서브에이전트, MCP 서버를 에이전트 런타임에 설치합니다. "플러그인"이라는 단어는 같지만 매니페스트 형식, 설치 경로, 그리고 이 문서에서 설명하는 모듈 로더나 SDK와의 관계 모두 완전히 다른 별개의 시스템입니다. 이러한 이름 구분은 정확히 이 충돌을 피하기 위한 것으로, 이 문서에서 다루는 시스템은 제품 UI에서 항상 "App Plugins"라고 표기됩니다.
신뢰 모델¶
플러그인 번들은 호스트 WebView 안에서 애플리케이션과 같은 영역(realm)의 일반 JavaScript로 실행되므로, 다른 호스트 코드와 마찬가지로 DOM, 전역 객체, fetch에 접근할 수 있습니다. 권한 목록은 SDK API에 대한 최소 권한과 동의 표시 장치이지 악의적 코드를 가둘 수 있는 샌드박스가 아니고, 악성 번들은 SDK를 그냥 우회하면 됩니다. 권한 시스템이 실제로 보장하는 것은 SDK를 사용하는 플러그인이 선언한 범위를 실수로 넘지 못한다는 점과 사용자가 활성화 전에 정직한 기능 목록을 볼 수 있다는 점입니다. 데스크탑 애플리케이션 확장을 다루듯 신뢰할 수 있는 출처의 플러그인만 설치하세요.
플러그인의 구조¶
플러그인은 최소한 매니페스트와 진입점 번들을 포함하는 디렉토리입니다:
my-plugin/
├── plugin.json # 필수: 매니페스트
├── dist/
│ ├── index.js # 필수: 번들된 진입점, "entry"가 참조
│ └── index.css # 선택: 추출된 스타일, "styles"가 참조
└── icon.png # 선택: "icon"이 참조
dist/ 아래의 모든 파일은 빌드 단계(자세한 내용은 플러그인 빌드하기 참고)에서 생성되며, 번들된 index.js를 직접 작성하지 않습니다. React/TypeScript 소스가 담긴 src/ 디렉토리는 빌드 타임에만 존재하며 설치된 플러그인의 런타임 레이아웃에는 포함되지 않습니다.
매니페스트 (plugin.json)¶
모든 플러그인 디렉토리에는 src-tauri/src/plugins/manifest.rs에 정의된 PluginManifest 스키마로 역직렬화되는 plugin.json 파일이 있어야 합니다. 호스트는 플러그인을 설치하거나(기본 제공 플러그인의 경우) 등록하기 전에 모든 매니페스트를 이 스키마에 대해 검증합니다. 유효하지 않은 매니페스트는 첫 번째 오류만이 아니라 실패한 모든 필드의 목록과 함께 거부됩니다.
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
id | string | 예 | kebab-case, 소문자/숫자/하이픈만 허용, 하이픈으로 시작하거나 끝날 수 없고, 연속된 하이픈도 불가, 최대 64자. plugins/installed/ 아래의 디렉토리 이름이자 다른 모든 곳(메모리 네임스페이스 접두사, 스토리지 스코핑, 에셋 URL)에서 사용되는 키입니다. |
name | string | 예 | App Plugins 페이지에 표시되는 사람이 읽을 수 있는 이름. |
version | string | 예 | 유효한 SemVer로 파싱되어야 합니다 (예: "1.0.0"). |
description | string | 예 | 플러그인 카드에 표시되는 짧은 설명. |
author | string | 예 | 작성자 또는 조직 이름. |
entry | string | 예 | 번들된 JS 진입점의 상대 경로 (예: "dist/index.js"). .js로 끝나야 하며 경로 탐색(..), 절대 경로, 백슬래시를 포함할 수 없습니다. |
styles | string[] | 아니오 | 호스트가 활성화 시 주입하고 비활성화 시 제거하는 스타일시트의 상대 경로. 각 항목은 .css로 끝나야 하며 entry와 동일한 경로 탐색 검사를 통과해야 합니다. 스타일시트가 없는 플러그인은 생략(또는 빈 배열)합니다. CSS 파이프라인 참고. |
homepage | string | 아니오 | 플러그인 홈페이지 또는 저장소 URL. |
license | string | 아니오 | SPDX 라이선스 식별자 (예: "MIT"). |
icon | string | 아니오 | 아이콘 파일의 상대 경로. entry와 동일한 경로 탐색 검사가 적용됩니다. |
minAppVersion | string | 아니오 | 필요한 최소 Backend.AI GO 버전. 존재한다면 유효한 SemVer여야 합니다. |
category | enum | 예 | model, ui, data, integration, utility, other 중 하나. 순전히 정보 제공용이며 App Plugins 페이지의 필터링에 사용됩니다. |
slots | string[] | 아니오 | 플러그인이 렌더링할 UI 슬롯. UI 슬롯 참고. 기본값은 빈 목록입니다. |
permissions | string[] | 아니오 | 플러그인이 요청하는 SDK 기능. 모든 값은 13개의 알려진 권한 id 중 하나여야 합니다. 권한 참고. 기본값은 빈 목록입니다. |
tools | string[] | 아니오 | chat.tools 완성 중 플러그인이 모델에 광고할 수 있는 호스트 도구 이름의 허용 목록. 각 이름은 소문자 [a-z0-9_]+이며 비어 있지 않고 고유해야 합니다. 도구를 호출하지 않는 플러그인은 생략(또는 빈 배열)합니다. 도구 호출 참고. 기본값은 빈 목록입니다. |
settings | object[] | 아니오 | 사용자가 구성할 수 있는 설정 스키마. 설정 스키마 참고. 기본값은 빈 목록입니다. |
최소한의 유효한 매니페스트:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "Does one small thing well.",
"author": "Your Name",
"entry": "dist/index.js",
"category": "utility"
}
Rust 검증기와 프런트엔드 SDK 레지스트리(src/types/plugins.ts)는 설계상 동일한 권한 어휘를 공유합니다. manifest.rs의 테스트가 src/types/plugins.ts를 직접 읽어서 두 목록이 어긋나면 빌드가 실패합니다. 새 권한을 추가할 때는 반드시 두 파일을 함께 수정하세요.
UI 슬롯¶
플러그인은 slots 배열을 통해 렌더링할 슬롯을 선언합니다. 어떤 슬롯에 등록되어 활성화된 각 플러그인은 호스트의 레이아웃 컴포넌트가 마운트합니다:
| 슬롯 | 용도 | 마운트 위치 |
|---|---|---|
overlay | 전체 화면 위에 떠 있는 레이어. 페이지 콘텐츠 위에 표시되어야 하는 위젯용(예: 떠다니는 컴패니언 아바타). | MainLayout.tsx |
statusbar | 애플리케이션 하단 상태 표시줄. | MainLayout.tsx |
toolbar | 애플리케이션 상단 툴바. | MainLayout.tsx |
sidebar | 메인 내비게이션 사이드바, 네비게이션 항목 아래. 사이드바가 펼쳐져 있을 때만 렌더링됩니다. | Sidebar.tsx |
각 슬롯은 해당 슬롯에 활성화된 플러그인을 조회하여 PluginRenderer를 통해 각각 렌더링하는 <PluginSlot name="..."> 컨테이너(src/components/Plugins/PluginSlot.tsx)이며, PluginRenderer는 플러그인을 오류 경계(error boundary)로 감쌉니다(라이프사이클과 오류 격리 참고). 활성화된 플러그인이 없는 슬롯은 아무것도 렌더링하지 않으므로, 아무 플러그인도 사용하지 않는 슬롯을 선언해도 레이아웃 비용이 없습니다.
플러그인은 하나 이상의 슬롯을 선언할 수 있으며, 슬롯 인지 동작이 필요하다면 자신의 컴포넌트 안에서 manifest.slots를 확인해 현재 자신을 마운트하고 있는 슬롯 인스턴스에 따라 다른 콘텐츠(혹은 아무것도 렌더링하지 않음)를 표시할 수 있습니다. 대부분의 플러그인은 정확히 하나의 슬롯만 선언합니다.
권한¶
플러그인은 매니페스트에 선언한 권한이 허용하는 SDK 메서드만 호출할 수 있습니다. 권한 없이 제한된 메서드를 호출하면 설치 시점이나 로드 시점이 아니라 호출 시점에 PluginPermissionError가 발생하므로, 아직 사용하지 않는 기능에 대한 권한을 미리 요청해 두어도 안전합니다.
| 권한 | 설명 |
|---|---|
chat.inference | 추론 서버에 메시지를 보내고 응답을 받습니다. |
chat.models | 사용 가능한 모델 목록을 조회하고 정보를 읽습니다. |
chat.tools | 플러그인 채팅 완성 중 호스트 도구를 광고하고 실행합니다. 매니페스트 tools 허용 목록으로 제한됩니다. chat.streamMessageWithTools에 필요합니다. 도구 호출 참고. |
storage.scoped | 플러그인 자체 데이터 디렉토리에서 파일을 읽고 씁니다. |
storage.conversations | 플러그인 범위의 대화를 생성, 읽기, 업데이트, 삭제합니다. |
memory.read | 플러그인 자체 Memory Bank 네임스페이스에서 항목을 읽습니다. |
memory.write | 플러그인 자체 Memory Bank 네임스페이스에서 항목을 생성, 업데이트, 삭제합니다. |
memory.extract | 플러그인 네임스페이스에 LLM 기반 메모리 추출을 실행합니다. |
ui.overlay | 오버레이 레이어에 UI 컴포넌트를 렌더링합니다. |
ui.sidebar | 사이드바에 UI 컴포넌트를 렌더링합니다. |
ui.statusbar | 상태 표시줄에 UI 컴포넌트를 렌더링합니다. |
ui.toolbar | 툴바에 UI 컴포넌트를 렌더링합니다. |
ui.notifications | 사용자에게 알림을 표시합니다. |
플러그인이 실제로 필요로 하는 가장 좁은 범위의 권한만 요청하세요. 권한 목록은 사용자가 플러그인을 활성화하기 전에 표시되므로, 과도한 권한 요청은 신뢰 신호이자 유지보수 부담이 됩니다.
설정 스키마¶
플러그인은 사용자가 구성할 수 있는 설정을 선언할 수 있으며, 플러그인이 직접 설정 UI를 만들 필요 없이 호스트가 알맞은 컨트롤을 자동으로 렌더링합니다. settings 배열의 각 항목은 PluginSettingSchema입니다:
| 필드 | 타입 | 설명 |
|---|---|---|
key | string | 플러그인 내에서 고유해야 합니다. 키가 중복되면 검증에 실패합니다. |
label | string | 표시되는 라벨. |
description | string, 선택 | 컨트롤 아래 표시되는 도움말 텍스트. |
type | enum | string, number, boolean, select, textarea 중 하나. |
defaultValue | any, 선택 | 사용자가 값을 설정하지 않았을 때 적용됩니다. |
options | array, 선택 | type이 "select"일 때 필수(그리고 비어 있으면 안 됩니다). 각 옵션은 { "label": string, "value": any }입니다. |
required | boolean, 선택 | 사용자가 값을 반드시 제공해야 하는지 여부. |
rows | number, 선택 | 보이는 텍스트 행 수. type이 "textarea"일 때만 의미가 있습니다. |
presetMap | object, 선택 | select 설정에서만 의미가 있습니다. 각 옵션의 value를, 해당 옵션이 선택되었을 때 자동으로 채울 다른 설정 키와 값의 레코드에 매핑합니다. 특수 값 "__SKIP__"은 대상 키를 변경하지 않고 그대로 둡니다. 이를 이용해 (보통 "Custom"인) 한 옵션은 자동 채움에서 제외하고 나머지 옵션에만 프리셋을 적용할 수 있습니다. |
설정 값은 런타임에 api.settings.get(key) / api.settings.getAll()로 읽고, api.settings.onChange(callback)으로 변경에 반응할 수 있습니다. Plugin SDK 참고.
의존성 해석 계약¶
이것이 플러그인의 UI가 실제로 렌더링되게 만드는 메커니즘입니다. 이 부분을 잘못 이해하는 것이 플러그인이 조용히 실패하는 가장 흔한 원인입니다: 모듈은 빌드 타임에는 성공적으로 임포트되지만 런타임에는 해석되지 않아, 플러그인이 눈에 보이는 UI 없이 영원히 "error" 상태로 남습니다.
문제¶
플러그인의 컴포넌트는 격리된 iframe이나 별도의 React 루트가 아니라 호스트 자체의 React 트리 내부(PluginSlot → PluginRenderer → 여러분의 컴포넌트)에서 렌더링됩니다. React의 훅은 단일한, 공유된 디스패처 인스턴스에 의존합니다. 플러그인 번들이 자체 react 사본을 포함하면, 호스트의 React 트리 아래 마운트된 상태에서 그 사본으로부터 useState나 useEffect를 호출하는 것은 훅을 깨뜨리거나("Invalid hook call" 부류의 실패), 더 교묘하게는 일부 경우엔 동작하고 다른 경우엔 조용히 오작동합니다. i18next/react-i18next도 같은 문제가 있습니다: 별도로 초기화된 두 번째 i18next 인스턴스는 호스트가 로드한 번역 리소스를 절대 받지 못하므로, useTranslation()은 원시 키나 사용자의 언어 변경에 절대 반응하지 않는 영어 전용 폴백을 렌더링합니다.
순진한 해결책, 즉 플러그인 자체 번들러 설정에서 react를 external 의존성으로 취급하는 것은 이 문제를 해결하지 못합니다. 그것은 그저 번들 출력물에 import ... from "react"라는 bare specifier를 그대로 남겨둘 뿐입니다. 호스트가 네이티브 import()로 플러그인의 dist/index.js를 로드할 때는 Node 스타일의 모듈 해석도, 구성된 import map도 없으므로 bare specifier는 해석될 수 없고, import()는 거부(reject)되며, 플러그인의 로드 프로미스는 실패합니다.
두 가지 대안이 검토되었지만 기각되었습니다:
- Import map (
<script type="importmap">): 호스트 자체의 React/i18next 청크 URL이 빌드 타임에 Vite에 의해 해시되기 때문에, import map을 특정 빌드에 결합시켜 매 리빌드마다 깨지게 만들어 기각되었습니다. 또한 앱이 지원하는 Linux 배포판마다 WebKitGTK의 import map 지원 수준도 제각각입니다. - 플러그인에 React를 번들링: 위에서 설명한 이중 React 인스턴스 훅 파손 문제를 그대로 재도입하므로 즉시 기각되었습니다.
해결책: 호스트 모듈 레지스트리¶
호스트는 이미 초기화된 자신의 모듈 인스턴스를 잘 알려진 전역 객체에 게시하고, 공유 esbuild 플러그인이 플러그인 번들의 임포트를 그 객체를 읽도록 재작성합니다. 그래서 플러그인은 자체 사본을 절대 포함하지 않고, 해석 불가능한 bare specifier도 절대 남지 않습니다.
정확한 계약은 다음과 같습니다:
- 호스트 모듈 목록. 정확히 다섯 개의 bare specifier가 대상입니다:
react,react-dom,react/jsx-runtime,i18next,react-i18next. 이 목록은HOST_MODULE_IDS이며, 서로 동기화되어야 하는 두 곳에 정의되어 있습니다:src/lib/plugins/hostModules.ts(호스트가 부트스트랩 시 사용하는 TypeScript 원본)와scripts/esbuild-host-modules-plugin.mjs(빌드 스크립트는 TypeScript 경로 해석 밖에서 실행되기 때문에 존재하는 순수 Node 사본). - 호스트 부트스트랩 시 게시.
src/lib/plugins/hostModules.ts는 다섯 모듈 모두를 정적으로 임포트하여(그래서 Vite가 호스트의 실제 인스턴스를 메인 앱 청크에 번들링합니다)installHostModules()를 노출하며, 이 함수는 그것들을 동결(freeze)하고globalThis.__AIGO_HOST_MODULES__에 할당합니다. 이 호출은 멱등적입니다: 두 번째 호출(핫 리로드, 여러 진입점, 테스트)은 아무 동작도 하지 않으며 이미 설치된 레지스트리를 절대 덮어쓰지 않습니다. -
플러그인 빌드 시 재작성.
scripts/esbuild-host-modules-plugin.mjs의hostModulesPlugin()은 esbuild의onResolve에서 정확히 그 다섯 개의 specifier를 가로채 각각을 가상의aigo-host-module네임스페이스로 리다이렉트합니다.onLoad훅은 각각에 대해 작은 CommonJS 셰임(shim)을 방출합니다:const registry = globalThis.__AIGO_HOST_MODULES__; const hostModule = registry ? registry["react"] : undefined; if (!hostModule) { throw new Error("AIGO host module registry missing: \"react\""); } module.exports = hostModule;여기서 CommonJS를 사용하는 것은 의도적입니다: esbuild의 CJS-to-ESM 상호운용은 플러그인 소스 코드가 어떤 스타일을 쓰든 상관없이 default 임포트(
import React from "react")와 named 임포트(import { useState } from "react") 모두가 동일한 런타임 모듈 객체에 대해 올바르게 해석되도록 해 줍니다. -
최종 효과. 플러그인이 출력하는
dist/index.js에는 이 다섯 패키지에 대한 bare specifier 임포트가 전혀 없습니다. 모든 참조는 런타임에 호스트 애플리케이션이 이미 실행 중인 바로 그 객체로 해석됩니다. React 인스턴스도 하나, i18next 인스턴스도 하나이며, 호스트와 플러그인이 이를 공유합니다.
그 외의 모든 의존성은 정상적으로 번들링됩니다. 오직 이 다섯 개의 specifier만 가로채집니다. 차트 라이브러리나 날짜 유틸리티, 그 밖의 어떤 패키지가 필요하다면 bundle: true로 평소처럼 번들링하면 됩니다.
규칙¶
플러그인 자체의 package.json 의존성에 react, react-dom, react/jsx-runtime, i18next, react-i18next를 절대 추가하지 마세요. 그리고 번들러가 이들을 직접 번들링하거나 external로 처리하도록 설정하지 마세요. 항상 hostModulesPlugin()을 거쳐 빌드하세요. 이것이 플러그인의 UI가 조용히 렌더링에 실패하지 않도록 지키는 단 하나의 규칙입니다.
그대로 복사해 쓰는 esbuild.config.mjs 템플릿¶
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { hostModulesPlugin } from "../../scripts/esbuild-host-modules-plugin.mjs";
import { pluginOwnCssPlugin } from "../../scripts/esbuild-plugin-own-css.mjs";
// Absolute path to this plugin's directory, independent of the process cwd,
// so pluginOwnCssPlugin can tell the plugin's own stylesheets from foreign CSS.
const pluginRoot = dirname(fileURLToPath(import.meta.url));
await esbuild.build({
entryPoints: ["src/index.tsx"],
bundle: true,
format: "esm",
outfile: "dist/index.js",
target: "es2020",
minify: true,
sourcemap: false,
treeShaking: true,
plugins: [hostModulesPlugin(), pluginOwnCssPlugin(pluginRoot)],
logLevel: "info",
});
두 상대 경로(../../scripts/...)는 플러그인 디렉토리가 저장소 루트에서 두 단계 아래에 있다고 가정하며, 이는 examples/plugin-template/과 일치합니다. src/plugins/<id>/ 아래의 기본 제공 플러그인은 세 단계 아래에 있으므로 src/plugins/companion-chat/esbuild.config.mjs가 그렇듯 ../../../scripts/...를 사용합니다. 플러그인 디렉토리가 저장소 루트에서 몇 단계 떨어져 있는지에 맞춰 깊이를 조정하면 되며, 플러그인 내보내기(export)나 빌드 옵션 자체는 바뀌지 않습니다.
CSS 파이프라인¶
플러그인 번들은 호스트의 Vite 빌드를 거치지 않으므로 Vite의 CSS 처리에 의존할 수 없습니다. 대신:
- 플러그인 자체 소스가 컴포넌트 파일 안에서
import "./styles/widget.css"처럼 스타일시트를 평소대로 임포트합니다. pluginOwnCssPlugin()(scripts/esbuild-plugin-own-css.mjs)은 플러그인 자체의 CSS와, 임포트한 호스트 컴포넌트를 통해 간접적으로 딸려 오는 CSS(예를 들어 공용MarkdownContent컴포넌트가 KaTeX 스타일시트를 끌어옵니다)나node_modules패키지를 통해 딸려 오는 CSS를 구분합니다. 플러그인 디렉토리 내부로 해석되는 상대 임포트인 자체 CSS는 유지되어 esbuild의 내장 CSS 로더에 의해 나란히 있는dist/index.css로 추출됩니다. 그 외 모든 CSS는 빈 모듈로 대체되어 버려지는데, 호스트 애플리케이션이 자체 Vite 빌드를 통해 이미 그 CSS를 전역으로 제공하고 있기 때문입니다. 이를 다시 번들링하면 스타일이 중복될 뿐 아니라, 이 가벼운 빌드에는 로더가 설정되어 있지 않은 폰트 에셋(.woff/.woff2/.ttf)까지 끌어들여 빌드 자체가 실패할 수 있습니다.- 매니페스트의
styles배열에 출력된 파일을 선언합니다:"styles": ["dist/index.css"]. - 런타임에는 로더가 플러그인 자체의 활성화 라이프사이클과 맞물려 스타일시트를 주입하고 제거합니다(
src/lib/plugins/pluginStyles.ts):- 데스크톱(Tauri):
asset:프로토콜을 통해 CSS 텍스트를 가져와document.head에<style data-plugin-id="...">노드로 인라인합니다. CSP의style-src 'unsafe-inline'이 이를 허용합니다.asset:을 직접 가리키는<link>는style-src가asset:도 허용해야 하는데, 그렇지 않습니다. - 헤드리스 WebUI: 동일 출처(same-origin) 인증 에셋 경로를
<link rel="stylesheet" data-plugin-id="...">로 직접 참조합니다.style-src 'self'가 이를 허용합니다. - 비활성화 시 해당 플러그인 id가 태그된 모든
<style>/<link>노드가 제거되어, 비활성화된 플러그인은 시각적 흔적을 남기지 않습니다. - 스타일시트 주입 실패는 경고로 수집되어 플러그인의 런타임 상태에 표시되지만, 그 외에는 로드 가능한 플러그인의 활성화를 절대 막지 않습니다.
- 데스크톱(Tauri):
플러그인에 스타일시트가 없다면 styles를 완전히 생략하세요. 이 필드가 생기기 전과 스타일 없는 플러그인의 매니페스트 wire shape는 달라지지 않습니다.
Plugin SDK¶
활성화된 모든 플러그인은 권한 범위가 지정된 PluginAPI 인스턴스(src/lib/pluginSDK.ts, createPluginAPI())를 받으며, 기본 내보내기(default export) 컴포넌트에는 api prop으로, onActivate에도 전달됩니다. 매니페스트가 권한을 요청하지 않은 메서드를 호출하면 누락된 권한의 이름을 담은 메시지와 함께 즉시 PluginPermissionError가 발생합니다.
| 도메인 | 주요 메서드 | 필요한 권한 |
|---|---|---|
chat | sendMessage(messages, options), streamMessage(messages, options, callbacks), streamMessageWithTools(messages, options, callbacks), cancelStream(), getActiveModel(), listModels() | chat.inference(전송/스트림/취소), chat.tools(도구 지원 스트리밍, 도구 호출 참고), chat.models(활성 모델/목록) |
storage | get(key), set(key, value), delete(key); conversations.list(), conversations.load(id), conversations.save(conv), conversations.delete(id) | storage.scoped(키-값), storage.conversations(대화 CRUD) |
memory | createNamespace, listNamespaces, toggleNamespace, deleteNamespace, createEntry, listEntries, updateEntry, deleteEntry, extractFromConversation, buildContext, consolidate | memory.read(목록/읽기), memory.write(생성/업데이트/삭제/통합/토글), memory.extract(LLM 추출) |
settings | getAll(), get(key), onChange(callback) | 없음 |
ui | showNotification(message, type), getTheme(), onThemeChange(callback) | ui.notifications(알림만; 테마 읽기는 권한 불필요) |
채팅 추론 옵션(temperature, maxTokens, topP, topK)은 요청이 전송되기 전에 안전한 범위로 검증되고 클램프(clamp)되므로, 플러그인이 범위를 벗어난 값을 추론 서버에 그대로 전달할 수 없습니다.
메모리 네임스페이스 격리¶
플러그인이 memory.createNamespace(name, ...)로 생성하는 모든 네임스페이스에는 자동으로 plugin:<pluginId>: 접두사가 붙습니다. id가 my-plugin인 플러그인이 memory.createNamespace("notes")를 호출하면 문자 그대로 plugin:my-plugin:notes라는 이름의 네임스페이스가 생성됩니다. 모든 읽기·쓰기 경로는 추가로 소유권을 검증합니다: 호출한 플러그인 자신의 접두사로 시작하지 않는 네임스페이스(호스트 애플리케이션 소유든 다른 플러그인 소유든)를 건드리려 하면 어떤 데이터도 읽거나 쓰기 전에 PluginPermissionError가 발생합니다. 이 격리는 관례가 아니라 SDK 자체에서 강제되므로, 작성자가 의도적으로 지키려 하지 않은 플러그인에도 그대로 적용됩니다.
memory.buildContext()도 같은 방식으로 범위가 제한됩니다: 반환되는 컨텍스트는 호출한 플러그인 자신의 활성화된 네임스페이스에서만 조립되며, 호스트 애플리케이션의 메모리 뱅크나 다른 플러그인의 네임스페이스는 절대 포함하지 않습니다.
설정 반응성¶
api.settings.getAll()은 플러그인의 현재 설정 값(사용자가 App Plugins 페이지에서 구성한 값과 매니페스트에 선언된 기본값을 병합한 것)을 읽습니다. api.settings.onChange(callback)은 이후의 변경을 구독하고 구독 해제 함수를 반환합니다. 예를 들어 채팅 위젯이 열려 있는 동안 사용자가 시스템 프롬프트 설정을 편집하는 것처럼 설정 변경에 실시간으로 반응해야 하는 컴포넌트라면 폴링 대신 이를 사용하세요.
도구 호출¶
플러그인은 모델이 호스트 도구를 호출하도록 허용할 수 있습니다. 호스트 도구는 애플리케이션을 관찰하거나 조작하는 함수입니다(모델 목록 조회, 추론 상태 보고, 모델 로드, 스쿼드 생성, 페이지 이동). 애플리케이션 제어 도구 전체 목록은 각 도구의 용도, 승인 플래그, 데스크톱 전용 여부를 한 행씩 정리한 앱 제어 도구 레퍼런스에 있습니다. 이 절은 플러그인이 어떻게 옵트인하고 루프를 렌더링하는지를 다룹니다.
설계 원칙은 호스트가 도구 루프 전체를 소유하고 플러그인은 그것을 렌더링만 한다는 것입니다. 플러그인이 자체 루프를 만들 수 있는 api.tools.execute() 원시 함수는 없습니다. 플러그인은 하나의 메서드 api.chat.streamMessageWithTools(...)를 호출하고, SDK가 기능 게이팅, 허용 목록 강제, 스트리밍 델타 누적, 승인 게이트, 호스트를 통한 실행, 루프 상한, HTTP 400 폴백을 메인 채팅 표면과 동일한 헬퍼를 재사용해 실행합니다. 플러그인은 상태를 그리고 승인을 요청하기 위한 라이프사이클 콜백만 받으며, 그 이상은 없습니다.
옵트인: chat.tools 권한과 tools 허용 목록¶
도구 호출에는 평문 채팅이 이미 필요로 하는 chat.inference 권한에 더해, 매니페스트에 두 가지 추가 선언이 필요합니다:
chat.tools권한. 이것 없이streamMessageWithTools를 호출하면 다른 모든 제한된 메서드와 마찬가지로PluginPermissionError가 발생합니다.tools허용 목록: 플러그인이 모델에 광고할 수 있는 호스트 도구 이름의 명시적 목록. 호스트 전체 카탈로그를 기본적으로 플러그인에 노출하는 것은 의도적으로 지원하지 않으며, 플러그인은 필요한 도구만 나열합니다.
{
"id": "my-assistant",
"name": "My Assistant",
"version": "0.1.0",
"description": "A helper that can look up and load models.",
"author": "Your Name",
"entry": "dist/index.js",
"category": "ui",
"slots": ["overlay"],
"permissions": ["chat.inference", "chat.tools", "ui.overlay"],
"tools": ["list_models", "get_inference_status", "load_model", "navigate_to_page"]
}
특정 턴에서 실제로 모델에 광고되는 도구 집합은 세 필터의 교집합입니다:
- 매니페스트
tools허용 목록, - 라이브 호스트 카탈로그(기본 제공 호스트 도구만 주소 지정 가능하며, MCP
server:tool이름은[a-z0-9_]+명명 규칙에 맞지 않아 절대 광고되지 않습니다), - 그리고 모든 실행 경로에서 백엔드 측으로 강제되는 엔터프라이즈 도구 정책. 관리자가 거부한 도구는 절대 광고되지 않으며, 모델이 그 이름을 대더라도 실행될 수 없습니다.
교집합은 매 턴 다시 계산되므로, 네트워크가 필요한 도구(예: search_huggingface_models, download_model)를 거부한 에어갭 배포에서는 플러그인이 로컬 도구만 광고하게 됩니다. 플러그인 코드 변경은 필요 없습니다.
streamMessageWithTools로 루프 실행하기¶
streamMessageWithTools(messages, options, callbacks)는 streamMessage를 그대로 따르면서 도구 라이프사이클 콜백을 추가합니다. 모든 콜백은 렌더링 전용이고 전부 선택 사항입니다. 완전한 최소 통합 예시:
const result = await api.chat.streamMessageWithTools(
[{ role: "user", content: userInput }],
{ maxToolLoops: 5, temperature: 0.7 },
{
// Plain-chat callbacks (identical to streamMessage):
onContent: (chunk) => bubble.appendText(chunk),
onError: (error) => bubble.showError(error.message),
onComplete: (final) => bubble.markDone(final.tokenCount),
// Tool-lifecycle callbacks:
onToolCallStart: (call) => bubble.addToolChip(call.id, call.name),
onToolResult: (res) =>
bubble.updateToolChip(res.toolCallId, res.success, res.result),
// Approval gate. Return a Promise<boolean>: true approves this one call,
// false denies it. Resolve false on dismiss (see the security note below).
onApprovalRequired: (call, toolDescription) =>
new Promise<boolean>((resolve) => {
bubble.showApprovalPrompt(call.name, toolDescription, resolve);
}),
// Fired once when the turn silently degraded to plain chat.
onToolsUnavailable: (reason) => bubble.showHint(reason),
},
);
// result.toolCalls is every call the host processed, in execution order.
for (const call of result.toolCalls) {
console.info(call.name, call.success, call.executionTimeMs);
}
콜백 표면은 모두 src/types/pluginSDK.ts에 정의되어 있습니다:
| 콜백 | 시그니처 | 호출 시점 |
|---|---|---|
onContent | (chunk: string) => void | 각 증분 콘텐츠 청크(평문 채팅과 동일). |
onError | (error: Error) => void | 완성이 실패할 때. |
onComplete | (result: PluginStreamResult) => void | 턴이 성공적으로 끝날 때. |
onToolCallStart | (call: PluginToolCallInfo) => void | 도구 호출마다 한 번, 호스트가 실행하기 직전. call은 id, name, 원시 JSON arguments를 담습니다. |
onToolResult | (result: PluginToolResultInfo) => void | 도구 호출마다 한 번, 그 결과와 함께. result는 toolCallId, name, success, result 페이로드, 선택적 error, executionTimeMs를 담습니다. |
onApprovalRequired | (call, toolDescription) => Promise<boolean> | 승인이 필요한 도구가 실행되려 할 때. 이 한 번의 호출을 승인하려면 true, 거부하려면 false로 resolve합니다. |
onToolsUnavailable | (reason: string) => void | 도구가 요청되었지만 턴이 평문 채팅으로 저하되었을 때(우아한 성능 저하 참고). |
options는 평문 PluginChatOptions(model, temperature, maxTokens, topP, topK)를 확장해 maxToolLoops를 추가합니다. 이는 SDK가 최종 도구 없는 완성을 강제해 사용자가 항상 산문으로 끝나도록 하기 전까지의 모델↔도구 왕복 횟수 상한입니다. 1..10으로 클램프되며 기본값은 5입니다. cancelStream()은 진행 중인 루프를 도구 턴을 포함해 중단합니다.
기본 거부 승인¶
상태를 변경하는 모든 호스트 도구(모델 로드, 언로드, 다운로드, 스쿼드 생성)는 requires_approval: true를 가지며, SDK는 명시적인 사용자 결정 없이 절대 그것을 실행하지 않습니다. 게이트는 두 가지 방식으로 기본 거부입니다:
- 플러그인이
onApprovalRequired콜백을 제공하지 않으면, 승인이 필요한 호출은 곧바로 거부되고 절대 실행되지 않습니다. - 콜백이 제공되면, 호출은 그 콜백이
true로 resolve할 때만 실행됩니다. 예외를 던지는 콜백은 거부로 처리됩니다. UI는 어떤 형태의 취소(Escape, 위젯 닫기·최소화, 언마운트)에서도 프로미스를false로 resolve해야, 대기 중인 승인이 우연한 승인으로 resolve되지 않습니다.
이는 단순한 UX가 아니라 보안 경계입니다. Memory Bank 콘텐츠를 시스템 프롬프트에 주입하는 컴패니언은 공격자가 영향을 줄 수 있는 텍스트를 주입하는 것이므로, 상태를 변경하는 동작은 모델의 말만 믿고 자동 실행되어서는 안 됩니다. 승인 콜백을 연결하는 코드에는 이 태세를 주석으로 명시하세요. 기본 제공 Companion Chat 플러그인의 ToolApprovalPrompt와 그 취소-시-거부 처리가 동작하는 참고 구현입니다.
읽기 전용 도구(list_models, get_inference_status 및 그 밖의 관찰 도구)는 requires_approval: false를 가지며 프롬프트 없이 실행됩니다. 다만 onToolCallStart / onToolResult는 여전히 발생하여 활동이 눈에 보이게 귀속됩니다. Companion Chat 플러그인의 ToolActivityChip이 그 호출별 상태 줄에 대한 동작하는 참고 구현입니다.
우아한 성능 저하¶
도구 요청은 절대 턴을 깨뜨리지 않습니다. 세 가지 경우에 SDK는 턴을 조용히 평문 채팅으로 완성하고, 빈 toolCalls를 반환하며, onToolsUnavailable(reason)을 정확히 한 번 발생시킵니다:
- 해석된 모델이 도구 호출을 지원하지 않는 경우(기능 게이트가 메인 채팅의
resolveToolCallingSupport를 재사용하므로, 파서 없는 자체 호스팅 서버는 절대 도구를 받지 않고 HTTP 400을 유발하지 않습니다), - 허용 목록이 빈 교집합이 되는 경우(위 세 필터 이후 광고할 것이 없음), 또는
- 도구를 포함한 요청이 HTTP 400으로 거부되어 도구 없이 한 번 재시도되는 경우.
플러그인은 onToolsUnavailable을 정보성으로 다뤄야 합니다: onContent / onComplete를 통해 여전히 정상적인 산문 답변을 받습니다. tools 허용 목록이 빈 플러그인은 오류 없이 평문 채팅 동작을 얻고, chat.tools 권한이 없는 플러그인은 streamMessageWithTools에서 PluginPermissionError를 받으며, 평문 streamMessage는 전혀 영향을 받지 않습니다.
사전 점검: canUseTools¶
시스템 프롬프트가 모델에게 호출 가능한 도구를 알려준다면, 그 광고를 await api.chat.canUseTools(model?)로 게이팅하세요. 이 메서드는 다음 streamMessageWithTools 호출이 실제로 도구를 첨부할 때만 true로 해석됩니다: 해석된 모델이 기능 게이트를 통과하고 매니페스트 허용 목록이 호스트 카탈로그(또는 허용된 프런트엔드 도구)와 비어 있지 않게 교차하는 경우입니다. 첨부되지 않을 도구를 광고하면 모델이 도구 호출 구문과 지어낸 결과를 평문 산문으로 출력합니다.
액션 메서드와 달리 canUseTools는 권한이 없어도 예외를 던지지 않습니다: chat.inference와 chat.tools가 없는 플러그인은 그저 false를 받습니다. 이 메서드는 이전 호스트에서도 번들이 로드될 수 있도록 인터페이스에서 선택 사항이므로, typeof api.chat.canUseTools === "function"으로 가드하고 설정 기반 동작으로 폴백하세요. 번들된 Companion Chat 플러그인의 전송 경로가 실제 참고 사례입니다.
프런트엔드 도구와 내비게이션¶
어떤 동작은 그 효과가 WebView 안에 있기 때문에 호스트 백엔드에서 실행할 수 없습니다. 프런트엔드 도구는 백엔드 카탈로그 도구와 똑같이 모델에 광고되지만 SDK 계층에서 디스패치되며 백엔드 실행 경로에 절대 도달하지 않습니다. 오늘 제공되는 유일한 프런트엔드 도구는 호스트 라우터를 조종하는 navigate_to_page입니다.
navigate_to_page는 기존 api.ui.navigate() 표면을 재사용하므로 ui.overlay 권한을 재사용합니다. 새 권한 id는 도입되지 않습니다. 모델이 이동할 수 있게 하려면 플러그인이 permissions에 ui.overlay를, tools에 navigate_to_page를 선언합니다. 두 게이트가 모두 통과해야 합니다. 도구의 path 인자는 모델에 보내는 JSON 스키마 enum과 실행 시점의 재검증 양쪽으로 최상위 인앱 경로의 고정 허용 목록으로 제한되므로, 환각된 경로는 구조화된 오류를 반환하고 이동하지 않습니다. 프런트엔드 도구는 승인 게이트를 거치지 않지만 onToolCallStart / onToolResult는 여전히 발생합니다.
애플리케이션 제어 도구 전체 목록과 정확한 의미는 앱 제어 도구 레퍼런스를 참고하세요.
라이프사이클과 오류 격리¶
플러그인의 진입점 모듈은 다음을 내보낼 수 있습니다:
default(필수):{ api, pluginId, manifest }를 props로 받는 React 컴포넌트.onActivate(api)(선택): 동적 임포트가 성공한 후, 플러그인 상태가"active"가 되기 전에 한 번 호출됩니다. 여기서 예외가 발생하면 플러그인 상태는"error"가 되고 컴포넌트는 렌더링되지 않습니다.onDeactivate()(선택): 모듈이 언로드되기 전(사용자가 플러그인을 비활성화하거나 앱이 종료될 때) 호출됩니다. 여기서 발생한 오류는 로그로 남지만 언로드를 막지는 않습니다.
런타임 상태(LoadedPluginStatus)는 "loading", "active", "error", "unloaded" 중 하나이며, App Plugins 페이지에 표시되어 실패가 조용히 빈 슬롯으로 남지 않고 눈에 보이게 됩니다. 성공적으로 활성화되었지만 하나 이상의 스타일시트 주입에 실패한 플러그인은 로드 실패로 취급되지 않고, 치명적이지 않은 styleWarning이 기록된 채 "active" 상태를 유지합니다.
라이프사이클 훅 오류와는 별개로, 마운트된 모든 플러그인 컴포넌트는 PluginRenderer에 의해 React 오류 경계로 감싸집니다. 렌더링 중 발생한 크래시(onActivate/onDeactivate 중이 아니라 컴포넌트가 렌더링되는 동안 던져진 예외)는 여기서 잡혀 해당 플러그인에 한정된 다시 시도 버튼이 있는 대체 패널을 표시하며, 애플리케이션의 나머지 부분을 절대 무너뜨리지 않습니다.
국제화(i18n)¶
i18next와 react-i18next가 호스트 모듈 레지스트리를 통해 해석되므로(의존성 해석 계약 참고), 플러그인 컴포넌트 안에서 useTranslation()을 호출하면 호스트 애플리케이션이 사용하는 것과 정확히 동일한, 이미 초기화된 i18next 인스턴스에 바인딩됩니다. 플러그인은 절대 자체 i18next 인스턴스를 초기화하지 않고, 런타임 리소스로 자체 로케일 파일을 함께 배포하지도 않습니다.
이는 번역 키가 어디에 있어야 하는지에 실질적인 영향을 줍니다. 플러그인은 자체 번역 리소스 번들을 갖고 있지 않으므로, t("...")로 호출하는 키는 이미 호스트 자체 로케일 파일에 존재해야 합니다. 실제로는 다음을 의미합니다:
- 이 저장소를 클론한 상태에서 빌드하는 플러그인(기본 제공 플러그인, 또는 패키징하기 전에 모노레포에 대해 개발 중인 플러그인)이라면: 원하는 네임스페이스로
src/locales/<lang>/translation.json의 모든 로케일 파일에 키를 추가하세요(기본 제공 Companion Chat 플러그인은 예를 들어companion.widget.ariaLabel처럼 최상위companion.*네임스페이스를 사용합니다). 영어(en)는fallbackLng이자 정본(canonical)이므로, 다른 로케일에는 있지만en에는 없는 키는 영어 사용자만이 아니라 모든 사용자에게 원시 키 그대로 렌더링됩니다. 따라서en에 먼저 추가하고 다른 모든 로케일에 동일하게 반영하세요. .zip이나 디렉토리에서 설치되어 호스트 소스 트리에 접근할 수 없는 서드파티 플러그인이라면, 현재 런타임에 리소스 번들을 등록할 수 있는 SDK 메서드가 없습니다. 원하는 문구를 이미 담고 있는 기존 호스트 키를 재사용하거나,useTranslation()을 쓰지 말고 UI 문자열을 하드코딩하세요. 이는 우회할 방법이 아니라 현재 SDK 표면의 알려진 한계로 받아들이는 것이 맞습니다. 향후 SDK 버전에서는 플러그인이 자체 번역 리소스를 등록할 방법이 추가될 수 있습니다.
플러그인 빌드하기¶
플러그인이 어디에 있는지에 따라 esbuild를 실행하는 두 가지 방법이 있습니다:
src/plugins/<id>/아래의 플러그인 (기본 제공 플러그인과, 집계 스크립트가 인식하길 원하는 모든 플러그인): 저장소 루트에서pnpm build:plugins를 실행하세요.scripts/build-plugins.mjs는src/plugins/의 모든 직계 하위 디렉토리를 스캔해esbuild.config.mjs가 있는 곳마다node esbuild.config.mjs를 실행합니다. 이는pnpm tauri dev와pnpm tauri build전에도 자동으로 실행되므로(src-tauri/tauri.conf.json의beforeDevCommand/beforeBuildCommand에 연결됨), 기본 제공 플러그인의dist/는 로컬 개발과 패키징 모두에서 항상 최신 상태로 유지됩니다.- 그 밖의 위치에 있는 플러그인(집계 스크립트와 dev 모드 기본 제공 플러그인 폴백이 절대 자동으로 인식하지 못하도록 의도적으로
src/plugins/밖에 둔examples/plugin-template/포함): 플러그인 자체 디렉토리 안에서node esbuild.config.mjs를 직접 실행하세요. Node의 모듈 해석은 상위 디렉토리를 거슬러 올라가며 저장소 루트의node_modules에서esbuild와 공유 플러그인 스크립트 두 개를 찾으므로, 별도의package.json이나node_modules없이도 동작합니다.
어느 방법이든 결과는 같습니다: 매니페스트의 entry/styles 필드가 가리키는 것과 일치하는 dist/index.js(그리고 스타일시트가 있다면 dist/index.css)가 생성됩니다.
패키징과 설치¶
디스크 레이아웃¶
사용자가 설치한 플러그인은 애플리케이션 데이터 디렉토리 아래에 있습니다:
{app_data_dir}/plugins/
├── registry.json
└── installed/
└── {plugin-id}/
├── plugin.json
├── index.js # ("entry"가 가리키는 위치, 예: dist/index.js)
├── assets/
└── data/
├── settings.json
├── state.json
└── conversations/
├── index.json
└── {conv-id}.json
data/는 플러그인 자체의 범위 지정 스토리지(storage.scoped, storage.conversations)로, 설치 시 자동으로 생성되며 다른 어떤 플러그인과도 공유되지 않습니다.
.zip에서 설치¶
zip 설치기(src-tauri/src/plugins/installer.rs)는 순서대로 다음을 강제합니다: 아카이브 크기 상한 50MB, 최대 10,000개 항목, 아카이브 루트 또는 정확히 한 단계 아래에 위치한 plugin.json, 매니페스트 스키마 검증, 선언된 entry 파일이 아카이브에 실제로 존재하는지 확인, 심볼릭 링크나 경로 탐색 항목 거부. 모든 검사를 통과한 후에만 파일이 plugins/installed/{plugin-id}/로 복사되고 플러그인이 등록됩니다. 플러그인은 이미 압축이 풀린 로컬 디렉토리에서 직접 설치할 수도 있습니다(아카이브 전용 검사를 제외하고 동일한 검증이 적용됩니다).
기본 제공 번들링¶
Companion Chat처럼 애플리케이션 자체에 포함되어 출시되는 플러그인은 src-tauri/tauri.conf.json에 Tauri 번들 리소스로 등록되며, 각 파일이 명시적으로 나열됩니다:
"resources": [
{ "../src/plugins/companion-chat/plugin.json": "plugins/builtin/companion-chat/plugin.json" },
{ "../src/plugins/companion-chat/dist/index.js": "plugins/builtin/companion-chat/dist/index.js" },
{ "../src/plugins/companion-chat/dist/index.css": "plugins/builtin/companion-chat/dist/index.css" }
]
시작 시 register_builtin_plugins_from_resource_dir(src-tauri/src/plugins/builtin.rs)는 번들된 plugins/builtin/ 리소스 디렉토리의 모든 직계 하위 디렉토리를 읽어(패키징된 빌드가 아니라 소스 체크아웃에서 실행 중이라면 dev 소스 트리 src/plugins/로 폴백), 각 plugin.json을 파싱하고 검증한 다음 각 플러그인에 대해:
- 아직 설치되지 않음: 파일을
plugins/installed/{plugin-id}/로 복사하고builtIn: true로 등록합니다. - 설치되어 있으나 이전 버전: 파일을 업데이트하고, 기록된 버전을 올리며, 사용자의 기존 활성화/비활성화 설정을 유지합니다.
- 설치되어 있고 동일하거나 더 최신 버전: 건너뛰며 아무 작업도 하지 않습니다.
스키마 검증에 실패한 기본 제공 매니페스트는 시작을 중단시키거나 검증 없이 설치되지 않고 완전히 건너뛰어집니다(오류로 로그됩니다). 나머지 기본 제공 플러그인은 정상적으로 계속 등록됩니다.
헤드리스 시딩¶
헤드리스 서버에는 Tauri 번들 리소스 디렉토리가 없으므로, 다음 우선순위로 런타임에 기본 제공 플러그인 소스를 해석합니다:
AIGO_BUILTIN_PLUGINS_DIR환경 변수가 존재하는 디렉토리를 가리키는 경우 그 값.- 실행 파일과 같은 위치의
plugins/builtin/디렉토리(패키징된 헤드리스 서버 레이아웃). - dev 소스 트리
src/plugins/(소스 체크아웃으로 바이너리를 빌드한 바로 그 머신에만 존재하며, 따라서 그 머신에서만 선택됩니다).
찾아낸 디렉토리가 무엇이든 데스크톱 경로와 정확히 동일한 register_builtin_plugins 핵심 로직을 거치므로, 두 플랫폼 모두 동일한 로직으로 기본 제공 플러그인을 시딩합니다.
제거와 업데이트¶
기본 제공 플러그인은 비활성화할 수는 있지만 절대 제거할 수 없습니다(제거 동작이 숨겨집니다). 사용자가 설치한 플러그인은 자유롭게 제거할 수 있으며, 이는 범위 지정 스토리지를 포함한 plugins/installed/{plugin-id}/ 디렉토리 전체를 제거합니다.
예제 템플릿¶
examples/plugin-template/은 있는 그대로 빌드하고 설치할 수 있는, 혹은 시작점으로 복사할 수 있는 최소한의 동작하는 플러그인입니다. statusbar 슬롯을 요청하는 plugin.json, 위에서 설명한 공유 host-modules 및 own-CSS 플러그인을 그대로 사용하는 esbuild.config.mjs, SDK를 통해 api.ui.showNotification을 호출하는 작은 상태 표시줄 컴포넌트, CSS 파일 하나, 빌드 및 설치 방법을 담은 README를 포함합니다.
이 템플릿은 의도적으로 src/plugins/가 아니라 examples/ 아래에 있습니다. 헤드리스 시딩에서 설명한 dev 모드 기본 제공 플러그인 폴백은 src/plugins/만 스캔하므로, 이 템플릿은 저장소에 존재하는 것만으로는 절대 자동으로 설치되지 않습니다. 최종 사용자가 서드파티 플러그인을 설치하는 것과 똑같이, 명시적으로 빌드하고 설치해야 합니다.
함께 보기¶
- 앱 제어 도구 레퍼런스:
chat.tools플러그인이 호출할 수 있는 호스트 도구의 전체 목록을 한 행씩 정리한 레퍼런스. - 플러그인 관리: App Plugins 페이지에서 플러그인을 설치, 구성, 문제 해결하는 최종 사용자 가이드.
docs/DEVELOP.md: "App Plugin System" 절에서 더 넓은 내부 아키텍처 문서로부터 이 가이드를 상호 참조합니다.src-tauri/src/plugins/manifest.rs: 매니페스트 스키마와 검증 규칙의 원본.src/types/plugins.ts,src/types/pluginSDK.ts: 매니페스트와 SDK를 그대로 반영하는 TypeScript 타입.