Skip to content

Commit 0c867ee

Browse files
feat: expose plugin approval action metadata
Expose plugin approval action metadata so plugins can describe richer approval actions across gateway, SDK, channel, and UI surfaces.
1 parent e74a039 commit 0c867ee

43 files changed

Lines changed: 1928 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5868,6 +5868,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
58685868
public let toolname: String?
58695869
public let toolcallid: String?
58705870
public let alloweddecisions: [String]?
5871+
public let actions: [[String: AnyCodable]]?
58715872
public let agentid: String?
58725873
public let sessionkey: String?
58735874
public let turnsourcechannel: String?
@@ -5876,6 +5877,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
58765877
public let turnsourcethreadid: AnyCodable?
58775878
public let timeoutms: Int?
58785879
public let twophase: Bool?
5880+
public let keeppendingwithoutroute: Bool?
58795881

58805882
public init(
58815883
pluginid: String?,
@@ -5885,14 +5887,16 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
58855887
toolname: String?,
58865888
toolcallid: String?,
58875889
alloweddecisions: [String]?,
5890+
actions: [[String: AnyCodable]]?,
58885891
agentid: String?,
58895892
sessionkey: String?,
58905893
turnsourcechannel: String?,
58915894
turnsourceto: String?,
58925895
turnsourceaccountid: String?,
58935896
turnsourcethreadid: AnyCodable?,
58945897
timeoutms: Int?,
5895-
twophase: Bool?)
5898+
twophase: Bool?,
5899+
keeppendingwithoutroute: Bool?)
58965900
{
58975901
self.pluginid = pluginid
58985902
self.title = title
@@ -5901,6 +5905,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
59015905
self.toolname = toolname
59025906
self.toolcallid = toolcallid
59035907
self.alloweddecisions = alloweddecisions
5908+
self.actions = actions
59045909
self.agentid = agentid
59055910
self.sessionkey = sessionkey
59065911
self.turnsourcechannel = turnsourcechannel
@@ -5909,6 +5914,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
59095914
self.turnsourcethreadid = turnsourcethreadid
59105915
self.timeoutms = timeoutms
59115916
self.twophase = twophase
5917+
self.keeppendingwithoutroute = keeppendingwithoutroute
59125918
}
59135919

59145920
private enum CodingKeys: String, CodingKey {
@@ -5919,6 +5925,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
59195925
case toolname = "toolName"
59205926
case toolcallid = "toolCallId"
59215927
case alloweddecisions = "allowedDecisions"
5928+
case actions
59225929
case agentid = "agentId"
59235930
case sessionkey = "sessionKey"
59245931
case turnsourcechannel = "turnSourceChannel"
@@ -5927,6 +5934,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
59275934
case turnsourcethreadid = "turnSourceThreadId"
59285935
case timeoutms = "timeoutMs"
59295936
case twophase = "twoPhase"
5937+
case keeppendingwithoutroute = "keepPendingWithoutRoute"
59305938
}
59315939
}
59325940

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
65cb96d0aa2888ddb7b014f810d7cd415f1f0ccdce7792fbf12b4aad11f146f8 plugin-sdk-api-baseline.json
2-
662b37da529f199ee9b56482f2f6897bdd010dfb72be778d208b289adaca1298 plugin-sdk-api-baseline.jsonl
1+
afd47fa416fe8fc5a2364078727d811a55842280d7cdf37625480de4f51ff22a plugin-sdk-api-baseline.json
2+
79d5b76cb0ec23311fbdd58043a15bca3353afcc5e9da91348ec5c8124d65e39 plugin-sdk-api-baseline.jsonl

docs/plugins/building-plugins.md

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,81 @@ local proof.
183183
</Step>
184184
</Steps>
185185

