Plugin Development
Write a plugin
Plugins add new tools to AI WorkDeck so the AI can call your code from a conversation. This guide starts with a minimal working example and ends with submitting for review.
First, decide: do you need a plugin or a Skill
The two are easy to confuse, and picking the wrong one wastes a lot of work. The difference fits in one sentence: if it takes code, write a plugin; if explaining it clearly is enough, write a Skill.
| Scenario | Use |
|---|---|
| Call an external system's API to fetch data | Plugin |
| Parse a special file format | Plugin |
| Have the AI write review comments in your firm's format | Skill |
| Define the steps and deliverable structure for a type of matter | Skill |
A Skill is plain text (a prompt plus trigger words): it goes live on submission with no review, and is much faster to write. If a Skill covers your need, go write a Skill and skip the rest of this page.
Run your first plugin in five minutes
A plugin is a Java project compiled into a JAR, plus a manifest.json, zipped up and submitted. You need JDK 21 and Maven.
Download the template project above — it is itself a complete working example. Only two files matter. The first is the tool class:
package com.example.myplugin;
import dev.langchain4j.agent.tool.Tool;
/**
* 插件工具类。
*
* 三条硬约定,违反任意一条工具都不会出现在 AI 面前:
* 1. 必须有无参构造函数——宿主用反射实例化;
* 2. 工具方法加 @Tool 注解,方法名即工具名,要与 manifest.json 的 tools[].name 一致;
* 3. 参数与返回值用 String 最省事(复杂结构自己序列化成 JSON 字符串)。
*
* @Tool 里的描述是写给 AI 看的,直接决定它会不会在恰当的时候调用这个工具。
* 写清楚"什么时候用",比写"这个方法做什么"有用得多。
*/
public class MyTools {
@Tool("统计一段中文文本的字数,返回可读的统计结果。用户问'多少字'时调用。")
public String countChinese(String text) {
if (text == null || text.isBlank()) {
return "输入为空,字数 0";
}
long cjk = text.codePoints()
.filter(cp -> Character.UnicodeScript.of(cp) == Character.UnicodeScript.HAN)
.count();
return String.format("总字符 %d,其中汉字 %d", text.length(), cjk);
}
}
The description in @Tool is written for the AI — it directly decides whether the AI calls your tool at the right moment. Writing "when to use it" is far more useful than "what this method does": the former is what the AI has to judge.
The second is manifest.json, which describes what the plugin is:
{
"id": "my-plugin",
"name": "我的插件",
"version": "1.0.0",
"description": "一句话说明这个插件替用户做什么。会展示在插件广场的卡片上。",
"author": "你的名字或团队",
"homepage": "https://example.com",
"permissions": [],
"tools": [
{
"name": "countChinese",
"description": "统计中文文本字数",
"permissions": []
}
],
"backendJars": ["my-plugin-1.0.0.jar"]
}
Tool names must match on both sides: tools[].name must equal the Java method name. If they differ, the tool fails to register and the AI cannot see it.
Then package it:
mvn package
mkdir -p dist && cp target/my-plugin-1.0.0.jar manifest.json dist/
cd dist && zip -r ../my-plugin-1.0.0.zip . && cd ..Note the zip contains the files themselves — don't nest an extra directory. Unzipping should reveal manifest.json directly, not a folder.
Try it on your own machine before submitting
No need to wait for review. Copy the entire dist/ directory into your local plugin folder and restart AI WorkDeck:
# macOS / Linux
~/.aiworkdeck/plugins/my-plugin/Your plugin should appear under Plugin Marketplace → Installed. Enable it, then ask something in a conversation that should use your tool and see whether the AI calls it. If it doesn't, the @Tool description most likely fails to explain when to use it.
What each manifest.json field means
| Field | Description |
|---|---|
id | Globally unique; lowercase letters, digits and hyphens. It can never change once published — it is how upgrades identify "the same plugin". |
version | Semantic version. Every submission must be higher than the previous one, or it is rejected. |
name / description | Shown on the marketplace card. Describe what it does for the user, not the implementation. |
author / homepage | Author and project homepage. Optional but recommended — users judge trust by them. |
permissions | The capabilities this plugin uses; see the next section. |
tools | Tool list. name must equal the Java method name; write the description so its purpose is clear. |
backendJars | JAR file names relative to the package root. ../ paths outside the package are not allowed. |
permissions: declare honestly — review cross-checks
Four optional values; declare what you use:
file_readRead project filesfile_writeCreate, modify or delete filesnetworkAccess the external networkeditorOperate the document editor
This is not a sandbox
Plugins run in the same process as the host app, so undeclared behavior cannot be blocked technically — a tool that declares no permissions can still read files. The declaration is not a runtime restriction but review evidence: we cross-check it against the JAR's static scan. Declaring no network while referencing network APIs — or the reverse — gets the submission rejected.
What review looks at
Every version is reviewed by a human, usually within one to two business days. An automated scan runs first, and its report sits next to your permissions declaration on the reviewer's desk.
These are rejected outright:
- Declared permissions don't match the APIs actually called
- Custom TrustManager or any other way of bypassing certificate validation
- Hardcoded IP addresses, or data sent out over plaintext HTTP
- Reflection into the host's internal objects (database connections, config services, etc.)
- Obfuscated or packed code, or anything that hides what the code does
- Version number not higher than the previous one
Once approved, the platform signs the whole package with its private key and clients verify the signature on install. After that, nobody — including us — can alter the package contents without re-signing.
If a problem surfaces after release, we revoke that version. Clients pick up the revocation list, disable it automatically and notify the user.
A promise to users — and a constraint on you
Our users are lawyers, and their machines hold clients' confidential material. A plugin gets the same access as the host app — far beyond what it needs for itself.
So review errs on the strict side, and rejections come with reasons. If your plugin genuinely needs a sensitive-looking capability, explain why in the submission notes — it makes review much faster.
Web plugins: build the UI in HTML/JS
If what you want is an interface — a form, a lookup tool, a dashboard — you do not need Java. Put a web/ directory in the package (plain static HTML/JS/CSS, entry web/index.html) and point manifest frontendEntry at it. It may ship no JAR at all.
Here the permissions are real. The desktop app loads a Web plugin into a sandboxed iframe, deliberately not same-origin: plugin scripts never see the host session, every capability travels over the postMessage bridge, and the host checks each call against the manifest permissions. Unlike a JAR plugin, this declaration is an enforcement boundary rather than a self-description.
web/awd-plugin-sdk.js in the template wraps that bridge. Nothing works before the handshake:
| Method | Params | Result | Permission |
|---|---|---|---|
context.get | {} | { pluginId, projectId, language, theme, themeTokens } | - |
files.list | {} | { files: [{ path, name, size }] } | file_read |
files.read | { path } | { path, content, truncated } | file_read |
ui.toast | { message } | {} | - |
storage.get | { key } | { key, value } | - |
storage.set | { key, value } | {} | - |
evidence.link | { anchor: { selection: true } | { quote }, docPath?, targets: [{ path, locator?, relation?, method?, note? }] } | { linkKey, targetIds } | editor |
evidence.list | { docPath?, path?, sectionPath?, status? } | { links: [{ linkKey, docPath, anchorText, sectionPath, status, targets }] } | file_read |
evidence.locate | { linkKey, targetId? } | {} | editor |
tools.invoke | { name, args? } | { output } | -(工具须为本插件 manifest 声明) |
chat.send | { prompt } | {} | -(上限 4000 字) |
ui.openFile | { path } | {} | file_read |
doc.exec | { action, params? } | { result } | editor(宿主 0.27.4+;action 为 doc_/sheet_/slide_ 安全子集) |
doc.active | {} | { fileId, kind } | editor(宿主 0.27.4+) |
events.subscribe | { events: ["files.changed" | "selection.changed" | "project.switched"] } | { subscribed } | 按事件(宿主 0.27.4+) |
events.unsubscribe | { events } | { subscribed } | - |
ai.request | { prompt, system?, purpose? } | { text, modelId } | ai(宿主 0.27.4+;16000 字符、10 次/分钟,走用户 Credits) |
settings.get | { key } | { key, value } | -(宿主 0.28+;manifest 顶层 settings 声明的配置项,secret 项拿不到) |
<script src="awd-plugin-sdk.js"></script>
<script>
const ctx = await awd.ready(); // { pluginId, projectId, language, theme, themeTokens }
const files = await awd.files.list(); // 需 file_read
const doc = await awd.files.read(files[0].path); // { path, content, truncated }
await awd.ui.toast('已完成');
await awd.storage.set('draft', { title: 'x' }); // 插件级 KV,上限 64 KB
</script>Errors arrive as rejected promises with err.code: permission_denied when the manifest does not declare what the call needs, unknown_method when the host does not recognise it. files.read caps text at 5 MB (truncated becomes true), and storage caps each plugin at 64 KB total.
Theme channel (v2.6)
The handshake context now carries theme ('light'|'dark') and themeTokens (a table of --awd-* CSS variables); the host also pushes { type: 'theme', theme, tokens } on every later switch. The SDK applies it automatically — setting data-theme, toggling the awd-theme-light/awd-theme-dark class, and writing each token as a CSS custom property — so plugin CSS can just use var(--awd-surface) with zero JS. For script-driven behavior, use awd.theme.get() to read the current value and awd.theme.onChange(cb) to subscribe. The host simulator has a light/dark toggle button in its header, so you can test both themes without installing the desktop app.
The template also ships a host simulator, dev/host-simulator.html: it impersonates the desktop host, performs the handshake, serves every method with fake data, and logs each bridge message. Run a local static server and you can develop in a browser without installing AI WorkDeck.
python3 -m http.server 8000
# -> http://localhost:8000/dev/host-simulator.htmlOpening it directly over file:// usually shows a blank frame — browsers refuse to load sandboxed child documents from file://. The server must not rewrite URLs either: npx serve enables clean URLs by default, redirecting /web/index.html to /web so the relative SDK reference 404s.