186-
<a id="registering-agent-tools"></a>
187-
188-
## Registering tools
189-
190-
Tools can be required or optional. Required tools are always available when the
191-
plugin is enabled. Optional tools require user opt-in.
186+
## Plugin capabilities
187+
188+
A single plugin can register any number of capabilities via the `api` object:
189+
190+
| Capability | Registration method | Detailed guide |
191+
| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- |
192+
| Text inference (LLM) | `api.registerProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins) |
193+
| CLI inference backend | `api.registerCliBackend(...)` | [CLI Backend Plugins](/plugins/cli-backend-plugins) |
194+
| Channel / messaging | `api.registerChannel(...)` | [Channel Plugins](/plugins/sdk-channel-plugins) |
195+
| Speech (TTS/STT) | `api.registerSpeechProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
196+
| Realtime transcription | `api.registerRealtimeTranscriptionProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
197+
| Realtime voice | `api.registerRealtimeVoiceProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
198+
| Media understanding | `api.registerMediaUnderstandingProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
199+
| Image generation | `api.registerImageGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
200+
| Music generation | `api.registerMusicGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
201+
| Video generation | `api.registerVideoGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
202+
| Web fetch | `api.registerWebFetchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
203+
| Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
204+
| Tool-result middleware | `api.registerAgentToolResultMiddleware(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) |
205+
| Agent tools | `api.registerTool(...)` | Below |
206+
| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
207+
| Plugin hooks | `api.on(...)` | [Plugin hooks](/plugins/hooks) |
208+
| Internal event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
209+
| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture-internals#gateway-http-routes) |
210+
| CLI subcommands | `api.registerCli(...)` | [Entry Points](/plugins/sdk-entrypoints) |
211+
212+
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
213+
214+
Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when they
215+
need async tool-result rewriting before the model sees the output. Declare the
216+
targeted runtimes in `contracts.agentToolResultMiddleware`, for example
217+
`["pi", "codex"]`. This is a trusted bundled-plugin seam; external
218+
plugins should prefer regular OpenClaw plugin hooks unless OpenClaw grows an
219+
explicit trust policy for this capability.
220+
221+
If your plugin registers custom gateway RPC methods, keep them on a
222+
plugin-specific prefix. Core admin namespaces (`config.*`,
223+
`exec.approvals.*`, `wizard.*`, `update.*`) stay reserved and always resolve to
224+
`operator.admin`, even if a plugin asks for a narrower scope.
225+
226+
`openclaw/plugin-sdk/gateway-method-runtime` is a reserved control-plane bridge
227+
for plugin HTTP routes that declare
228+
`contracts.gatewayMethodDispatch: ["authenticated-request"]`. It is an
229+
intentional-use guard for reviewed native plugins, not a sandbox boundary.
230+
231+
Hook guard semantics to keep in mind:
232+
233+
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
234+
- `before_tool_call`: `{ block: false }` is treated as no decision.
235+
- `before_tool_call`: `{ requireApproval: { ... } }` pauses agent execution and prompts the user for approval via the exec approval overlay, native channel approval clients, or the `/approve` command on any channel.
236+
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
237+
- `before_install`: `{ block: false }` is treated as no decision.
238+
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
239+
- `message_sending`: `{ cancel: false }` is treated as no decision.
240+
- `message_received`: prefer the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
241+
- `message_sending`: prefer typed `replyToId` / `threadId` routing fields over channel-specific metadata keys.
242+
243+
The `/approve` command handles both exec and plugin approvals with bounded fallback: when an exec approval id is not found, OpenClaw retries the same id through plugin approvals. Plugin approval forwarding can be configured independently via `approvals.plugin` in config.
244+
245+
If custom approval plumbing needs to detect that same bounded fallback case,
246+
prefer `isApprovalNotFoundError` from `openclaw/plugin-sdk/error-runtime`
247+
instead of matching approval-expiry strings manually.
248+
249+
See [Plugin hooks](/plugins/hooks) for examples and the hook reference.
250+
251+
## Registering agent tools
252+
253+
Tools are typed functions the LLM can call. They can be required (always
254+
available) or optional (user opt-in):
255+
256+
For simple plugins that only own a fixed set of tools, prefer
257+
[`defineToolPlugin`](/plugins/tool-plugins). It generates manifest metadata and
258+
keeps `contracts.tools` aligned. Use the lower-level `api.registerTool(...)`
259+
surface when the plugin also owns channels, providers, hooks, services,
260+
commands, or fully dynamic tool registration.
192261

193262
```typescript
194263
register(api) {

docs/plugins/hooks.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,22 @@ type BeforeToolCallResult = {
196196
timeoutMs?: number;
197197
timeoutBehavior?: "allow" | "deny";
198198
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
199+
actions?: Array<
200+
| {
201+
kind: "decision";
202+
label: string;
203+
style: "primary" | "secondary" | "success" | "danger";
204+
decision: "allow-once" | "allow-always" | "deny";
205+
commandTemplate: string;
206+
}
207+
| {
208+
kind: "command";
209+
label: string;
210+
style: "primary" | "secondary" | "success" | "danger";
211+
commandTemplate: string;
212+
}
213+
>;
214+
keepPendingWithoutRoute?: boolean;
199215
pluginId?: string;
200216
onResolution?: (
201217
decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled",
@@ -211,6 +227,21 @@ Hook guard behavior for typed lifecycle hooks:
211227
- `params` rewrites the tool parameters for execution.
212228
- `requireApproval` pauses the agent run and asks the user through plugin
213229
approvals. The `/approve` command can approve both exec and plugin approvals.
230+
- `allowedDecisions` limits the built-in `/approve` decisions. Omit it to offer
231+
`allow-once`, `allow-always`, and `deny`.
232+
- `actions` customizes the commands shown with the approval. OpenClaw replaces
233+
`{id}` in each `commandTemplate` with the generated approval id before storing
234+
or rendering the request.
235+
- Use `kind: "decision"` plus `decision` when the action resolves the approval
236+
through OpenClaw's approval system. Native clients can render those actions as
237+
buttons with canonical OpenClaw approval callbacks. Decision actions must be
238+
included in `allowedDecisions` when that field is present. Use
239+
`kind: "command"` for plugin-owned commands that collect more context or
240+
verify an external workflow before resolving the approval; native clients
241+
should leave those as visible command text.
242+
- `keepPendingWithoutRoute: true` keeps the request pending when no approval
243+
client or initiating-channel route can receive it. Use this only when your
244+
plugin provides another documented command or UI path to resolve the request.
214245
- A lower-priority `block: true` can still block after a higher-priority hook
215246
requested approval.
216247
- `onResolution` receives the resolved approval decision - `allow-once`,

extensions/discord/src/approval-handler.runtime.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,58 @@
1+
import type { PendingApprovalView } from "openclaw/plugin-sdk/approval-handler-runtime";
2+
import type { PluginApprovalRequest } from "openclaw/plugin-sdk/approval-runtime";
13
import { describe, expect, it } from "vitest";
24
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
35

46
describe("discordApprovalNativeRuntime", () => {
7+
it("renders command-only plugin approval actions as visible text", async () => {
8+
const request = {
9+
id: "plugin:req-1",
10+
request: {
11+
title: "World proof required",
12+
description: "Verify with World before the tool runs.",
13+
pluginId: "agentkit",
14+
toolName: "shell.exec",
15+
},
16+
createdAtMs: 0,
17+
expiresAtMs: 1_000,
18+
} satisfies PluginApprovalRequest;
19+
const view = {
20+
approvalKind: "plugin",
21+
approvalId: "plugin:req-1",
22+
phase: "pending",
23+
title: "World proof required",
24+
description: "Verify with World before the tool runs.",
25+
metadata: [],
26+
severity: "warning",
27+
pluginId: "agentkit",
28+
toolName: "shell.exec",
29+
expiresAtMs: 1_000,
30+
actions: [
31+
{
32+
kind: "command",
33+
label: "Verify with World",
34+
style: "primary",
35+
command: "/agentkit approve plugin:req-1 allow-once",
36+
},
37+
],
38+
} satisfies PendingApprovalView;
39+
40+
const pending = await discordApprovalNativeRuntime.presentation.buildPendingPayload({
41+
cfg: {} as never,
42+
accountId: "main",
43+
context: {
44+
token: "discord-token",
45+
config: {} as never,
46+
},
47+
request,
48+
approvalKind: "plugin",
49+
nowMs: 0,
50+
view,
51+
});
52+
53+
expect(JSON.stringify(pending.body)).toContain("/agentkit approve plugin:req-1 allow-once");
54+
});
55+
556
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
657
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
758
cfg: {} as never,

extensions/discord/src/approval-handler.runtime.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ButtonStyle } from "discord-api-types/v10";
22
import type {
3+
ApprovalActionView,
34
ChannelApprovalCapabilityHandlerContext,
45
ExecApprovalExpiredView,
56
ExecApprovalPendingView,
@@ -10,7 +11,6 @@ import type {
1011
PluginApprovalResolvedView,
1112
} from "openclaw/plugin-sdk/approval-handler-runtime";
1213
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
13-
import type { ExecApprovalActionDescriptor } from "openclaw/plugin-sdk/approval-reply-runtime";
1414
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
1515
import type {
1616
DiscordExecApprovalConfig,
@@ -47,12 +47,26 @@ type PreparedDeliveryTarget = {
4747
discordChannelId: string;
4848
recipientUserId?: string;
4949
};
50+
type DecisionApprovalActionView = ApprovalActionView & {
51+
decision: ExecApprovalDecision;
52+
};
5053

5154
export type DiscordApprovalHandlerContext = {
5255
token: string;
5356
config: DiscordExecApprovalConfig;
5457
};
5558

59+
function isDecisionApprovalAction(
60+
action: ApprovalActionView,
61+
): action is DecisionApprovalActionView {
62+
return (
63+
action.kind === "decision" &&
64+
(action.decision === "allow-once" ||
65+
action.decision === "allow-always" ||
66+
action.decision === "deny")
67+
);
68+
}
69+
5670
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
5771
accountId: string;
5872
context: DiscordApprovalHandlerContext;
@@ -115,7 +129,7 @@ class ExecApprovalActionButton extends Button {
115129
override label: string;
116130
override style: ButtonStyle;
117131

118-
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
132+
constructor(params: { approvalId: string; descriptor: DecisionApprovalActionView }) {
119133
super();
120134
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
121135
this.label = params.descriptor.label;
@@ -131,7 +145,7 @@ class ExecApprovalActionButton extends Button {
131145
}
132146

133147
class ExecApprovalActionRow extends Row<Button> {
134-
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
148+
constructor(params: { approvalId: string; actions: readonly DecisionApprovalActionView[] }) {
135149
super(
136150
params.actions.map(
137151
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
@@ -140,13 +154,38 @@ class ExecApprovalActionRow extends Row<Button> {
140154
}
141155
}
142156

143-
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
157+
function createApprovalActionRow(view: PendingApprovalView): Row<Button> | undefined {
158+
const actions = view.actions.filter(isDecisionApprovalAction);
159+
if (actions.length === 0) {
160+
return undefined;
161+
}
144162
return new ExecApprovalActionRow({
145163
approvalId: view.approvalId,
146-
actions: view.actions,
164+
actions,
147165
});
148166
}
149167

168+
function isCommandOnlyApprovalAction(action: ApprovalActionView): boolean {
169+
return (
170+
action.kind === "command" &&
171+
typeof action.command === "string" &&
172+
action.command.trim().length > 0
173+
);
174+
}
175+
176+
function buildPluginCommandActionLines(actions: readonly ApprovalActionView[]): string[] {
177+
const commandActions = actions.filter(isCommandOnlyApprovalAction);
178+
if (commandActions.length === 0) {
179+
return [];
180+
}
181+
return [
182+
"### Actions",
183+
...commandActions.map(
184+
(action) => `- ${action.label}: \`${formatCommandPreview(action.command.trim(), 240)}\``,
185+
),
186+
];
187+
}
188+
150189
function buildApprovalMetadataLines(
151190
metadata: readonly { label: string; value: string }[],
152191
): string[] {
@@ -231,7 +270,10 @@ function createPluginApprovalRequestContainer(params: {
231270
description: "A plugin action needs your approval.",
232271
commandPreview: formatCommandPreview(params.view.title, 700),
233272
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
234-
metadataLines: buildApprovalMetadataLines(params.view.metadata),
273+
metadataLines: [
274+
...buildApprovalMetadataLines(params.view.metadata),
275+
...buildPluginCommandActionLines(params.view.actions),
276+
],
235277
actionRow: params.actionRow,
236278
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
237279
accentColor,

0 commit comments

Comments
 (0)